From 7c11d56eb10654ca0929c6b8d2a3d2576064db61 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 18:46:26 -0500 Subject: [PATCH 001/240] native/ude: define architecture and versioned broker ABI Establish the isolated UdeCx bus design, ViGEmBus-inspired lifecycle invariants, packed C protocol, and a platform-independent Go codec with bounds, layout, copy-ownership, malformed-input, and fuzz tests. --- docs/architecture/native-udecx.md | 162 ++++++++++ internal/transport/udecx/protocol.go | 363 ++++++++++++++++++++++ internal/transport/udecx/protocol_test.go | 141 +++++++++ native/udecx/README.md | 16 + native/udecx/THIRD_PARTY_NOTICES.md | 15 + native/udecx/include/ViiperUdeProtocol.h | 185 +++++++++++ 6 files changed, 882 insertions(+) create mode 100644 docs/architecture/native-udecx.md create mode 100644 internal/transport/udecx/protocol.go create mode 100644 internal/transport/udecx/protocol_test.go create mode 100644 native/udecx/README.md create mode 100644 native/udecx/THIRD_PARTY_NOTICES.md create mode 100644 native/udecx/include/ViiperUdeProtocol.h diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md new file mode 100644 index 00000000..8ccb4e19 --- /dev/null +++ b/docs/architecture/native-udecx.md @@ -0,0 +1,162 @@ +# Native VIIPER UdeCx bus + +## Objective + +Replace VIIPER's localhost USB/IP attachment path on Windows with a native +KMDF/UdeCx bus while retaining the existing, tested Go controller engines. +The native bus must expose the same HID, audio, microphone, isochronous, state, +and feedback contracts without a TCP loopback or an external USB/IP driver. + +The first correctness target is feature parity. Performance work follows only +after transfer ordering, cancellation, teardown, and recovery are proven. + +## Evidence and reference points + +- Microsoft's UdeCx contract owns USB device creation, endpoint queues, reset, + start, purge, and power lifecycle. Purge is asynchronous: pending work must be + cancelled before `UdecxUsbEndpointPurgeComplete` is called. +- The local usbip-win2 0.9.7.8 reference proves that UdeCx can expose VIIPER's + bidirectional isochronous PlayStation audio topology on Windows. +- ViGEmBus provides the lifecycle north star: explicit protocol negotiation, + handle-scoped ownership, bounded manual queues, cancel-safe requests, + generation-aware target teardown, and synchronization per target rather than + one global lock. + +Reference code is used for architecture and documented protocol behavior. New +VIIPER code is independently named and implemented. See +`native/udecx/THIRD_PARTY_NOTICES.md`. + +## Architecture + +```text +DS4Windows + | existing VIIPER API +VIIPER Go service + | usb.Device controller engines (unchanged) +native UDE broker + | versioned IOCTL ABI, overlapped inverted calls +VIIPER UdeCx KMDF driver + | UDE endpoint queues and lifecycle +Windows USB/HID/Audio stacks +``` + +The Go service remains the controller model. It already owns descriptors, +control requests, HID reports, audio media, alternate settings, endpoint reset, +and the state rules learned while stabilizing DualSense and DualShock 4. +The kernel driver owns only Windows USB presentation and transfer lifecycle. + +## Non-negotiable invariants + +1. Every device is owned by exactly one open broker handle. +2. Every device identity includes a monotonically increasing generation. +3. Every operation token completes exactly once or is cancelled exactly once. +4. A completion from an old generation is rejected without touching a new + device that reused the numeric identifier. +5. Purge stops admission, cancels queued and in-flight work, waits for ownership + to settle, then acknowledges UdeCx. +6. Driver unload and file cleanup leave no UDE device, request, or worker alive. +7. Endpoint queues are bounded. Saturation is observable and never overwrites + live media or state silently. +8. Shared report state is snapshotted atomically before encoding. Media and + state never share mutable buffers. +9. No raw user pointer crosses the ABI. +10. The ABI is size- and version-negotiated before any mutating operation. + +## Kernel/user transport + +The first implementation uses a cancel-safe inverted-call model. VIIPER posts +multiple `DEQUEUE_OPERATION` requests. When UdeCx delivers an endpoint request, +the driver pairs it with a waiting user request and returns an immutable +operation record. VIIPER processes it through the existing `usb.Device` +interface and submits `COMPLETE_OPERATION`. + +This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping +before introducing a shared-memory optimization. Once correctness gates pass, +high-rate media payloads may move to a preallocated ring while keeping the same +token/generation lifecycle. Control and lifecycle operations remain IOCTL based. + +### Operation identity + +Every operation carries: + +- device ID and generation; +- a globally unique token for that generation; +- endpoint address and transfer direction; +- operation kind and URB function; +- transfer flags, setup packet, and start frame where applicable; +- ordered isochronous packet metadata; +- a bounded payload. + +### Device creation + +VIIPER serializes the exact descriptor set returned by the controller engine: +device, configuration, BOS, language/string records, and device-speed policy. +The driver validates all offsets and lengths before constructing a UDE device. +Descriptor normalization required by UdeCx is a named policy, not an implicit +mutation: high-speed bulk packets are 512 bytes and interval conversion is +covered by descriptor tests. + +## Lifecycle + +```text +Absent -> Creating -> Enumerating -> Active + | | + v v + Failed <- Purging -> Removed +``` + +- **Creating:** validate ABI, descriptors, limits, and owner handle. +- **Enumerating:** create UDE device/endpoints and plug it into UdeCx. +- **Active:** accept transfers and endpoint lifecycle notifications. +- **Purging:** close admission, cancel all tokens, drain queues, acknowledge + endpoint purge, and invalidate the generation. +- **Removed:** delete the UDE object and release all references. + +Power loss, owner process exit, DS4Windows restart, VIIPER restart, and explicit +unplug all converge on the same idempotent purge path. + +## Synchronization model + +- A controller-level lock protects the device table and owner registration. +- Each device has a short-held state lock and independent endpoint queues. +- Media callbacks do not take the controller lock. +- UDE callbacks never wait on user mode while holding a WDF lock. +- Blocking work is represented by cancelable WDF requests, not sleeping kernel + threads. +- Completion lookup is keyed by `(device ID, generation, token)`. + +This follows the useful ViGEmBus pattern of per-target ownership and manual +request queues while accounting for UdeCx's endpoint-specific purge contract. + +## Delivery checkpoints + +1. Versioned ABI, independent C/Go layout tests, architecture notes. +2. Installable root-enumerated KMDF/UdeCx controller with negotiation, owner + cleanup, diagnostics, and no virtual child. +3. Dynamic HID-only child, control endpoint, interrupt IN/OUT, reset and purge. +4. VIIPER Go broker implementing the existing controller interface. +5. Xbox, DualShock 4, and DualSense HID/state parity. +6. Bidirectional isochronous audio and microphone parity, alternate settings, + haptics, lightbar, triggers, and reconnect recovery. +7. Fault injection, soak, latency, CPU, install/update/rollback, and signing. + +## Release gates + +- No verifier findings under KMDF/USB/UdeCx stress. +- Repeated create/remove, service kill, process crash, sleep/resume, and device + reconnect leave zero stale children and zero stuck requests. +- Descriptor and protocol fuzzing rejects malformed inputs without a bugcheck. +- HID report ordering has no duplication or regression across generations. +- DualSense and DualShock 4 media survive concurrent state and feedback traffic. +- Native latency and CPU are measured against the current USB/IP path and + ViGEmBus-style virtual input under the same workload. +- Installation is signed, reversible, version-gated, and never replaces a live + kernel driver across an unsafe reboot boundary. + +## Primary documentation + +- Microsoft, *Write a UDE client driver* +- Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` +- Microsoft, *Install the WDK using NuGet* +- Microsoft Windows Driver Samples CI guidance + diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go new file mode 100644 index 00000000..4f96c319 --- /dev/null +++ b/internal/transport/udecx/protocol.go @@ -0,0 +1,363 @@ +// Package udecx defines the user-mode half of the native VIIPER UdeCx ABI. +// It intentionally has no Windows dependency so layout and fuzz tests run on +// every supported development host. +package udecx + +import ( + "encoding/binary" + "errors" + "fmt" + "math" +) + +const ( + Magic uint32 = 0x45445556 + ABIMajor uint16 = 1 + ABIMinor uint16 = 0 + + HeaderSize = 16 + NegotiateRequestSize = 32 + NegotiateResponseSize = 56 + DescriptorRecordSize = 16 + CreateDeviceSize = 56 + DeviceIdentitySize = 32 + IsoPacketSize = 16 + OperationSize = 88 + CompletionSize = 72 + StatsSize = 112 + + MaxDevices = 32 + MaxDescriptorBytes = 256 * 1024 + MaxTransferBytes = 1024 * 1024 + MaxIsoPackets = 1024 + MaxPendingOperations = 4096 +) + +var ( + ErrShortMessage = errors.New("native UDE message is shorter than its fixed header") + ErrBadMagic = errors.New("native UDE message has an invalid magic value") + ErrIncompatibleMajor = errors.New("native UDE ABI major version is incompatible") + ErrInvalidSize = errors.New("native UDE message size is invalid") + ErrInvalidRange = errors.New("native UDE message contains an invalid range") + ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") +) + +type Capabilities uint32 + +const ( + CapabilityIsochronous Capabilities = 1 << iota + CapabilityStreams + CapabilityDeviceLifecycle +) + +type Header struct { + Magic uint32 + Major uint16 + Minor uint16 + Size uint32 + Flags uint32 +} + +func NewHeader(size int) (Header, error) { + if size < HeaderSize || uint64(size) > math.MaxUint32 { + return Header{}, ErrInvalidSize + } + return Header{Magic: Magic, Major: ABIMajor, Minor: ABIMinor, Size: uint32(size)}, nil +} + +func ParseHeader(src []byte) (Header, error) { + if len(src) < HeaderSize { + return Header{}, ErrShortMessage + } + h := Header{ + Magic: binary.LittleEndian.Uint32(src[0:4]), + Major: binary.LittleEndian.Uint16(src[4:6]), + Minor: binary.LittleEndian.Uint16(src[6:8]), + Size: binary.LittleEndian.Uint32(src[8:12]), + Flags: binary.LittleEndian.Uint32(src[12:16]), + } + if h.Magic != Magic { + return Header{}, ErrBadMagic + } + if h.Major != ABIMajor { + return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMajor, h.Major, ABIMajor) + } + if h.Size < HeaderSize || uint64(h.Size) > uint64(len(src)) { + return Header{}, ErrInvalidSize + } + return h, nil +} + +func putHeader(dst []byte, h Header) { + binary.LittleEndian.PutUint32(dst[0:4], h.Magic) + binary.LittleEndian.PutUint16(dst[4:6], h.Major) + binary.LittleEndian.PutUint16(dst[6:8], h.Minor) + binary.LittleEndian.PutUint32(dst[8:12], h.Size) + binary.LittleEndian.PutUint32(dst[12:16], h.Flags) +} + +type NegotiateRequest struct { + ClientNonce uint64 + RequestedCapabilities Capabilities +} + +func (m NegotiateRequest) MarshalBinary() ([]byte, error) { + h, err := NewHeader(NegotiateRequestSize) + if err != nil { + return nil, err + } + dst := make([]byte, NegotiateRequestSize) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.ClientNonce) + binary.LittleEndian.PutUint32(dst[24:28], uint32(m.RequestedCapabilities)) + return dst, nil +} + +type NegotiateResponse struct { + ClientNonce uint64 + DriverNonce uint64 + Capabilities Capabilities + MaxDevices uint32 + MaxDescriptorBytes uint32 + MaxTransferBytes uint32 + MaxIsoPackets uint32 + MaxPendingOperations uint32 +} + +func ParseNegotiateResponse(src []byte) (NegotiateResponse, error) { + h, err := ParseHeader(src) + if err != nil { + return NegotiateResponse{}, err + } + if h.Size != NegotiateResponseSize { + return NegotiateResponse{}, ErrInvalidSize + } + return NegotiateResponse{ + ClientNonce: binary.LittleEndian.Uint64(src[16:24]), + DriverNonce: binary.LittleEndian.Uint64(src[24:32]), + Capabilities: Capabilities(binary.LittleEndian.Uint32(src[32:36])), + MaxDevices: binary.LittleEndian.Uint32(src[36:40]), + MaxDescriptorBytes: binary.LittleEndian.Uint32(src[40:44]), + MaxTransferBytes: binary.LittleEndian.Uint32(src[44:48]), + MaxIsoPackets: binary.LittleEndian.Uint32(src[48:52]), + MaxPendingOperations: binary.LittleEndian.Uint32(src[52:56]), + }, nil +} + +type DescriptorKind uint16 + +const ( + DescriptorDevice DescriptorKind = iota + 1 + DescriptorConfiguration + DescriptorBOS + DescriptorString +) + +type DescriptorRecord struct { + Kind DescriptorKind + Index uint16 + LanguageID uint16 + Offset uint32 + Length uint32 +} + +type DeviceSpeed uint32 + +const ( + DeviceSpeedLow DeviceSpeed = iota + 1 + DeviceSpeedFull + DeviceSpeedHigh + DeviceSpeedSuper +) + +type CreateDevice struct { + DeviceID uint64 + Generation uint32 + Speed DeviceSpeed + MaxPendingOperations uint32 + Descriptors []DescriptorRecord + DescriptorData []byte +} + +func (m CreateDevice) MarshalBinary() ([]byte, error) { + if m.DeviceID == 0 || m.Generation == 0 { + return nil, fmt.Errorf("%w: zero device identity", ErrInvalidRange) + } + if len(m.Descriptors) == 0 || len(m.DescriptorData) == 0 { + return nil, fmt.Errorf("%w: empty descriptor set", ErrInvalidRange) + } + if len(m.DescriptorData) > MaxDescriptorBytes || len(m.Descriptors) > MaxDescriptorBytes/DescriptorRecordSize { + return nil, ErrLimitExceeded + } + recordBytes := len(m.Descriptors) * DescriptorRecordSize + total := CreateDeviceSize + recordBytes + len(m.DescriptorData) + if uint64(total) > math.MaxUint32 { + return nil, ErrLimitExceeded + } + h, err := NewHeader(total) + if err != nil { + return nil, err + } + dst := make([]byte, total) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + binary.LittleEndian.PutUint32(dst[28:32], uint32(m.Speed)) + binary.LittleEndian.PutUint32(dst[32:36], uint32(len(m.Descriptors))) + binary.LittleEndian.PutUint32(dst[36:40], CreateDeviceSize) + binary.LittleEndian.PutUint32(dst[40:44], uint32(CreateDeviceSize+recordBytes)) + binary.LittleEndian.PutUint32(dst[44:48], uint32(len(m.DescriptorData))) + binary.LittleEndian.PutUint32(dst[48:52], m.MaxPendingOperations) + + for i, record := range m.Descriptors { + if !validRange(record.Offset, record.Length, uint32(len(m.DescriptorData))) { + return nil, fmt.Errorf("%w: descriptor %d", ErrInvalidRange, i) + } + off := CreateDeviceSize + i*DescriptorRecordSize + binary.LittleEndian.PutUint16(dst[off:off+2], uint16(record.Kind)) + binary.LittleEndian.PutUint16(dst[off+2:off+4], record.Index) + binary.LittleEndian.PutUint16(dst[off+4:off+6], record.LanguageID) + binary.LittleEndian.PutUint32(dst[off+8:off+12], record.Offset) + binary.LittleEndian.PutUint32(dst[off+12:off+16], record.Length) + } + copy(dst[CreateDeviceSize+recordBytes:], m.DescriptorData) + return dst, nil +} + +type OperationKind uint32 + +const ( + OperationControl OperationKind = iota + 1 + OperationTransfer + OperationEndpointStart + OperationEndpointPurge + OperationEndpointReset + OperationDeviceReset + OperationSetInterface + OperationDeviceD0Entry + OperationDeviceD0Exit +) + +type IsoPacket struct { + Offset uint32 + Length uint32 + Status int32 +} + +type Operation struct { + Token uint64 + DeviceID uint64 + Generation uint32 + Kind OperationKind + EndpointAddress uint8 + Direction uint8 + URBFunction uint32 + TransferFlags uint32 + StartFrame uint32 + TransferLength uint32 + SetupPacket [8]byte + IsoPackets []IsoPacket + Payload []byte +} + +func ParseOperation(src []byte) (Operation, error) { + h, err := ParseHeader(src) + if err != nil { + return Operation{}, err + } + if h.Size < OperationSize || h.Size > MaxTransferBytes+OperationSize+MaxIsoPackets*IsoPacketSize { + return Operation{}, ErrInvalidSize + } + src = src[:h.Size] + packetCount := binary.LittleEndian.Uint32(src[56:60]) + transferLength := binary.LittleEndian.Uint32(src[60:64]) + payloadOffset := binary.LittleEndian.Uint32(src[64:68]) + payloadLength := binary.LittleEndian.Uint32(src[68:72]) + isoOffset := binary.LittleEndian.Uint32(src[72:76]) + if packetCount > MaxIsoPackets || transferLength > MaxTransferBytes || payloadLength > MaxTransferBytes { + return Operation{}, ErrLimitExceeded + } + if !validRange(payloadOffset, payloadLength, h.Size) || !validArrayRange(isoOffset, packetCount, IsoPacketSize, h.Size) { + return Operation{}, ErrInvalidRange + } + op := Operation{ + Token: binary.LittleEndian.Uint64(src[16:24]), + DeviceID: binary.LittleEndian.Uint64(src[24:32]), + Generation: binary.LittleEndian.Uint32(src[32:36]), + Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), + EndpointAddress: src[40], + Direction: src[41], + URBFunction: binary.LittleEndian.Uint32(src[44:48]), + TransferFlags: binary.LittleEndian.Uint32(src[48:52]), + StartFrame: binary.LittleEndian.Uint32(src[52:56]), + TransferLength: transferLength, + IsoPackets: make([]IsoPacket, int(packetCount)), + Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), + } + copy(op.SetupPacket[:], src[76:84]) + for i := range op.IsoPackets { + off := int(isoOffset) + i*IsoPacketSize + op.IsoPackets[i] = IsoPacket{ + Offset: binary.LittleEndian.Uint32(src[off : off+4]), + Length: binary.LittleEndian.Uint32(src[off+4 : off+8]), + Status: int32(binary.LittleEndian.Uint32(src[off+8 : off+12])), + } + } + return op, nil +} + +type Completion struct { + Token uint64 + DeviceID uint64 + Generation uint32 + Status int32 + USBDStatus uint32 + IsoPackets []IsoPacket + Payload []byte +} + +func (m Completion) MarshalBinary() ([]byte, error) { + if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { + return nil, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) + } + if len(m.Payload) > MaxTransferBytes || len(m.IsoPackets) > MaxIsoPackets { + return nil, ErrLimitExceeded + } + isoBytes := len(m.IsoPackets) * IsoPacketSize + total := CompletionSize + isoBytes + len(m.Payload) + h, err := NewHeader(total) + if err != nil { + return nil, err + } + dst := make([]byte, total) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.Token) + binary.LittleEndian.PutUint64(dst[24:32], m.DeviceID) + binary.LittleEndian.PutUint32(dst[32:36], m.Generation) + binary.LittleEndian.PutUint32(dst[36:40], uint32(m.Status)) + binary.LittleEndian.PutUint32(dst[40:44], m.USBDStatus) + binary.LittleEndian.PutUint32(dst[44:48], uint32(len(m.Payload))) + binary.LittleEndian.PutUint32(dst[48:52], uint32(len(m.IsoPackets))) + binary.LittleEndian.PutUint32(dst[52:56], uint32(CompletionSize+isoBytes)) + binary.LittleEndian.PutUint32(dst[56:60], uint32(len(m.Payload))) + binary.LittleEndian.PutUint32(dst[60:64], CompletionSize) + for i, packet := range m.IsoPackets { + off := CompletionSize + i*IsoPacketSize + binary.LittleEndian.PutUint32(dst[off:off+4], packet.Offset) + binary.LittleEndian.PutUint32(dst[off+4:off+8], packet.Length) + binary.LittleEndian.PutUint32(dst[off+8:off+12], uint32(packet.Status)) + } + copy(dst[CompletionSize+isoBytes:], m.Payload) + return dst, nil +} + +func validRange(offset, length, total uint32) bool { + return offset <= total && length <= total-offset +} + +func validArrayRange(offset, count uint32, elementSize uint32, total uint32) bool { + if count != 0 && elementSize > math.MaxUint32/count { + return false + } + return validRange(offset, count*elementSize, total) +} diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go new file mode 100644 index 00000000..3467f024 --- /dev/null +++ b/internal/transport/udecx/protocol_test.go @@ -0,0 +1,141 @@ +package udecx + +import ( + "encoding/binary" + "errors" + "testing" +) + +func TestABISizes(t *testing.T) { + for name, got := range map[string]int{ + "header": HeaderSize, "negotiate request": NegotiateRequestSize, + "negotiate response": NegotiateResponseSize, "descriptor": DescriptorRecordSize, + "create device": CreateDeviceSize, "identity": DeviceIdentitySize, + "iso packet": IsoPacketSize, "operation": OperationSize, + "completion": CompletionSize, "stats": StatsSize, + } { + if got%8 != 0 { + t.Fatalf("%s ABI size %d is not 8-byte aligned", name, got) + } + } +} + +func TestHeaderRejectsMalformedInput(t *testing.T) { + valid, err := NewHeader(HeaderSize) + if err != nil { + t.Fatal(err) + } + raw := make([]byte, HeaderSize) + putHeader(raw, valid) + + tests := []struct { + name string + edit func([]byte) []byte + want error + }{ + {"short", func(b []byte) []byte { return b[:15] }, ErrShortMessage}, + {"magic", func(b []byte) []byte { binary.LittleEndian.PutUint32(b, 0); return b }, ErrBadMagic}, + {"major", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[4:6], ABIMajor+1); return b }, ErrIncompatibleMajor}, + {"size below header", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 15); return b }, ErrInvalidSize}, + {"size beyond buffer", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 17); return b }, ErrInvalidSize}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + candidate := append([]byte(nil), raw...) + _, got := ParseHeader(tc.edit(candidate)) + if !errors.Is(got, tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestCreateDeviceMarshallingBoundsDescriptors(t *testing.T) { + msg := CreateDevice{ + DeviceID: 7, Generation: 2, Speed: DeviceSpeedHigh, + MaxPendingOperations: 128, + DescriptorData: []byte{0x12, 0x01, 0xaa, 0xbb}, + Descriptors: []DescriptorRecord{ + {Kind: DescriptorDevice, Offset: 0, Length: 2}, + {Kind: DescriptorConfiguration, Offset: 2, Length: 2}, + }, + } + raw, err := msg.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got, want := len(raw), CreateDeviceSize+2*DescriptorRecordSize+4; got != want { + t.Fatalf("size=%d want=%d", got, want) + } + if got := binary.LittleEndian.Uint32(raw[8:12]); got != uint32(len(raw)) { + t.Fatalf("header size=%d want=%d", got, len(raw)) + } + + msg.Descriptors[1].Offset = 4 + msg.Descriptors[1].Length = 1 + if _, err = msg.MarshalBinary(); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("invalid descriptor range: got %v", err) + } +} + +func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { + payload := []byte{1, 2, 3, 4} + total := OperationSize + IsoPacketSize + len(payload) + h, _ := NewHeader(total) + raw := make([]byte, total) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 99) + binary.LittleEndian.PutUint64(raw[24:32], 4) + binary.LittleEndian.PutUint32(raw[32:36], 8) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) + raw[40], raw[41] = 0x84, 1 + binary.LittleEndian.PutUint32(raw[56:60], 1) + binary.LittleEndian.PutUint32(raw[60:64], uint32(len(payload))) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize) + binary.LittleEndian.PutUint32(raw[68:72], uint32(len(payload))) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 0) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], uint32(len(payload))) + copy(raw[OperationSize+IsoPacketSize:], payload) + + op, err := ParseOperation(raw) + if err != nil { + t.Fatal(err) + } + if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || len(op.IsoPackets) != 1 { + t.Fatalf("unexpected operation: %+v", op) + } + raw[len(raw)-1] = 0xff + if op.Payload[3] != 4 { + t.Fatal("operation retained mutable caller payload") + } +} + +func TestCompletionMarshalling(t *testing.T) { + raw, err := (Completion{ + Token: 3, DeviceID: 9, Generation: 4, Status: -1, USBDStatus: 0xc0000001, + IsoPackets: []IsoPacket{{Offset: 0, Length: 3}}, Payload: []byte{7, 8, 9}, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got, want := len(raw), CompletionSize+IsoPacketSize+3; got != want { + t.Fatalf("size=%d want=%d", got, want) + } + if got := binary.LittleEndian.Uint32(raw[52:56]); got != CompletionSize+IsoPacketSize { + t.Fatalf("payload offset=%d", got) + } +} + +func FuzzParseOperation(f *testing.F) { + f.Add([]byte{}) + valid := make([]byte, OperationSize) + h, _ := NewHeader(OperationSize) + putHeader(valid, h) + binary.LittleEndian.PutUint32(valid[64:68], OperationSize) + binary.LittleEndian.PutUint32(valid[72:76], OperationSize) + f.Add(valid) + f.Fuzz(func(t *testing.T, raw []byte) { + _, _ = ParseOperation(raw) + }) +} diff --git a/native/udecx/README.md b/native/udecx/README.md new file mode 100644 index 00000000..e2889a75 --- /dev/null +++ b/native/udecx/README.md @@ -0,0 +1,16 @@ +# VIIPER native UdeCx bus + +This directory contains the Windows native USB device-emulation layer. It is +developed on `feature/native-udecx-bus`; it does not alter the supported USB/IP +implementation on `main` while the native path is incomplete. + +Directory contract: + +- `include/` is the stable C ABI shared by the driver and Go broker. +- `driver/` is the KMDF/UdeCx controller driver. +- `package/` contains INF and installation metadata. +- `tests/` contains ABI, lifecycle, descriptor, cancellation, and fault tests. + +The design and release gates are in +`docs/architecture/native-udecx.md`. + diff --git a/native/udecx/THIRD_PARTY_NOTICES.md b/native/udecx/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..3b6e8dfa --- /dev/null +++ b/native/udecx/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Native UDE development references + +The VIIPER native UdeCx implementation is original project code. The following +projects are used as protocol and architecture references: + +- **ViGEmBus**, BSD 3-Clause: target lifecycle, request ownership, manual queues, + concurrency, cancellation, and version negotiation. +- **usbip-win2**, BSD 2-Clause: documented UdeCx endpoint lifecycle and proof of + bidirectional isochronous operation on Windows. +- **Microsoft Windows Driver Samples**, MIT: supported KMDF project and CI + patterns. + +No third-party binary is redistributed by this directory. Any source adapted in +the future must retain its applicable license notice in the affected file and +in packaged notices. diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h new file mode 100644 index 00000000..952549ea --- /dev/null +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -0,0 +1,185 @@ +#pragma once + +#include + +#if defined(_WIN32) +#include +#endif + +#define VIIPER_UDE_MAGIC UINT32_C(0x45445556) /* "VUDE" little-endian */ +#define VIIPER_UDE_ABI_MAJOR UINT16_C(1) +#define VIIPER_UDE_ABI_MINOR UINT16_C(0) + +#define VIIPER_UDE_MAX_DEVICES UINT32_C(32) +#define VIIPER_UDE_MAX_DESCRIPTOR_BYTES UINT32_C(262144) +#define VIIPER_UDE_MAX_TRANSFER_BYTES UINT32_C(1048576) +#define VIIPER_UDE_MAX_ISO_PACKETS UINT32_C(1024) +#define VIIPER_UDE_MAX_PENDING_OPERATIONS UINT32_C(4096) + +#define VIIPER_UDE_CAP_ISOCHRONOUS UINT32_C(0x00000001) +#define VIIPER_UDE_CAP_STREAMS UINT32_C(0x00000002) +#define VIIPER_UDE_CAP_DEVICE_LIFECYCLE UINT32_C(0x00000004) + +#if defined(_WIN32) +#define VIIPER_UDE_IOCTL_BASE 0x900 +#define IOCTL_VIIPER_UDE_NEGOTIATE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 0, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_CREATE_DEVICE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 1, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_DESTROY_DEVICE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 2, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_DEQUEUE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 3, METHOD_OUT_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_COMPLETE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 4, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_QUERY_STATS CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 5, METHOD_BUFFERED, FILE_READ_DATA) +#endif + +#pragma pack(push, 1) + +typedef struct VIIPER_UDE_HEADER { + uint32_t Magic; + uint16_t Major; + uint16_t Minor; + uint32_t Size; + uint32_t Flags; +} VIIPER_UDE_HEADER; + +typedef struct VIIPER_UDE_NEGOTIATE_REQUEST { + VIIPER_UDE_HEADER Header; + uint64_t ClientNonce; + uint32_t RequestedCapabilities; + uint32_t Reserved; +} VIIPER_UDE_NEGOTIATE_REQUEST; + +typedef struct VIIPER_UDE_NEGOTIATE_RESPONSE { + VIIPER_UDE_HEADER Header; + uint64_t ClientNonce; + uint64_t DriverNonce; + uint32_t Capabilities; + uint32_t MaxDevices; + uint32_t MaxDescriptorBytes; + uint32_t MaxTransferBytes; + uint32_t MaxIsoPackets; + uint32_t MaxPendingOperations; +} VIIPER_UDE_NEGOTIATE_RESPONSE; + +typedef enum VIIPER_UDE_DESCRIPTOR_KIND { + ViiperUdeDescriptorDevice = 1, + ViiperUdeDescriptorConfiguration = 2, + ViiperUdeDescriptorBos = 3, + ViiperUdeDescriptorString = 4 +} VIIPER_UDE_DESCRIPTOR_KIND; + +typedef struct VIIPER_UDE_DESCRIPTOR_RECORD { + uint16_t Kind; + uint16_t Index; + uint16_t LanguageId; + uint16_t Reserved; + uint32_t Offset; + uint32_t Length; +} VIIPER_UDE_DESCRIPTOR_RECORD; + +typedef struct VIIPER_UDE_CREATE_DEVICE { + VIIPER_UDE_HEADER Header; + uint64_t DeviceId; + uint32_t Generation; + uint32_t Speed; + uint32_t DescriptorCount; + uint32_t DescriptorRecordsOffset; + uint32_t DescriptorDataOffset; + uint32_t DescriptorDataLength; + uint32_t MaxPendingOperations; + uint32_t Reserved; +} VIIPER_UDE_CREATE_DEVICE; + +typedef struct VIIPER_UDE_DEVICE_IDENTITY { + VIIPER_UDE_HEADER Header; + uint64_t DeviceId; + uint32_t Generation; + uint32_t Reserved; +} VIIPER_UDE_DEVICE_IDENTITY; + +typedef enum VIIPER_UDE_OPERATION_KIND { + ViiperUdeOperationControl = 1, + ViiperUdeOperationTransfer = 2, + ViiperUdeOperationEndpointStart = 3, + ViiperUdeOperationEndpointPurge = 4, + ViiperUdeOperationEndpointReset = 5, + ViiperUdeOperationDeviceReset = 6, + ViiperUdeOperationSetInterface = 7, + ViiperUdeOperationDeviceD0Entry = 8, + ViiperUdeOperationDeviceD0Exit = 9 +} VIIPER_UDE_OPERATION_KIND; + +typedef struct VIIPER_UDE_ISO_PACKET { + uint32_t Offset; + uint32_t Length; + int32_t Status; + uint32_t Reserved; +} VIIPER_UDE_ISO_PACKET; + +typedef struct VIIPER_UDE_OPERATION { + VIIPER_UDE_HEADER Header; + uint64_t Token; + uint64_t DeviceId; + uint32_t Generation; + uint32_t Kind; + uint8_t EndpointAddress; + uint8_t Direction; + uint16_t Reserved0; + uint32_t UrbFunction; + uint32_t TransferFlags; + uint32_t StartFrame; + uint32_t IsoPacketCount; + uint32_t TransferLength; + uint32_t PayloadOffset; + uint32_t PayloadLength; + uint32_t IsoPacketsOffset; + uint8_t SetupPacket[8]; + uint32_t Reserved1; +} VIIPER_UDE_OPERATION; + +typedef struct VIIPER_UDE_COMPLETION { + VIIPER_UDE_HEADER Header; + uint64_t Token; + uint64_t DeviceId; + uint32_t Generation; + int32_t Status; + uint32_t UsbdStatus; + uint32_t TransferLength; + uint32_t IsoPacketCount; + uint32_t PayloadOffset; + uint32_t PayloadLength; + uint32_t IsoPacketsOffset; + uint32_t Reserved; +} VIIPER_UDE_COMPLETION; + +typedef struct VIIPER_UDE_STATS { + VIIPER_UDE_HEADER Header; + uint64_t OperationsDequeued; + uint64_t OperationsCompleted; + uint64_t OperationsCancelled; + uint64_t OperationsPurged; + uint64_t LateCompletions; + uint64_t InvalidMessages; + uint64_t QueueExhaustions; + uint64_t IsoPackets; + uint64_t BytesToDevice; + uint64_t BytesFromDevice; + uint32_t ActiveDevices; + uint32_t PendingOperations; + uint32_t WaitingDequeues; + uint32_t Reserved; +} VIIPER_UDE_STATS; + +#pragma pack(pop) + +#if defined(__cplusplus) +static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); +static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); +static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); +static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); +static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); +static_assert(sizeof(VIIPER_UDE_OPERATION) == 88, "VIIPER_UDE_OPERATION ABI drift"); +static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 112, "VIIPER_UDE_STATS ABI drift"); +#endif + From e1f2d0ee27e41be6db83dc2d10ba8ea0afbae6b4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 18:50:19 -0500 Subject: [PATCH 002/240] native/ude: add installable controller and owner lifecycle Add the root-enumerated KMDF/UdeCx controller skeleton, admin-only device interface, exclusive handle-scoped broker ownership, negotiation and diagnostic IOCTLs, purge/restart cleanup, INF packaging, WDK NuGet build, and isolated CI gate. --- .github/workflows/native-ude.yml | 48 ++++++ native/udecx/ViiperUde.sln | 19 ++ native/udecx/driver/Controller.c | 210 +++++++++++++++++++++++ native/udecx/driver/Driver.c | 41 +++++ native/udecx/driver/Ioctl.c | 177 +++++++++++++++++++ native/udecx/driver/ViiperUde.h | 54 ++++++ native/udecx/driver/ViiperUde.vcxproj | 96 +++++++++++ native/udecx/include/ViiperUdeProtocol.h | 16 +- native/udecx/package/ViiperUde.inf | 44 +++++ native/udecx/packages.config | 7 + 10 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/native-ude.yml create mode 100644 native/udecx/ViiperUde.sln create mode 100644 native/udecx/driver/Controller.c create mode 100644 native/udecx/driver/Driver.c create mode 100644 native/udecx/driver/Ioctl.c create mode 100644 native/udecx/driver/ViiperUde.h create mode 100644 native/udecx/driver/ViiperUde.vcxproj create mode 100644 native/udecx/package/ViiperUde.inf create mode 100644 native/udecx/packages.config diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml new file mode 100644 index 00000000..d92f2e97 --- /dev/null +++ b/.github/workflows/native-ude.yml @@ -0,0 +1,48 @@ +name: Native UdeCx bus + +on: + push: + branches: [feature/native-udecx-bus] + paths: + - "native/udecx/**" + - "internal/transport/udecx/**" + - ".github/workflows/native-ude.yml" + pull_request: + paths: + - "native/udecx/**" + - "internal/transport/udecx/**" + - ".github/workflows/native-ude.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + protocol: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.5" + cache: true + - run: go test ./internal/transport/udecx + + driver: + runs-on: windows-2025-vs2026 + steps: + - uses: actions/checkout@v4 + - uses: microsoft/setup-msbuild@v2 + - uses: NuGet/setup-nuget@v2 + - name: Restore WDK packages + run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive + - name: Build x64 driver + run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" + - uses: actions/upload-artifact@v4 + with: + name: ViiperUde-x64-test-signed + path: | + native/udecx/driver/x64/Release/** + native/udecx/package/x64/Release/** + if-no-files-found: error + diff --git a/native/udecx/ViiperUde.sln b/native/udecx/ViiperUde.sln new file mode 100644 index 00000000..2e2cb92b --- /dev/null +++ b/native/udecx/ViiperUde.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "ViiperUde", "driver\ViiperUde.vcxproj", "{74754772-2AA1-4CE6-B251-0A3DD40A46E1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Debug|x64.ActiveCfg = Debug|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Debug|x64.Build.0 = Debug|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Release|x64.ActiveCfg = Release|x64 + {74754772-2AA1-4CE6-B251-0A3DD40A46E1}.Release|x64.Build.0 = Release|x64 + EndGlobalSection +EndGlobal + diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c new file mode 100644 index 00000000..ba68138c --- /dev/null +++ b/native/udecx/driver/Controller.c @@ -0,0 +1,210 @@ +#include +#include "ViiperUde.h" + +DEFINE_GUID( + GUID_DEVINTERFACE_VIIPER_UDE, + 0x32d03f48, 0x725b, 0x4baa, 0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87); + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperEvtDeviceAdd) +#pragma alloc_text(PAGE, ViiperEvtControllerCleanup) +#pragma alloc_text(PAGE, ViiperEvtFileCreate) +#pragma alloc_text(PAGE, ViiperEvtFileCleanup) +#pragma alloc_text(PAGE, ViiperCreateQueues) +#endif + +NTSTATUS +ViiperEvtQueryUsbCapability( + _In_ WDFDEVICE UdecxWdfDevice, + _In_ GUID *CapabilityType, + _In_ ULONG OutputBufferLength, + _Out_writes_to_opt_(OutputBufferLength, *ResultLength) PVOID OutputBuffer, + _Out_ PULONG ResultLength + ) +{ + UNREFERENCED_PARAMETER(UdecxWdfDevice); + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(OutputBuffer); + + *ResultLength = 0; + if (IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_CHAINED_MDLS) || + IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_SELECTIVE_SUSPEND) || + IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_DEVICE_CONNECTION_HIGH_SPEED_COMPATIBLE)) { + return STATUS_SUCCESS; + } + + return STATUS_NOT_SUPPORTED; +} + +NTSTATUS +ViiperEvtDeviceAdd( + _In_ WDFDRIVER Driver, + _Inout_ PWDFDEVICE_INIT DeviceInit + ) +{ + NTSTATUS status; + WDFDEVICE device; + WDF_OBJECT_ATTRIBUTES attributes; + WDF_OBJECT_ATTRIBUTES fileAttributes; + WDF_FILEOBJECT_CONFIG fileConfig; + UDECX_WDF_DEVICE_CONFIG udeConfig; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); + + PAGED_CODE(); + UNREFERENCED_PARAMETER(Driver); + + WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE); + status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_FILEOBJECT_CONFIG_INIT( + &fileConfig, + ViiperEvtFileCreate, + WDF_NO_EVENT_CALLBACK, + ViiperEvtFileCleanup); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&fileAttributes, VIIPER_UDE_FILE_CONTEXT); + WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, &fileAttributes); + + status = UdecxInitializeWdfDeviceInit(DeviceInit); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_CONTROLLER_CONTEXT); + attributes.EvtCleanupCallback = ViiperEvtControllerCleanup; + status = WdfDeviceCreate(&DeviceInit, &attributes, &device); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(device); + RtlZeroMemory(context, sizeof(*context)); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + status = WdfWaitLockCreate(&attributes, &context->OwnerLock); + if (!NT_SUCCESS(status)) { + return status; + } + + status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_VIIPER_UDE, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + UDECX_WDF_DEVICE_CONFIG_INIT(&udeConfig, ViiperEvtQueryUsbCapability); + udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; + udeConfig.NumberOfUsb30Ports = 0; + status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig); + if (!NT_SUCCESS(status)) { + return status; + } + + return ViiperCreateQueues(device); +} + +VOID +ViiperEvtControllerCleanup( + _In_ WDFOBJECT ControllerObject + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context; + + PAGED_CODE(); + context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + if (context->WaitingDequeues != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + } +} + +VOID +ViiperEvtFileCreate( + _In_ WDFDEVICE Device, + _In_ WDFREQUEST Request, + _In_ WDFFILEOBJECT FileObject + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + NTSTATUS status = STATUS_SUCCESS; + + PAGED_CODE(); + context = ViiperGetControllerContext(Device); + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { + status = STATUS_SHARING_VIOLATION; + } else { + fileContext = ViiperGetFileContext(FileObject); + RtlZeroMemory(fileContext, sizeof(*fileContext)); + context->OwnerFile = FileObject; + WdfIoQueueStart(context->WaitingDequeues); + } + WdfWaitLockRelease(context->OwnerLock); + WdfRequestComplete(Request, status); +} + +VOID +ViiperEvtFileCleanup( + _In_ WDFFILEOBJECT FileObject + ) +{ + WDFDEVICE device; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + BOOLEAN ownsController = FALSE; + + PAGED_CODE(); + device = WdfFileObjectGetDevice(FileObject); + context = ViiperGetControllerContext(device); + fileContext = ViiperGetFileContext(FileObject); + fileContext->Closing = TRUE; + + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile == FileObject) { + context->CleanupInProgress = TRUE; + ownsController = TRUE; + } + WdfWaitLockRelease(context->OwnerLock); + + if (ownsController && context->WaitingDequeues != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + } + + if (ownsController) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + context->OwnerFile = WDF_NO_HANDLE; + context->CleanupInProgress = FALSE; + WdfWaitLockRelease(context->OwnerLock); + } +} + +NTSTATUS +ViiperCreateQueues( + _In_ WDFDEVICE Device + ) +{ + NTSTATUS status; + WDF_IO_QUEUE_CONFIG queueConfig; + WDF_OBJECT_ATTRIBUTES attributes; + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + + PAGED_CODE(); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + attributes.ExecutionLevel = WdfExecutionLevelPassive; + + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchSequential); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl; + status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->DefaultQueue); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual); + queueConfig.PowerManaged = WdfFalse; + return WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->WaitingDequeues); +} diff --git a/native/udecx/driver/Driver.c b/native/udecx/driver/Driver.c new file mode 100644 index 00000000..2082de7b --- /dev/null +++ b/native/udecx/driver/Driver.c @@ -0,0 +1,41 @@ +#include "ViiperUde.h" + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(INIT, DriverEntry) +#pragma alloc_text(PAGE, ViiperEvtDeviceAdd) +#pragma alloc_text(PAGE, ViiperEvtDriverCleanup) +#endif + +NTSTATUS +DriverEntry( + _In_ PDRIVER_OBJECT DriverObject, + _In_ PUNICODE_STRING RegistryPath + ) +{ + WDF_DRIVER_CONFIG config; + WDF_OBJECT_ATTRIBUTES attributes; + + ExInitializeDriverRuntime(DrvRtPoolNxOptIn); + WDF_DRIVER_CONFIG_INIT(&config, ViiperEvtDeviceAdd); + config.DriverPoolTag = 'eUiV'; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.EvtCleanupCallback = ViiperEvtDriverCleanup; + + return WdfDriverCreate( + DriverObject, + RegistryPath, + &attributes, + &config, + WDF_NO_HANDLE); +} + +VOID +ViiperEvtDriverCleanup( + _In_ WDFOBJECT DriverObject + ) +{ + PAGED_CODE(); + UNREFERENCED_PARAMETER(DriverObject); +} + diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c new file mode 100644 index 00000000..75d4f80a --- /dev/null +++ b/native/udecx/driver/Ioctl.c @@ -0,0 +1,177 @@ +#include "ViiperUde.h" + +static +BOOLEAN +ViiperValidateHeader( + _In_ const VIIPER_UDE_HEADER *Header, + _In_ size_t BufferLength, + _In_ size_t ExpectedSize + ) +{ + return BufferLength >= ExpectedSize && + Header->Magic == VIIPER_UDE_MAGIC && + Header->Major == VIIPER_UDE_ABI_MAJOR && + Header->Size == ExpectedSize; +} + +static +LONG64 +ViiperReadCounter( + _In_ volatile LONG64 *Counter + ) +{ + return InterlockedCompareExchange64(Counter, 0, 0); +} + +static +NTSTATUS +ViiperHandleNegotiate( + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_NEGOTIATE_REQUEST *input; + VIIPER_UDE_NEGOTIATE_RESPONSE *output; + size_t inputLength; + size_t outputLength; + WDFFILEOBJECT fileObject; + VIIPER_UDE_FILE_CONTEXT *fileContext; + LARGE_INTEGER ticks; + + status = WdfRequestRetrieveInputBuffer( + Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, &outputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (!ViiperValidateHeader(&input->Header, inputLength, sizeof(*input)) || + input->ClientNonce == 0) { + return STATUS_INVALID_PARAMETER; + } + + fileObject = WdfRequestGetFileObject(Request); + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (fileContext->Closing) { + return STATUS_FILE_CLOSED; + } + if (fileContext->Negotiated && fileContext->ClientNonce != input->ClientNonce) { + return STATUS_INVALID_DEVICE_STATE; + } + + if (!fileContext->Negotiated) { + ticks = KeQueryPerformanceCounter(NULL); + fileContext->ClientNonce = input->ClientNonce; + fileContext->DriverNonce = ((uint64_t)ticks.QuadPart) ^ + ((uint64_t)(ULONG_PTR)fileObject << 13) ^ input->ClientNonce; + if (fileContext->DriverNonce == 0) { + fileContext->DriverNonce = 1; + } + fileContext->Negotiated = TRUE; + } + + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->ClientNonce = fileContext->ClientNonce; + output->DriverNonce = fileContext->DriverNonce; + output->Capabilities = VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE; + output->MaxDevices = VIIPER_UDE_MAX_DEVICES; + output->MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; + output->MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; + output->MaxIsoPackets = VIIPER_UDE_MAX_ISO_PACKETS; + output->MaxPendingOperations = VIIPER_UDE_MAX_PENDING_OPERATIONS; + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperHandleQueryStats( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_STATS *output; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (!fileContext->Negotiated || fileContext->Closing) { + return STATUS_INVALID_DEVICE_STATE; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->OperationsDequeued = (uint64_t)ViiperReadCounter(&context->OperationsDequeued); + output->OperationsCompleted = (uint64_t)ViiperReadCounter(&context->OperationsCompleted); + output->OperationsCancelled = (uint64_t)ViiperReadCounter(&context->OperationsCancelled); + output->OperationsPurged = (uint64_t)ViiperReadCounter(&context->OperationsPurged); + output->LateCompletions = (uint64_t)ViiperReadCounter(&context->LateCompletions); + output->InvalidMessages = (uint64_t)ViiperReadCounter(&context->InvalidMessages); + output->QueueExhaustions = (uint64_t)ViiperReadCounter(&context->QueueExhaustions); + output->IsoPackets = (uint64_t)ViiperReadCounter(&context->IsoPackets); + output->BytesToDevice = (uint64_t)ViiperReadCounter(&context->BytesToDevice); + output->BytesFromDevice = (uint64_t)ViiperReadCounter(&context->BytesFromDevice); + output->ActiveDevices = (uint32_t)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); + output->PendingOperations = (uint32_t)InterlockedCompareExchange(&context->PendingOperations, 0, 0); + output->WaitingDequeues = (uint32_t)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + switch (IoControlCode) { + case IOCTL_VIIPER_UDE_NEGOTIATE: + status = ViiperHandleNegotiate(Request); + break; + case IOCTL_VIIPER_UDE_QUERY_STATS: + status = ViiperHandleQueryStats(Queue, Request); + break; + default: + status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) + ? STATUS_PENDING + : STATUS_INVALID_DEVICE_REQUEST; + break; + } + + if (status != STATUS_PENDING) { + WdfRequestComplete(Request, status); + } +} + diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h new file mode 100644 index 00000000..dd2a3c69 --- /dev/null +++ b/native/udecx/driver/ViiperUde.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include "..\include\ViiperUdeProtocol.h" + +EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; + +typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { + WDFWAITLOCK OwnerLock; + WDFFILEOBJECT OwnerFile; + WDFQUEUE DefaultQueue; + WDFQUEUE WaitingDequeues; + BOOLEAN CleanupInProgress; + volatile LONG ActiveDevices; + volatile LONG PendingOperations; + volatile LONG WaitingDequeueCount; + volatile LONG64 OperationsDequeued; + volatile LONG64 OperationsCompleted; + volatile LONG64 OperationsCancelled; + volatile LONG64 OperationsPurged; + volatile LONG64 LateCompletions; + volatile LONG64 InvalidMessages; + volatile LONG64 QueueExhaustions; + volatile LONG64 IsoPackets; + volatile LONG64 BytesToDevice; + volatile LONG64 BytesFromDevice; +} VIIPER_UDE_CONTROLLER_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) + +typedef struct VIIPER_UDE_FILE_CONTEXT { + BOOLEAN Negotiated; + BOOLEAN Closing; + uint64_t ClientNonce; + uint64_t DriverNonce; +} VIIPER_UDE_FILE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext) + +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_DEVICE_ADD ViiperEvtDeviceAdd; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtDriverCleanup; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtControllerCleanup; +EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; +EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; +EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; + +NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); + diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj new file mode 100644 index 00000000..245c2c0e --- /dev/null +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -0,0 +1,96 @@ + + + + + + + Debugx64 + Releasex64 + + + {74754772-2AA1-4CE6-B251-0A3DD40A46E1} + ViiperUde + ViiperUde + 17.0 + x64 + + + + Driver + KMDF + Universal + WindowsKernelModeDriver10.0 + true + Windows10 + true + 1 + 1 + 1 + 33 + Spectre + + + Driver + KMDF + Universal + WindowsKernelModeDriver10.0 + false + Windows10 + true + 1 + 1 + 1 + 33 + Spectre + + + + + + + + + Level4 + true + stdc17 + ..\include;%(AdditionalIncludeDirectories) + _KERNEL_MODE;POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) + true + + + %(AdditionalDependencies);usbd.lib + /PDBALTPATH:%_PDB% %(AdditionalOptions) + + + certHash + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 952549ea..48d81be8 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -2,7 +2,9 @@ #include -#if defined(_WIN32) +#if defined(_KERNEL_MODE) +#include +#elif defined(_WIN32) #include #endif @@ -181,5 +183,15 @@ static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI dr static_assert(sizeof(VIIPER_UDE_OPERATION) == 88, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 112, "VIIPER_UDE_STATS ABI drift"); +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +_Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 88, "VIIPER_UDE_OPERATION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_STATS) == 112, "VIIPER_UDE_STATS ABI drift"); #endif - diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf new file mode 100644 index 00000000..4bfecfbc --- /dev/null +++ b/native/udecx/package/ViiperUde.inf @@ -0,0 +1,44 @@ +[Version] +Signature="$WINDOWS NT$" +Class=USB +ClassGuid={36FC9E60-C465-11CF-8056-444553540000} +Provider=%ProviderName% +CatalogFile=ViiperUde.cat +DriverVer=08/09/2026,0.1.0.0 +PnpLockDown=1 + +[DestinationDirs] +DefaultDestDir=13 + +[SourceDisksNames] +1=%DiskName% + +[SourceDisksFiles] +ViiperUde.sys=1 + +[Manufacturer] +%ProviderName%=Standard,NTamd64.10.0...17763 + +[Standard.NTamd64.10.0...17763] +%DeviceName%=ViiperUde_Install,ROOT\VIIPER\UDE + +[ViiperUde_Install.NT] +CopyFiles=@ViiperUde.sys + +[ViiperUde_Install.NT.Services] +AddService=ViiperUde,0x00000002,ViiperUde_Service + +[ViiperUde_Service] +DisplayName=%ServiceName% +ServiceType=1 +StartType=3 +ErrorControl=1 +ServiceBinary=%13%\ViiperUde.sys +Dependencies=ucx01000,udecx + +[Strings] +ProviderName="VIIPER Project" +DeviceName="VIIPER Native USB Emulation Controller" +ServiceName="VIIPER Native UdeCx Bus" +DiskName="VIIPER Native UdeCx Installation Media" + diff --git a/native/udecx/packages.config b/native/udecx/packages.config new file mode 100644 index 00000000..c18d5965 --- /dev/null +++ b/native/udecx/packages.config @@ -0,0 +1,7 @@ + + + + + + + From 733eaf79ba6dc336f594d1d18b039b39fcc73ce5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 18:53:16 -0500 Subject: [PATCH 003/240] native/ude: add exclusive Windows broker handshake Discover the controller by device-interface GUID, open it exclusively with overlapped I/O, negotiate and validate session nonces and limits, make close/cancel handle-safe, lock IOCTL lifetimes, test MULTI_SZ parsing and IOCTL parity, and correct WDK package restore discovery. --- internal/transport/udecx/client_windows.go | 264 ++++++++++++++++++ .../transport/udecx/client_windows_test.go | 29 ++ native/udecx/driver/ViiperUde.vcxproj | 3 +- native/udecx/{ => driver}/packages.config | 0 4 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 internal/transport/udecx/client_windows.go create mode 100644 internal/transport/udecx/client_windows_test.go rename native/udecx/{ => driver}/packages.config (100%) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go new file mode 100644 index 00000000..fbf5773f --- /dev/null +++ b/internal/transport/udecx/client_windows.go @@ -0,0 +1,264 @@ +//go:build windows + +package udecx + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "runtime" + "sync" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + crSuccess = 0 + crBufferSmall = 0x1a + cmGetDeviceInterfaceListPresent = 0 + fileDeviceUnknown = 0x22 + methodBuffered = 0 + methodInDirect = 1 + methodOutDirect = 2 + fileReadData = 1 + fileWriteData = 2 + ioctlBase = 0x900 + ioctlNegotiate = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 0) << 2) | methodBuffered + ioctlCreateDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 1) << 2) | methodBuffered + ioctlDestroyDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 2) << 2) | methodBuffered + ioctlDequeueOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 3) << 2) | methodOutDirect + ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect + ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered +) + +var ( + interfaceGUID = windows.GUID{ + Data1: 0x32d03f48, + Data2: 0x725b, + Data3: 0x4baa, + Data4: [8]byte{0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}, + } + cfgmgr32 = windows.NewLazySystemDLL("cfgmgr32.dll") + procCMGetDeviceInterfaceListSize = cfgmgr32.NewProc("CM_Get_Device_Interface_List_SizeW") + procCMGetDeviceInterfaceList = cfgmgr32.NewProc("CM_Get_Device_Interface_ListW") +) + +type Client struct { + mu sync.RWMutex + handle windows.Handle + driverNonce uint64 + capabilities Capabilities + limits NegotiateResponse +} + +func Open(ctx context.Context) (*Client, error) { + paths, err := discoverInterfacePaths() + if err != nil { + return nil, err + } + if len(paths) == 0 { + return nil, errors.New("VIIPER native UDE interface is not present") + } + if len(paths) != 1 { + return nil, fmt.Errorf("refusing ambiguous native UDE ownership: found %d controller interfaces", len(paths)) + } + + path, err := windows.UTF16PtrFromString(paths[0]) + if err != nil { + return nil, fmt.Errorf("encode native UDE interface path: %w", err) + } + handle, err := windows.CreateFile( + path, + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, + 0) + if err != nil { + return nil, fmt.Errorf("open native UDE controller: %w", err) + } + + client := &Client{handle: handle} + if err = client.negotiate(ctx); err != nil { + _ = windows.CloseHandle(handle) + return nil, err + } + return client, nil +} + +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.handle == 0 || c.handle == windows.InvalidHandle { + return nil + } + handle := c.handle + c.handle = windows.InvalidHandle + _ = windows.CancelIoEx(handle, nil) + return windows.CloseHandle(handle) +} + +func (c *Client) Capabilities() Capabilities { + c.mu.RLock() + defer c.mu.RUnlock() + return c.capabilities +} + +func (c *Client) Limits() NegotiateResponse { + c.mu.RLock() + defer c.mu.RUnlock() + return c.limits +} + +func (c *Client) negotiate(ctx context.Context) error { + var nonceBytes [8]byte + if _, err := rand.Read(nonceBytes[:]); err != nil { + return fmt.Errorf("create native UDE session nonce: %w", err) + } + nonce := binary.LittleEndian.Uint64(nonceBytes[:]) + if nonce == 0 { + nonce = 1 + } + request, err := (NegotiateRequest{ + ClientNonce: nonce, + RequestedCapabilities: CapabilityIsochronous | CapabilityDeviceLifecycle, + }).MarshalBinary() + if err != nil { + return err + } + response := make([]byte, NegotiateResponseSize) + written, err := c.ioctl(ctx, ioctlNegotiate, request, response) + if err != nil { + return fmt.Errorf("negotiate native UDE ABI: %w", err) + } + if written != NegotiateResponseSize { + return fmt.Errorf("negotiate native UDE ABI: response bytes=%d want=%d", written, NegotiateResponseSize) + } + negotiated, err := ParseNegotiateResponse(response) + if err != nil { + return fmt.Errorf("validate native UDE negotiation: %w", err) + } + if negotiated.ClientNonce != nonce || negotiated.DriverNonce == 0 { + return errors.New("validate native UDE negotiation: session nonce mismatch") + } + if negotiated.MaxDevices == 0 || negotiated.MaxDescriptorBytes == 0 || + negotiated.MaxTransferBytes == 0 || negotiated.MaxIsoPackets == 0 || + negotiated.MaxPendingOperations == 0 { + return errors.New("validate native UDE negotiation: driver returned a zero limit") + } + c.driverNonce = negotiated.DriverNonce + c.capabilities = negotiated.Capabilities + c.limits = negotiated + return nil +} + +func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) (uint32, error) { + c.mu.RLock() + defer c.mu.RUnlock() + handle := c.handle + if handle == 0 || handle == windows.InvalidHandle { + return 0, windows.ERROR_INVALID_HANDLE + } + + event, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + return 0, err + } + defer windows.CloseHandle(event) + overlapped := windows.Overlapped{HEvent: event} + var inputPointer *byte + var outputPointer *byte + if len(input) != 0 { + inputPointer = &input[0] + } + if len(output) != 0 { + outputPointer = &output[0] + } + var immediate uint32 + err = windows.DeviceIoControl( + handle, code, + inputPointer, uint32(len(input)), + outputPointer, uint32(len(output)), + &immediate, &overlapped) + if err == nil { + var transferred uint32 + err = windows.GetOverlappedResult(handle, &overlapped, &transferred, false) + runtime.KeepAlive(input) + runtime.KeepAlive(output) + return transferred, err + } + if !errors.Is(err, windows.ERROR_IO_PENDING) { + return 0, err + } + + done := make(chan struct{}) + var transferred uint32 + var resultErr error + go func() { + resultErr = windows.GetOverlappedResult(handle, &overlapped, &transferred, true) + close(done) + }() + select { + case <-ctx.Done(): + _ = windows.CancelIoEx(handle, &overlapped) + <-done + return 0, ctx.Err() + case <-done: + runtime.KeepAlive(input) + runtime.KeepAlive(output) + return transferred, resultErr + } +} + +func discoverInterfacePaths() ([]string, error) { + for attempt := 0; attempt < 4; attempt++ { + var required uint32 + ret, _, _ := procCMGetDeviceInterfaceListSize.Call( + uintptr(unsafe.Pointer(&required)), + uintptr(unsafe.Pointer(&interfaceGUID)), + 0, + cmGetDeviceInterfaceListPresent) + if uint32(ret) != crSuccess { + return nil, fmt.Errorf("CM_Get_Device_Interface_List_SizeW returned CONFIGRET %#x", uint32(ret)) + } + if required <= 1 { + return nil, nil + } + buffer := make([]uint16, required) + ret, _, _ = procCMGetDeviceInterfaceList.Call( + uintptr(unsafe.Pointer(&interfaceGUID)), + 0, + uintptr(unsafe.Pointer(&buffer[0])), + uintptr(required), + cmGetDeviceInterfaceListPresent) + if uint32(ret) == crBufferSmall { + continue + } + if uint32(ret) != crSuccess { + return nil, fmt.Errorf("CM_Get_Device_Interface_ListW returned CONFIGRET %#x", uint32(ret)) + } + return parseMultiSZ(buffer), nil + } + return nil, errors.New("native UDE interface list changed repeatedly during discovery") +} + +func parseMultiSZ(raw []uint16) []string { + result := make([]string, 0, 1) + start := 0 + for i, value := range raw { + if value != 0 { + continue + } + if i == start { + break + } + result = append(result, string(utf16.Decode(raw[start:i]))) + start = i + 1 + } + return result +} diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go new file mode 100644 index 00000000..6586925e --- /dev/null +++ b/internal/transport/udecx/client_windows_test.go @@ -0,0 +1,29 @@ +//go:build windows + +package udecx + +import "testing" + +func TestIOCTLCodesMatchPackedHeader(t *testing.T) { + wants := map[string]struct{ got, want uint32 }{ + "negotiate": {ioctlNegotiate, 0x22e400}, + "create": {ioctlCreateDevice, 0x22e404}, + "destroy": {ioctlDestroyDevice, 0x22e408}, + "dequeue": {ioctlDequeueOperation, 0x22e40e}, + "complete": {ioctlCompleteOperation, 0x22e411}, + "stats": {ioctlQueryStats, 0x226414}, + } + for name, pair := range wants { + if pair.got != pair.want { + t.Errorf("%s IOCTL=%#x want=%#x", name, pair.got, pair.want) + } + } +} + +func TestParseMultiSZ(t *testing.T) { + raw := []uint16{'a', 'b', 0, 'c', 0, 0, 'x'} + got := parseMultiSZ(raw) + if len(got) != 2 || got[0] != "ab" || got[1] != "c" { + t.Fatalf("parseMultiSZ=%q", got) + } +} diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 245c2c0e..ab988220 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -78,7 +78,7 @@ - + @@ -93,4 +93,3 @@ - diff --git a/native/udecx/packages.config b/native/udecx/driver/packages.config similarity index 100% rename from native/udecx/packages.config rename to native/udecx/driver/packages.config From a782386465b685b8d500861cfacff0739391b6bf Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 18:55:22 -0500 Subject: [PATCH 004/240] ci: expose restored WDK build tools Add all executable directories from the verified NuGet WDK payload to the subsequent build step and fail explicitly if StampInf is absent. --- .github/workflows/native-ude.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index d92f2e97..08b72f47 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -36,6 +36,13 @@ jobs: - uses: NuGet/setup-nuget@v2 - name: Restore WDK packages run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive + - name: Expose WDK tools + shell: pwsh + run: | + $tools = Get-ChildItem native/udecx/packages -Recurse -File -Filter *.exe + $stampInf = $tools | Where-Object Name -ieq stampinf.exe | Select-Object -First 1 + if (-not $stampInf) { throw "Restored WDK package did not contain stampinf.exe" } + $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Build x64 driver run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" - uses: actions/upload-artifact@v4 @@ -45,4 +52,3 @@ jobs: native/udecx/driver/x64/Release/** native/udecx/package/x64/Release/** if-no-files-found: error - From 58e0e5af066189d4fe2927e60c34cfb449860ff3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:01:42 -0500 Subject: [PATCH 005/240] native/ude: create broker-owned dynamic USB devices Add explicit per-session UdeCx device and endpoint lifecycle, strict descriptor validation, owner teardown, dynamic endpoint queues, SuperSpeed power callbacks, and queue purge ordering. Correct the WDK/UdeCx header contract and use a 64-bit MSBuild host so InfVerif loads the matching native DLL. The endpoint URB path remains deliberately unsupported until the versioned broker completion path is connected. --- .github/workflows/native-ude.yml | 3 + native/udecx/driver/Controller.c | 22 +- native/udecx/driver/Device.c | 626 ++++++++++++++++++++++++++ native/udecx/driver/Ioctl.c | 7 +- native/udecx/driver/ViiperUde.h | 44 +- native/udecx/driver/ViiperUde.vcxproj | 3 +- 6 files changed, 698 insertions(+), 7 deletions(-) create mode 100644 native/udecx/driver/Device.c diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 08b72f47..cf3d9652 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -33,6 +33,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: microsoft/setup-msbuild@v2 + with: + msbuild-architecture: x64 - uses: NuGet/setup-nuget@v2 - name: Restore WDK packages run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive @@ -49,6 +51,7 @@ jobs: with: name: ViiperUde-x64-test-signed path: | + native/udecx/x64/Release/** native/udecx/driver/x64/Release/** native/udecx/package/x64/Release/** if-no-files-found: error diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index ba68138c..8bb7a1ef 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -89,6 +89,10 @@ ViiperEvtDeviceAdd( if (!NT_SUCCESS(status)) { return status; } + status = WdfWaitLockCreate(&attributes, &context->DeviceLock); + if (!NT_SUCCESS(status)) { + return status; + } status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_VIIPER_UDE, NULL); if (!NT_SUCCESS(status)) { @@ -97,7 +101,7 @@ ViiperEvtDeviceAdd( UDECX_WDF_DEVICE_CONFIG_INIT(&udeConfig, ViiperEvtQueryUsbCapability); udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; - udeConfig.NumberOfUsb30Ports = 0; + udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig); if (!NT_SUCCESS(status)) { return status; @@ -115,6 +119,9 @@ ViiperEvtControllerCleanup( PAGED_CODE(); context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + if (context->DefaultQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->DefaultQueue); + } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); } @@ -140,6 +147,7 @@ ViiperEvtFileCreate( fileContext = ViiperGetFileContext(FileObject); RtlZeroMemory(fileContext, sizeof(*fileContext)); context->OwnerFile = FileObject; + WdfIoQueueStart(context->DefaultQueue); WdfIoQueueStart(context->WaitingDequeues); } WdfWaitLockRelease(context->OwnerLock); @@ -169,8 +177,16 @@ ViiperEvtFileCleanup( } WdfWaitLockRelease(context->OwnerLock); - if (ownsController && context->WaitingDequeues != WDF_NO_HANDLE) { - WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + if (ownsController) { + if (context->DefaultQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->DefaultQueue); + } + if (context->WaitingDequeues != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + } + } + if (ownsController) { + ViiperDestroyOwnedDevices(device, FileObject); } if (ownsController) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c new file mode 100644 index 00000000..a4ae771c --- /dev/null +++ b/native/udecx/driver/Device.c @@ -0,0 +1,626 @@ +/* + * Dynamic UdeCx device and endpoint lifecycle. + * + * The endpoint creation and purge order follows the documented UdeCx contract + * and the permissively licensed usbip-win2 implementation identified in + * THIRD_PARTY_NOTICES.md. VIIPER-specific ownership, identity, and broker + * semantics are implemented here. + */ + +#include "ViiperUde.h" + +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperCreateVirtualDevice) +#pragma alloc_text(PAGE, ViiperDestroyVirtualDevice) +#pragma alloc_text(PAGE, ViiperDestroyOwnedDevices) +#pragma alloc_text(PAGE, ViiperEvtEndpointAdd) +#pragma alloc_text(PAGE, ViiperEvtDefaultEndpointAdd) +#pragma alloc_text(PAGE, ViiperEvtVirtualDeviceCleanup) +#endif + +static +BOOLEAN +ViiperRangeValid( + _In_ uint32_t Offset, + _In_ uint32_t Length, + _In_ uint32_t Total + ) +{ + return Offset <= Total && Length <= Total - Offset; +} + +static +BOOLEAN +ViiperValidateCreateDevice( + _In_reads_bytes_(InputLength) const VIIPER_UDE_CREATE_DEVICE *Input, + _In_ size_t InputLength + ) +{ + const VIIPER_UDE_DESCRIPTOR_RECORD *records; + uint32_t recordsLength; + uint32_t index; + BOOLEAN foundDevice = FALSE; + BOOLEAN foundConfiguration = FALSE; + + if (InputLength < sizeof(*Input) || + InputLength > (size_t)VIIPER_UDE_MAX_DESCRIPTOR_BYTES * 2 + sizeof(*Input) || + Input->Header.Magic != VIIPER_UDE_MAGIC || + Input->Header.Major != VIIPER_UDE_ABI_MAJOR || + Input->Header.Size != InputLength || + Input->DeviceId == 0 || Input->Generation == 0 || + Input->DescriptorCount == 0 || + Input->DescriptorCount > VIIPER_UDE_MAX_DESCRIPTOR_BYTES / sizeof(*records) || + Input->DescriptorDataLength == 0 || + Input->DescriptorDataLength > VIIPER_UDE_MAX_DESCRIPTOR_BYTES || + Input->MaxPendingOperations == 0 || + Input->MaxPendingOperations > VIIPER_UDE_MAX_PENDING_OPERATIONS) { + return FALSE; + } + + if (Input->DescriptorCount > UINT32_MAX / sizeof(*records)) { + return FALSE; + } + recordsLength = Input->DescriptorCount * sizeof(*records); + if (!ViiperRangeValid(Input->DescriptorRecordsOffset, recordsLength, Input->Header.Size) || + !ViiperRangeValid(Input->DescriptorDataOffset, Input->DescriptorDataLength, Input->Header.Size)) { + return FALSE; + } + + records = (const VIIPER_UDE_DESCRIPTOR_RECORD *) + ((const UCHAR *)Input + Input->DescriptorRecordsOffset); + for (index = 0; index < Input->DescriptorCount; ++index) { + const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; + if (!ViiperRangeValid(record->Offset, record->Length, Input->DescriptorDataLength)) { + return FALSE; + } + if (record->Kind == ViiperUdeDescriptorDevice && record->Length >= sizeof(USB_DEVICE_DESCRIPTOR)) { + foundDevice = TRUE; + } + if (record->Kind == ViiperUdeDescriptorConfiguration && record->Length >= sizeof(USB_CONFIGURATION_DESCRIPTOR)) { + foundConfiguration = TRUE; + } + } + + return foundDevice && foundConfiguration; +} + +static +NTSTATUS +ViiperValidateOwner( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _Out_ WDFFILEOBJECT *OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + !fileContext->Negotiated || fileContext->Closing) { + status = STATUS_INVALID_DEVICE_STATE; + } + WdfWaitLockRelease(controllerContext->OwnerLock); + if (NT_SUCCESS(status)) { + *OwnerFile = fileObject; + } + return status; +} + +static +UDECX_USB_DEVICE_SPEED +ViiperMapSpeed( + _In_ uint32_t Speed + ) +{ + switch (Speed) { + case 1: + return UdecxUsbLowSpeed; + case 2: + return UdecxUsbFullSpeed; + case 3: + return UdecxUsbHighSpeed; + case 4: + return UdecxUsbSuperSpeed; + default: + return (UDECX_USB_DEVICE_SPEED)0; + } +} + +static +NTSTATUS +ViiperClaimDeviceSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device, + _In_ uint64_t DeviceId, + _Out_ uint32_t *Slot + ) +{ + uint32_t index; + uint32_t freeSlot = VIIPER_UDE_MAX_DEVICES; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE current = ControllerContext->Devices[index]; + if (current == WDF_NO_HANDLE) { + if (freeSlot == VIIPER_UDE_MAX_DEVICES) { + freeSlot = index; + } + continue; + } + if (ViiperGetDeviceContext(current)->DeviceId == DeviceId) { + status = STATUS_OBJECT_NAME_COLLISION; + goto Exit; + } + } + if (freeSlot != VIIPER_UDE_MAX_DEVICES) { + ControllerContext->Devices[freeSlot] = Device; + *Slot = freeSlot; + status = STATUS_SUCCESS; + } + +Exit: + WdfWaitLockRelease(ControllerContext->DeviceLock); + return status; +} + +static +VOID +ViiperReleaseDeviceSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device, + _In_ uint32_t Slot + ) +{ + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + if (Slot < VIIPER_UDE_MAX_DEVICES && ControllerContext->Devices[Slot] == Device) { + ControllerContext->Devices[Slot] = WDF_NO_HANDLE; + } + WdfWaitLockRelease(ControllerContext->DeviceLock); +} + +NTSTATUS +ViiperCreateVirtualDevice( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_CREATE_DEVICE *input; + size_t inputLength; + WDFFILEOBJECT ownerFile; + _UDECXUSBDEVICE_INIT *deviceInit; + UDECX_USB_DEVICE_STATE_CHANGE_CALLBACKS callbacks; + UDECX_USB_DEVICE_SPEED speed; + WDF_OBJECT_ATTRIBUTES attributes; + UDECXUSBDEVICE device = WDF_NO_HANDLE; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + UDECX_USB_DEVICE_PLUG_IN_OPTIONS plugOptions; + uint32_t slot; + + PAGED_CODE(); + status = ViiperValidateOwner(controller, Request, &ownerFile); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (!ViiperValidateCreateDevice(input, inputLength)) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + speed = ViiperMapSpeed(input->Speed); + if (speed == (UDECX_USB_DEVICE_SPEED)0) { + return STATUS_NOT_SUPPORTED; + } + + deviceInit = UdecxUsbDeviceInitAllocate(controller); + if (deviceInit == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + UDECX_USB_DEVICE_CALLBACKS_INIT(&callbacks); + callbacks.EvtUsbDeviceLinkPowerEntry = ViiperEvtUsbDeviceD0Entry; + callbacks.EvtUsbDeviceLinkPowerExit = ViiperEvtUsbDeviceD0Exit; + if (speed == UdecxUsbSuperSpeed) { + callbacks.EvtUsbDeviceSetFunctionSuspendAndWake = + ViiperEvtUsbDeviceSetFunctionSuspendAndWake; + } + callbacks.EvtUsbDeviceDefaultEndpointAdd = ViiperEvtDefaultEndpointAdd; + callbacks.EvtUsbDeviceEndpointAdd = ViiperEvtEndpointAdd; + callbacks.EvtUsbDeviceEndpointsConfigure = ViiperEvtEndpointsConfigure; + UdecxUsbDeviceInitSetStateChangeCallbacks(deviceInit, &callbacks); + UdecxUsbDeviceInitSetSpeed(deviceInit, speed); + UdecxUsbDeviceInitSetEndpointsType(deviceInit, UdecxEndpointTypeDynamic); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); + attributes.ParentObject = controller; + attributes.EvtCleanupCallback = ViiperEvtVirtualDeviceCleanup; + status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); + if (!NT_SUCCESS(status)) { + UdecxUsbDeviceInitFree(deviceInit); + return status; + } + + deviceContext = ViiperGetDeviceContext(device); + RtlZeroMemory(deviceContext, sizeof(*deviceContext)); + deviceContext->Controller = controller; + deviceContext->OwnerFile = ownerFile; + deviceContext->DeviceId = input->DeviceId; + deviceContext->Generation = input->Generation; + deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; + + status = ViiperClaimDeviceSlot(controllerContext, device, input->DeviceId, &slot); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(device); + return status; + } + deviceContext->Slot = slot; + + UDECX_USB_DEVICE_PLUG_IN_OPTIONS_INIT(&plugOptions); + if (speed == UdecxUsbSuperSpeed) { + plugOptions.Usb30PortNumber = slot + 1; + } else { + plugOptions.Usb20PortNumber = slot + 1; + } + status = UdecxUsbDevicePlugIn(device, &plugOptions); + if (!NT_SUCCESS(status)) { + ViiperReleaseDeviceSlot(controllerContext, device, slot); + WdfObjectDelete(device); + return status; + } + + deviceContext->Plugged = TRUE; + InterlockedIncrement(&controllerContext->ActiveDevices); + WdfRequestSetInformation(Request, 0); + return STATUS_SUCCESS; +} + +static +UDECXUSBDEVICE +ViiperTakeDevice( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFFILEOBJECT OwnerFile, + _In_ uint64_t DeviceId, + _In_ uint32_t Generation, + _In_ BOOLEAN MatchGeneration + ) +{ + UDECXUSBDEVICE found = WDF_NO_HANDLE; + uint32_t index; + + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE current = ControllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + if (current == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(current); + if (deviceContext->OwnerFile != OwnerFile || deviceContext->DeviceId != DeviceId || + (MatchGeneration && deviceContext->Generation != Generation)) { + continue; + } + deviceContext->Purging = TRUE; + ControllerContext->Devices[index] = WDF_NO_HANDLE; + found = current; + break; + } + WdfWaitLockRelease(ControllerContext->DeviceLock); + return found; +} + +NTSTATUS +ViiperDestroyVirtualDevice( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_DEVICE_IDENTITY *input; + size_t inputLength; + WDFFILEOBJECT ownerFile; + UDECXUSBDEVICE device; + + PAGED_CODE(); + status = ViiperValidateOwner(controller, Request, &ownerFile); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (inputLength < sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Major != VIIPER_UDE_ABI_MAJOR || input->Header.Size != sizeof(*input) || + input->DeviceId == 0 || input->Generation == 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + + device = ViiperTakeDevice( + controllerContext, ownerFile, input->DeviceId, input->Generation, TRUE); + if (device == WDF_NO_HANDLE) { + return STATUS_NOT_FOUND; + } + status = UdecxUsbDevicePlugOutAndDelete(device); + if (NT_SUCCESS(status)) { + InterlockedDecrement(&controllerContext->ActiveDevices); + } + return status; +} + +VOID +ViiperDestroyOwnedDevices( + _In_ WDFDEVICE Controller, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + PAGED_CODE(); + for (;;) { + UDECXUSBDEVICE device; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + uint64_t deviceId = 0; + uint32_t index; + + WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + device = controllerContext->Devices[index]; + if (device != WDF_NO_HANDLE && ViiperGetDeviceContext(device)->OwnerFile == OwnerFile) { + deviceId = ViiperGetDeviceContext(device)->DeviceId; + break; + } + } + WdfWaitLockRelease(controllerContext->DeviceLock); + if (deviceId == 0) { + break; + } + + device = ViiperTakeDevice(controllerContext, OwnerFile, deviceId, 0, FALSE); + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->Plugged) { + if (NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { + InterlockedDecrement(&controllerContext->ActiveDevices); + } + } else { + WdfObjectDelete(device); + } + } +} + +VOID +ViiperEvtVirtualDeviceCleanup( + _In_ WDFOBJECT DeviceObject + ) +{ + UDECXUSBDEVICE device = (UDECXUSBDEVICE)DeviceObject; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + + PAGED_CODE(); + if (deviceContext->Controller == WDF_NO_HANDLE) { + return; + } + controllerContext = ViiperGetControllerContext(deviceContext->Controller); + ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot); +} + +NTSTATUS +ViiperEvtUsbDeviceD0Entry( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device + ) +{ + UNREFERENCED_PARAMETER(Controller); + UNREFERENCED_PARAMETER(Device); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperEvtUsbDeviceD0Exit( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ UDECX_USB_DEVICE_WAKE_SETTING WakeSetting + ) +{ + UNREFERENCED_PARAMETER(Controller); + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(WakeSetting); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperEvtUsbDeviceSetFunctionSuspendAndWake( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ ULONG Interface, + _In_ UDECX_USB_DEVICE_FUNCTION_POWER FunctionPower + ) +{ + UNREFERENCED_PARAMETER(Controller); + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(Interface); + UNREFERENCED_PARAMETER(FunctionPower); + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperCreateEndpointQueue( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDF_IO_QUEUE_DISPATCH_TYPE DispatchType + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + WDF_IO_QUEUE_CONFIG queueConfig; + WDF_OBJECT_ATTRIBUTES attributes; + UDECXUSBENDPOINT *queueEndpoint; + NTSTATUS status; + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, DispatchType); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, UDECXUSBENDPOINT); + attributes.ParentObject = Endpoint; + status = WdfIoQueueCreate(deviceContext->Controller, &queueConfig, &attributes, &endpointContext->Queue); + if (!NT_SUCCESS(status)) { + return status; + } + queueEndpoint = ViiperGetQueueEndpoint(endpointContext->Queue); + *queueEndpoint = Endpoint; + UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperEvtEndpointAdd( + _In_ UDECXUSBDEVICE Device, + _In_ UDECX_USB_ENDPOINT_INIT_AND_METADATA *EndpointData + ) +{ + USB_ENDPOINT_DESCRIPTOR descriptor; + UDECX_USB_ENDPOINT_CALLBACKS callbacks; + WDF_OBJECT_ATTRIBUTES attributes; + UDECXUSBENDPOINT endpoint; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + WDF_IO_QUEUE_DISPATCH_TYPE dispatchType; + NTSTATUS status; + + PAGED_CODE(); + RtlZeroMemory(&descriptor, sizeof(descriptor)); + if (EndpointData->EndpointDescriptor != NULL) { + if (EndpointData->EndpointDescriptorBufferLength < sizeof(USB_ENDPOINT_DESCRIPTOR)) { + return STATUS_INVALID_PARAMETER; + } + RtlCopyMemory(&descriptor, EndpointData->EndpointDescriptor, sizeof(descriptor)); + } + UdecxUsbEndpointInitSetEndpointAddress( + EndpointData->UdecxUsbEndpointInit, descriptor.bEndpointAddress); + + UDECX_USB_ENDPOINT_CALLBACKS_INIT(&callbacks, ViiperEvtEndpointReset); + callbacks.EvtUsbEndpointStart = ViiperEvtEndpointStart; + callbacks.EvtUsbEndpointPurge = ViiperEvtEndpointPurge; + UdecxUsbEndpointInitSetCallbacks(EndpointData->UdecxUsbEndpointInit, &callbacks); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_ENDPOINT_CONTEXT); + attributes.ParentObject = Device; + status = UdecxUsbEndpointCreate(&EndpointData->UdecxUsbEndpointInit, &attributes, &endpoint); + if (!NT_SUCCESS(status)) { + return status; + } + endpointContext = ViiperGetEndpointContext(endpoint); + RtlZeroMemory(endpointContext, sizeof(*endpointContext)); + endpointContext->Device = Device; + endpointContext->Descriptor = descriptor; + if (descriptor.bEndpointAddress == 0) { + ViiperGetDeviceContext(Device)->DefaultEndpoint = endpoint; + dispatchType = WdfIoQueueDispatchSequential; + } else { + dispatchType = WdfIoQueueDispatchParallel; + } + return ViiperCreateEndpointQueue(endpoint, dispatchType); +} + +NTSTATUS +ViiperEvtDefaultEndpointAdd( + _In_ UDECXUSBDEVICE Device, + _In_ _UDECXUSBENDPOINT_INIT *EndpointInit + ) +{ + UDECX_USB_ENDPOINT_INIT_AND_METADATA endpointData; + + PAGED_CODE(); + RtlZeroMemory(&endpointData, sizeof(endpointData)); + endpointData.UdecxUsbEndpointInit = EndpointInit; + return ViiperEvtEndpointAdd(Device, &endpointData); +} + +VOID +ViiperEvtEndpointReset( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request + ) +{ + UNREFERENCED_PARAMETER(Endpoint); + WdfRequestComplete(Request, STATUS_SUCCESS); +} + +VOID +ViiperEvtEndpointQueuePurged( + _In_ WDFQUEUE Queue, + _In_ WDFCONTEXT Context + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + UNREFERENCED_PARAMETER(Queue); + ViiperGetEndpointContext(endpoint)->Purging = FALSE; + UdecxUsbEndpointPurgeComplete(endpoint); +} + +VOID +ViiperEvtEndpointPurge( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + endpointContext->Purging = TRUE; + WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); +} + +VOID +ViiperEvtEndpointStart( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + WdfIoQueueStart(ViiperGetEndpointContext(Endpoint)->Queue); +} + +VOID +ViiperEvtEndpointsConfigure( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UDECX_ENDPOINTS_CONFIGURE_PARAMS *ConfigureParams + ) +{ + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(ConfigureParams); + WdfRequestComplete(Request, STATUS_SUCCESS); +} + +VOID +ViiperEvtEndpointIoInternalControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + UNREFERENCED_PARAMETER(Queue); + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB) { + UdecxUrbCompleteWithNtStatus(Request, STATUS_NOT_SUPPORTED); + } else { + WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST); + } +} diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 75d4f80a..e4c07a7f 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -163,6 +163,12 @@ ViiperEvtIoDeviceControl( case IOCTL_VIIPER_UDE_QUERY_STATS: status = ViiperHandleQueryStats(Queue, Request); break; + case IOCTL_VIIPER_UDE_CREATE_DEVICE: + status = ViiperCreateVirtualDevice(Queue, Request); + break; + case IOCTL_VIIPER_UDE_DESTROY_DEVICE: + status = ViiperDestroyVirtualDevice(Queue, Request); + break; default: status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) ? STATUS_PENDING @@ -174,4 +180,3 @@ ViiperEvtIoDeviceControl( WdfRequestComplete(Request, status); } } - diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index dd2a3c69..9cc513c4 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -2,8 +2,9 @@ #include #include -#include #include +#include +#include #include "..\include\ViiperUdeProtocol.h" @@ -11,6 +12,7 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFWAITLOCK OwnerLock; + WDFWAITLOCK DeviceLock; WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; @@ -28,6 +30,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; + UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; } VIIPER_UDE_CONTROLLER_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) @@ -41,6 +44,29 @@ typedef struct VIIPER_UDE_FILE_CONTEXT { WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext) +typedef struct VIIPER_UDE_DEVICE_CONTEXT { + WDFDEVICE Controller; + WDFFILEOBJECT OwnerFile; + uint64_t DeviceId; + uint32_t Generation; + uint32_t Slot; + BOOLEAN Plugged; + BOOLEAN Purging; + UDECXUSBENDPOINT DefaultEndpoint; +} VIIPER_UDE_DEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceContext) + +typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { + UDECXUSBDEVICE Device; + WDFQUEUE Queue; + USB_ENDPOINT_DESCRIPTOR Descriptor; + BOOLEAN Purging; +} VIIPER_UDE_ENDPOINT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_ENDPOINT_CONTEXT, ViiperGetEndpointContext) +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(UDECXUSBENDPOINT, ViiperGetQueueEndpoint) + DRIVER_INITIALIZE DriverEntry; EVT_WDF_DRIVER_DEVICE_ADD ViiperEvtDeviceAdd; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtDriverCleanup; @@ -49,6 +75,20 @@ EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; +EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; +EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; +EVT_UDECX_USB_DEVICE_SET_FUNCTION_SUSPEND_AND_WAKE ViiperEvtUsbDeviceSetFunctionSuspendAndWake; +EVT_UDECX_USB_DEVICE_DEFAULT_ENDPOINT_ADD ViiperEvtDefaultEndpointAdd; +EVT_UDECX_USB_DEVICE_ENDPOINT_ADD ViiperEvtEndpointAdd; +EVT_UDECX_USB_DEVICE_ENDPOINTS_CONFIGURE ViiperEvtEndpointsConfigure; +EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; +EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; +EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; +EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; +EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); - +NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +VOID ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index ab988220..f15a74b5 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -54,7 +54,7 @@ true stdc17 ..\include;%(AdditionalIncludeDirectories) - _KERNEL_MODE;POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) + POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) true @@ -68,6 +68,7 @@ + From 703f6d45e2b74156c706386265fbe06cd6950dff Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:05:19 -0500 Subject: [PATCH 006/240] native/ude: isolate kernel ABI from CRT headers Use WDK-native fixed-width types in the shared kernel ABI, include the public USB capability and URB declarations, and use the public UdeCx opaque initializer typedefs. This removes CRT/kernel macro collisions and aligns the skeleton with the supported WDK surface. --- native/udecx/driver/Device.c | 38 ++--- native/udecx/driver/Ioctl.c | 30 ++-- native/udecx/driver/ViiperUde.h | 12 +- native/udecx/include/ViiperUdeProtocol.h | 209 +++++++++++++---------- 4 files changed, 157 insertions(+), 132 deletions(-) diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index a4ae771c..a04a0010 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -21,9 +21,9 @@ static BOOLEAN ViiperRangeValid( - _In_ uint32_t Offset, - _In_ uint32_t Length, - _In_ uint32_t Total + _In_ ULONG Offset, + _In_ ULONG Length, + _In_ ULONG Total ) { return Offset <= Total && Length <= Total - Offset; @@ -37,8 +37,8 @@ ViiperValidateCreateDevice( ) { const VIIPER_UDE_DESCRIPTOR_RECORD *records; - uint32_t recordsLength; - uint32_t index; + ULONG recordsLength; + ULONG index; BOOLEAN foundDevice = FALSE; BOOLEAN foundConfiguration = FALSE; @@ -116,7 +116,7 @@ ViiperValidateOwner( static UDECX_USB_DEVICE_SPEED ViiperMapSpeed( - _In_ uint32_t Speed + _In_ ULONG Speed ) { switch (Speed) { @@ -138,12 +138,12 @@ NTSTATUS ViiperClaimDeviceSlot( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ UDECXUSBDEVICE Device, - _In_ uint64_t DeviceId, - _Out_ uint32_t *Slot + _In_ ULONGLONG DeviceId, + _Out_ ULONG *Slot ) { - uint32_t index; - uint32_t freeSlot = VIIPER_UDE_MAX_DEVICES; + ULONG index; + ULONG freeSlot = VIIPER_UDE_MAX_DEVICES; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); @@ -176,7 +176,7 @@ VOID ViiperReleaseDeviceSlot( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ UDECXUSBDEVICE Device, - _In_ uint32_t Slot + _In_ ULONG Slot ) { WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); @@ -198,14 +198,14 @@ ViiperCreateVirtualDevice( VIIPER_UDE_CREATE_DEVICE *input; size_t inputLength; WDFFILEOBJECT ownerFile; - _UDECXUSBDEVICE_INIT *deviceInit; + PUDECXUSBDEVICE_INIT deviceInit; UDECX_USB_DEVICE_STATE_CHANGE_CALLBACKS callbacks; UDECX_USB_DEVICE_SPEED speed; WDF_OBJECT_ATTRIBUTES attributes; UDECXUSBDEVICE device = WDF_NO_HANDLE; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; UDECX_USB_DEVICE_PLUG_IN_OPTIONS plugOptions; - uint32_t slot; + ULONG slot; PAGED_CODE(); status = ViiperValidateOwner(controller, Request, &ownerFile); @@ -292,13 +292,13 @@ UDECXUSBDEVICE ViiperTakeDevice( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ WDFFILEOBJECT OwnerFile, - _In_ uint64_t DeviceId, - _In_ uint32_t Generation, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, _In_ BOOLEAN MatchGeneration ) { UDECXUSBDEVICE found = WDF_NO_HANDLE; - uint32_t index; + ULONG index; WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { @@ -375,8 +375,8 @@ ViiperDestroyOwnedDevices( for (;;) { UDECXUSBDEVICE device; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; - uint64_t deviceId = 0; - uint32_t index; + ULONGLONG deviceId = 0; + ULONG index; WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { @@ -543,7 +543,7 @@ ViiperEvtEndpointAdd( NTSTATUS ViiperEvtDefaultEndpointAdd( _In_ UDECXUSBDEVICE Device, - _In_ _UDECXUSBENDPOINT_INIT *EndpointInit + _In_ PUDECXUSBENDPOINT_INIT EndpointInit ) { UDECX_USB_ENDPOINT_INIT_AND_METADATA endpointData; diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index e4c07a7f..5a67b503 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -68,8 +68,8 @@ ViiperHandleNegotiate( if (!fileContext->Negotiated) { ticks = KeQueryPerformanceCounter(NULL); fileContext->ClientNonce = input->ClientNonce; - fileContext->DriverNonce = ((uint64_t)ticks.QuadPart) ^ - ((uint64_t)(ULONG_PTR)fileObject << 13) ^ input->ClientNonce; + fileContext->DriverNonce = ((ULONGLONG)ticks.QuadPart) ^ + ((ULONGLONG)(ULONG_PTR)fileObject << 13) ^ input->ClientNonce; if (fileContext->DriverNonce == 0) { fileContext->DriverNonce = 1; } @@ -125,19 +125,19 @@ ViiperHandleQueryStats( output->Header.Major = VIIPER_UDE_ABI_MAJOR; output->Header.Minor = VIIPER_UDE_ABI_MINOR; output->Header.Size = sizeof(*output); - output->OperationsDequeued = (uint64_t)ViiperReadCounter(&context->OperationsDequeued); - output->OperationsCompleted = (uint64_t)ViiperReadCounter(&context->OperationsCompleted); - output->OperationsCancelled = (uint64_t)ViiperReadCounter(&context->OperationsCancelled); - output->OperationsPurged = (uint64_t)ViiperReadCounter(&context->OperationsPurged); - output->LateCompletions = (uint64_t)ViiperReadCounter(&context->LateCompletions); - output->InvalidMessages = (uint64_t)ViiperReadCounter(&context->InvalidMessages); - output->QueueExhaustions = (uint64_t)ViiperReadCounter(&context->QueueExhaustions); - output->IsoPackets = (uint64_t)ViiperReadCounter(&context->IsoPackets); - output->BytesToDevice = (uint64_t)ViiperReadCounter(&context->BytesToDevice); - output->BytesFromDevice = (uint64_t)ViiperReadCounter(&context->BytesFromDevice); - output->ActiveDevices = (uint32_t)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); - output->PendingOperations = (uint32_t)InterlockedCompareExchange(&context->PendingOperations, 0, 0); - output->WaitingDequeues = (uint32_t)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); + output->OperationsDequeued = (ULONGLONG)ViiperReadCounter(&context->OperationsDequeued); + output->OperationsCompleted = (ULONGLONG)ViiperReadCounter(&context->OperationsCompleted); + output->OperationsCancelled = (ULONGLONG)ViiperReadCounter(&context->OperationsCancelled); + output->OperationsPurged = (ULONGLONG)ViiperReadCounter(&context->OperationsPurged); + output->LateCompletions = (ULONGLONG)ViiperReadCounter(&context->LateCompletions); + output->InvalidMessages = (ULONGLONG)ViiperReadCounter(&context->InvalidMessages); + output->QueueExhaustions = (ULONGLONG)ViiperReadCounter(&context->QueueExhaustions); + output->IsoPackets = (ULONGLONG)ViiperReadCounter(&context->IsoPackets); + output->BytesToDevice = (ULONGLONG)ViiperReadCounter(&context->BytesToDevice); + output->BytesFromDevice = (ULONGLONG)ViiperReadCounter(&context->BytesFromDevice); + output->ActiveDevices = (ULONG)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); + output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); + output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 9cc513c4..4941ef92 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -38,8 +40,8 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetContr typedef struct VIIPER_UDE_FILE_CONTEXT { BOOLEAN Negotiated; BOOLEAN Closing; - uint64_t ClientNonce; - uint64_t DriverNonce; + ULONGLONG ClientNonce; + ULONGLONG DriverNonce; } VIIPER_UDE_FILE_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext) @@ -47,9 +49,9 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext typedef struct VIIPER_UDE_DEVICE_CONTEXT { WDFDEVICE Controller; WDFFILEOBJECT OwnerFile; - uint64_t DeviceId; - uint32_t Generation; - uint32_t Slot; + ULONGLONG DeviceId; + ULONG Generation; + ULONG Slot; BOOLEAN Plugged; BOOLEAN Purging; UDECXUSBENDPOINT DefaultEndpoint; diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 48d81be8..850f7c30 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -1,26 +1,49 @@ #pragma once -#include - #if defined(_KERNEL_MODE) +#include #include +typedef UCHAR VIIPER_UDE_UINT8; +typedef USHORT VIIPER_UDE_UINT16; +typedef ULONG VIIPER_UDE_UINT32; +typedef ULONGLONG VIIPER_UDE_UINT64; +typedef LONG VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) value##U +#define VIIPER_UDE_UINT32_C(value) value##UL #elif defined(_WIN32) +#include #include +typedef uint8_t VIIPER_UDE_UINT8; +typedef uint16_t VIIPER_UDE_UINT16; +typedef uint32_t VIIPER_UDE_UINT32; +typedef uint64_t VIIPER_UDE_UINT64; +typedef int32_t VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) UINT16_C(value) +#define VIIPER_UDE_UINT32_C(value) UINT32_C(value) +#else +#include +typedef uint8_t VIIPER_UDE_UINT8; +typedef uint16_t VIIPER_UDE_UINT16; +typedef uint32_t VIIPER_UDE_UINT32; +typedef uint64_t VIIPER_UDE_UINT64; +typedef int32_t VIIPER_UDE_INT32; +#define VIIPER_UDE_UINT16_C(value) UINT16_C(value) +#define VIIPER_UDE_UINT32_C(value) UINT32_C(value) #endif -#define VIIPER_UDE_MAGIC UINT32_C(0x45445556) /* "VUDE" little-endian */ -#define VIIPER_UDE_ABI_MAJOR UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR UINT16_C(0) +#define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ +#define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(0) -#define VIIPER_UDE_MAX_DEVICES UINT32_C(32) -#define VIIPER_UDE_MAX_DESCRIPTOR_BYTES UINT32_C(262144) -#define VIIPER_UDE_MAX_TRANSFER_BYTES UINT32_C(1048576) -#define VIIPER_UDE_MAX_ISO_PACKETS UINT32_C(1024) -#define VIIPER_UDE_MAX_PENDING_OPERATIONS UINT32_C(4096) +#define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) +#define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) +#define VIIPER_UDE_MAX_TRANSFER_BYTES VIIPER_UDE_UINT32_C(1048576) +#define VIIPER_UDE_MAX_ISO_PACKETS VIIPER_UDE_UINT32_C(1024) +#define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) -#define VIIPER_UDE_CAP_ISOCHRONOUS UINT32_C(0x00000001) -#define VIIPER_UDE_CAP_STREAMS UINT32_C(0x00000002) -#define VIIPER_UDE_CAP_DEVICE_LIFECYCLE UINT32_C(0x00000004) +#define VIIPER_UDE_CAP_ISOCHRONOUS VIIPER_UDE_UINT32_C(0x00000001) +#define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) +#define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) #if defined(_WIN32) #define VIIPER_UDE_IOCTL_BASE 0x900 @@ -35,30 +58,30 @@ #pragma pack(push, 1) typedef struct VIIPER_UDE_HEADER { - uint32_t Magic; - uint16_t Major; - uint16_t Minor; - uint32_t Size; - uint32_t Flags; + VIIPER_UDE_UINT32 Magic; + VIIPER_UDE_UINT16 Major; + VIIPER_UDE_UINT16 Minor; + VIIPER_UDE_UINT32 Size; + VIIPER_UDE_UINT32 Flags; } VIIPER_UDE_HEADER; typedef struct VIIPER_UDE_NEGOTIATE_REQUEST { VIIPER_UDE_HEADER Header; - uint64_t ClientNonce; - uint32_t RequestedCapabilities; - uint32_t Reserved; + VIIPER_UDE_UINT64 ClientNonce; + VIIPER_UDE_UINT32 RequestedCapabilities; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_NEGOTIATE_REQUEST; typedef struct VIIPER_UDE_NEGOTIATE_RESPONSE { VIIPER_UDE_HEADER Header; - uint64_t ClientNonce; - uint64_t DriverNonce; - uint32_t Capabilities; - uint32_t MaxDevices; - uint32_t MaxDescriptorBytes; - uint32_t MaxTransferBytes; - uint32_t MaxIsoPackets; - uint32_t MaxPendingOperations; + VIIPER_UDE_UINT64 ClientNonce; + VIIPER_UDE_UINT64 DriverNonce; + VIIPER_UDE_UINT32 Capabilities; + VIIPER_UDE_UINT32 MaxDevices; + VIIPER_UDE_UINT32 MaxDescriptorBytes; + VIIPER_UDE_UINT32 MaxTransferBytes; + VIIPER_UDE_UINT32 MaxIsoPackets; + VIIPER_UDE_UINT32 MaxPendingOperations; } VIIPER_UDE_NEGOTIATE_RESPONSE; typedef enum VIIPER_UDE_DESCRIPTOR_KIND { @@ -69,32 +92,32 @@ typedef enum VIIPER_UDE_DESCRIPTOR_KIND { } VIIPER_UDE_DESCRIPTOR_KIND; typedef struct VIIPER_UDE_DESCRIPTOR_RECORD { - uint16_t Kind; - uint16_t Index; - uint16_t LanguageId; - uint16_t Reserved; - uint32_t Offset; - uint32_t Length; + VIIPER_UDE_UINT16 Kind; + VIIPER_UDE_UINT16 Index; + VIIPER_UDE_UINT16 LanguageId; + VIIPER_UDE_UINT16 Reserved; + VIIPER_UDE_UINT32 Offset; + VIIPER_UDE_UINT32 Length; } VIIPER_UDE_DESCRIPTOR_RECORD; typedef struct VIIPER_UDE_CREATE_DEVICE { VIIPER_UDE_HEADER Header; - uint64_t DeviceId; - uint32_t Generation; - uint32_t Speed; - uint32_t DescriptorCount; - uint32_t DescriptorRecordsOffset; - uint32_t DescriptorDataOffset; - uint32_t DescriptorDataLength; - uint32_t MaxPendingOperations; - uint32_t Reserved; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Speed; + VIIPER_UDE_UINT32 DescriptorCount; + VIIPER_UDE_UINT32 DescriptorRecordsOffset; + VIIPER_UDE_UINT32 DescriptorDataOffset; + VIIPER_UDE_UINT32 DescriptorDataLength; + VIIPER_UDE_UINT32 MaxPendingOperations; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_CREATE_DEVICE; typedef struct VIIPER_UDE_DEVICE_IDENTITY { VIIPER_UDE_HEADER Header; - uint64_t DeviceId; - uint32_t Generation; - uint32_t Reserved; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_DEVICE_IDENTITY; typedef enum VIIPER_UDE_OPERATION_KIND { @@ -110,64 +133,64 @@ typedef enum VIIPER_UDE_OPERATION_KIND { } VIIPER_UDE_OPERATION_KIND; typedef struct VIIPER_UDE_ISO_PACKET { - uint32_t Offset; - uint32_t Length; - int32_t Status; - uint32_t Reserved; + VIIPER_UDE_UINT32 Offset; + VIIPER_UDE_UINT32 Length; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_ISO_PACKET; typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_HEADER Header; - uint64_t Token; - uint64_t DeviceId; - uint32_t Generation; - uint32_t Kind; - uint8_t EndpointAddress; - uint8_t Direction; - uint16_t Reserved0; - uint32_t UrbFunction; - uint32_t TransferFlags; - uint32_t StartFrame; - uint32_t IsoPacketCount; - uint32_t TransferLength; - uint32_t PayloadOffset; - uint32_t PayloadLength; - uint32_t IsoPacketsOffset; - uint8_t SetupPacket[8]; - uint32_t Reserved1; + VIIPER_UDE_UINT64 Token; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Kind; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Direction; + VIIPER_UDE_UINT16 Reserved0; + VIIPER_UDE_UINT32 UrbFunction; + VIIPER_UDE_UINT32 TransferFlags; + VIIPER_UDE_UINT32 StartFrame; + VIIPER_UDE_UINT32 IsoPacketCount; + VIIPER_UDE_UINT32 TransferLength; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT32 IsoPacketsOffset; + VIIPER_UDE_UINT8 SetupPacket[8]; + VIIPER_UDE_UINT32 Reserved1; } VIIPER_UDE_OPERATION; typedef struct VIIPER_UDE_COMPLETION { VIIPER_UDE_HEADER Header; - uint64_t Token; - uint64_t DeviceId; - uint32_t Generation; - int32_t Status; - uint32_t UsbdStatus; - uint32_t TransferLength; - uint32_t IsoPacketCount; - uint32_t PayloadOffset; - uint32_t PayloadLength; - uint32_t IsoPacketsOffset; - uint32_t Reserved; + VIIPER_UDE_UINT64 Token; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_UINT32 UsbdStatus; + VIIPER_UDE_UINT32 TransferLength; + VIIPER_UDE_UINT32 IsoPacketCount; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT32 IsoPacketsOffset; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_COMPLETION; typedef struct VIIPER_UDE_STATS { VIIPER_UDE_HEADER Header; - uint64_t OperationsDequeued; - uint64_t OperationsCompleted; - uint64_t OperationsCancelled; - uint64_t OperationsPurged; - uint64_t LateCompletions; - uint64_t InvalidMessages; - uint64_t QueueExhaustions; - uint64_t IsoPackets; - uint64_t BytesToDevice; - uint64_t BytesFromDevice; - uint32_t ActiveDevices; - uint32_t PendingOperations; - uint32_t WaitingDequeues; - uint32_t Reserved; + VIIPER_UDE_UINT64 OperationsDequeued; + VIIPER_UDE_UINT64 OperationsCompleted; + VIIPER_UDE_UINT64 OperationsCancelled; + VIIPER_UDE_UINT64 OperationsPurged; + VIIPER_UDE_UINT64 LateCompletions; + VIIPER_UDE_UINT64 InvalidMessages; + VIIPER_UDE_UINT64 QueueExhaustions; + VIIPER_UDE_UINT64 IsoPackets; + VIIPER_UDE_UINT64 BytesToDevice; + VIIPER_UDE_UINT64 BytesFromDevice; + VIIPER_UDE_UINT32 ActiveDevices; + VIIPER_UDE_UINT32 PendingOperations; + VIIPER_UDE_UINT32 WaitingDequeues; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_STATS; #pragma pack(pop) From 59da0a4edddb1cd14600afc8e94d24fa0ab94daa Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:06:31 -0500 Subject: [PATCH 007/240] native/ude: complete broker client lifecycle API Add create, destroy, dequeue, complete, and statistics operations to the Windows client; preserve direct-I/O framing for variable transfer tails; and fix Close so CancelIoEx can actually interrupt blocking dequeue calls before waiting for in-flight operations. Add ABI layout tests for device identity and counters. --- internal/transport/udecx/client_windows.go | 78 ++++++++++++++++++++-- internal/transport/udecx/protocol.go | 61 +++++++++++++++++ internal/transport/udecx/protocol_test.go | 27 ++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index fbf5773f..0018bf10 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -49,6 +49,7 @@ var ( type Client struct { mu sync.RWMutex + inflight sync.WaitGroup handle windows.Handle driverNonce uint64 capabilities Capabilities @@ -93,13 +94,16 @@ func Open(ctx context.Context) (*Client, error) { func (c *Client) Close() error { c.mu.Lock() - defer c.mu.Unlock() if c.handle == 0 || c.handle == windows.InvalidHandle { + c.mu.Unlock() return nil } handle := c.handle c.handle = windows.InvalidHandle + c.mu.Unlock() + _ = windows.CancelIoEx(handle, nil) + c.inflight.Wait() return windows.CloseHandle(handle) } @@ -157,13 +161,77 @@ func (c *Client) negotiate(ctx context.Context) error { return nil } -func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) (uint32, error) { +func (c *Client) CreateDevice(ctx context.Context, device CreateDevice) error { + request, err := device.MarshalBinary() + if err != nil { + return err + } + _, err = c.ioctl(ctx, ioctlCreateDevice, request, nil) + return err +} + +func (c *Client) DestroyDevice(ctx context.Context, identity DeviceIdentity) error { + request, err := identity.MarshalBinary() + if err != nil { + return err + } + _, err = c.ioctl(ctx, ioctlDestroyDevice, request, nil) + return err +} + +func (c *Client) Dequeue(ctx context.Context, buffer []byte) (Operation, error) { + if len(buffer) < OperationSize { + return Operation{}, ErrShortMessage + } + written, err := c.ioctl(ctx, ioctlDequeueOperation, nil, buffer) + if err != nil { + return Operation{}, err + } + if written < OperationSize || written > uint32(len(buffer)) { + return Operation{}, ErrInvalidSize + } + return ParseOperation(buffer[:written]) +} + +func (c *Client) Complete(ctx context.Context, completion Completion) error { + request, err := completion.MarshalBinary() + if err != nil { + return err + } + // METHOD_IN_DIRECT keeps the fixed metadata in the system buffer and maps + // the variable packet/payload tail read-only into the driver. + _, err = c.ioctl(ctx, ioctlCompleteOperation, request[:CompletionSize], request[CompletionSize:]) + return err +} + +func (c *Client) QueryStats(ctx context.Context) (Stats, error) { + buffer := make([]byte, StatsSize) + written, err := c.ioctl(ctx, ioctlQueryStats, nil, buffer) + if err != nil { + return Stats{}, err + } + if written != StatsSize { + return Stats{}, ErrInvalidSize + } + return ParseStats(buffer) +} + +func (c *Client) beginIO() (windows.Handle, error) { c.mu.RLock() defer c.mu.RUnlock() - handle := c.handle - if handle == 0 || handle == windows.InvalidHandle { - return 0, windows.ERROR_INVALID_HANDLE + if c.handle == 0 || c.handle == windows.InvalidHandle { + return windows.InvalidHandle, windows.ERROR_INVALID_HANDLE + } + c.inflight.Add(1) + return c.handle, nil +} + +func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) (uint32, error) { + handle, err := c.beginIO() + if err != nil { + return 0, err } + defer c.inflight.Done() event, err := windows.CreateEvent(nil, 1, 0, nil) if err != nil { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 4f96c319..f01e3f7d 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -170,6 +170,26 @@ const ( DeviceSpeedSuper ) +type DeviceIdentity struct { + DeviceID uint64 + Generation uint32 +} + +func (m DeviceIdentity) MarshalBinary() ([]byte, error) { + if m.DeviceID == 0 || m.Generation == 0 { + return nil, fmt.Errorf("%w: zero device identity", ErrInvalidRange) + } + h, err := NewHeader(DeviceIdentitySize) + if err != nil { + return nil, err + } + dst := make([]byte, DeviceIdentitySize) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + return dst, nil +} + type CreateDevice struct { DeviceID uint64 Generation uint32 @@ -316,6 +336,47 @@ type Completion struct { Payload []byte } +type Stats struct { + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 +} + +func ParseStats(src []byte) (Stats, error) { + h, err := ParseHeader(src) + if err != nil { + return Stats{}, err + } + if h.Size != StatsSize { + return Stats{}, ErrInvalidSize + } + return Stats{ + OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), + OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), + OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), + OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), + LateCompletions: binary.LittleEndian.Uint64(src[48:56]), + InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), + QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), + IsoPackets: binary.LittleEndian.Uint64(src[72:80]), + BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), + BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), + ActiveDevices: binary.LittleEndian.Uint32(src[96:100]), + PendingOperations: binary.LittleEndian.Uint32(src[100:104]), + WaitingDequeues: binary.LittleEndian.Uint32(src[104:108]), + }, nil +} + func (m Completion) MarshalBinary() ([]byte, error) { if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { return nil, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 3467f024..1da0fa85 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -127,6 +127,33 @@ func TestCompletionMarshalling(t *testing.T) { } } +func TestIdentityAndStatsLayout(t *testing.T) { + identity, err := (DeviceIdentity{DeviceID: 0x1122334455667788, Generation: 7}).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if len(identity) != DeviceIdentitySize || binary.LittleEndian.Uint64(identity[16:24]) != 0x1122334455667788 { + t.Fatalf("invalid identity layout: %x", identity) + } + + raw := make([]byte, StatsSize) + h, _ := NewHeader(StatsSize) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 11) + binary.LittleEndian.PutUint64(raw[88:96], 29) + binary.LittleEndian.PutUint32(raw[96:100], 3) + binary.LittleEndian.PutUint32(raw[100:104], 5) + binary.LittleEndian.PutUint32(raw[104:108], 7) + stats, err := ParseStats(raw) + if err != nil { + t.Fatal(err) + } + if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || + stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + func FuzzParseOperation(f *testing.F) { f.Add([]byte{}) valid := make([]byte, OperationSize) From 3bd2536d7cb4141b4e0d2f860e3a030af5260f0f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:07:52 -0500 Subject: [PATCH 008/240] native/ude: include WDK capability declarations --- native/udecx/driver/Device.c | 2 +- native/udecx/driver/ViiperUde.h | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index a04a0010..7fd154b9 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -57,7 +57,7 @@ ViiperValidateCreateDevice( return FALSE; } - if (Input->DescriptorCount > UINT32_MAX / sizeof(*records)) { + if (Input->DescriptorCount > MAXULONG / sizeof(*records)) { return FALSE; } recordsLength = Input->DescriptorCount * sizeof(*records); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 4941ef92..fa523f4d 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include From 1a99aaf6508f5254bf7805f477b0380ebd9cb7b8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:14:30 -0500 Subject: [PATCH 009/240] native/ude: broker bounded cancellable USB operations Route UdeCx endpoint URBs through a single-owner user-mode broker with preallocated O(1) token slots, strict size validation, direct-I/O payloads, and isochronous packet metadata. Serialize cancellation, endpoint purge, owner cleanup, and late completion handling so requests have one completion owner across teardown races. Explicitly initialize the nonpaged broker table and avoid duplicate purge ownership. --- native/udecx/driver/Broker.c | 853 ++++++++++++++++++++++++++ native/udecx/driver/Controller.c | 21 +- native/udecx/driver/Device.c | 7 +- native/udecx/driver/Ioctl.c | 6 + native/udecx/driver/ViiperUde.h | 41 ++ native/udecx/driver/ViiperUde.vcxproj | 1 + 6 files changed, 925 insertions(+), 4 deletions(-) create mode 100644 native/udecx/driver/Broker.c diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c new file mode 100644 index 00000000..425cb6bc --- /dev/null +++ b/native/udecx/driver/Broker.c @@ -0,0 +1,853 @@ +/* + * Bounded, single-owner user/kernel transfer broker. + * + * Every submitted URB occupies one preallocated slot. Tokens encode the slot + * and a monotonically increasing generation, which makes completion lookup + * O(1) and rejects stale/duplicate replies without allocating on the media + * path. Cancellation is handed between WDF and the broker with an explicit + * unmark/remark boundary while a request is serialized to user mode. + */ + +#include "ViiperUde.h" + +EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; + +static +VOID +ViiperClearSlotLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot + ) +{ + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; + + pending->Request = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->Token = 0; + pending->State = ViiperUdePendingEmpty; + pending->AbortPending = FALSE; + pending->AbortStatus = STATUS_SUCCESS; + InterlockedDecrement(&ControllerContext->PendingOperations); +} + +static +BOOLEAN +ViiperSlotMatches( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token + ) +{ + return Pending->Request == Request && Pending->Token == Token && + Pending->State != ViiperUdePendingEmpty; +} + +static +NTSTATUS +ViiperValidateBrokerOwner( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + VIIPER_UDE_FILE_CONTEXT *fileContext; + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + !fileContext->Negotiated || fileContext->Closing) { + status = STATUS_INVALID_DEVICE_STATE; + } + WdfWaitLockRelease(controllerContext->OwnerLock); + return status; +} + +NTSTATUS +ViiperInitializeBroker( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Device); + WDF_OBJECT_ATTRIBUTES attributes; + NTSTATUS status; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfSpinLockCreate(&attributes, &controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495542, + sizeof(VIIPER_UDE_PENDING_SLOT) * VIIPER_UDE_MAX_PENDING_OPERATIONS, + &controllerContext->PendingStorage, + (PVOID *)&controllerContext->PendingSlots); + if (!NT_SUCCESS(status)) { + controllerContext->PendingStorage = WDF_NO_HANDLE; + controllerContext->PendingSlots = NULL; + return status; + } + + RtlZeroMemory( + controllerContext->PendingSlots, + sizeof(VIIPER_UDE_PENDING_SLOT) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperAllocatePendingSlot( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFREQUEST Request, + _In_ UDECXUSBENDPOINT Endpoint, + _Out_ ULONG *Slot, + _Out_ ULONGLONG *Token + ) +{ + ULONG offset; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + for (offset = 0; offset < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++offset) { + ULONG index = (ControllerContext->NextPendingSlot + offset) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[index]; + if (pending->State != ViiperUdePendingEmpty) { + continue; + } + ++pending->Generation; + if (pending->Generation == 0) { + ++pending->Generation; + } + pending->Request = Request; + pending->Endpoint = Endpoint; + pending->Token = ((ULONGLONG)pending->Generation << 32) | (index + 1); + pending->State = ViiperUdePendingPreparing; + pending->AbortPending = FALSE; + pending->AbortStatus = STATUS_SUCCESS; + ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; + InterlockedIncrement(&ControllerContext->PendingOperations); + *Slot = index; + *Token = pending->Token; + status = STATUS_SUCCESS; + break; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + + if (!NT_SUCCESS(status)) { + InterlockedIncrement64(&ControllerContext->QueueExhaustions); + } + return status; +} + +VOID +ViiperEvtUrbCancel( + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(requestContext->Controller); + BOOLEAN ownsRequest = FALSE; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (requestContext->PendingSlot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { + VIIPER_UDE_PENDING_SLOT *pending = + &controllerContext->PendingSlots[requestContext->PendingSlot]; + if (ViiperSlotMatches(pending, Request, requestContext->Token)) { + ViiperClearSlotLocked(controllerContext, requestContext->PendingSlot); + ownsRequest = TRUE; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (ownsRequest) { + InterlockedIncrement64(&controllerContext->OperationsCancelled); + UdecxUrbCompleteWithNtStatus(Request, STATUS_CANCELLED); + } +} + +static +PURB +ViiperGetUrb( + _In_ WDFREQUEST Request + ) +{ + PIRP irp = WdfRequestWdmGetIrp(Request); + if (irp == NULL) { + return NULL; + } + return (PURB)URB_FROM_IRP(irp); +} + +static +NTSTATUS +ViiperGetTransferMetadata( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Out_ ULONG *TransferFlags, + _Out_ ULONG *TransferLength, + _Out_ ULONG *StartFrame, + _Out_ ULONG *IsoPacketCount, + _Out_ BOOLEAN *DirectionIn, + _Out_writes_bytes_(8) UCHAR SetupPacket[8] + ) +{ + WDF_USB_CONTROL_SETUP_PACKET setup; + NTSTATUS status; + + *StartFrame = 0; + *IsoPacketCount = 0; + RtlZeroMemory(SetupPacket, 8); + + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + *TransferFlags = Urb->UrbBulkOrInterruptTransfer.TransferFlags; + *TransferLength = Urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + break; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + *TransferFlags = Urb->UrbIsochronousTransfer.TransferFlags; + *TransferLength = Urb->UrbIsochronousTransfer.TransferBufferLength; + *StartFrame = Urb->UrbIsochronousTransfer.StartFrame; + *IsoPacketCount = Urb->UrbIsochronousTransfer.NumberOfPackets; + if (*IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS) { + return STATUS_INVALID_BUFFER_SIZE; + } + break; + case URB_FUNCTION_CONTROL_TRANSFER: + *TransferFlags = Urb->UrbControlTransfer.TransferFlags; + *TransferLength = Urb->UrbControlTransfer.TransferBufferLength; + status = UdecxUrbRetrieveControlSetupPacket(Request, &setup); + if (!NT_SUCCESS(status)) { + return status; + } + RtlCopyMemory(SetupPacket, &setup, 8); + break; + case URB_FUNCTION_CONTROL_TRANSFER_EX: + *TransferFlags = Urb->UrbControlTransferEx.TransferFlags; + *TransferLength = Urb->UrbControlTransferEx.TransferBufferLength; + status = UdecxUrbRetrieveControlSetupPacket(Request, &setup); + if (!NT_SUCCESS(status)) { + return status; + } + RtlCopyMemory(SetupPacket, &setup, 8); + break; + default: + return STATUS_NOT_SUPPORTED; + } + + if (*TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES) { + return STATUS_INVALID_BUFFER_SIZE; + } + *DirectionIn = ((*TransferFlags & USBD_TRANSFER_DIRECTION_IN) != 0); + if (Urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER || + Urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER_EX) { + *DirectionIn = ((SetupPacket[0] & USB_ENDPOINT_DIRECTION_MASK) != 0); + } + return STATUS_SUCCESS; +} + +static +NTSTATUS +ViiperSerializeOperation( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ WDFREQUEST UrbRequest, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ ULONGLONG Token, + _In_ WDFREQUEST DequeueRequest + ) +{ + PURB urb = ViiperGetUrb(UrbRequest); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(UrbRequest); + VIIPER_UDE_OPERATION *operation; + VIIPER_UDE_ISO_PACKET *packets; + UCHAR *payload; + UCHAR *transferBuffer = NULL; + ULONG transferBufferLength = 0; + ULONG transferFlags; + ULONG transferLength; + ULONG startFrame; + ULONG packetCount; + ULONG isoBytes; + ULONG payloadLength; + ULONG totalLength; + ULONG index; + BOOLEAN directionIn; + UCHAR setupPacket[8]; + NTSTATUS status; + + if (urb == NULL) { + return STATUS_INVALID_DEVICE_REQUEST; + } + status = ViiperGetTransferMetadata( + UrbRequest, urb, &transferFlags, &transferLength, &startFrame, + &packetCount, &directionIn, setupPacket); + if (!NT_SUCCESS(status)) { + return status; + } + + isoBytes = packetCount * sizeof(VIIPER_UDE_ISO_PACKET); + payloadLength = directionIn ? 0 : transferLength; + if (payloadLength > 0) { + status = UdecxUrbRetrieveBuffer(UrbRequest, &transferBuffer, &transferBufferLength); + if (!NT_SUCCESS(status) || transferBufferLength < payloadLength) { + return NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; + } + } + if (isoBytes > MAXULONG - sizeof(*operation) || + payloadLength > MAXULONG - sizeof(*operation) - isoBytes) { + return STATUS_INTEGER_OVERFLOW; + } + totalLength = sizeof(*operation) + isoBytes + payloadLength; + status = WdfRequestRetrieveOutputBuffer( + DequeueRequest, totalLength, (PVOID *)&operation, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + RtlZeroMemory(operation, totalLength); + operation->Header.Magic = VIIPER_UDE_MAGIC; + operation->Header.Major = VIIPER_UDE_ABI_MAJOR; + operation->Header.Minor = VIIPER_UDE_ABI_MINOR; + operation->Header.Size = totalLength; + operation->Token = Token; + operation->DeviceId = deviceContext->DeviceId; + operation->Generation = deviceContext->Generation; + operation->Kind = (urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER || + urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER_EX) + ? ViiperUdeOperationControl : ViiperUdeOperationTransfer; + operation->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; + operation->Direction = directionIn ? 1 : 0; + operation->UrbFunction = urb->UrbHeader.Function; + operation->TransferFlags = transferFlags; + operation->StartFrame = startFrame; + operation->IsoPacketCount = packetCount; + operation->TransferLength = transferLength; + operation->IsoPacketsOffset = sizeof(*operation); + operation->PayloadOffset = sizeof(*operation) + isoBytes; + operation->PayloadLength = payloadLength; + RtlCopyMemory(operation->SetupPacket, setupPacket, sizeof(setupPacket)); + + packets = (VIIPER_UDE_ISO_PACKET *)((UCHAR *)operation + operation->IsoPacketsOffset); + for (index = 0; index < packetCount; ++index) { + ULONG offset = urb->UrbIsochronousTransfer.IsoPacket[index].Offset; + ULONG nextOffset = index + 1 < packetCount + ? urb->UrbIsochronousTransfer.IsoPacket[index + 1].Offset + : transferLength; + if (offset > nextOffset || nextOffset > transferLength) { + return STATUS_INVALID_PARAMETER; + } + packets[index].Offset = offset; + packets[index].Length = nextOffset - offset; + packets[index].Status = urb->UrbIsochronousTransfer.IsoPacket[index].Status; + } + payload = (UCHAR *)operation + operation->PayloadOffset; + if (payloadLength > 0) { + RtlCopyMemory(payload, transferBuffer, payloadLength); + } + + requestContext->TransferLength = transferLength; + requestContext->IsoPacketCount = packetCount; + requestContext->DirectionIn = directionIn; + WdfRequestSetInformation(DequeueRequest, totalLength); + InterlockedIncrement64(&ControllerContext->OperationsDequeued); + if (!directionIn) { + InterlockedAdd64(&ControllerContext->BytesToDevice, transferLength); + } + return STATUS_SUCCESS; +} + +static +VOID +ViiperRemovePublishingRequest( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status + ) +{ + BOOLEAN ownsRequest = FALSE; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token)) { + ViiperClearSlotLocked(ControllerContext, Slot); + ownsRequest = TRUE; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (ownsRequest) { + UdecxUrbCompleteWithNtStatus(Request, Status); + } +} + +static +VOID +ViiperDispatchAvailable( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + for (;;) { + WDFREQUEST urbRequest = WDF_NO_HANDLE; + WDFREQUEST dequeueRequest = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + ULONGLONG token = 0; + ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + ULONG index; + NTSTATUS status; + BOOLEAN abortPending = FALSE; + NTSTATUS abortStatus = STATUS_CANCELLED; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + ULONG candidate = (controllerContext->NextPendingSlot + index) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; + if (pending->State == ViiperUdePendingQueued) { + status = WdfIoQueueRetrieveNextRequest( + controllerContext->WaitingDequeues, &dequeueRequest); + if (!NT_SUCCESS(status)) { + dequeueRequest = WDF_NO_HANDLE; + break; + } + pending->State = ViiperUdePendingPublishing; + urbRequest = pending->Request; + endpoint = pending->Endpoint; + token = pending->Token; + slot = candidate; + WdfObjectReference(urbRequest); + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + break; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (urbRequest == WDF_NO_HANDLE || dequeueRequest == WDF_NO_HANDLE) { + break; + } + + status = WdfRequestUnmarkCancelable(urbRequest); + if (status == STATUS_CANCELLED) { + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } + if (!NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, status); + WdfRequestComplete(dequeueRequest, status); + WdfObjectDereference(urbRequest); + continue; + } + + status = ViiperSerializeOperation( + controllerContext, urbRequest, endpoint, token, dequeueRequest); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], urbRequest, token)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + } else { + status = STATUS_CANCELLED; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (!NT_SUCCESS(status) || abortPending) { + NTSTATUS completionStatus = abortPending ? abortStatus : status; + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, completionStatus); + WdfRequestComplete(dequeueRequest, completionStatus); + WdfObjectDereference(urbRequest); + continue; + } + + status = WdfRequestMarkCancelableEx(urbRequest, ViiperEvtUrbCancel); + if (!NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, STATUS_CANCELLED); + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } + + abortPending = FALSE; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], urbRequest, token)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + pending->State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingInFlight; + } else { + status = STATUS_CANCELLED; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status) || abortPending) { + NTSTATUS completionStatus = abortPending ? abortStatus : STATUS_CANCELLED; + NTSTATUS unmarkStatus = WdfRequestUnmarkCancelable(urbRequest); + if (NT_SUCCESS(unmarkStatus)) { + ViiperRemovePublishingRequest( + controllerContext, slot, urbRequest, token, completionStatus); + } + WdfRequestComplete(dequeueRequest, completionStatus); + WdfObjectDereference(urbRequest); + continue; + } + + WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); + WdfObjectDereference(urbRequest); + } +} + +NTSTATUS +ViiperQueueDequeueOperation( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + NTSTATUS status = ViiperValidateBrokerOwner(controller, Request); + + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestForwardToIoQueue(Request, controllerContext->WaitingDequeues); + if (!NT_SUCCESS(status)) { + return status; + } + InterlockedIncrement(&controllerContext->WaitingDequeueCount); + ViiperDispatchAvailable(controller); + return STATUS_PENDING; +} + +NTSTATUS +ViiperQueueUrb( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + UDECXUSBENDPOINT endpoint = *ViiperGetQueueEndpoint(Queue); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + ULONG slot; + ULONGLONG token; + NTSTATUS status; + BOOLEAN abortPending = FALSE; + NTSTATUS abortStatus = STATUS_CANCELLED; + + if (deviceContext->Purging || endpointContext->Purging) { + return STATUS_DEVICE_NOT_READY; + } + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = deviceContext->Controller; + requestContext->Endpoint = endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + status = ViiperAllocatePendingSlot( + controllerContext, Request, endpoint, &slot, &token); + if (!NT_SUCCESS(status)) { + return status; + } + requestContext->PendingSlot = slot; + requestContext->Token = token; + + status = WdfRequestMarkCancelableEx(Request, ViiperEvtUrbCancel); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], Request, token)) { + if (NT_SUCCESS(status)) { + abortPending = controllerContext->PendingSlots[slot].AbortPending; + abortStatus = controllerContext->PendingSlots[slot].AbortStatus; + controllerContext->PendingSlots[slot].State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingQueued; + } else { + ViiperClearSlotLocked(controllerContext, slot); + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return STATUS_CANCELLED; + } + if (abortPending) { + status = WdfRequestUnmarkCancelable(Request); + if (NT_SUCCESS(status)) { + ViiperRemovePublishingRequest( + controllerContext, slot, Request, token, abortStatus); + } + return STATUS_PENDING; + } + + ViiperDispatchAvailable(deviceContext->Controller); + return STATUS_PENDING; +} + +static +BOOLEAN +ViiperRangeValid( + _In_ ULONG Offset, + _In_ ULONG Length, + _In_ ULONG Total + ) +{ + return Offset <= Total && Length <= Total - Offset; +} + +NTSTATUS +ViiperCompleteOperation( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST CompletionRequest + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_COMPLETION *completion; + VIIPER_UDE_REQUEST_CONTEXT *requestContext; + VIIPER_UDE_ISO_PACKET *packets = NULL; + UCHAR *tail = NULL; + UCHAR *payload = NULL; + WDFREQUEST urbRequest = WDF_NO_HANDLE; + PURB urb; + UCHAR *transferBuffer = NULL; + ULONG transferBufferLength = 0; + size_t inputLength; + size_t tailLength = 0; + ULONG slot; + ULONG index; + NTSTATUS status; + BOOLEAN slotRemoved = FALSE; + + status = ViiperValidateBrokerOwner(controller, CompletionRequest); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveInputBuffer( + CompletionRequest, sizeof(*completion), (PVOID *)&completion, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (inputLength != sizeof(*completion) || completion->Header.Magic != VIIPER_UDE_MAGIC || + completion->Header.Major != VIIPER_UDE_ABI_MAJOR || + completion->Header.Size < sizeof(*completion) || completion->Token == 0 || + completion->DeviceId == 0 || completion->Generation == 0 || + completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || + completion->PayloadLength > VIIPER_UDE_MAX_TRANSFER_BYTES || + completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + tailLength = completion->Header.Size - sizeof(*completion); + if (tailLength > 0) { + status = WdfRequestRetrieveOutputBuffer( + CompletionRequest, tailLength, (PVOID *)&tail, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + } + if (!ViiperRangeValid( + completion->IsoPacketsOffset, + completion->IsoPacketCount * sizeof(VIIPER_UDE_ISO_PACKET), + completion->Header.Size) || + !ViiperRangeValid( + completion->PayloadOffset, completion->PayloadLength, completion->Header.Size) || + (completion->IsoPacketCount != 0 && completion->IsoPacketsOffset < sizeof(*completion)) || + (completion->PayloadLength != 0 && completion->PayloadOffset < sizeof(*completion))) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + if (completion->IsoPacketCount != 0) { + packets = (VIIPER_UDE_ISO_PACKET *)( + tail + completion->IsoPacketsOffset - sizeof(*completion)); + } + if (completion->PayloadLength != 0) { + payload = tail + completion->PayloadOffset - sizeof(*completion); + } + + slot = (ULONG)(completion->Token & MAXULONG); + if (slot == 0 || slot > VIIPER_UDE_MAX_PENDING_OPERATIONS) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + --slot; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->PendingSlots[slot].Token == completion->Token && + controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight) { + urbRequest = controllerContext->PendingSlots[slot].Request; + controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; + WdfObjectReference(urbRequest); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (urbRequest == WDF_NO_HANDLE) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + + status = WdfRequestUnmarkCancelable(urbRequest); + if (!NT_SUCCESS(status)) { + WdfObjectDereference(urbRequest); + InterlockedIncrement64(&controllerContext->LateCompletions); + return status; + } + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (ViiperSlotMatches( + &controllerContext->PendingSlots[slot], urbRequest, completion->Token)) { + ViiperClearSlotLocked(controllerContext, slot); + slotRemoved = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!slotRemoved) { + InterlockedIncrement64(&controllerContext->LateCompletions); + WdfObjectDereference(urbRequest); + return STATUS_NOT_FOUND; + } + + requestContext = ViiperGetRequestContext(urbRequest); + urb = ViiperGetUrb(urbRequest); + if (urb == NULL || completion->DeviceId != + ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->DeviceId || + completion->Generation != + ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->Generation || + completion->TransferLength > requestContext->TransferLength || + completion->IsoPacketCount != requestContext->IsoPacketCount || + (requestContext->DirectionIn && completion->PayloadLength != completion->TransferLength)) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + if (!NT_SUCCESS((NTSTATUS)completion->Status)) { + status = (NTSTATUS)completion->Status; + goto CompleteWithNtStatus; + } + + if (requestContext->DirectionIn && completion->TransferLength > 0) { + status = UdecxUrbRetrieveBuffer( + urbRequest, &transferBuffer, &transferBufferLength); + if (!NT_SUCCESS(status) || transferBufferLength < completion->TransferLength) { + status = NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; + goto CompleteWithNtStatus; + } + RtlCopyMemory(transferBuffer, payload, completion->TransferLength); + InterlockedAdd64(&controllerContext->BytesFromDevice, completion->TransferLength); + } + if (completion->IsoPacketCount != 0) { + for (index = 0; index < completion->IsoPacketCount; ++index) { + if (packets[index].Offset > completion->TransferLength || + packets[index].Length > completion->TransferLength - packets[index].Offset) { + status = STATUS_INVALID_PARAMETER; + goto CompleteWithNtStatus; + } + urb->UrbIsochronousTransfer.IsoPacket[index].Offset = packets[index].Offset; + urb->UrbIsochronousTransfer.IsoPacket[index].Length = packets[index].Length; + urb->UrbIsochronousTransfer.IsoPacket[index].Status = packets[index].Status; + } + InterlockedAdd64(&controllerContext->IsoPackets, completion->IsoPacketCount); + } + + UdecxUrbSetBytesCompleted(urbRequest, completion->TransferLength); + UdecxUrbComplete(urbRequest, (USBD_STATUS)completion->UsbdStatus); + InterlockedIncrement64(&controllerContext->OperationsCompleted); + WdfObjectDereference(urbRequest); + return STATUS_SUCCESS; + +CompleteWithNtStatus: + UdecxUrbCompleteWithNtStatus(urbRequest, status); + WdfObjectDereference(urbRequest); + return status; +} + +static +VOID +ViiperAbortMatchingOperations( + _In_ WDFDEVICE Controller, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + ULONG index; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->PendingSlots == NULL) { + return; + } + + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + WDFREQUEST request = WDF_NO_HANDLE; + ULONGLONG token = 0; + NTSTATUS unmarkStatus; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->PendingSlots[index].State != ViiperUdePendingEmpty && + (Endpoint == WDF_NO_HANDLE || controllerContext->PendingSlots[index].Endpoint == Endpoint)) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[index]; + if (pending->State == ViiperUdePendingPublishing) { + pending->AbortPending = TRUE; + pending->AbortStatus = Status; + } else if (pending->State != ViiperUdePendingPreparing && + pending->State != ViiperUdePendingCompleting) { + request = pending->Request; + token = pending->Token; + pending->State = ViiperUdePendingCompleting; + WdfObjectReference(request); + } else { + pending->AbortPending = TRUE; + pending->AbortStatus = Status; + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (request == WDF_NO_HANDLE) { + continue; + } + unmarkStatus = WdfRequestUnmarkCancelable(request); + if (NT_SUCCESS(unmarkStatus)) { + ViiperRemovePublishingRequest( + controllerContext, index, request, token, Status); + InterlockedIncrement64(&controllerContext->OperationsPurged); + } + WdfObjectDereference(request); + } +} + +VOID +ViiperPurgeEndpointOperations( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + ViiperAbortMatchingOperations(deviceContext->Controller, Endpoint, Status); +} + +VOID +ViiperPurgeOwnerOperations( + _In_ WDFDEVICE Controller, + _In_ NTSTATUS Status + ) +{ + ViiperAbortMatchingOperations(Controller, WDF_NO_HANDLE, Status); +} diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 8bb7a1ef..47674fb8 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -27,9 +27,12 @@ ViiperEvtQueryUsbCapability( UNREFERENCED_PARAMETER(OutputBuffer); *ResultLength = 0; - if (IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_CHAINED_MDLS) || - IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_SELECTIVE_SUSPEND) || - IsEqualGUIDAligned(*CapabilityType, GUID_USB_CAPABILITY_DEVICE_CONNECTION_HIGH_SPEED_COMPATIBLE)) { + if (RtlEqualMemory(CapabilityType, &GUID_USB_CAPABILITY_CHAINED_MDLS, sizeof(GUID)) || + RtlEqualMemory(CapabilityType, &GUID_USB_CAPABILITY_SELECTIVE_SUSPEND, sizeof(GUID)) || + RtlEqualMemory( + CapabilityType, + &GUID_USB_CAPABILITY_DEVICE_CONNECTION_HIGH_SPEED_COMPATIBLE, + sizeof(GUID))) { return STATUS_SUCCESS; } @@ -46,6 +49,7 @@ ViiperEvtDeviceAdd( WDFDEVICE device; WDF_OBJECT_ATTRIBUTES attributes; WDF_OBJECT_ATTRIBUTES fileAttributes; + WDF_OBJECT_ATTRIBUTES requestAttributes; WDF_FILEOBJECT_CONFIG fileConfig; UDECX_WDF_DEVICE_CONFIG udeConfig; VIIPER_UDE_CONTROLLER_CONTEXT *context; @@ -68,6 +72,9 @@ ViiperEvtDeviceAdd( WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&fileAttributes, VIIPER_UDE_FILE_CONTEXT); WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, &fileAttributes); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&requestAttributes, VIIPER_UDE_REQUEST_CONTEXT); + WdfDeviceInitSetRequestAttributes(DeviceInit, &requestAttributes); + status = UdecxInitializeWdfDeviceInit(DeviceInit); if (!NT_SUCCESS(status)) { return status; @@ -93,6 +100,10 @@ ViiperEvtDeviceAdd( if (!NT_SUCCESS(status)) { return status; } + status = ViiperInitializeBroker(device); + if (!NT_SUCCESS(status)) { + return status; + } status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_VIIPER_UDE, NULL); if (!NT_SUCCESS(status)) { @@ -119,11 +130,13 @@ ViiperEvtControllerCleanup( PAGED_CODE(); context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + ViiperPurgeOwnerOperations((WDFDEVICE)ControllerObject, STATUS_DEVICE_REMOVED); if (context->DefaultQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->DefaultQueue); } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + InterlockedExchange(&context->WaitingDequeueCount, 0); } } @@ -178,11 +191,13 @@ ViiperEvtFileCleanup( WdfWaitLockRelease(context->OwnerLock); if (ownsController) { + ViiperPurgeOwnerOperations(device, STATUS_FILE_CLOSED); if (context->DefaultQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->DefaultQueue); } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); + InterlockedExchange(&context->WaitingDequeueCount, 0); } } if (ownsController) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 7fd154b9..fedff3be 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -481,6 +481,7 @@ ViiperCreateEndpointQueue( queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, UDECXUSBENDPOINT); attributes.ParentObject = Endpoint; + attributes.ExecutionLevel = WdfExecutionLevelPassive; status = WdfIoQueueCreate(deviceContext->Controller, &queueConfig, &attributes, &endpointContext->Queue); if (!NT_SUCCESS(status)) { return status; @@ -583,6 +584,7 @@ ViiperEvtEndpointPurge( { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); endpointContext->Purging = TRUE; + ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); } @@ -619,7 +621,10 @@ ViiperEvtEndpointIoInternalControl( UNREFERENCED_PARAMETER(OutputBufferLength); UNREFERENCED_PARAMETER(InputBufferLength); if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB) { - UdecxUrbCompleteWithNtStatus(Request, STATUS_NOT_SUPPORTED); + NTSTATUS status = ViiperQueueUrb(Queue, Request); + if (status != STATUS_PENDING) { + UdecxUrbCompleteWithNtStatus(Request, status); + } } else { WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST); } diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 5a67b503..20aea7c8 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -169,6 +169,12 @@ ViiperEvtIoDeviceControl( case IOCTL_VIIPER_UDE_DESTROY_DEVICE: status = ViiperDestroyVirtualDevice(Queue, Request); break; + case IOCTL_VIIPER_UDE_DEQUEUE_OPERATION: + status = ViiperQueueDequeueOperation(Queue, Request); + break; + case IOCTL_VIIPER_UDE_COMPLETE_OPERATION: + status = ViiperCompleteOperation(Queue, Request); + break; default: status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) ? STATUS_PENDING diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index fa523f4d..83dba898 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -13,9 +13,44 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; +typedef enum VIIPER_UDE_PENDING_STATE { + ViiperUdePendingEmpty = 0, + ViiperUdePendingPreparing, + ViiperUdePendingQueued, + ViiperUdePendingPublishing, + ViiperUdePendingInFlight, + ViiperUdePendingCompleting +} VIIPER_UDE_PENDING_STATE; + +typedef struct VIIPER_UDE_PENDING_SLOT { + WDFREQUEST Request; + UDECXUSBENDPOINT Endpoint; + ULONGLONG Token; + ULONG Generation; + VIIPER_UDE_PENDING_STATE State; + BOOLEAN AbortPending; + NTSTATUS AbortStatus; +} VIIPER_UDE_PENDING_SLOT; + +typedef struct VIIPER_UDE_REQUEST_CONTEXT { + WDFDEVICE Controller; + UDECXUSBENDPOINT Endpoint; + ULONG PendingSlot; + ULONGLONG Token; + ULONG TransferLength; + ULONG IsoPacketCount; + BOOLEAN DirectionIn; +} VIIPER_UDE_REQUEST_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestContext) + typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFWAITLOCK OwnerLock; WDFWAITLOCK DeviceLock; + WDFSPINLOCK BrokerLock; + WDFMEMORY PendingStorage; + VIIPER_UDE_PENDING_SLOT *PendingSlots; + ULONG NextPendingSlot; WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; @@ -92,6 +127,12 @@ EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); +NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); VOID ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); +NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); +VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index f15a74b5..c218bed6 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -66,6 +66,7 @@ + From a13391b3b631798adc92e93384bb3dacc411fc63 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:31:30 -0500 Subject: [PATCH 010/240] native/ude: add ordered host transport and exact cancellation Build immutable UDE descriptor snapshots from the same configuration encoder used by USB/IP, and route native control, interrupt, and isochronous operations through the existing device engines. Assign monotonic sequences per device endpoint so parallel overlapped dequeues cannot reorder USB traffic. Add an allocation-free kernel cancellation event ring keyed by the original URB token and a bounded host token-state table so Windows purge/cancel interrupts in-flight device work without timeouts or double completion. Carry explicit ISO transfer spans separately from packet actual lengths, preserve sparse ISO-IN layouts, expose cancellation telemetry, and test out-of-order dequeue, stale generations, rollback, pre-dispatch cancellation, and active processor cancellation. --- internal/server/usb/native.go | 243 ++++++++++ internal/server/usb/server.go | 54 +-- internal/transport/udecx/descriptors.go | 64 +++ internal/transport/udecx/descriptors_test.go | 54 +++ internal/transport/udecx/host.go | 483 +++++++++++++++++++ internal/transport/udecx/host_test.go | 271 +++++++++++ internal/transport/udecx/protocol.go | 132 ++--- internal/transport/udecx/protocol_test.go | 14 +- native/udecx/driver/Broker.c | 173 ++++++- native/udecx/driver/Controller.c | 12 + native/udecx/driver/Ioctl.c | 2 + native/udecx/driver/ViiperUde.h | 19 + native/udecx/include/ViiperUdeProtocol.h | 16 +- usb/usbdesc.go | 61 +++ 14 files changed, 1465 insertions(+), 133 deletions(-) create mode 100644 internal/server/usb/native.go create mode 100644 internal/transport/udecx/descriptors.go create mode 100644 internal/transport/udecx/descriptors_test.go create mode 100644 internal/transport/udecx/host.go create mode 100644 internal/transport/udecx/host_test.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go new file mode 100644 index 00000000..6b82a48e --- /dev/null +++ b/internal/server/usb/native.go @@ -0,0 +1,243 @@ +package usb + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type nativeLaneKey struct { + deviceID uint64 + generation uint32 + endpoint uint8 +} + +// NativeProcessor adapts the native UdeCx broker to the same control and +// transfer engine used by USB/IP. Transport-specific clocks live here; device +// state, feedback, HID, audio, and descriptor behavior remain in usb.Device. +type NativeProcessor struct { + server *Server + mu sync.Mutex + next map[nativeLaneKey]time.Time + lastIn map[nativeLaneKey][]byte +} + +func NewNativeProcessor(server *Server) (*NativeProcessor, error) { + if server == nil { + return nil, errors.New("native UDE processor requires a USB server engine") + } + return &NativeProcessor{ + server: server, + next: make(map[nativeLaneKey]time.Time), + lastIn: make(map[nativeLaneKey][]byte), + }, nil +} + +func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdentity) { + p.server.resetInterfaceAlts(dev) + p.mu.Lock() + for key := range p.next { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.next, key) + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + +func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op udecx.Operation) (udecx.Completion, error) { + if dev == nil { + return udecx.Completion{}, errors.New("native UDE operation has no device") + } + if op.TransferLength > udecx.MaxTransferBytes || len(op.Payload) > udecx.MaxTransferBytes { + return udecx.Completion{}, udecx.ErrLimitExceeded + } + ep := uint32(op.EndpointAddress & 0x0f) + dir := uint32(usbip.DirOut) + if op.Direction != 0 { + dir = usbip.DirIn + } + key := nativeLaneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} + + switch { + case op.Kind == udecx.OperationControl: + return p.processControl(ctx, dev, op, ep, dir) + case len(op.IsoPackets) != 0 && dir == usbip.DirIn: + return p.processIsoIn(ctx, dev, op, ep, dir, key) + case len(op.IsoPackets) != 0: + return p.processIsoOut(ctx, dev, op, ep, dir, key) + case dir == usbip.DirIn: + return p.processInterruptIn(ctx, dev, op, ep, dir, key) + default: + p.server.processSubmit(ctx, dev, ep, dir, nil, op.Payload) + return successCompletion(op, op.TransferLength, nil, nil), ctx.Err() + } +} + +func (p *NativeProcessor) processControl(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32) (udecx.Completion, error) { + setup := op.SetupPacket[:] + response := p.server.processSubmit(ctx, dev, ep, dir, setup, op.Payload) + if err := ctx.Err(); err != nil { + return udecx.Completion{}, err + } + if dir == usbip.DirOut { + return successCompletion(op, op.TransferLength, nil, nil), nil + } + if uint32(len(response)) > op.TransferLength { + response = response[:op.TransferLength] + } + return successCompletion(op, uint32(len(response)), response, nil), nil +} + +func (p *NativeProcessor) processInterruptIn(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { + interval := endpointInterval(dev.GetDescriptor(), ep) + if interval <= 0 { + interval = time.Millisecond + } + + for { + serviceTime := p.reserveServiceTime(key, interval) + if !waitUntilContext(ctx, serviceTime) { + return udecx.Completion{}, ctx.Err() + } + attemptCtx, cancel := context.WithTimeout(ctx, interval) + response := p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + expired := len(response) == 0 && errors.Is(attemptCtx.Err(), context.DeadlineExceeded) + cancel() + if ctx.Err() != nil { + return udecx.Completion{}, ctx.Err() + } + if len(response) != 0 { + if uint32(len(response)) > op.TransferLength { + response = response[:op.TransferLength] + } + p.mu.Lock() + p.lastIn[key] = append(p.lastIn[key][:0], response...) + p.mu.Unlock() + return successCompletion(op, uint32(len(response)), response, nil), nil + } + if expired { + p.mu.Lock() + cached := append([]byte(nil), p.lastIn[key]...) + p.mu.Unlock() + if len(cached) != 0 { + if uint32(len(cached)) > op.TransferLength { + cached = cached[:op.TransferLength] + } + return successCompletion(op, uint32(len(cached)), cached, nil), nil + } + continue + } + return successCompletion(op, 0, nil, nil), nil + } +} + +func (p *NativeProcessor) processIsoOut(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { + duration := isoCompletionDelay(dev.GetDescriptor(), ep, len(op.IsoPackets)) + deadline := p.reserveCompletionDeadline(key, duration) + p.server.processSubmit(ctx, dev, ep, dir, nil, op.Payload) + if !waitUntilContext(ctx, deadline) { + return udecx.Completion{}, ctx.Err() + } + packets := make([]udecx.IsoPacket, len(op.IsoPackets)) + for i, packet := range op.IsoPackets { + packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: packet.Length} + } + return successCompletion(op, op.TransferLength, nil, packets), nil +} + +func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device, + op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { + interval := isoPacketInterval(dev.GetDescriptor(), ep) + if interval <= 0 { + interval = time.Millisecond + } + duration := time.Duration(len(op.IsoPackets)) * interval + serviceStart := p.reserveServiceWindow(key, duration, interval) + payload := make([]byte, op.TransferLength) + packets := make([]udecx.IsoPacket, len(op.IsoPackets)) + actualTotal := uint32(0) + serviceTime := serviceStart + for i, packet := range op.IsoPackets { + if packet.Offset > op.TransferLength || packet.Length > op.TransferLength-packet.Offset { + return udecx.Completion{}, fmt.Errorf("native ISO packet %d is outside transfer buffer", i) + } + if !waitUntilContext(ctx, serviceTime) { + return udecx.Completion{}, ctx.Err() + } + serviceTime = serviceTime.Add(interval) + attemptCtx, cancel := context.WithTimeout(ctx, interval) + packetData := p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + cancel() + if ctx.Err() != nil { + return udecx.Completion{}, ctx.Err() + } + if len(packetData) == 0 { + packetData = make([]byte, packet.Length) + } + actual := min(packet.Length, uint32(len(packetData))) + copy(payload[packet.Offset:packet.Offset+actual], packetData[:actual]) + packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: actual} + actualTotal += actual + } + return successCompletion(op, actualTotal, payload, packets), nil +} + +func (p *NativeProcessor) reserveServiceTime(key nativeLaneKey, interval time.Duration) time.Time { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + serviceTime := p.next[key] + if serviceTime.IsZero() || now.Sub(serviceTime) >= interval { + serviceTime = now + } + p.next[key] = serviceTime.Add(interval) + return serviceTime +} + +func (p *NativeProcessor) reserveCompletionDeadline(key nativeLaneKey, duration time.Duration) time.Time { + if duration <= 0 { + return time.Now() + } + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + deadline := p.next[key] + if deadline.IsZero() || now.Sub(deadline) >= duration { + deadline = now.Add(duration) + } else { + deadline = deadline.Add(duration) + } + p.next[key] = deadline + return deadline +} + +func (p *NativeProcessor) reserveServiceWindow(key nativeLaneKey, duration, interval time.Duration) time.Time { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + start := p.next[key] + if start.IsZero() || (interval > 0 && now.Sub(start) >= interval) { + start = now + } + p.next[key] = start.Add(duration) + return start +} + +func successCompletion(op udecx.Operation, transferLength uint32, payload []byte, + packets []udecx.IsoPacket) udecx.Completion { + return udecx.Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + Status: 0, USBDStatus: 0, IsoPackets: packets, Payload: payload, + TransferLength: transferLength, + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 4e279da3..43669b86 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -1626,57 +1626,11 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d } func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { - var b bytes.Buffer - configValue := desc.Configuration.BConfigurationValue - if configValue == 0 { - configValue = usbConfigValueDefault - } - attrs := desc.Configuration.BMAttributes - if attrs == 0 { - attrs = usbConfigAttrBusPowered - } - maxPower := desc.Configuration.BMaxPower - if maxPower == 0 { - maxPower = usbConfigMaxPower100mA - } - h := usb.ConfigHeader{ - WTotalLength: 0, // to be patched - BNumInterfaces: desc.NumInterfaces(), - BConfigurationValue: configValue, - IConfiguration: desc.Configuration.IConfiguration, - BMAttributes: attrs, - BMaxPower: maxPower, - } - h.Write(&b) - for _, iface := range desc.Interfaces { - for _, iad := range desc.Associations { - if iad.BFirstInterface == iface.Descriptor.BInterfaceNumber && iface.Descriptor.BAlternateSetting == 0 { - iad.Write(&b) - } - } - iface.Descriptor.Write(&b) - if iface.HID != nil { - hd, err := iface.HID.DescriptorBytes() - if err != nil { - s.logger.Error("failed to build HID descriptor", "iface", iface.Descriptor.BInterfaceNumber, "error", err) - // Stall/return minimal config descriptor. - return nil - } - b.Write([]byte(hd)) - } - for _, cd := range iface.ClassDescriptors { - b.Write([]byte(cd.Bytes())) - } - for _, ep := range iface.Endpoints { - ep.Write(&b) - for _, cd := range ep.ClassDescriptors { - b.Write([]byte(cd.Bytes())) - } - } + data, err := desc.ConfigurationBytes() + if err != nil { + s.logger.Error("failed to build configuration descriptor", "error", err) + return nil } - - data := b.Bytes() - binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) return data } diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go new file mode 100644 index 00000000..679ac266 --- /dev/null +++ b/internal/transport/udecx/descriptors.go @@ -0,0 +1,64 @@ +package udecx + +import ( + "fmt" + "sort" + + "github.com/Alia5/VIIPER/usb" +) + +const defaultDevicePendingOperations = 512 + +// SnapshotDevice builds the immutable descriptor payload used to create one +// native UdeCx child. It intentionally consumes the same usb.Descriptor object +// as the existing USB/IP server so switching transports cannot silently change +// a controller's VID/PID, HID reports, audio topology, or string descriptors. +func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateDevice, error) { + if dev == nil || dev.GetDescriptor() == nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE device: nil USB device") + } + desc := dev.GetDescriptor() + deviceDescriptor := desc.Bytes() + configurationDescriptor, err := desc.ConfigurationBytes() + if err != nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE configuration: %w", err) + } + + message := CreateDevice{ + DeviceID: deviceID, + Generation: generation, + Speed: DeviceSpeed(desc.Device.Speed), + MaxPendingOperations: defaultDevicePendingOperations, + } + appendDescriptor := func(kind DescriptorKind, index, languageID uint16, data []byte) { + offset := uint32(len(message.DescriptorData)) + message.DescriptorData = append(message.DescriptorData, data...) + message.Descriptors = append(message.Descriptors, DescriptorRecord{ + Kind: kind, Index: index, LanguageID: languageID, + Offset: offset, Length: uint32(len(data)), + }) + } + appendDescriptor(DescriptorDevice, 0, 0, deviceDescriptor) + appendDescriptor(DescriptorConfiguration, 0, 0, configurationDescriptor) + + indices := make([]int, 0, len(desc.Strings)) + for index := range desc.Strings { + indices = append(indices, int(index)) + } + sort.Ints(indices) + for _, value := range indices { + index := uint8(value) + languageID := uint16(0x0409) + if index == 0 { + languageID = 0 + } + appendDescriptor( + DescriptorString, uint16(index), languageID, + usb.EncodeStringDescriptor(desc.Strings[index])) + } + + if _, err := message.MarshalBinary(); err != nil { + return CreateDevice{}, fmt.Errorf("snapshot native UDE descriptors: %w", err) + } + return message, nil +} diff --git a/internal/transport/udecx/descriptors_test.go b/internal/transport/udecx/descriptors_test.go new file mode 100644 index 00000000..816cf681 --- /dev/null +++ b/internal/transport/udecx/descriptors_test.go @@ -0,0 +1,54 @@ +package udecx + +import ( + "context" + "encoding/binary" + "testing" + + "github.com/Alia5/VIIPER/usb" +) + +type snapshotDevice struct{ descriptor usb.Descriptor } + +func (d *snapshotDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + return nil +} +func (d *snapshotDevice) GetDescriptor() *usb.Descriptor { return &d.descriptor } +func (d *snapshotDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestSnapshotDevicePreservesDescriptorBytes(t *testing.T) { + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x054c, + IDProduct: 0x0ce6, BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, + Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: 0x84, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}, + }}, + Strings: map[uint8]string{0: "\u0409", 2: "Controller"}, + }} + + snapshot, err := SnapshotDevice(7, 3, dev) + if err != nil { + t.Fatal(err) + } + if snapshot.DeviceID != 7 || snapshot.Generation != 3 || snapshot.Speed != DeviceSpeedHigh { + t.Fatalf("unexpected identity: %+v", snapshot) + } + if len(snapshot.Descriptors) != 4 { + t.Fatalf("descriptor count=%d want=4", len(snapshot.Descriptors)) + } + if snapshot.Descriptors[0].Kind != DescriptorDevice || snapshot.Descriptors[1].Kind != DescriptorConfiguration || + snapshot.Descriptors[2].Index != 0 || snapshot.Descriptors[3].Index != 2 { + t.Fatalf("unexpected descriptor ordering: %+v", snapshot.Descriptors) + } + config := snapshot.DescriptorData[snapshot.Descriptors[1].Offset : snapshot.Descriptors[1].Offset+snapshot.Descriptors[1].Length] + if got := binary.LittleEndian.Uint16(config[2:4]); got != uint16(len(config)) { + t.Fatalf("configuration total length=%d want=%d", got, len(config)) + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go new file mode 100644 index 00000000..a9abb033 --- /dev/null +++ b/internal/transport/udecx/host.go @@ -0,0 +1,483 @@ +package udecx + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/Alia5/VIIPER/usb" +) + +const ( + defaultDequeueWorkers = 8 + laneQueueDepth = 128 + completionTimeout = 2 * time.Second + completedTokenHistory = MaxPendingOperations * 2 + statusUnsuccessful = int32(-1073741823) // STATUS_UNSUCCESSFUL +) + +// Driver is the narrow host-side contract implemented by the overlapped +// Windows UdeCx client. Keeping it as an interface makes ordering, teardown, +// and stale-generation behavior testable without loading a kernel driver. +type Driver interface { + CreateDevice(context.Context, CreateDevice) error + DestroyDevice(context.Context, DeviceIdentity) error + Dequeue(context.Context, []byte) (Operation, error) + Complete(context.Context, Completion) error + QueryStats(context.Context) (Stats, error) +} + +// OperationProcessor translates one native USB operation through VIIPER's +// existing usb.Device engines. Implementations must not retain operation +// payload slices after Process returns. +type OperationProcessor interface { + Process(context.Context, usb.Device, Operation) (Completion, error) + Reset(usb.Device, DeviceIdentity) +} + +type registeredDevice struct { + identity DeviceIdentity + device usb.Device + ctx context.Context + cancel context.CancelFunc +} + +type laneKey struct { + deviceID uint64 + generation uint32 + endpoint uint8 +} + +type operationLane struct { + key laneKey + ctx context.Context + cancel context.CancelFunc + input chan Operation +} + +type operationState struct { + deviceID uint64 + generation uint32 + cancel context.CancelFunc + cancelled bool + received bool + processing bool + done bool +} + +// Host owns one exclusive driver session and routes operations concurrently +// across endpoints while preserving strict FIFO within each endpoint. +type Host struct { + driver Driver + processor OperationProcessor + workers int + + lifecycleMu sync.Mutex + mu sync.RWMutex + devices map[uint64]*registeredDevice + generations map[uint64]uint32 + lanes map[laneKey]*operationLane + runCtx context.Context + runCancel context.CancelFunc + running bool + laneWG sync.WaitGroup + operationMu sync.Mutex + operations map[uint64]*operationState + completed []uint64 +} + +func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, error) { + if driver == nil || processor == nil { + return nil, errors.New("native UDE host requires a driver and operation processor") + } + if workers <= 0 { + workers = defaultDequeueWorkers + } + return &Host{ + driver: driver, processor: processor, workers: workers, + devices: make(map[uint64]*registeredDevice), + generations: make(map[uint64]uint32), + lanes: make(map[laneKey]*operationLane), + operations: make(map[uint64]*operationState), + }, nil +} + +// Register publishes a USB device using a fresh generation. The routing entry +// is installed before the driver plugs in the child because Windows can submit +// its first descriptor request before CreateDevice returns. +func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceIdentity, error) { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + if deviceID == 0 || dev == nil { + return DeviceIdentity{}, ErrInvalidRange + } + + h.mu.Lock() + if _, exists := h.devices[deviceID]; exists { + h.mu.Unlock() + return DeviceIdentity{}, fmt.Errorf("native UDE device %d is already registered", deviceID) + } + generation := h.generations[deviceID] + 1 + if generation == 0 { + generation = 1 + } + identity := DeviceIdentity{DeviceID: deviceID, Generation: generation} + deviceCtx, cancel := context.WithCancel(context.Background()) + entry := ®isteredDevice{identity: identity, device: dev, ctx: deviceCtx, cancel: cancel} + h.devices[deviceID] = entry + h.generations[deviceID] = generation + h.mu.Unlock() + + snapshot, err := SnapshotDevice(deviceID, generation, dev) + if err == nil { + err = h.driver.CreateDevice(ctx, snapshot) + } + if err != nil { + h.mu.Lock() + if h.devices[deviceID] == entry { + delete(h.devices, deviceID) + } + h.mu.Unlock() + cancel() + return DeviceIdentity{}, err + } + return identity, nil +} + +func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + + h.mu.Lock() + entry := h.devices[identity.DeviceID] + if entry == nil || entry.identity.Generation != identity.Generation { + h.mu.Unlock() + return fmt.Errorf("native UDE device %d generation %d is not registered", + identity.DeviceID, identity.Generation) + } + delete(h.devices, identity.DeviceID) + entry.cancel() + for key, lane := range h.lanes { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + lane.cancel() + delete(h.lanes, key) + } + } + h.mu.Unlock() + h.cancelDeviceOperations(identity) + h.processor.Reset(entry.device, identity) + return h.driver.DestroyDevice(ctx, identity) +} + +type dequeueResult struct { + op Operation + err error +} + +func (h *Host) Serve(ctx context.Context) error { + h.mu.Lock() + if h.running { + h.mu.Unlock() + return errors.New("native UDE host is already running") + } + runCtx, cancel := context.WithCancel(ctx) + h.runCtx, h.runCancel, h.running = runCtx, cancel, true + h.mu.Unlock() + defer func() { + cancel() + h.mu.Lock() + for key, lane := range h.lanes { + lane.cancel() + delete(h.lanes, key) + } + h.running, h.runCtx, h.runCancel = false, nil, nil + h.mu.Unlock() + h.laneWG.Wait() + }() + + results := make(chan dequeueResult, h.workers*2) + var workers sync.WaitGroup + workers.Add(h.workers) + for i := 0; i < h.workers; i++ { + go func() { + defer workers.Done() + buffer := make([]byte, OperationSize+MaxIsoPackets*IsoPacketSize+MaxTransferBytes) + for runCtx.Err() == nil { + op, err := h.driver.Dequeue(runCtx, buffer) + select { + case results <- dequeueResult{op: op, err: err}: + case <-runCtx.Done(): + return + } + if err != nil { + return + } + } + }() + } + + for { + select { + case <-runCtx.Done(): + workers.Wait() + return nil + case result := <-results: + if result.err != nil { + cancel() + workers.Wait() + if ctx.Err() != nil || errors.Is(result.err, context.Canceled) { + return nil + } + return fmt.Errorf("dequeue native UDE operation: %w", result.err) + } + if result.op.Kind == OperationCancel { + h.cancelOperation(result.op) + continue + } + if err := h.trackOperation(result.op); err != nil { + h.completeUntrackedFailure(runCtx, result.op) + continue + } + if err := h.dispatch(runCtx, result.op); err != nil { + h.completeFailure(runCtx, result.op) + } + } + } +} + +func (h *Host) Close() { + h.mu.RLock() + cancel := h.runCancel + h.mu.RUnlock() + if cancel != nil { + cancel() + } +} + +func (h *Host) dispatch(ctx context.Context, op Operation) error { + if op.EndpointSequence == 0 { + return errors.New("native UDE operation has zero endpoint sequence") + } + key := laneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} + + h.mu.Lock() + entry := h.devices[op.DeviceID] + if entry == nil || entry.identity.Generation != op.Generation { + h.mu.Unlock() + return errors.New("native UDE operation targets a stale device generation") + } + lane := h.lanes[key] + if lane == nil { + laneCtx, cancel := context.WithCancel(entry.ctx) + lane = &operationLane{key: key, ctx: laneCtx, cancel: cancel, input: make(chan Operation, laneQueueDepth)} + h.lanes[key] = lane + h.laneWG.Add(1) + go h.runLane(lane, entry) + } + h.mu.Unlock() + + select { + case lane.input <- op: + return nil + case <-lane.ctx.Done(): + return lane.ctx.Err() + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { + defer h.laneWG.Done() + expected := uint64(1) + pending := make(map[uint64]Operation) + for { + select { + case <-lane.ctx.Done(): + return + case op := <-lane.input: + if op.EndpointSequence < expected { + h.completeFailure(lane.ctx, op) + continue + } + if _, duplicate := pending[op.EndpointSequence]; duplicate { + h.completeFailure(lane.ctx, op) + continue + } + pending[op.EndpointSequence] = op + if len(pending) > laneQueueDepth { + for _, queued := range pending { + h.completeFailure(lane.ctx, queued) + } + return + } + for { + current, ready := pending[expected] + if !ready { + break + } + delete(pending, expected) + h.process(lane.ctx, entry.device, current) + expected++ + } + } + } +} + +func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) { + opCtx, cancel, active := h.beginOperation(ctx, op) + if !active { + h.finishOperation(op.Token) + return + } + defer cancel() + + completion, err := h.processor.Process(opCtx, dev, op) + if err != nil { + completion = failureCompletion(op) + } + if h.operationCancelled(op.Token) { + h.finishOperation(op.Token) + return + } + completion.Token = op.Token + completion.DeviceID = op.DeviceID + completion.Generation = op.Generation + completionCtx, completionCancel := context.WithTimeout(ctx, completionTimeout) + defer completionCancel() + _ = h.driver.Complete(completionCtx, completion) + h.finishOperation(op.Token) +} + +func (h *Host) completeFailure(ctx context.Context, op Operation) { + if h.operationCancelled(op.Token) { + h.finishOperation(op.Token) + return + } + completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + _ = h.driver.Complete(completionCtx, failureCompletion(op)) + h.finishOperation(op.Token) +} + +func (h *Host) completeUntrackedFailure(ctx context.Context, op Operation) { + completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + _ = h.driver.Complete(completionCtx, failureCompletion(op)) +} + +func (h *Host) trackOperation(op Operation) error { + if op.Token == 0 { + return errors.New("native UDE operation has zero token") + } + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[op.Token] + if state == nil { + h.operations[op.Token] = &operationState{ + deviceID: op.DeviceID, generation: op.Generation, received: true, + } + return nil + } + if state.done || state.received || state.deviceID != op.DeviceID || state.generation != op.Generation { + return errors.New("native UDE operation reuses a completed or mismatched token") + } + state.received = true + return nil +} + +func (h *Host) beginOperation(parent context.Context, op Operation) (context.Context, context.CancelFunc, bool) { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[op.Token] + if state == nil || state.done || state.cancelled { + return parent, func() {}, false + } + opCtx, cancel := context.WithCancel(parent) + state.cancel = cancel + state.processing = true + return opCtx, cancel, true +} + +func (h *Host) cancelOperation(op Operation) { + if op.Token == 0 || op.DeviceID == 0 || op.Generation == 0 { + return + } + h.mu.RLock() + entry := h.devices[op.DeviceID] + validDevice := entry != nil && entry.identity.Generation == op.Generation + h.mu.RUnlock() + if !validDevice { + return + } + h.operationMu.Lock() + state := h.operations[op.Token] + if state == nil { + state = &operationState{ + deviceID: op.DeviceID, generation: op.Generation, cancelled: true, + } + h.operations[op.Token] = state + } else if !state.done && state.deviceID == op.DeviceID && state.generation == op.Generation { + state.cancelled = true + } + cancel := state.cancel + h.operationMu.Unlock() + if cancel != nil { + cancel() + } +} + +func (h *Host) cancelDeviceOperations(identity DeviceIdentity) { + var cancels []context.CancelFunc + h.operationMu.Lock() + for token, state := range h.operations { + if !state.done && state.deviceID == identity.DeviceID && state.generation == identity.Generation { + state.cancelled = true + if state.cancel != nil { + cancels = append(cancels, state.cancel) + } + if !state.processing { + delete(h.operations, token) + } + } + } + h.operationMu.Unlock() + for _, cancel := range cancels { + cancel() + } +} + +func (h *Host) operationCancelled(token uint64) bool { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[token] + return state != nil && state.cancelled +} + +func (h *Host) finishOperation(token uint64) { + h.operationMu.Lock() + defer h.operationMu.Unlock() + state := h.operations[token] + if state == nil || state.done { + return + } + state.cancel = nil + state.processing = false + state.done = true + h.completed = append(h.completed, token) + if len(h.completed) > completedTokenHistory { + oldest := h.completed[0] + h.completed = h.completed[1:] + if old := h.operations[oldest]; old != nil && old.done { + delete(h.operations, oldest) + } + } +} + +func failureCompletion(op Operation) Completion { + return Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + Status: statusUnsuccessful, + } +} diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go new file mode 100644 index 00000000..acb8c9c8 --- /dev/null +++ b/internal/transport/udecx/host_test.go @@ -0,0 +1,271 @@ +package udecx + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/usb" +) + +type fakeHostDriver struct { + operations chan Operation + completions chan Completion + createErr error + mu sync.Mutex + created []CreateDevice + destroyed []DeviceIdentity +} + +func newFakeHostDriver() *fakeHostDriver { + return &fakeHostDriver{ + operations: make(chan Operation, 16), completions: make(chan Completion, 16), + } +} +func (d *fakeHostDriver) CreateDevice(_ context.Context, device CreateDevice) error { + d.mu.Lock() + defer d.mu.Unlock() + d.created = append(d.created, device) + return d.createErr +} +func (d *fakeHostDriver) DestroyDevice(_ context.Context, identity DeviceIdentity) error { + d.mu.Lock() + defer d.mu.Unlock() + d.destroyed = append(d.destroyed, identity) + return nil +} +func (d *fakeHostDriver) Dequeue(ctx context.Context, _ []byte) (Operation, error) { + select { + case op := <-d.operations: + return op, nil + case <-ctx.Done(): + return Operation{}, ctx.Err() + } +} +func (d *fakeHostDriver) Complete(ctx context.Context, completion Completion) error { + select { + case d.completions <- completion: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (d *fakeHostDriver) QueryStats(context.Context) (Stats, error) { return Stats{}, nil } + +type recordingProcessor struct { + processed chan uint64 + resets chan DeviceIdentity +} + +func (p *recordingProcessor) Process(_ context.Context, _ usb.Device, op Operation) (Completion, error) { + p.processed <- op.EndpointSequence + return Completion{TransferLength: op.TransferLength}, nil +} +func (p *recordingProcessor) Reset(_ usb.Device, identity DeviceIdentity) { p.resets <- identity } + +type cancellableProcessor struct { + started chan struct{} + cancelled chan struct{} +} + +func (p *cancellableProcessor) Process(ctx context.Context, _ usb.Device, _ Operation) (Completion, error) { + close(p.started) + <-ctx.Done() + close(p.cancelled) + return Completion{}, ctx.Err() +} +func (*cancellableProcessor) Reset(usb.Device, DeviceIdentity) {} + +func hostTestDevice() usb.Device { + return &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 1, IDProduct: 2, + BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: 0x81, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}}}, + }} +} + +func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 2), resets: make(chan DeviceIdentity, 1)} + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 9, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, TransferLength: 8, + } + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, TransferLength: 8, + } + + for want := uint64(1); want <= 2; want++ { + select { + case got := <-processor.processed: + if got != want { + t.Fatalf("processed endpoint sequence=%d want=%d", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for endpoint sequence %d", want) + } + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after context cancellation") + } +} + +func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { + driver := newFakeHostDriver() + driver.createErr = errors.New("plug failed") + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + if _, err := host.Register(context.Background(), 4, hostTestDevice()); err == nil { + t.Fatal("register unexpectedly succeeded") + } + driver.createErr = nil + identity, err := host.Register(context.Background(), 4, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + if identity.Generation != 2 { + t.Fatalf("generation=%d want=2 after failed creation", identity.Generation) + } + if err := host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } + select { + case got := <-processor.resets: + if got != identity { + t.Fatalf("reset identity=%+v want=%+v", got, identity) + } + case <-time.After(time.Second): + t.Fatal("processor was not reset during unregister") + } +} + +func TestHostRejectsStaleOperationGeneration(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 5, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + err = host.dispatch(context.Background(), Operation{ + Token: 3, DeviceID: identity.DeviceID, Generation: identity.Generation + 1, + EndpointAddress: 0x81, EndpointSequence: 1, + }) + if err == nil { + t.Fatal("stale generation was accepted") + } +} + +func TestHostCancelBeforeOperationSkipsProcessingAndCompletion(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 6, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, Kind: OperationCancel, + } + driver.operations <- Operation{ + Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + + deadline := time.Now().Add(time.Second) + for { + host.operationMu.Lock() + state := host.operations[44] + finished := state != nil && state.done + host.operationMu.Unlock() + if finished { + break + } + if time.Now().After(deadline) { + t.Fatal("cancelled operation was not retired") + } + time.Sleep(time.Millisecond) + } + select { + case sequence := <-processor.processed: + t.Fatalf("cancelled operation reached processor with sequence %d", sequence) + default: + } + select { + case completion := <-driver.completions: + t.Fatalf("cancelled operation was completed twice: %+v", completion) + default: + } + cancel() + <-done +} + +func TestHostCancelInterruptsActiveProcessor(t *testing.T) { + driver := newFakeHostDriver() + processor := &cancellableProcessor{started: make(chan struct{}), cancelled: make(chan struct{})} + host, _ := NewHost(driver, processor, 2) + identity, err := host.Register(context.Background(), 7, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, Kind: OperationCancel, + } + select { + case <-processor.cancelled: + case <-time.After(time.Second): + t.Fatal("processor context was not cancelled") + } + select { + case completion := <-driver.completions: + t.Fatalf("cancelled operation was completed twice: %+v", completion) + case <-time.After(20 * time.Millisecond): + } + cancel() + <-done +} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index f01e3f7d..7cd3d4ff 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 0 + ABIMinor uint16 = 1 HeaderSize = 16 NegotiateRequestSize = 32 @@ -22,9 +22,9 @@ const ( CreateDeviceSize = 56 DeviceIdentitySize = 32 IsoPacketSize = 16 - OperationSize = 88 + OperationSize = 96 CompletionSize = 72 - StatsSize = 112 + StatsSize = 128 MaxDevices = 32 MaxDescriptorBytes = 256 * 1024 @@ -256,6 +256,7 @@ const ( OperationSetInterface OperationDeviceD0Entry OperationDeviceD0Exit + OperationCancel ) type IsoPacket struct { @@ -265,19 +266,20 @@ type IsoPacket struct { } type Operation struct { - Token uint64 - DeviceID uint64 - Generation uint32 - Kind OperationKind - EndpointAddress uint8 - Direction uint8 - URBFunction uint32 - TransferFlags uint32 - StartFrame uint32 - TransferLength uint32 - SetupPacket [8]byte - IsoPackets []IsoPacket - Payload []byte + Token uint64 + DeviceID uint64 + Generation uint32 + Kind OperationKind + EndpointAddress uint8 + Direction uint8 + URBFunction uint32 + TransferFlags uint32 + StartFrame uint32 + TransferLength uint32 + SetupPacket [8]byte + IsoPackets []IsoPacket + Payload []byte + EndpointSequence uint64 } func ParseOperation(src []byte) (Operation, error) { @@ -301,18 +303,19 @@ func ParseOperation(src []byte) (Operation, error) { return Operation{}, ErrInvalidRange } op := Operation{ - Token: binary.LittleEndian.Uint64(src[16:24]), - DeviceID: binary.LittleEndian.Uint64(src[24:32]), - Generation: binary.LittleEndian.Uint32(src[32:36]), - Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), - EndpointAddress: src[40], - Direction: src[41], - URBFunction: binary.LittleEndian.Uint32(src[44:48]), - TransferFlags: binary.LittleEndian.Uint32(src[48:52]), - StartFrame: binary.LittleEndian.Uint32(src[52:56]), - TransferLength: transferLength, - IsoPackets: make([]IsoPacket, int(packetCount)), - Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), + Token: binary.LittleEndian.Uint64(src[16:24]), + DeviceID: binary.LittleEndian.Uint64(src[24:32]), + Generation: binary.LittleEndian.Uint32(src[32:36]), + Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), + EndpointAddress: src[40], + Direction: src[41], + URBFunction: binary.LittleEndian.Uint32(src[44:48]), + TransferFlags: binary.LittleEndian.Uint32(src[48:52]), + StartFrame: binary.LittleEndian.Uint32(src[52:56]), + TransferLength: transferLength, + EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), + IsoPackets: make([]IsoPacket, int(packetCount)), + Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), } copy(op.SetupPacket[:], src[76:84]) for i := range op.IsoPackets { @@ -332,24 +335,30 @@ type Completion struct { Generation uint32 Status int32 USBDStatus uint32 - IsoPackets []IsoPacket - Payload []byte + // TransferLength is the number of bytes completed. For ISO-IN transfers, + // Payload may span the original gapped transfer buffer and therefore be + // larger than this sum of packet actual lengths. + TransferLength uint32 + IsoPackets []IsoPacket + Payload []byte } type Stats struct { - OperationsDequeued uint64 - OperationsCompleted uint64 - OperationsCancelled uint64 - OperationsPurged uint64 - LateCompletions uint64 - InvalidMessages uint64 - QueueExhaustions uint64 - IsoPackets uint64 - BytesToDevice uint64 - BytesFromDevice uint64 - ActiveDevices uint32 - PendingOperations uint32 - WaitingDequeues uint32 + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + CancelEvents uint64 + CancelEventOverflows uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 } func ParseStats(src []byte) (Stats, error) { @@ -361,19 +370,21 @@ func ParseStats(src []byte) (Stats, error) { return Stats{}, ErrInvalidSize } return Stats{ - OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), - OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), - OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), - OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), - LateCompletions: binary.LittleEndian.Uint64(src[48:56]), - InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), - QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), - IsoPackets: binary.LittleEndian.Uint64(src[72:80]), - BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), - BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), - ActiveDevices: binary.LittleEndian.Uint32(src[96:100]), - PendingOperations: binary.LittleEndian.Uint32(src[100:104]), - WaitingDequeues: binary.LittleEndian.Uint32(src[104:108]), + OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), + OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), + OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), + OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), + LateCompletions: binary.LittleEndian.Uint64(src[48:56]), + InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), + QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), + IsoPackets: binary.LittleEndian.Uint64(src[72:80]), + BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), + BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), + CancelEvents: binary.LittleEndian.Uint64(src[96:104]), + CancelEventOverflows: binary.LittleEndian.Uint64(src[104:112]), + ActiveDevices: binary.LittleEndian.Uint32(src[112:116]), + PendingOperations: binary.LittleEndian.Uint32(src[116:120]), + WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), }, nil } @@ -384,6 +395,13 @@ func (m Completion) MarshalBinary() ([]byte, error) { if len(m.Payload) > MaxTransferBytes || len(m.IsoPackets) > MaxIsoPackets { return nil, ErrLimitExceeded } + transferLength := m.TransferLength + if transferLength == 0 && len(m.Payload) != 0 { + transferLength = uint32(len(m.Payload)) + } + if transferLength > MaxTransferBytes { + return nil, ErrLimitExceeded + } isoBytes := len(m.IsoPackets) * IsoPacketSize total := CompletionSize + isoBytes + len(m.Payload) h, err := NewHeader(total) @@ -397,7 +415,7 @@ func (m Completion) MarshalBinary() ([]byte, error) { binary.LittleEndian.PutUint32(dst[32:36], m.Generation) binary.LittleEndian.PutUint32(dst[36:40], uint32(m.Status)) binary.LittleEndian.PutUint32(dst[40:44], m.USBDStatus) - binary.LittleEndian.PutUint32(dst[44:48], uint32(len(m.Payload))) + binary.LittleEndian.PutUint32(dst[44:48], transferLength) binary.LittleEndian.PutUint32(dst[48:52], uint32(len(m.IsoPackets))) binary.LittleEndian.PutUint32(dst[52:56], uint32(CompletionSize+isoBytes)) binary.LittleEndian.PutUint32(dst[56:60], uint32(len(m.Payload))) diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 1da0fa85..5030e711 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -94,6 +94,7 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize) binary.LittleEndian.PutUint32(raw[68:72], uint32(len(payload))) binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint64(raw[88:96], 17) binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 0) binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], uint32(len(payload))) copy(raw[OperationSize+IsoPacketSize:], payload) @@ -102,7 +103,8 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { if err != nil { t.Fatal(err) } - if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || len(op.IsoPackets) != 1 { + if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || + op.EndpointSequence != 17 || len(op.IsoPackets) != 1 { t.Fatalf("unexpected operation: %+v", op) } raw[len(raw)-1] = 0xff @@ -141,14 +143,16 @@ func TestIdentityAndStatsLayout(t *testing.T) { putHeader(raw, h) binary.LittleEndian.PutUint64(raw[16:24], 11) binary.LittleEndian.PutUint64(raw[88:96], 29) - binary.LittleEndian.PutUint32(raw[96:100], 3) - binary.LittleEndian.PutUint32(raw[100:104], 5) - binary.LittleEndian.PutUint32(raw[104:108], 7) + binary.LittleEndian.PutUint64(raw[96:104], 31) + binary.LittleEndian.PutUint64(raw[104:112], 0) + binary.LittleEndian.PutUint32(raw[112:116], 3) + binary.LittleEndian.PutUint32(raw[116:120], 5) + binary.LittleEndian.PutUint32(raw[120:124], 7) stats, err := ParseStats(raw) if err != nil { t.Fatal(err) } - if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || + if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.CancelEvents != 31 || stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 { t.Fatalf("unexpected stats: %+v", stats) } diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 425cb6bc..47ac9c6c 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -12,6 +12,91 @@ EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; +static +BOOLEAN +ViiperQueueCancelEventLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ const VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + VIIPER_UDE_CANCEL_EVENT *event; + + if (!Pending->PublishedToOwner) { + return FALSE; + } + if (ControllerContext->CancelCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + InterlockedIncrement64(&ControllerContext->CancelEventOverflows); + return FALSE; + } + + event = &ControllerContext->CancelEvents[ControllerContext->CancelTail]; + event->Token = Pending->Token; + event->DeviceId = Pending->DeviceId; + event->Generation = Pending->DeviceGeneration; + event->EndpointAddress = Pending->EndpointAddress; + ControllerContext->CancelTail = (ControllerContext->CancelTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->CancelCount; + return TRUE; +} + +static +VOID +ViiperDispatchCancelEvents( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + for (;;) { + WDFREQUEST dequeueRequest = WDF_NO_HANDLE; + VIIPER_UDE_OPERATION *operation = NULL; + VIIPER_UDE_CANCEL_EVENT event; + NTSTATUS status; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->CancelCount == 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + status = WdfIoQueueRetrieveNextRequest( + controllerContext->WaitingDequeues, &dequeueRequest); + if (!NT_SUCCESS(status)) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + status = WdfRequestRetrieveOutputBuffer( + dequeueRequest, sizeof(*operation), (PVOID *)&operation, NULL); + if (NT_SUCCESS(status)) { + event = controllerContext->CancelEvents[controllerContext->CancelHead]; + controllerContext->CancelHead = (controllerContext->CancelHead + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + --controllerContext->CancelCount; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (!NT_SUCCESS(status)) { + WdfRequestComplete(dequeueRequest, status); + continue; + } + + RtlZeroMemory(operation, sizeof(*operation)); + operation->Header.Magic = VIIPER_UDE_MAGIC; + operation->Header.Major = VIIPER_UDE_ABI_MAJOR; + operation->Header.Minor = VIIPER_UDE_ABI_MINOR; + operation->Header.Size = sizeof(*operation); + operation->Token = event.Token; + operation->DeviceId = event.DeviceId; + operation->Generation = event.Generation; + operation->Kind = ViiperUdeOperationCancel; + operation->EndpointAddress = event.EndpointAddress; + WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); + InterlockedIncrement64(&controllerContext->CancelEventsDelivered); + WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); + } +} + static VOID ViiperClearSlotLocked( @@ -24,8 +109,12 @@ ViiperClearSlotLocked( pending->Request = WDF_NO_HANDLE; pending->Endpoint = WDF_NO_HANDLE; pending->Token = 0; + pending->DeviceId = 0; + pending->DeviceGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->AbortPending = FALSE; + pending->PublishedToOwner = FALSE; + pending->EndpointAddress = 0; pending->AbortStatus = STATUS_SUCCESS; InterlockedDecrement(&ControllerContext->PendingOperations); } @@ -101,6 +190,24 @@ ViiperInitializeBroker( RtlZeroMemory( controllerContext->PendingSlots, sizeof(VIIPER_UDE_PENDING_SLOT) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495543, + sizeof(VIIPER_UDE_CANCEL_EVENT) * VIIPER_UDE_MAX_PENDING_OPERATIONS, + &controllerContext->CancelStorage, + (PVOID *)&controllerContext->CancelEvents); + if (!NT_SUCCESS(status)) { + controllerContext->CancelStorage = WDF_NO_HANDLE; + controllerContext->CancelEvents = NULL; + return status; + } + RtlZeroMemory( + controllerContext->CancelEvents, + sizeof(VIIPER_UDE_CANCEL_EVENT) * VIIPER_UDE_MAX_PENDING_OPERATIONS); return STATUS_SUCCESS; } @@ -114,6 +221,8 @@ ViiperAllocatePendingSlot( _Out_ ULONGLONG *Token ) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); ULONG offset; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; @@ -132,8 +241,12 @@ ViiperAllocatePendingSlot( pending->Request = Request; pending->Endpoint = Endpoint; pending->Token = ((ULONGLONG)pending->Generation << 32) | (index + 1); + pending->DeviceId = deviceContext->DeviceId; + pending->DeviceGeneration = deviceContext->Generation; pending->State = ViiperUdePendingPreparing; pending->AbortPending = FALSE; + pending->PublishedToOwner = FALSE; + pending->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; pending->AbortStatus = STATUS_SUCCESS; ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; InterlockedIncrement(&ControllerContext->PendingOperations); @@ -159,12 +272,14 @@ ViiperEvtUrbCancel( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(requestContext->Controller); BOOLEAN ownsRequest = FALSE; + BOOLEAN notifyOwner = FALSE; WdfSpinLockAcquire(controllerContext->BrokerLock); if (requestContext->PendingSlot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[requestContext->PendingSlot]; if (ViiperSlotMatches(pending, Request, requestContext->Token)) { + notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); ViiperClearSlotLocked(controllerContext, requestContext->PendingSlot); ownsRequest = TRUE; } @@ -174,6 +289,9 @@ ViiperEvtUrbCancel( if (ownsRequest) { InterlockedIncrement64(&controllerContext->OperationsCancelled); UdecxUrbCompleteWithNtStatus(Request, STATUS_CANCELLED); + if (notifyOwner) { + ViiperDispatchCancelEvents(requestContext->Controller); + } } } @@ -266,7 +384,8 @@ ViiperSerializeOperation( _In_ WDFREQUEST UrbRequest, _In_ UDECXUSBENDPOINT Endpoint, _In_ ULONGLONG Token, - _In_ WDFREQUEST DequeueRequest + _In_ WDFREQUEST DequeueRequest, + _Out_ VIIPER_UDE_OPERATION **SerializedOperation ) { PURB urb = ViiperGetUrb(UrbRequest); @@ -368,6 +487,7 @@ ViiperSerializeOperation( if (!directionIn) { InterlockedAdd64(&ControllerContext->BytesToDevice, transferLength); } + *SerializedOperation = operation; return STATUS_SUCCESS; } @@ -378,20 +498,29 @@ ViiperRemovePublishingRequest( _In_ ULONG Slot, _In_ WDFREQUEST Request, _In_ ULONGLONG Token, - _In_ NTSTATUS Status + _In_ NTSTATUS Status, + _In_ BOOLEAN NotifyOwner ) { BOOLEAN ownsRequest = FALSE; + BOOLEAN notifyOwner = FALSE; WdfSpinLockAcquire(ControllerContext->BrokerLock); if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token)) { + if (NotifyOwner) { + notifyOwner = ViiperQueueCancelEventLocked( + ControllerContext, &ControllerContext->PendingSlots[Slot]); + } ViiperClearSlotLocked(ControllerContext, Slot); ownsRequest = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (ownsRequest) { UdecxUrbCompleteWithNtStatus(Request, Status); + if (notifyOwner) { + ViiperDispatchCancelEvents(ViiperGetRequestContext(Request)->Controller); + } } } @@ -407,6 +536,7 @@ ViiperDispatchAvailable( WDFREQUEST urbRequest = WDF_NO_HANDLE; WDFREQUEST dequeueRequest = WDF_NO_HANDLE; UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + VIIPER_UDE_OPERATION *serializedOperation = NULL; ULONGLONG token = 0; ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; ULONG index; @@ -414,6 +544,7 @@ ViiperDispatchAvailable( BOOLEAN abortPending = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; + ViiperDispatchCancelEvents(Controller); WdfSpinLockAcquire(controllerContext->BrokerLock); for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { ULONG candidate = (controllerContext->NextPendingSlot + index) % @@ -450,14 +581,15 @@ ViiperDispatchAvailable( } if (!NT_SUCCESS(status)) { ViiperRemovePublishingRequest( - controllerContext, slot, urbRequest, token, status); + controllerContext, slot, urbRequest, token, status, FALSE); WdfRequestComplete(dequeueRequest, status); WdfObjectDereference(urbRequest); continue; } status = ViiperSerializeOperation( - controllerContext, urbRequest, endpoint, token, dequeueRequest); + controllerContext, urbRequest, endpoint, token, dequeueRequest, + &serializedOperation); WdfSpinLockAcquire(controllerContext->BrokerLock); if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && @@ -473,7 +605,7 @@ ViiperDispatchAvailable( if (!NT_SUCCESS(status) || abortPending) { NTSTATUS completionStatus = abortPending ? abortStatus : status; ViiperRemovePublishingRequest( - controllerContext, slot, urbRequest, token, completionStatus); + controllerContext, slot, urbRequest, token, completionStatus, FALSE); WdfRequestComplete(dequeueRequest, completionStatus); WdfObjectDereference(urbRequest); continue; @@ -482,7 +614,7 @@ ViiperDispatchAvailable( status = WdfRequestMarkCancelableEx(urbRequest, ViiperEvtUrbCancel); if (!NT_SUCCESS(status)) { ViiperRemovePublishingRequest( - controllerContext, slot, urbRequest, token, STATUS_CANCELLED); + controllerContext, slot, urbRequest, token, STATUS_CANCELLED, FALSE); WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); WdfObjectDereference(urbRequest); continue; @@ -498,6 +630,14 @@ ViiperDispatchAvailable( pending->State = abortPending ? ViiperUdePendingCompleting : ViiperUdePendingInFlight; + if (!abortPending) { + serializedOperation->EndpointSequence = + (ULONGLONG)InterlockedIncrement64( + &ViiperGetDeviceContext( + ViiperGetEndpointContext(endpoint)->Device)->EndpointSequences[ + ViiperGetEndpointContext(endpoint)->Descriptor.bEndpointAddress]); + pending->PublishedToOwner = TRUE; + } } else { status = STATUS_CANCELLED; } @@ -507,7 +647,7 @@ ViiperDispatchAvailable( NTSTATUS unmarkStatus = WdfRequestUnmarkCancelable(urbRequest); if (NT_SUCCESS(unmarkStatus)) { ViiperRemovePublishingRequest( - controllerContext, slot, urbRequest, token, completionStatus); + controllerContext, slot, urbRequest, token, completionStatus, FALSE); } WdfRequestComplete(dequeueRequest, completionStatus); WdfObjectDereference(urbRequest); @@ -596,7 +736,7 @@ ViiperQueueUrb( status = WdfRequestUnmarkCancelable(Request); if (NT_SUCCESS(status)) { ViiperRemovePublishingRequest( - controllerContext, slot, Request, token, abortStatus); + controllerContext, slot, Request, token, abortStatus, FALSE); } return STATUS_PENDING; } @@ -732,7 +872,10 @@ ViiperCompleteOperation( ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->Generation || completion->TransferLength > requestContext->TransferLength || completion->IsoPacketCount != requestContext->IsoPacketCount || - (requestContext->DirectionIn && completion->PayloadLength != completion->TransferLength)) { + (requestContext->DirectionIn && completion->IsoPacketCount == 0 && + completion->PayloadLength != completion->TransferLength) || + (requestContext->DirectionIn && completion->IsoPacketCount != 0 && + completion->PayloadLength > requestContext->TransferLength)) { status = STATUS_INVALID_PARAMETER; InterlockedIncrement64(&controllerContext->InvalidMessages); goto CompleteWithNtStatus; @@ -742,20 +885,20 @@ ViiperCompleteOperation( goto CompleteWithNtStatus; } - if (requestContext->DirectionIn && completion->TransferLength > 0) { + if (requestContext->DirectionIn && completion->PayloadLength > 0) { status = UdecxUrbRetrieveBuffer( urbRequest, &transferBuffer, &transferBufferLength); - if (!NT_SUCCESS(status) || transferBufferLength < completion->TransferLength) { + if (!NT_SUCCESS(status) || transferBufferLength < completion->PayloadLength) { status = NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; goto CompleteWithNtStatus; } - RtlCopyMemory(transferBuffer, payload, completion->TransferLength); + RtlCopyMemory(transferBuffer, payload, completion->PayloadLength); InterlockedAdd64(&controllerContext->BytesFromDevice, completion->TransferLength); } if (completion->IsoPacketCount != 0) { for (index = 0; index < completion->IsoPacketCount; ++index) { - if (packets[index].Offset > completion->TransferLength || - packets[index].Length > completion->TransferLength - packets[index].Offset) { + if (packets[index].Offset > completion->PayloadLength || + packets[index].Length > completion->PayloadLength - packets[index].Offset) { status = STATUS_INVALID_PARAMETER; goto CompleteWithNtStatus; } @@ -825,7 +968,7 @@ ViiperAbortMatchingOperations( unmarkStatus = WdfRequestUnmarkCancelable(request); if (NT_SUCCESS(unmarkStatus)) { ViiperRemovePublishingRequest( - controllerContext, index, request, token, Status); + controllerContext, index, request, token, Status, TRUE); InterlockedIncrement64(&controllerContext->OperationsPurged); } WdfObjectDereference(request); diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 47674fb8..77030006 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -138,6 +138,13 @@ ViiperEvtControllerCleanup( WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); } + if (context->BrokerLock != WDF_NO_HANDLE) { + WdfSpinLockAcquire(context->BrokerLock); + context->CancelHead = 0; + context->CancelTail = 0; + context->CancelCount = 0; + WdfSpinLockRelease(context->BrokerLock); + } } VOID @@ -199,6 +206,11 @@ ViiperEvtFileCleanup( WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); } + WdfSpinLockAcquire(context->BrokerLock); + context->CancelHead = 0; + context->CancelTail = 0; + context->CancelCount = 0; + WdfSpinLockRelease(context->BrokerLock); } if (ownsController) { ViiperDestroyOwnedDevices(device, FileObject); diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 20aea7c8..d17dc4d6 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -135,6 +135,8 @@ ViiperHandleQueryStats( output->IsoPackets = (ULONGLONG)ViiperReadCounter(&context->IsoPackets); output->BytesToDevice = (ULONGLONG)ViiperReadCounter(&context->BytesToDevice); output->BytesFromDevice = (ULONGLONG)ViiperReadCounter(&context->BytesFromDevice); + output->CancelEvents = (ULONGLONG)ViiperReadCounter(&context->CancelEventsDelivered); + output->CancelEventOverflows = (ULONGLONG)ViiperReadCounter(&context->CancelEventOverflows); output->ActiveDevices = (ULONG)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 83dba898..c45f2021 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -26,12 +26,23 @@ typedef struct VIIPER_UDE_PENDING_SLOT { WDFREQUEST Request; UDECXUSBENDPOINT Endpoint; ULONGLONG Token; + ULONGLONG DeviceId; ULONG Generation; + ULONG DeviceGeneration; VIIPER_UDE_PENDING_STATE State; BOOLEAN AbortPending; + BOOLEAN PublishedToOwner; + UCHAR EndpointAddress; NTSTATUS AbortStatus; } VIIPER_UDE_PENDING_SLOT; +typedef struct VIIPER_UDE_CANCEL_EVENT { + ULONGLONG Token; + ULONGLONG DeviceId; + ULONG Generation; + UCHAR EndpointAddress; +} VIIPER_UDE_CANCEL_EVENT; + typedef struct VIIPER_UDE_REQUEST_CONTEXT { WDFDEVICE Controller; UDECXUSBENDPOINT Endpoint; @@ -51,6 +62,11 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; + WDFMEMORY CancelStorage; + VIIPER_UDE_CANCEL_EVENT *CancelEvents; + ULONG CancelHead; + ULONG CancelTail; + ULONG CancelCount; WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; @@ -65,6 +81,8 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 LateCompletions; volatile LONG64 InvalidMessages; volatile LONG64 QueueExhaustions; + volatile LONG64 CancelEventsDelivered; + volatile LONG64 CancelEventOverflows; volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; @@ -91,6 +109,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { BOOLEAN Plugged; BOOLEAN Purging; UDECXUSBENDPOINT DefaultEndpoint; + volatile LONG64 EndpointSequences[256]; } VIIPER_UDE_DEVICE_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceContext) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 850f7c30..f6020b21 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(0) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) @@ -129,7 +129,8 @@ typedef enum VIIPER_UDE_OPERATION_KIND { ViiperUdeOperationDeviceReset = 6, ViiperUdeOperationSetInterface = 7, ViiperUdeOperationDeviceD0Entry = 8, - ViiperUdeOperationDeviceD0Exit = 9 + ViiperUdeOperationDeviceD0Exit = 9, + ViiperUdeOperationCancel = 10 } VIIPER_UDE_OPERATION_KIND; typedef struct VIIPER_UDE_ISO_PACKET { @@ -158,6 +159,7 @@ typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_UINT32 IsoPacketsOffset; VIIPER_UDE_UINT8 SetupPacket[8]; VIIPER_UDE_UINT32 Reserved1; + VIIPER_UDE_UINT64 EndpointSequence; } VIIPER_UDE_OPERATION; typedef struct VIIPER_UDE_COMPLETION { @@ -187,6 +189,8 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT64 IsoPackets; VIIPER_UDE_UINT64 BytesToDevice; VIIPER_UDE_UINT64 BytesFromDevice; + VIIPER_UDE_UINT64 CancelEvents; + VIIPER_UDE_UINT64 CancelEventOverflows; VIIPER_UDE_UINT32 ActiveDevices; VIIPER_UDE_UINT32 PendingOperations; VIIPER_UDE_UINT32 WaitingDequeues; @@ -203,9 +207,9 @@ static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -static_assert(sizeof(VIIPER_UDE_OPERATION) == 88, "VIIPER_UDE_OPERATION ABI drift"); +static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -static_assert(sizeof(VIIPER_UDE_STATS) == 112, "VIIPER_UDE_STATS ABI drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 128, "VIIPER_UDE_STATS ABI drift"); #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L _Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); @@ -214,7 +218,7 @@ _Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTO _Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 88, "VIIPER_UDE_OPERATION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_STATS) == 112, "VIIPER_UDE_STATS ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_STATS) == 128, "VIIPER_UDE_STATS ABI drift"); #endif diff --git a/usb/usbdesc.go b/usb/usbdesc.go index 84e34ff2..8a10b0e2 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -253,6 +253,67 @@ func (d Descriptor) Bytes() []byte { return b.Bytes() } +// ConfigurationBytes builds the complete active USB configuration descriptor, +// including IADs, alternate interfaces, HID/class descriptors, and endpoints. +// Both the USB/IP server and the native UdeCx host use this single encoder so +// Windows sees byte-identical device topology on either transport. +func (d Descriptor) ConfigurationBytes() ([]byte, error) { + var b bytes.Buffer + configValue := d.Configuration.BConfigurationValue + if configValue == 0 { + configValue = 1 + } + attrs := d.Configuration.BMAttributes + if attrs == 0 { + attrs = 0x80 // Bus powered. + } + maxPower := d.Configuration.BMaxPower + if maxPower == 0 { + maxPower = 50 // 100 mA, expressed in 2 mA units. + } + h := ConfigHeader{ + BNumInterfaces: d.NumInterfaces(), + BConfigurationValue: configValue, + IConfiguration: d.Configuration.IConfiguration, + BMAttributes: attrs, + BMaxPower: maxPower, + } + h.Write(&b) + for _, iface := range d.Interfaces { + for _, iad := range d.Associations { + if iad.BFirstInterface == iface.Descriptor.BInterfaceNumber && + iface.Descriptor.BAlternateSetting == 0 { + iad.Write(&b) + } + } + iface.Descriptor.Write(&b) + if iface.HID != nil { + hidDescriptor, err := iface.HID.DescriptorBytes() + if err != nil { + return nil, fmt.Errorf("build HID descriptor for interface %d: %w", + iface.Descriptor.BInterfaceNumber, err) + } + b.Write([]byte(hidDescriptor)) + } + for _, classDescriptor := range iface.ClassDescriptors { + b.Write([]byte(classDescriptor.Bytes())) + } + for _, endpoint := range iface.Endpoints { + endpoint.Write(&b) + for _, classDescriptor := range endpoint.ClassDescriptors { + b.Write([]byte(classDescriptor.Bytes())) + } + } + } + + data := b.Bytes() + if len(data) > 0xffff { + return nil, fmt.Errorf("USB configuration descriptor exceeds 65535 bytes: %d", len(data)) + } + binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) + return append([]byte(nil), data...), nil +} + // ConfigHeader represents the USB configuration descriptor header (9 bytes). type ConfigHeader struct { WTotalLength uint16 // LE, to be patched after building From e9dccf878af8eb490afce1e728dde540714dffbf Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:34:04 -0500 Subject: [PATCH 011/240] native/ude: honor chained MDLs and transactional removal Implement segment-safe transfer copies for contiguous and chained-MDL URBs, matching the capability the controller advertises and avoiding truncated audio/HID buffers or unsafe access across MDL boundaries. Keep devices registered until UdeCx confirms plug-out, restore the purging state on failure, and move active-device accounting into object cleanup so deletion and cleanup races cannot leak slots or double-decrement telemetry. Also initialize cancel-event records explicitly to satisfy the WDK warning-as-error release gate. --- native/udecx/driver/Broker.c | 105 ++++++++++++++++++++++++++------ native/udecx/driver/Device.c | 63 +++++++++++++------ native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 134 insertions(+), 35 deletions(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 47ac9c6c..5f7464f6 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -51,7 +51,7 @@ ViiperDispatchCancelEvents( for (;;) { WDFREQUEST dequeueRequest = WDF_NO_HANDLE; VIIPER_UDE_OPERATION *operation = NULL; - VIIPER_UDE_CANCEL_EVENT event; + VIIPER_UDE_CANCEL_EVENT event = {0}; NTSTATUS status; WdfSpinLockAcquire(controllerContext->BrokerLock); @@ -308,6 +308,85 @@ ViiperGetUrb( return (PURB)URB_FROM_IRP(irp); } +static +PMDL +ViiperGetTransferMdl( + _In_ PURB Urb + ) +{ + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbBulkOrInterruptTransfer.TransferBufferMDL; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbIsochronousTransfer.TransferBufferMDL; + case URB_FUNCTION_CONTROL_TRANSFER: + case URB_FUNCTION_CONTROL_TRANSFER_EX: + return Urb->UrbControlTransferEx.TransferBufferMDL; + default: + return NULL; + } +} + +static +NTSTATUS +ViiperCopyTransferBuffer( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Inout_updates_bytes_(Length) UCHAR *Buffer, + _In_ ULONG Length, + _In_ BOOLEAN ToUrb + ) +{ + UCHAR *contiguous = NULL; + ULONG contiguousLength = 0; + PMDL mdl; + ULONG copied = 0; + NTSTATUS status; + + if (Length == 0) { + return STATUS_SUCCESS; + } + + status = UdecxUrbRetrieveBuffer(Request, &contiguous, &contiguousLength); + if (NT_SUCCESS(status) && contiguous != NULL && contiguousLength >= Length) { + if (ToUrb) { + RtlCopyMemory(contiguous, Buffer, Length); + } else { + RtlCopyMemory(Buffer, contiguous, Length); + } + return STATUS_SUCCESS; + } + + mdl = ViiperGetTransferMdl(Urb); + while (mdl != NULL && copied < Length) { + ULONG mdlLength = MmGetMdlByteCount(mdl); + ULONG chunk = min(mdlLength, Length - copied); + UCHAR *mapped; + + if (chunk != 0) { + mapped = (UCHAR *)MmGetSystemAddressForMdlSafe( + mdl, (MM_PAGE_PRIORITY)(NormalPagePriority | MdlMappingNoExecute)); + if (mapped == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + if (ToUrb) { + RtlCopyMemory(mapped, Buffer + copied, chunk); + } else { + RtlCopyMemory(Buffer + copied, mapped, chunk); + } + copied += chunk; + } + mdl = mdl->Next; + } + + if (copied != Length) { + return NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; + } + return STATUS_SUCCESS; +} + static NTSTATUS ViiperGetTransferMetadata( @@ -395,8 +474,6 @@ ViiperSerializeOperation( VIIPER_UDE_OPERATION *operation; VIIPER_UDE_ISO_PACKET *packets; UCHAR *payload; - UCHAR *transferBuffer = NULL; - ULONG transferBufferLength = 0; ULONG transferFlags; ULONG transferLength; ULONG startFrame; @@ -421,12 +498,6 @@ ViiperSerializeOperation( isoBytes = packetCount * sizeof(VIIPER_UDE_ISO_PACKET); payloadLength = directionIn ? 0 : transferLength; - if (payloadLength > 0) { - status = UdecxUrbRetrieveBuffer(UrbRequest, &transferBuffer, &transferBufferLength); - if (!NT_SUCCESS(status) || transferBufferLength < payloadLength) { - return NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; - } - } if (isoBytes > MAXULONG - sizeof(*operation) || payloadLength > MAXULONG - sizeof(*operation) - isoBytes) { return STATUS_INTEGER_OVERFLOW; @@ -476,7 +547,11 @@ ViiperSerializeOperation( } payload = (UCHAR *)operation + operation->PayloadOffset; if (payloadLength > 0) { - RtlCopyMemory(payload, transferBuffer, payloadLength); + status = ViiperCopyTransferBuffer( + UrbRequest, urb, payload, payloadLength, FALSE); + if (!NT_SUCCESS(status)) { + return status; + } } requestContext->TransferLength = transferLength; @@ -771,8 +846,6 @@ ViiperCompleteOperation( UCHAR *payload = NULL; WDFREQUEST urbRequest = WDF_NO_HANDLE; PURB urb; - UCHAR *transferBuffer = NULL; - ULONG transferBufferLength = 0; size_t inputLength; size_t tailLength = 0; ULONG slot; @@ -886,13 +959,11 @@ ViiperCompleteOperation( } if (requestContext->DirectionIn && completion->PayloadLength > 0) { - status = UdecxUrbRetrieveBuffer( - urbRequest, &transferBuffer, &transferBufferLength); - if (!NT_SUCCESS(status) || transferBufferLength < completion->PayloadLength) { - status = NT_SUCCESS(status) ? STATUS_BUFFER_TOO_SMALL : status; + status = ViiperCopyTransferBuffer( + urbRequest, urb, payload, completion->PayloadLength, TRUE); + if (!NT_SUCCESS(status)) { goto CompleteWithNtStatus; } - RtlCopyMemory(transferBuffer, payload, completion->PayloadLength); InterlockedAdd64(&controllerContext->BytesFromDevice, completion->TransferLength); } if (completion->IsoPacketCount != 0) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index fedff3be..f220ec02 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -282,22 +282,24 @@ ViiperCreateVirtualDevice( } deviceContext->Plugged = TRUE; + InterlockedExchange(&deviceContext->ActiveCounted, 1); InterlockedIncrement(&controllerContext->ActiveDevices); WdfRequestSetInformation(Request, 0); return STATUS_SUCCESS; } static -UDECXUSBDEVICE -ViiperTakeDevice( +NTSTATUS +ViiperBeginRemoveDevice( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ WDFFILEOBJECT OwnerFile, _In_ ULONGLONG DeviceId, _In_ ULONG Generation, - _In_ BOOLEAN MatchGeneration + _In_ BOOLEAN MatchGeneration, + _Out_ UDECXUSBDEVICE *Device ) { - UDECXUSBDEVICE found = WDF_NO_HANDLE; + NTSTATUS status = STATUS_NOT_FOUND; ULONG index; WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); @@ -312,13 +314,32 @@ ViiperTakeDevice( (MatchGeneration && deviceContext->Generation != Generation)) { continue; } + if (deviceContext->Purging) { + status = STATUS_DEVICE_BUSY; + break; + } deviceContext->Purging = TRUE; - ControllerContext->Devices[index] = WDF_NO_HANDLE; - found = current; + *Device = current; + status = STATUS_SUCCESS; break; } WdfWaitLockRelease(ControllerContext->DeviceLock); - return found; + return status; +} + +static +VOID +ViiperCancelRemoveDevice( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device + ) +{ + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + if (ViiperGetDeviceContext(Device)->Slot < VIIPER_UDE_MAX_DEVICES && + ControllerContext->Devices[ViiperGetDeviceContext(Device)->Slot] == Device) { + ViiperGetDeviceContext(Device)->Purging = FALSE; + } + WdfWaitLockRelease(ControllerContext->DeviceLock); } NTSTATUS @@ -351,14 +372,14 @@ ViiperDestroyVirtualDevice( return STATUS_INVALID_PARAMETER; } - device = ViiperTakeDevice( - controllerContext, ownerFile, input->DeviceId, input->Generation, TRUE); - if (device == WDF_NO_HANDLE) { - return STATUS_NOT_FOUND; + status = ViiperBeginRemoveDevice( + controllerContext, ownerFile, input->DeviceId, input->Generation, TRUE, &device); + if (!NT_SUCCESS(status)) { + return status; } status = UdecxUsbDevicePlugOutAndDelete(device); - if (NT_SUCCESS(status)) { - InterlockedDecrement(&controllerContext->ActiveDevices); + if (!NT_SUCCESS(status)) { + ViiperCancelRemoveDevice(controllerContext, device); } return status; } @@ -381,7 +402,9 @@ ViiperDestroyOwnedDevices( WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { device = controllerContext->Devices[index]; - if (device != WDF_NO_HANDLE && ViiperGetDeviceContext(device)->OwnerFile == OwnerFile) { + if (device != WDF_NO_HANDLE && + ViiperGetDeviceContext(device)->OwnerFile == OwnerFile && + !ViiperGetDeviceContext(device)->Purging) { deviceId = ViiperGetDeviceContext(device)->DeviceId; break; } @@ -391,14 +414,15 @@ ViiperDestroyOwnedDevices( break; } - device = ViiperTakeDevice(controllerContext, OwnerFile, deviceId, 0, FALSE); - if (device == WDF_NO_HANDLE) { + if (!NT_SUCCESS(ViiperBeginRemoveDevice( + controllerContext, OwnerFile, deviceId, 0, FALSE, &device))) { continue; } deviceContext = ViiperGetDeviceContext(device); if (deviceContext->Plugged) { - if (NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { - InterlockedDecrement(&controllerContext->ActiveDevices); + if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { + ViiperCancelRemoveDevice(controllerContext, device); + break; } } else { WdfObjectDelete(device); @@ -421,6 +445,9 @@ ViiperEvtVirtualDeviceCleanup( } controllerContext = ViiperGetControllerContext(deviceContext->Controller); ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot); + if (InterlockedExchange(&deviceContext->ActiveCounted, 0) != 0) { + InterlockedDecrement(&controllerContext->ActiveDevices); + } } NTSTATUS diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index c45f2021..9fd9d774 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -108,6 +108,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { ULONG Slot; BOOLEAN Plugged; BOOLEAN Purging; + volatile LONG ActiveCounted; UDECXUSBENDPOINT DefaultEndpoint; volatile LONG64 EndpointSequences[256]; } VIIPER_UDE_DEVICE_CONTEXT; From 0605991abc958e82a0464b0f3c913eabb6492330 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:39:29 -0500 Subject: [PATCH 012/240] Order UDE lifecycle events with endpoint traffic Put endpoint reset, purge, start, and device power notifications on the same per-endpoint sequence clock as URBs. Generalize the cancellation ring into a bounded notification ring, reset the native USB engine at lifecycle boundaries, and ensure lifecycle messages are never completed as transfer URBs. This prevents stale HID/audio state from crossing Windows endpoint transitions while preserving parallel dequeue with FIFO endpoint execution. --- internal/server/usb/native.go | 31 ++++++ internal/transport/udecx/host.go | 41 +++++-- internal/transport/udecx/host_test.go | 57 +++++++++- internal/transport/udecx/protocol.go | 60 +++++------ internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Broker.c | 125 +++++++++++++++++----- native/udecx/driver/Controller.c | 12 +-- native/udecx/driver/Device.c | 15 +-- native/udecx/driver/Ioctl.c | 5 +- native/udecx/driver/ViiperUde.h | 26 +++-- native/udecx/include/ViiperUdeProtocol.h | 4 +- 11 files changed, 289 insertions(+), 89 deletions(-) diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 6b82a48e..d49101c3 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -51,6 +51,37 @@ func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdent p.mu.Unlock() } +func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op udecx.Operation) error { + identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} + key := nativeLaneKey{ + deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, + } + + switch op.Kind { + case udecx.OperationEndpointStart: + p.clearLane(key) + case udecx.OperationEndpointPurge, udecx.OperationEndpointReset: + p.clearLane(key) + if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { + resetter.ResetEndpoint(op.EndpointAddress) + } + case udecx.OperationDeviceReset, udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: + p.Reset(dev, identity) + case udecx.OperationSetInterface: + p.clearLane(key) + default: + return fmt.Errorf("unsupported native UDE lifecycle operation %d", op.Kind) + } + return nil +} + +func (p *NativeProcessor) clearLane(key nativeLaneKey) { + p.mu.Lock() + delete(p.next, key) + delete(p.lastIn, key) + p.mu.Unlock() +} + func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op udecx.Operation) (udecx.Completion, error) { if dev == nil { return udecx.Completion{}, errors.New("native UDE operation has no device") diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index a9abb033..6f9788db 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -34,6 +34,7 @@ type Driver interface { // payload slices after Process returns. type OperationProcessor interface { Process(context.Context, usb.Device, Operation) (Completion, error) + Lifecycle(context.Context, usb.Device, Operation) error Reset(usb.Device, DeviceIdentity) } @@ -236,12 +237,16 @@ func (h *Host) Serve(ctx context.Context) error { h.cancelOperation(result.op) continue } - if err := h.trackOperation(result.op); err != nil { - h.completeUntrackedFailure(runCtx, result.op) - continue + if !isLifecycleOperation(result.op.Kind) { + if err := h.trackOperation(result.op); err != nil { + h.completeUntrackedFailure(runCtx, result.op) + continue + } } if err := h.dispatch(runCtx, result.op); err != nil { - h.completeFailure(runCtx, result.op) + if !isLifecycleOperation(result.op.Kind) { + h.completeFailure(runCtx, result.op) + } } } } @@ -298,17 +303,23 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { return case op := <-lane.input: if op.EndpointSequence < expected { - h.completeFailure(lane.ctx, op) + if !isLifecycleOperation(op.Kind) { + h.completeFailure(lane.ctx, op) + } continue } if _, duplicate := pending[op.EndpointSequence]; duplicate { - h.completeFailure(lane.ctx, op) + if !isLifecycleOperation(op.Kind) { + h.completeFailure(lane.ctx, op) + } continue } pending[op.EndpointSequence] = op if len(pending) > laneQueueDepth { for _, queued := range pending { - h.completeFailure(lane.ctx, queued) + if !isLifecycleOperation(queued.Kind) { + h.completeFailure(lane.ctx, queued) + } } return } @@ -318,13 +329,27 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { break } delete(pending, expected) - h.process(lane.ctx, entry.device, current) + if isLifecycleOperation(current.Kind) { + _ = h.processor.Lifecycle(lane.ctx, entry.device, current) + } else { + h.process(lane.ctx, entry.device, current) + } expected++ } } } } +func isLifecycleOperation(kind OperationKind) bool { + switch kind { + case OperationEndpointStart, OperationEndpointPurge, OperationEndpointReset, + OperationDeviceReset, OperationSetInterface, OperationDeviceD0Entry, OperationDeviceD0Exit: + return true + default: + return false + } +} + func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) { opCtx, cancel, active := h.beginOperation(ctx, op) if !active { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index acb8c9c8..4173b226 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -56,6 +56,7 @@ func (d *fakeHostDriver) QueryStats(context.Context) (Stats, error) { return Sta type recordingProcessor struct { processed chan uint64 + lifecycle chan uint64 resets chan DeviceIdentity } @@ -63,6 +64,12 @@ func (p *recordingProcessor) Process(_ context.Context, _ usb.Device, op Operati p.processed <- op.EndpointSequence return Completion{TransferLength: op.TransferLength}, nil } +func (p *recordingProcessor) Lifecycle(_ context.Context, _ usb.Device, op Operation) error { + if p.lifecycle != nil { + p.lifecycle <- op.EndpointSequence + } + return nil +} func (p *recordingProcessor) Reset(_ usb.Device, identity DeviceIdentity) { p.resets <- identity } type cancellableProcessor struct { @@ -76,7 +83,8 @@ func (p *cancellableProcessor) Process(ctx context.Context, _ usb.Device, _ Oper close(p.cancelled) return Completion{}, ctx.Err() } -func (*cancellableProcessor) Reset(usb.Device, DeviceIdentity) {} +func (*cancellableProcessor) Reset(usb.Device, DeviceIdentity) {} +func (*cancellableProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ @@ -137,6 +145,53 @@ func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { } } +func TestHostOrdersLifecycleBeforeFollowingTransfer(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 10, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationTransfer, + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointPurge, + } + + select { + case got := <-processor.lifecycle: + if got != 1 { + t.Fatalf("lifecycle endpoint sequence=%d want=1", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for lifecycle operation") + } + select { + case got := <-processor.processed: + if got != 2 { + t.Fatalf("transfer endpoint sequence=%d want=2", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for transfer after lifecycle") + } + cancel() + <-done +} + func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { driver := newFakeHostDriver() driver.createErr = errors.New("plug failed") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 7cd3d4ff..03971f20 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -344,21 +344,21 @@ type Completion struct { } type Stats struct { - OperationsDequeued uint64 - OperationsCompleted uint64 - OperationsCancelled uint64 - OperationsPurged uint64 - LateCompletions uint64 - InvalidMessages uint64 - QueueExhaustions uint64 - IsoPackets uint64 - BytesToDevice uint64 - BytesFromDevice uint64 - CancelEvents uint64 - CancelEventOverflows uint64 - ActiveDevices uint32 - PendingOperations uint32 - WaitingDequeues uint32 + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + NotificationEvents uint64 + NotificationEventOverflows uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 } func ParseStats(src []byte) (Stats, error) { @@ -370,21 +370,21 @@ func ParseStats(src []byte) (Stats, error) { return Stats{}, ErrInvalidSize } return Stats{ - OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), - OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), - OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), - OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), - LateCompletions: binary.LittleEndian.Uint64(src[48:56]), - InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), - QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), - IsoPackets: binary.LittleEndian.Uint64(src[72:80]), - BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), - BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), - CancelEvents: binary.LittleEndian.Uint64(src[96:104]), - CancelEventOverflows: binary.LittleEndian.Uint64(src[104:112]), - ActiveDevices: binary.LittleEndian.Uint32(src[112:116]), - PendingOperations: binary.LittleEndian.Uint32(src[116:120]), - WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), + OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), + OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), + OperationsCancelled: binary.LittleEndian.Uint64(src[32:40]), + OperationsPurged: binary.LittleEndian.Uint64(src[40:48]), + LateCompletions: binary.LittleEndian.Uint64(src[48:56]), + InvalidMessages: binary.LittleEndian.Uint64(src[56:64]), + QueueExhaustions: binary.LittleEndian.Uint64(src[64:72]), + IsoPackets: binary.LittleEndian.Uint64(src[72:80]), + BytesToDevice: binary.LittleEndian.Uint64(src[80:88]), + BytesFromDevice: binary.LittleEndian.Uint64(src[88:96]), + NotificationEvents: binary.LittleEndian.Uint64(src[96:104]), + NotificationEventOverflows: binary.LittleEndian.Uint64(src[104:112]), + ActiveDevices: binary.LittleEndian.Uint32(src[112:116]), + PendingOperations: binary.LittleEndian.Uint32(src[116:120]), + WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), }, nil } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 5030e711..ab50e8f1 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -152,7 +152,7 @@ func TestIdentityAndStatsLayout(t *testing.T) { if err != nil { t.Fatal(err) } - if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.CancelEvents != 31 || + if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.NotificationEvents != 31 || stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 { t.Fatalf("unexpected stats: %+v", stats) } diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 5f7464f6..2cb31305 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -19,30 +19,32 @@ ViiperQueueCancelEventLocked( _In_ const VIIPER_UDE_PENDING_SLOT *Pending ) { - VIIPER_UDE_CANCEL_EVENT *event; + VIIPER_UDE_NOTIFICATION *event; if (!Pending->PublishedToOwner) { return FALSE; } - if (ControllerContext->CancelCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { - InterlockedIncrement64(&ControllerContext->CancelEventOverflows); + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); return FALSE; } - event = &ControllerContext->CancelEvents[ControllerContext->CancelTail]; + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; event->Token = Pending->Token; event->DeviceId = Pending->DeviceId; + event->EndpointSequence = 0; event->Generation = Pending->DeviceGeneration; + event->Kind = ViiperUdeOperationCancel; event->EndpointAddress = Pending->EndpointAddress; - ControllerContext->CancelTail = (ControllerContext->CancelTail + 1) % + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; - ++ControllerContext->CancelCount; + ++ControllerContext->NotificationCount; return TRUE; } static VOID -ViiperDispatchCancelEvents( +ViiperDispatchNotificationEvents( _In_ WDFDEVICE Controller ) { @@ -51,11 +53,11 @@ ViiperDispatchCancelEvents( for (;;) { WDFREQUEST dequeueRequest = WDF_NO_HANDLE; VIIPER_UDE_OPERATION *operation = NULL; - VIIPER_UDE_CANCEL_EVENT event = {0}; + VIIPER_UDE_NOTIFICATION event = {0}; NTSTATUS status; WdfSpinLockAcquire(controllerContext->BrokerLock); - if (controllerContext->CancelCount == 0) { + if (controllerContext->NotificationCount == 0) { WdfSpinLockRelease(controllerContext->BrokerLock); break; } @@ -69,10 +71,10 @@ ViiperDispatchCancelEvents( status = WdfRequestRetrieveOutputBuffer( dequeueRequest, sizeof(*operation), (PVOID *)&operation, NULL); if (NT_SUCCESS(status)) { - event = controllerContext->CancelEvents[controllerContext->CancelHead]; - controllerContext->CancelHead = (controllerContext->CancelHead + 1) % + event = controllerContext->Notifications[controllerContext->NotificationHead]; + controllerContext->NotificationHead = (controllerContext->NotificationHead + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; - --controllerContext->CancelCount; + --controllerContext->NotificationCount; } WdfSpinLockRelease(controllerContext->BrokerLock); @@ -89,10 +91,11 @@ ViiperDispatchCancelEvents( operation->Token = event.Token; operation->DeviceId = event.DeviceId; operation->Generation = event.Generation; - operation->Kind = ViiperUdeOperationCancel; + operation->Kind = event.Kind; operation->EndpointAddress = event.EndpointAddress; + operation->EndpointSequence = event.EndpointSequence; WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); - InterlockedIncrement64(&controllerContext->CancelEventsDelivered); + InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); } } @@ -197,17 +200,91 @@ ViiperInitializeBroker( &attributes, NonPagedPoolNx, 0x56495543, - sizeof(VIIPER_UDE_CANCEL_EVENT) * VIIPER_UDE_MAX_PENDING_OPERATIONS, - &controllerContext->CancelStorage, - (PVOID *)&controllerContext->CancelEvents); + sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS, + &controllerContext->NotificationStorage, + (PVOID *)&controllerContext->Notifications); if (!NT_SUCCESS(status)) { - controllerContext->CancelStorage = WDF_NO_HANDLE; - controllerContext->CancelEvents = NULL; + controllerContext->NotificationStorage = WDF_NO_HANDLE; + controllerContext->Notifications = NULL; return status; } RtlZeroMemory( - controllerContext->CancelEvents, - sizeof(VIIPER_UDE_CANCEL_EVENT) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + controllerContext->Notifications, + sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + return STATUS_SUCCESS; +} + +static +BOOLEAN +ViiperQueueLifecycleEventLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext, + _In_ UCHAR EndpointAddress, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + VIIPER_UDE_NOTIFICATION *event; + + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); + return FALSE; + } + + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; + RtlZeroMemory(event, sizeof(*event)); + event->DeviceId = DeviceContext->DeviceId; + event->Generation = DeviceContext->Generation; + event->Kind = Kind; + event->EndpointAddress = EndpointAddress; + event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->EndpointSequences[EndpointAddress]); + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->NotificationCount; + return TRUE; +} + +NTSTATUS +ViiperQueueEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN queued; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + queued = ViiperQueueLifecycleEventLocked( + controllerContext, deviceContext, endpointContext->Descriptor.bEndpointAddress, Kind); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + ViiperDispatchNotificationEvents(deviceContext->Controller); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperQueueDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN queued; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + queued = ViiperQueueLifecycleEventLocked(controllerContext, deviceContext, 0, Kind); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + ViiperDispatchNotificationEvents(deviceContext->Controller); return STATUS_SUCCESS; } @@ -290,7 +367,7 @@ ViiperEvtUrbCancel( InterlockedIncrement64(&controllerContext->OperationsCancelled); UdecxUrbCompleteWithNtStatus(Request, STATUS_CANCELLED); if (notifyOwner) { - ViiperDispatchCancelEvents(requestContext->Controller); + ViiperDispatchNotificationEvents(requestContext->Controller); } } } @@ -594,7 +671,7 @@ ViiperRemovePublishingRequest( if (ownsRequest) { UdecxUrbCompleteWithNtStatus(Request, Status); if (notifyOwner) { - ViiperDispatchCancelEvents(ViiperGetRequestContext(Request)->Controller); + ViiperDispatchNotificationEvents(ViiperGetRequestContext(Request)->Controller); } } } @@ -619,7 +696,7 @@ ViiperDispatchAvailable( BOOLEAN abortPending = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; - ViiperDispatchCancelEvents(Controller); + ViiperDispatchNotificationEvents(Controller); WdfSpinLockAcquire(controllerContext->BrokerLock); for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { ULONG candidate = (controllerContext->NextPendingSlot + index) % diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 77030006..5768002b 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -140,9 +140,9 @@ ViiperEvtControllerCleanup( } if (context->BrokerLock != WDF_NO_HANDLE) { WdfSpinLockAcquire(context->BrokerLock); - context->CancelHead = 0; - context->CancelTail = 0; - context->CancelCount = 0; + context->NotificationHead = 0; + context->NotificationTail = 0; + context->NotificationCount = 0; WdfSpinLockRelease(context->BrokerLock); } } @@ -207,9 +207,9 @@ ViiperEvtFileCleanup( InterlockedExchange(&context->WaitingDequeueCount, 0); } WdfSpinLockAcquire(context->BrokerLock); - context->CancelHead = 0; - context->CancelTail = 0; - context->CancelCount = 0; + context->NotificationHead = 0; + context->NotificationTail = 0; + context->NotificationCount = 0; WdfSpinLockRelease(context->BrokerLock); } if (ownsController) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index f220ec02..360f35ac 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -457,8 +457,7 @@ ViiperEvtUsbDeviceD0Entry( ) { UNREFERENCED_PARAMETER(Controller); - UNREFERENCED_PARAMETER(Device); - return STATUS_SUCCESS; + return ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); } NTSTATUS @@ -469,9 +468,8 @@ ViiperEvtUsbDeviceD0Exit( ) { UNREFERENCED_PARAMETER(Controller); - UNREFERENCED_PARAMETER(Device); UNREFERENCED_PARAMETER(WakeSetting); - return STATUS_SUCCESS; + return ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); } NTSTATUS @@ -588,8 +586,11 @@ ViiperEvtEndpointReset( _In_ WDFREQUEST Request ) { - UNREFERENCED_PARAMETER(Endpoint); - WdfRequestComplete(Request, STATUS_SUCCESS); + NTSTATUS status; + + ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + status = ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointReset); + WdfRequestComplete(Request, status); } VOID @@ -612,6 +613,7 @@ ViiperEvtEndpointPurge( VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); endpointContext->Purging = TRUE; ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); } @@ -620,6 +622,7 @@ ViiperEvtEndpointStart( _In_ UDECXUSBENDPOINT Endpoint ) { + (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); WdfIoQueueStart(ViiperGetEndpointContext(Endpoint)->Queue); } diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index d17dc4d6..7efae799 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -135,8 +135,9 @@ ViiperHandleQueryStats( output->IsoPackets = (ULONGLONG)ViiperReadCounter(&context->IsoPackets); output->BytesToDevice = (ULONGLONG)ViiperReadCounter(&context->BytesToDevice); output->BytesFromDevice = (ULONGLONG)ViiperReadCounter(&context->BytesFromDevice); - output->CancelEvents = (ULONGLONG)ViiperReadCounter(&context->CancelEventsDelivered); - output->CancelEventOverflows = (ULONGLONG)ViiperReadCounter(&context->CancelEventOverflows); + output->NotificationEvents = (ULONGLONG)ViiperReadCounter(&context->NotificationEventsDelivered); + output->NotificationEventOverflows = + (ULONGLONG)ViiperReadCounter(&context->NotificationEventOverflows); output->ActiveDevices = (ULONG)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 9fd9d774..be727105 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -36,12 +36,14 @@ typedef struct VIIPER_UDE_PENDING_SLOT { NTSTATUS AbortStatus; } VIIPER_UDE_PENDING_SLOT; -typedef struct VIIPER_UDE_CANCEL_EVENT { +typedef struct VIIPER_UDE_NOTIFICATION { ULONGLONG Token; ULONGLONG DeviceId; + ULONGLONG EndpointSequence; ULONG Generation; + ULONG Kind; UCHAR EndpointAddress; -} VIIPER_UDE_CANCEL_EVENT; +} VIIPER_UDE_NOTIFICATION; typedef struct VIIPER_UDE_REQUEST_CONTEXT { WDFDEVICE Controller; @@ -62,11 +64,11 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; - WDFMEMORY CancelStorage; - VIIPER_UDE_CANCEL_EVENT *CancelEvents; - ULONG CancelHead; - ULONG CancelTail; - ULONG CancelCount; + WDFMEMORY NotificationStorage; + VIIPER_UDE_NOTIFICATION *Notifications; + ULONG NotificationHead; + ULONG NotificationTail; + ULONG NotificationCount; WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; @@ -81,8 +83,8 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 LateCompletions; volatile LONG64 InvalidMessages; volatile LONG64 QueueExhaustions; - volatile LONG64 CancelEventsDelivered; - volatile LONG64 CancelEventOverflows; + volatile LONG64 NotificationEventsDelivered; + volatile LONG64 NotificationEventOverflows; volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; @@ -156,3 +158,9 @@ NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); +NTSTATUS ViiperQueueEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ VIIPER_UDE_OPERATION_KIND Kind); diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index f6020b21..50396ac2 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -189,8 +189,8 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT64 IsoPackets; VIIPER_UDE_UINT64 BytesToDevice; VIIPER_UDE_UINT64 BytesFromDevice; - VIIPER_UDE_UINT64 CancelEvents; - VIIPER_UDE_UINT64 CancelEventOverflows; + VIIPER_UDE_UINT64 NotificationEvents; + VIIPER_UDE_UINT64 NotificationEventOverflows; VIIPER_UDE_UINT32 ActiveDevices; VIIPER_UDE_UINT32 PendingOperations; VIIPER_UDE_UINT32 WaitingDequeues; From 6534e41b77e7cc5c14615db8fa11e0d79838d381 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:43:12 -0500 Subject: [PATCH 013/240] Wire native UDE transport into VIIPER service Add an explicit native-ude service mode that opens the exclusive UdeCx broker and skips USB-IP prerequisites and localhost attachment. Publish and remove API-created devices transactionally across the virtual bus and UDE child, retain failed plug-outs for retry, and route reconnect cleanup through the same owner. Preserve usbip as the default mode while the new bus is validated. --- internal/cmd/native_transport.go | 6 + internal/cmd/native_transport_other.go | 14 +++ internal/cmd/native_transport_windows.go | 58 +++++++++ internal/cmd/server.go | 67 +++++++--- internal/server/api/handler/bus_device_add.go | 4 +- internal/server/api/server.go | 2 +- internal/server/usb/native_transport_test.go | 117 ++++++++++++++++++ internal/server/usb/server.go | 116 ++++++++++++++++- internal/transport/udecx/host.go | 20 ++- internal/transport/udecx/host_test.go | 27 +++- 10 files changed, 406 insertions(+), 25 deletions(-) create mode 100644 internal/cmd/native_transport.go create mode 100644 internal/cmd/native_transport_other.go create mode 100644 internal/cmd/native_transport_windows.go create mode 100644 internal/server/usb/native_transport_test.go diff --git a/internal/cmd/native_transport.go b/internal/cmd/native_transport.go new file mode 100644 index 00000000..b793c2de --- /dev/null +++ b/internal/cmd/native_transport.go @@ -0,0 +1,6 @@ +package cmd + +type nativeUDETransport interface { + Done() <-chan error + Close() error +} diff --git a/internal/cmd/native_transport_other.go b/internal/cmd/native_transport_other.go new file mode 100644 index 00000000..cb9da63d --- /dev/null +++ b/internal/cmd/native_transport_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + + serverusb "github.com/Alia5/VIIPER/internal/server/usb" +) + +func startNativeUDETransport(context.Context, *serverusb.Server) (nativeUDETransport, error) { + return nil, errors.New("native UDE transport is available only on Windows") +} diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go new file mode 100644 index 00000000..ae273f90 --- /dev/null +++ b/internal/cmd/native_transport_windows.go @@ -0,0 +1,58 @@ +//go:build windows + +package cmd + +import ( + "context" + "sync" + + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" +) + +type windowsNativeUDETransport struct { + host *udecx.Host + client *udecx.Client + done chan error + closeOnce sync.Once + closeErr error +} + +func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nativeUDETransport, error) { + client, err := udecx.Open(ctx) + if err != nil { + return nil, err + } + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + _ = client.Close() + return nil, err + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + _ = client.Close() + return nil, err + } + if err := server.EnableNativeTransport(host); err != nil { + _ = client.Close() + return nil, err + } + session := &windowsNativeUDETransport{ + host: host, client: client, done: make(chan error, 1), + } + go func() { + session.done <- host.Serve(ctx) + close(session.done) + }() + return session, nil +} + +func (s *windowsNativeUDETransport) Done() <-chan error { return s.done } + +func (s *windowsNativeUDETransport) Close() error { + s.closeOnce.Do(func() { + s.host.Close() + s.closeErr = s.client.Close() + }) + return s.closeErr +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 3d453fdf..b565a086 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" "os" @@ -26,6 +27,7 @@ type Server struct { USBServerConfig usb.ServerConfig `embed:"" prefix:"usb."` APIServerConfig api.ServerConfig `embed:"" prefix:"api."` ConnectionTimeout time.Duration `help:"ConnectionTimeout operation timeout" default:"30s" env:"VIIPER_CONNECTION_TIMEOUT"` + Transport string `help:"Virtual USB transport: usbip or native-ude" default:"usbip" env:"VIIPER_TRANSPORT"` } // Run is called by Kong when the server command is executed. @@ -36,9 +38,15 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { } func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { - if err := requireUSBIPRuntime(); err != nil { - logger.Error("Refusing to start VIIPER with an incompatible USB/IP runtime", "error", err) - return err + transport := strings.ToLower(strings.TrimSpace(s.Transport)) + if transport != "usbip" && transport != "native-ude" { + return fmt.Errorf("unsupported VIIPER transport %q (expected usbip or native-ude)", s.Transport) + } + if transport == "usbip" { + if err := requireUSBIPRuntime(); err != nil { + logger.Error("Refusing to start VIIPER with an incompatible USB/IP runtime", "error", err) + return err + } } ctx, cancel := context.WithCancel(ctx) @@ -84,15 +92,27 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger usbSrv := usb.New(s.USBServerConfig, logger, rawLogger) - usbErrCh := make(chan error, 1) - go func() { - usbErrCh <- usbSrv.ListenAndServe() - }() - - select { - case err := <-usbErrCh: - return err - case <-usbSrv.Ready(): + var usbErrCh <-chan error + var nativeSession nativeUDETransport + if transport == "usbip" { + errors := make(chan error, 1) + usbErrCh = errors + go func() { + errors <- usbSrv.ListenAndServe() + }() + select { + case err := <-usbErrCh: + return err + case <-usbSrv.Ready(): + } + } else { + var err error + nativeSession, err = startNativeUDETransport(ctx, usbSrv) + if err != nil { + return fmt.Errorf("start native UDE transport: %w", err) + } + defer nativeSession.Close() + logger.Info("Starting VIIPER native UDE transport") } if s.APIServerConfig.Addr == "" { @@ -111,7 +131,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) - if s.APIServerConfig.AutoAttachLocalClient { + if s.APIServerConfig.AutoAttachLocalClient && transport == "usbip" { logger.Info("Auto-attach is enabled, checking prerequisites...") if !api.CheckAutoAttachPrerequisites(s.APIServerConfig.AutoAttachWindowsNative, logger) { logger.Warn("Auto-attach prerequisites not met") @@ -132,13 +152,30 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger if apiSrv != nil { apiSrv.Close() } - _ = usbSrv.Close() - _ = <-usbErrCh // nolint + if transport == "usbip" { + _ = usbSrv.Close() + _ = <-usbErrCh // nolint + } return nil case err := <-usbErrCh: if apiSrv != nil { apiSrv.Close() } return err + case err := <-nativeDone(nativeSession): + if apiSrv != nil { + apiSrv.Close() + } + if err == nil && ctx.Err() == nil { + return errors.New("native UDE transport stopped unexpectedly") + } + return err + } +} + +func nativeDone(session nativeUDETransport) <-chan error { + if session == nil { + return nil } + return session.Done() } diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 7e8e6ff2..de7977d8 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -66,7 +66,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("failed to create device: %v", err)) } - devCtx, err := b.Add(dev) + devCtx, err := s.AddDeviceToBus(req.Ctx, uint32(busID), dev) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) } @@ -80,7 +80,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { fmt.Sprintf("%d", exportMeta.DevID), devCtx) autoAttachResult := api.AutoAttachResult{} - if apiSrv.Config().AutoAttachLocalClient { + if apiSrv.Config().AutoAttachLocalClient && !s.NativeTransportEnabled() { autoAttachResult, err = attachLocalhostClientWithResult( req.Ctx, exportMeta, diff --git a/internal/server/api/server.go b/internal/server/api/server.go index eb0b1d65..e5ac4592 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -324,7 +324,7 @@ func (s *Server) handleConn(conn net.Conn) { resetter.ResetMicrophonePCM() } }, func() { - if err := bus.RemoveDeviceByID(devIDStr); err != nil { + if err := s.usbs.RemoveDeviceByID(uint32(busID), devIDStr); err != nil { connLogger.Error("disconnect timeout: failed to remove device", "busID", busID, "deviceID", devIDStr, "error", err) } else { diff --git a/internal/server/usb/native_transport_test.go b/internal/server/usb/native_transport_test.go new file mode 100644 index 00000000..f1b7e3ec --- /dev/null +++ b/internal/server/usb/native_transport_test.go @@ -0,0 +1,117 @@ +package usb + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" +) + +type nativeTransportTestDriver struct { + createErr error + created []udecx.CreateDevice + destroyed []udecx.DeviceIdentity +} + +func (d *nativeTransportTestDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) error { + d.created = append(d.created, device) + return d.createErr +} +func (d *nativeTransportTestDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.destroyed = append(d.destroyed, identity) + return nil +} +func (*nativeTransportTestDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + <-ctx.Done() + return udecx.Operation{}, ctx.Err() +} +func (*nativeTransportTestDriver) Complete(context.Context, udecx.Completion) error { return nil } +func (*nativeTransportTestDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +type nativeTransportTestProcessor struct{} + +func (*nativeTransportTestProcessor) Process(context.Context, usbdevice.Device, udecx.Operation) (udecx.Completion, error) { + return udecx.Completion{}, nil +} +func (*nativeTransportTestProcessor) Lifecycle(context.Context, usbdevice.Device, udecx.Operation) error { + return nil +} +func (*nativeTransportTestProcessor) Reset(usbdevice.Device, udecx.DeviceIdentity) {} + +func newNativeTransportTestDevice() usbdevice.Device { + return &altSettingTestDevice{desc: &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 1, IDProduct: 2, + BNumConfigurations: 1, Speed: uint32(udecx.DeviceSpeedHigh), + }, + Interfaces: []usbdevice.InterfaceConfig{{Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 3, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x81, BMAttributes: 3, WMaxPacketSize: 64, BInterval: 4, + }}}}, + }} +} + +func TestNativeTransportPublishesAndUnpublishesWithVirtualBus(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ConnectionTimeout: time.Second}, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98101) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + if _, err := server.AddDeviceToBus(context.Background(), bus.BusID(), newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if len(driver.created) != 1 || len(bus.Devices()) != 1 { + t.Fatalf("created=%d bus devices=%d want 1/1", len(driver.created), len(bus.Devices())) + } + if err := server.RemoveDeviceByID(bus.BusID(), "1"); err != nil { + t.Fatal(err) + } + if len(driver.destroyed) != 1 || len(bus.Devices()) != 0 { + t.Fatalf("destroyed=%d bus devices=%d want 1/0", len(driver.destroyed), len(bus.Devices())) + } +} + +func TestNativeTransportRollsBackVirtualBusWhenPlugInFails(t *testing.T) { + driver := &nativeTransportTestDriver{createErr: errors.New("driver rejected child")} + host, _ := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + server := New(ServerConfig{}, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98102) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + if _, err := server.AddDeviceToBus(context.Background(), bus.BusID(), newNativeTransportTestDevice()); err == nil { + t.Fatal("native plug-in unexpectedly succeeded") + } + if len(bus.Devices()) != 0 { + t.Fatal("failed native plug-in leaked a virtual bus device") + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 43669b86..2695dab0 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -17,7 +17,9 @@ import ( "syscall" "time" + "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/transport/udecx" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/virtualbus" @@ -204,6 +206,14 @@ type Server struct { ready chan struct{} readyOnce sync.Once ln net.Listener + nativeMu sync.Mutex + native *udecx.Host + nativeIDs map[nativeDeviceKey]udecx.DeviceIdentity +} + +type nativeDeviceKey struct { + busID uint32 + devID uint32 } func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Server { @@ -214,7 +224,67 @@ func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Ser busses: make(map[uint32]*virtualbus.VirtualBus), alts: make(map[usb.Device]map[uint8]uint8), ready: make(chan struct{}), + nativeIDs: make(map[nativeDeviceKey]udecx.DeviceIdentity), + } +} + +// EnableNativeTransport binds bus lifecycle to a native UdeCx host. It must be +// called before API handlers can add devices. +func (s *Server) EnableNativeTransport(host *udecx.Host) error { + if host == nil { + return errors.New("native UDE host is nil") } + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + if s.native != nil { + return errors.New("native UDE transport is already enabled") + } + s.native = host + return nil +} + +func (s *Server) NativeTransportEnabled() bool { + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + return s.native != nil +} + +func nativeDeviceID(busID, devID uint32) uint64 { + return uint64(busID)<<32 | uint64(devID) +} + +// AddDeviceToBus publishes a device transactionally. A failed native plug-in +// rolls the in-memory bus back before the device becomes visible to clients. +func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Device) (context.Context, error) { + bus := s.GetBus(busID) + if bus == nil { + return nil, fmt.Errorf("bus %d not found", busID) + } + deviceCtx, err := bus.Add(dev) + if err != nil { + return nil, err + } + meta := device.GetDeviceMeta(deviceCtx) + if meta == nil { + _ = bus.Remove(dev) + return nil, errors.New("virtual bus returned no device metadata") + } + + s.nativeMu.Lock() + host := s.native + s.nativeMu.Unlock() + if host == nil { + return deviceCtx, nil + } + identity, err := host.Register(ctx, nativeDeviceID(busID, meta.DevID), dev) + if err != nil { + _ = bus.Remove(dev) + return nil, fmt.Errorf("plug native UDE device: %w", err) + } + s.nativeMu.Lock() + s.nativeIDs[nativeDeviceKey{busID: busID, devID: meta.DevID}] = identity + s.nativeMu.Unlock() + return deviceCtx, nil } // AddBus registers a bus with the server. If the bus number is already present, @@ -246,8 +316,10 @@ func (s *Server) RemoveBus(busID uint32) error { if len(devices) > 0 { s.logger.Warn(fmt.Sprintf("Removing non-empty bus %d with %d device(s) attached; removing devices", busID, len(devices))) - for _, dev := range devices { - _ = bus.Remove(dev) + for _, meta := range bus.GetAllDeviceMetas() { + if err := s.removeDevice(busID, meta.Meta.DevID, false); err != nil { + return err + } } } @@ -267,7 +339,11 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { if !ok { return fmt.Errorf("bus %d not found", busID) } - err := bus.RemoveDeviceByID(deviceID) + parsedDeviceID, err := strconv.ParseUint(deviceID, 10, 32) + if err != nil { + return fmt.Errorf("invalid device id %q: %w", deviceID, err) + } + err = s.removeDevice(busID, uint32(parsedDeviceID), true) if err != nil { return err } @@ -303,6 +379,40 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { return nil } +func (s *Server) removeDevice(busID, deviceID uint32, requireBus bool) error { + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + if requireBus { + return fmt.Errorf("bus %d not found", busID) + } + return nil + } + + key := nativeDeviceKey{busID: busID, devID: deviceID} + s.nativeMu.Lock() + host := s.native + identity, registered := s.nativeIDs[key] + s.nativeMu.Unlock() + if host != nil && registered { + timeout := s.config.ConnectionTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + err := host.Unregister(ctx, identity) + cancel() + if err != nil { + return fmt.Errorf("unplug native UDE device: %w", err) + } + s.nativeMu.Lock() + delete(s.nativeIDs, key) + s.nativeMu.Unlock() + } + return bus.RemoveDeviceByID(strconv.FormatUint(uint64(deviceID), 10)) +} + // ListBuses returns a snapshot of active bus numbers. func (s *Server) ListBuses() []uint32 { s.busesMu.Lock() diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 6f9788db..708b8144 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -151,13 +151,27 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { h.lifecycleMu.Lock() defer h.lifecycleMu.Unlock() - h.mu.Lock() + h.mu.RLock() entry := h.devices[identity.DeviceID] if entry == nil || entry.identity.Generation != identity.Generation { - h.mu.Unlock() + h.mu.RUnlock() return fmt.Errorf("native UDE device %d generation %d is not registered", identity.DeviceID, identity.Generation) } + h.mu.RUnlock() + + // Keep routing live until the driver has transactionally unplugged the + // child. If unplug fails, callers can retry without losing the generation, + // endpoint lanes, or the ability to complete already-issued Windows URBs. + if err := h.driver.DestroyDevice(ctx, identity); err != nil { + return err + } + + h.mu.Lock() + if h.devices[identity.DeviceID] != entry { + h.mu.Unlock() + return errors.New("native UDE device changed during serialized removal") + } delete(h.devices, identity.DeviceID) entry.cancel() for key, lane := range h.lanes { @@ -169,7 +183,7 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { h.mu.Unlock() h.cancelDeviceOperations(identity) h.processor.Reset(entry.device, identity) - return h.driver.DestroyDevice(ctx, identity) + return nil } type dequeueResult struct { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 4173b226..b44ff2d6 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -17,6 +17,7 @@ type fakeHostDriver struct { mu sync.Mutex created []CreateDevice destroyed []DeviceIdentity + destroyErr error } func newFakeHostDriver() *fakeHostDriver { @@ -34,7 +35,7 @@ func (d *fakeHostDriver) DestroyDevice(_ context.Context, identity DeviceIdentit d.mu.Lock() defer d.mu.Unlock() d.destroyed = append(d.destroyed, identity) - return nil + return d.destroyErr } func (d *fakeHostDriver) Dequeue(ctx context.Context, _ []byte) (Operation, error) { select { @@ -221,6 +222,30 @@ func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { } } +func TestHostUnregisterFailureKeepsDeviceRetryable(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 11, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + driver.destroyErr = errors.New("plug-out failed") + if err := host.Unregister(context.Background(), identity); err == nil { + t.Fatal("unregister unexpectedly succeeded") + } + host.mu.RLock() + entry := host.devices[identity.DeviceID] + host.mu.RUnlock() + if entry == nil || entry.identity != identity { + t.Fatal("failed unregister discarded the live device generation") + } + driver.destroyErr = nil + if err := host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } +} + func TestHostRejectsStaleOperationGeneration(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} From cffbce3f914df8a501b285dc55b397f580621dcc Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:45:02 -0500 Subject: [PATCH 014/240] Validate native isochronous completion directions Validate ISO packet ranges against the original transfer buffer for OUT URBs instead of the intentionally empty IN payload tail. Add native processor coverage for EP0 descriptors, sparse ISO capture layouts, and zero-copy-style ISO playback completion metadata. --- internal/server/usb/native_test.go | 102 +++++++++++++++++++++++++++++ native/udecx/driver/Broker.c | 8 ++- 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 internal/server/usb/native_test.go diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go new file mode 100644 index 00000000..2c4f385a --- /dev/null +++ b/internal/server/usb/native_test.go @@ -0,0 +1,102 @@ +package usb + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +func nativeProcessorForTest(t *testing.T) *NativeProcessor { + t.Helper() + processor, err := NewNativeProcessor(New(ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + return processor +} + +func TestNativeProcessorServesControlDescriptor(t *testing.T) { + dev := newNativeTransportTestDevice() + op := udecx.Operation{ + Token: 1, DeviceID: 1, Generation: 1, Kind: udecx.OperationControl, + Direction: 1, TransferLength: 18, + SetupPacket: [8]byte{0x80, usbReqGetDescriptor, 0, usbDescTypeDevice, 0, 0, 18, 0}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != 18 || len(completion.Payload) != 18 || + completion.Payload[1] != usbDescTypeDevice { + t.Fatalf("unexpected device descriptor completion: %+v payload=%x", completion, completion.Payload) + } +} + +func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 1, + }}}}, + } + dev := &isoInTestDevice{desc: desc, payloads: [][]byte{ + bytes.Repeat([]byte{0x11}, 12), bytes.Repeat([]byte{0x22}, 8), + }} + op := udecx.Operation{ + Token: 2, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, TransferLength: 48, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 32, Length: 16}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != 20 || len(completion.Payload) != 48 { + t.Fatalf("transfer=%d payload=%d want 20/48", completion.TransferLength, len(completion.Payload)) + } + if completion.IsoPackets[0].Length != 12 || completion.IsoPackets[1].Length != 8 || + !bytes.Equal(completion.Payload[:12], bytes.Repeat([]byte{0x11}, 12)) || + !bytes.Equal(completion.Payload[32:40], bytes.Repeat([]byte{0x22}, 8)) { + t.Fatalf("sparse ISO payload or packet actuals were not preserved: %+v", completion) + } +} + +type isoOutRecordingDevice struct { + desc *usbdevice.Descriptor + payload []byte +} + +func (d *isoOutRecordingDevice) HandleTransfer(_ context.Context, _ uint32, _ uint32, out []byte) []byte { + d.payload = append(d.payload[:0], out...) + return nil +} +func (d *isoOutRecordingDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*isoOutRecordingDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestNativeProcessorCompletesIsoOutWithoutEchoPayload(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 1, + }}}}, + } + dev := &isoOutRecordingDevice{desc: desc} + payload := bytes.Repeat([]byte{0x5a}, 32) + op := udecx.Operation{ + Token: 3, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, TransferLength: 32, Payload: payload, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 16, Length: 16}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(dev.payload, payload) || len(completion.Payload) != 0 || + completion.TransferLength != 32 || len(completion.IsoPackets) != 2 { + t.Fatalf("unexpected ISO OUT completion: %+v captured=%x", completion, dev.payload) + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 2cb31305..16d88fcf 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -927,6 +927,7 @@ ViiperCompleteOperation( size_t tailLength = 0; ULONG slot; ULONG index; + ULONG isoPayloadLimit; NTSTATUS status; BOOLEAN slotRemoved = FALSE; @@ -1044,9 +1045,12 @@ ViiperCompleteOperation( InterlockedAdd64(&controllerContext->BytesFromDevice, completion->TransferLength); } if (completion->IsoPacketCount != 0) { + isoPayloadLimit = requestContext->DirectionIn + ? completion->PayloadLength + : requestContext->TransferLength; for (index = 0; index < completion->IsoPacketCount; ++index) { - if (packets[index].Offset > completion->PayloadLength || - packets[index].Length > completion->PayloadLength - packets[index].Offset) { + if (packets[index].Offset > isoPayloadLimit || + packets[index].Length > isoPayloadLimit - packets[index].Offset) { status = STATUS_INVALID_PARAMETER; goto CompleteWithNtStatus; } From c6caca8701e53772ca09a036bfc87563e101a508 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:51:38 -0500 Subject: [PATCH 015/240] Honor UdeCx queue and interface lifecycle contracts Stop changing the class-extension-owned endpoint queue state, expose the standard USB host-controller interface without stealing broker ownership, and serialize dynamic SET_INTERFACE transitions through the ordered device lane. ABI 1.2 uses the former reserved bytes for interface/alternate-setting identity so DualSense audio interfaces enter the same device-engine state as USB/IP. Tests cover parsing and valid/invalid native alternate-setting transitions. --- internal/server/usb/native.go | 19 ++++++++- internal/server/usb/native_test.go | 37 +++++++++++++++++ internal/transport/udecx/protocol.go | 6 ++- internal/transport/udecx/protocol_test.go | 4 +- native/udecx/driver/Broker.c | 48 +++++++++++++++++++++-- native/udecx/driver/Controller.c | 39 +++++++++++++++++- native/udecx/driver/Device.c | 39 ++++++++++-------- native/udecx/driver/ViiperUde.h | 8 +++- native/udecx/include/ViiperUdeProtocol.h | 5 ++- 9 files changed, 177 insertions(+), 28 deletions(-) diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index d49101c3..bdc00559 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -68,13 +68,30 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op case udecx.OperationDeviceReset, udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: p.Reset(dev, identity) case udecx.OperationSetInterface: - p.clearLane(key) + if !descriptorHasInterfaceAlt(dev.GetDescriptor(), op.InterfaceNumber, op.InterfaceSetting) { + return fmt.Errorf("native UDE selected invalid alternate setting %d for interface %d", + op.InterfaceSetting, op.InterfaceNumber) + } + p.clearDeviceLanes(identity) + p.server.setInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) + p.server.notifyInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) default: return fmt.Errorf("unsupported native UDE lifecycle operation %d", op.Kind) } return nil } +func (p *NativeProcessor) clearDeviceLanes(identity udecx.DeviceIdentity) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.next, key) + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + func (p *NativeProcessor) clearLane(key nativeLaneKey) { p.mu.Lock() delete(p.next, key) diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 2c4f385a..f5a2652c 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -36,6 +36,43 @@ func TestNativeProcessorServesControlDescriptor(t *testing.T) { } } +func TestNativeProcessorAppliesUdeCxInterfaceSettingLifecycle(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 0, + }}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, + }}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + op := udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationSetInterface, + InterfaceNumber: 2, InterfaceSetting: 1, + } + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("interface 2 alt=%d want 1", got) + } + if len(dev.altEvents) != 1 || dev.altEvents[0] != [2]uint8{2, 1} { + t.Fatalf("device alternate-setting events=%v want [[2 1]]", dev.altEvents) + } + + op.InterfaceSetting = 3 + if err := processor.Lifecycle(context.Background(), dev, op); err == nil { + t.Fatal("invalid native alternate setting unexpectedly succeeded") + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("invalid transition changed interface 2 alt to %d", got) + } +} + func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { desc := &usbdevice.Descriptor{ Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 03971f20..35606d96 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 1 + ABIMinor uint16 = 2 HeaderSize = 16 NegotiateRequestSize = 32 @@ -272,6 +272,8 @@ type Operation struct { Kind OperationKind EndpointAddress uint8 Direction uint8 + InterfaceNumber uint8 + InterfaceSetting uint8 URBFunction uint32 TransferFlags uint32 StartFrame uint32 @@ -309,6 +311,8 @@ func ParseOperation(src []byte) (Operation, error) { Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), EndpointAddress: src[40], Direction: src[41], + InterfaceNumber: src[42], + InterfaceSetting: src[43], URBFunction: binary.LittleEndian.Uint32(src[44:48]), TransferFlags: binary.LittleEndian.Uint32(src[48:52]), StartFrame: binary.LittleEndian.Uint32(src[52:56]), diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index ab50e8f1..6e116bb1 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -89,6 +89,7 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { binary.LittleEndian.PutUint32(raw[32:36], 8) binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) raw[40], raw[41] = 0x84, 1 + raw[42], raw[43] = 2, 1 binary.LittleEndian.PutUint32(raw[56:60], 1) binary.LittleEndian.PutUint32(raw[60:64], uint32(len(payload))) binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize) @@ -104,7 +105,8 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { t.Fatal(err) } if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || - op.EndpointSequence != 17 || len(op.IsoPackets) != 1 { + op.EndpointSequence != 17 || op.InterfaceNumber != 2 || + op.InterfaceSetting != 1 || len(op.IsoPackets) != 1 { t.Fatalf("unexpected operation: %+v", op) } raw[len(raw)-1] = 0xff diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 16d88fcf..bba983d2 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -36,6 +36,8 @@ ViiperQueueCancelEventLocked( event->Generation = Pending->DeviceGeneration; event->Kind = ViiperUdeOperationCancel; event->EndpointAddress = Pending->EndpointAddress; + event->InterfaceNumber = 0; + event->InterfaceSetting = 0; ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ++ControllerContext->NotificationCount; @@ -93,6 +95,8 @@ ViiperDispatchNotificationEvents( operation->Generation = event.Generation; operation->Kind = event.Kind; operation->EndpointAddress = event.EndpointAddress; + operation->InterfaceNumber = event.InterfaceNumber; + operation->InterfaceSetting = event.InterfaceSetting; operation->EndpointSequence = event.EndpointSequence; WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); @@ -220,7 +224,9 @@ ViiperQueueLifecycleEventLocked( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext, _In_ UCHAR EndpointAddress, - _In_ VIIPER_UDE_OPERATION_KIND Kind + _In_ VIIPER_UDE_OPERATION_KIND Kind, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting ) { VIIPER_UDE_NOTIFICATION *event; @@ -236,6 +242,8 @@ ViiperQueueLifecycleEventLocked( event->Generation = DeviceContext->Generation; event->Kind = Kind; event->EndpointAddress = EndpointAddress; + event->InterfaceNumber = InterfaceNumber; + event->InterfaceSetting = InterfaceSetting; event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( &DeviceContext->EndpointSequences[EndpointAddress]); ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % @@ -258,7 +266,12 @@ ViiperQueueEndpointLifecycleEvent( WdfSpinLockAcquire(controllerContext->BrokerLock); queued = ViiperQueueLifecycleEventLocked( - controllerContext, deviceContext, endpointContext->Descriptor.bEndpointAddress, Kind); + controllerContext, + deviceContext, + endpointContext->Descriptor.bEndpointAddress, + Kind, + 0, + 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -279,7 +292,36 @@ ViiperQueueDeviceLifecycleEvent( BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = ViiperQueueLifecycleEventLocked(controllerContext, deviceContext, 0, Kind); + queued = ViiperQueueLifecycleEventLocked( + controllerContext, deviceContext, 0, Kind, 0, 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!queued) { + return STATUS_INSUFFICIENT_RESOURCES; + } + ViiperDispatchNotificationEvents(deviceContext->Controller); + return STATUS_SUCCESS; +} + +NTSTATUS +ViiperQueueInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN queued; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + queued = ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + 0, + ViiperUdeOperationSetInterface, + InterfaceNumber, + InterfaceSetting); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 5768002b..c47d8f24 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -54,6 +54,7 @@ ViiperEvtDeviceAdd( UDECX_WDF_DEVICE_CONFIG udeConfig; VIIPER_UDE_CONTROLLER_CONTEXT *context; UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); + UNICODE_STRING hostControllerReference; PAGED_CODE(); UNREFERENCED_PARAMETER(Driver); @@ -109,6 +110,14 @@ ViiperEvtDeviceAdd( if (!NT_SUCCESS(status)) { return status; } + RtlInitUnicodeString(&hostControllerReference, USB_HOST_DEVINTERFACE_REF_STRING); + status = WdfDeviceCreateDeviceInterface( + device, + (LPGUID)&GUID_DEVINTERFACE_USB_HOST_CONTROLLER, + &hostControllerReference); + if (!NT_SUCCESS(status)) { + return status; + } UDECX_WDF_DEVICE_CONFIG_INIT(&udeConfig, ViiperEvtQueryUsbCapability); udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; @@ -156,16 +165,38 @@ ViiperEvtFileCreate( { VIIPER_UDE_CONTROLLER_CONTEXT *context; VIIPER_UDE_FILE_CONTEXT *fileContext; + PUNICODE_STRING fileName; + UNICODE_STRING hostControllerReference; + BOOLEAN isHostControllerClient = FALSE; NTSTATUS status = STATUS_SUCCESS; PAGED_CODE(); context = ViiperGetControllerContext(Device); + fileContext = ViiperGetFileContext(FileObject); + RtlZeroMemory(fileContext, sizeof(*fileContext)); + + fileName = WdfFileObjectGetFileName(FileObject); + RtlInitUnicodeString(&hostControllerReference, USB_HOST_DEVINTERFACE_REF_STRING); + if (fileName != NULL && + fileName->Length == hostControllerReference.Length + sizeof(WCHAR) && + fileName->Buffer[0] == L'\\' && + RtlEqualMemory( + fileName->Buffer + 1, + hostControllerReference.Buffer, + hostControllerReference.Length)) { + isHostControllerClient = TRUE; + } + + if (isHostControllerClient) { + WdfRequestComplete(Request, STATUS_SUCCESS); + return; + } + WdfWaitLockAcquire(context->OwnerLock, NULL); if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { status = STATUS_SHARING_VIOLATION; } else { - fileContext = ViiperGetFileContext(FileObject); - RtlZeroMemory(fileContext, sizeof(*fileContext)); + fileContext->BrokerOwner = TRUE; context->OwnerFile = FileObject; WdfIoQueueStart(context->DefaultQueue); WdfIoQueueStart(context->WaitingDequeues); @@ -190,6 +221,10 @@ ViiperEvtFileCleanup( fileContext = ViiperGetFileContext(FileObject); fileContext->Closing = TRUE; + if (!fileContext->BrokerOwner) { + return; + } + WdfWaitLockAcquire(context->OwnerLock, NULL); if (context->OwnerFile == FileObject) { context->CleanupInProgress = TRUE; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 360f35ac..e8be19f8 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -593,18 +593,6 @@ ViiperEvtEndpointReset( WdfRequestComplete(Request, status); } -VOID -ViiperEvtEndpointQueuePurged( - _In_ WDFQUEUE Queue, - _In_ WDFCONTEXT Context - ) -{ - UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; - UNREFERENCED_PARAMETER(Queue); - ViiperGetEndpointContext(endpoint)->Purging = FALSE; - UdecxUsbEndpointPurgeComplete(endpoint); -} - VOID ViiperEvtEndpointPurge( _In_ UDECXUSBENDPOINT Endpoint @@ -614,7 +602,8 @@ ViiperEvtEndpointPurge( endpointContext->Purging = TRUE; ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); + endpointContext->Purging = FALSE; + UdecxUsbEndpointPurgeComplete(Endpoint); } VOID @@ -623,7 +612,6 @@ ViiperEvtEndpointStart( ) { (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); - WdfIoQueueStart(ViiperGetEndpointContext(Endpoint)->Queue); } VOID @@ -633,9 +621,26 @@ ViiperEvtEndpointsConfigure( _In_ UDECX_ENDPOINTS_CONFIGURE_PARAMS *ConfigureParams ) { - UNREFERENCED_PARAMETER(Device); - UNREFERENCED_PARAMETER(ConfigureParams); - WdfRequestComplete(Request, STATUS_SUCCESS); + NTSTATUS status = STATUS_SUCCESS; + + switch (ConfigureParams->ConfigureType) { + case UdecxEndpointsConfigureTypeDeviceInitialize: + case UdecxEndpointsConfigureTypeDeviceConfigurationChange: + status = ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceReset); + break; + case UdecxEndpointsConfigureTypeInterfaceSettingChange: + status = ViiperQueueInterfaceLifecycleEvent( + Device, + ConfigureParams->InterfaceNumber, + ConfigureParams->NewInterfaceSetting); + break; + case UdecxEndpointsConfigureTypeEndpointsReleasedOnly: + break; + default: + status = STATUS_INVALID_PARAMETER; + break; + } + WdfRequestComplete(Request, status); } VOID diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index be727105..f2355180 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -43,6 +43,8 @@ typedef struct VIIPER_UDE_NOTIFICATION { ULONG Generation; ULONG Kind; UCHAR EndpointAddress; + UCHAR InterfaceNumber; + UCHAR InterfaceSetting; } VIIPER_UDE_NOTIFICATION; typedef struct VIIPER_UDE_REQUEST_CONTEXT { @@ -96,6 +98,7 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetContr typedef struct VIIPER_UDE_FILE_CONTEXT { BOOLEAN Negotiated; BOOLEAN Closing; + BOOLEAN BrokerOwner; ULONGLONG ClientNonce; ULONGLONG DriverNonce; } VIIPER_UDE_FILE_CONTEXT; @@ -145,7 +148,6 @@ EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; -EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); @@ -164,3 +166,7 @@ NTSTATUS ViiperQueueEndpointLifecycleEvent( NTSTATUS ViiperQueueDeviceLifecycleEvent( _In_ UDECXUSBDEVICE Device, _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting); diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 50396ac2..4cfb899a 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(1) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(2) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) @@ -148,7 +148,8 @@ typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_UINT32 Kind; VIIPER_UDE_UINT8 EndpointAddress; VIIPER_UDE_UINT8 Direction; - VIIPER_UDE_UINT16 Reserved0; + VIIPER_UDE_UINT8 InterfaceNumber; + VIIPER_UDE_UINT8 InterfaceSetting; VIIPER_UDE_UINT32 UrbFunction; VIIPER_UDE_UINT32 TransferFlags; VIIPER_UDE_UINT32 StartFrame; From c76a33b5e1f82fa11628925339ccebecdb826fd7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:53:47 -0500 Subject: [PATCH 016/240] Define the UDE host-controller reference string Use the reference value from Microsoft's UDE host-controller sample so the standard HCD interface is buildable and remains distinguishable from the exclusive VIIPER broker handle. --- native/udecx/driver/Controller.c | 4 ++-- native/udecx/driver/ViiperUde.h | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index c47d8f24..3a4fb6b2 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -110,7 +110,7 @@ ViiperEvtDeviceAdd( if (!NT_SUCCESS(status)) { return status; } - RtlInitUnicodeString(&hostControllerReference, USB_HOST_DEVINTERFACE_REF_STRING); + RtlInitUnicodeString(&hostControllerReference, VIIPER_UDE_HOST_REFERENCE_STRING); status = WdfDeviceCreateDeviceInterface( device, (LPGUID)&GUID_DEVINTERFACE_USB_HOST_CONTROLLER, @@ -176,7 +176,7 @@ ViiperEvtFileCreate( RtlZeroMemory(fileContext, sizeof(*fileContext)); fileName = WdfFileObjectGetFileName(FileObject); - RtlInitUnicodeString(&hostControllerReference, USB_HOST_DEVINTERFACE_REF_STRING); + RtlInitUnicodeString(&hostControllerReference, VIIPER_UDE_HOST_REFERENCE_STRING); if (fileName != NULL && fileName->Length == hostControllerReference.Length + sizeof(WCHAR) && fileName->Buffer[0] == L'\\' && diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index f2355180..d86522eb 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -13,6 +13,11 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; +// Reference string used by Microsoft's documented UDE host-controller sample. +// It lets the create callback distinguish standard HCD clients from the +// exclusive VIIPER broker interface. +#define VIIPER_UDE_HOST_REFERENCE_STRING L"GUID_DEVINTERFACE_USB_HOST_CONTROLLER" + typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingEmpty = 0, ViiperUdePendingPreparing, From 983850d217cd9d6074eac043162a62b238389774 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 19:58:47 -0500 Subject: [PATCH 017/240] Register virtual USB descriptors with UdeCx Validate ABI 1.2 exactly across every broker message, parse descriptor topology with overflow-safe bounds, and register device, configuration, BOS, and localized string descriptors before virtual-device creation. Previously the descriptors were validated then discarded, so Windows could not enumerate the controller. --- internal/transport/udecx/protocol.go | 4 + internal/transport/udecx/protocol_test.go | 1 + native/udecx/driver/Broker.c | 1 + native/udecx/driver/Device.c | 109 ++++++++++++++++++++-- native/udecx/driver/Ioctl.c | 1 + 5 files changed, 109 insertions(+), 7 deletions(-) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 35606d96..f1620715 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -37,6 +37,7 @@ var ( ErrShortMessage = errors.New("native UDE message is shorter than its fixed header") ErrBadMagic = errors.New("native UDE message has an invalid magic value") ErrIncompatibleMajor = errors.New("native UDE ABI major version is incompatible") + ErrIncompatibleMinor = errors.New("native UDE ABI minor version is incompatible") ErrInvalidSize = errors.New("native UDE message size is invalid") ErrInvalidRange = errors.New("native UDE message contains an invalid range") ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") @@ -82,6 +83,9 @@ func ParseHeader(src []byte) (Header, error) { if h.Major != ABIMajor { return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMajor, h.Major, ABIMajor) } + if h.Minor != ABIMinor { + return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMinor, h.Minor, ABIMinor) + } if h.Size < HeaderSize || uint64(h.Size) > uint64(len(src)) { return Header{}, ErrInvalidSize } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 6e116bb1..7e668dbf 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -36,6 +36,7 @@ func TestHeaderRejectsMalformedInput(t *testing.T) { {"short", func(b []byte) []byte { return b[:15] }, ErrShortMessage}, {"magic", func(b []byte) []byte { binary.LittleEndian.PutUint32(b, 0); return b }, ErrBadMagic}, {"major", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[4:6], ABIMajor+1); return b }, ErrIncompatibleMajor}, + {"minor", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[6:8], ABIMinor+1); return b }, ErrIncompatibleMinor}, {"size below header", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 15); return b }, ErrInvalidSize}, {"size beyond buffer", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 17); return b }, ErrInvalidSize}, } diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index bba983d2..0a035f08 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -984,6 +984,7 @@ ViiperCompleteOperation( } if (inputLength != sizeof(*completion) || completion->Header.Magic != VIIPER_UDE_MAGIC || completion->Header.Major != VIIPER_UDE_ABI_MAJOR || + completion->Header.Minor != VIIPER_UDE_ABI_MINOR || completion->Header.Size < sizeof(*completion) || completion->Token == 0 || completion->DeviceId == 0 || completion->Generation == 0 || completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index e8be19f8..8be07b8a 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -46,6 +46,7 @@ ViiperValidateCreateDevice( InputLength > (size_t)VIIPER_UDE_MAX_DESCRIPTOR_BYTES * 2 + sizeof(*Input) || Input->Header.Magic != VIIPER_UDE_MAGIC || Input->Header.Major != VIIPER_UDE_ABI_MAJOR || + Input->Header.Minor != VIIPER_UDE_ABI_MINOR || Input->Header.Size != InputLength || Input->DeviceId == 0 || Input->Generation == 0 || Input->DescriptorCount == 0 || @@ -61,8 +62,12 @@ ViiperValidateCreateDevice( return FALSE; } recordsLength = Input->DescriptorCount * sizeof(*records); - if (!ViiperRangeValid(Input->DescriptorRecordsOffset, recordsLength, Input->Header.Size) || - !ViiperRangeValid(Input->DescriptorDataOffset, Input->DescriptorDataLength, Input->Header.Size)) { + if (Input->DescriptorRecordsOffset < sizeof(*Input) || + !ViiperRangeValid(Input->DescriptorRecordsOffset, recordsLength, Input->Header.Size) || + !ViiperRangeValid(Input->DescriptorDataOffset, Input->DescriptorDataLength, Input->Header.Size) || + Input->DescriptorDataOffset < Input->DescriptorRecordsOffset || + Input->DescriptorDataOffset - Input->DescriptorRecordsOffset < recordsLength || + Input->DescriptorDataOffset + Input->DescriptorDataLength != Input->Header.Size) { return FALSE; } @@ -70,20 +75,103 @@ ViiperValidateCreateDevice( ((const UCHAR *)Input + Input->DescriptorRecordsOffset); for (index = 0; index < Input->DescriptorCount; ++index) { const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; - if (!ViiperRangeValid(record->Offset, record->Length, Input->DescriptorDataLength)) { + const UCHAR *descriptor; + if (record->Length < 2 || record->Length > MAXUSHORT || + !ViiperRangeValid(record->Offset, record->Length, Input->DescriptorDataLength)) { return FALSE; } - if (record->Kind == ViiperUdeDescriptorDevice && record->Length >= sizeof(USB_DEVICE_DESCRIPTOR)) { + descriptor = (const UCHAR *)Input + Input->DescriptorDataOffset + record->Offset; + switch (record->Kind) { + case ViiperUdeDescriptorDevice: + if (foundDevice || record->Index != 0 || + record->Length != sizeof(USB_DEVICE_DESCRIPTOR) || + descriptor[0] != sizeof(USB_DEVICE_DESCRIPTOR) || + descriptor[1] != USB_DEVICE_DESCRIPTOR_TYPE) { + return FALSE; + } foundDevice = TRUE; - } - if (record->Kind == ViiperUdeDescriptorConfiguration && record->Length >= sizeof(USB_CONFIGURATION_DESCRIPTOR)) { + break; + case ViiperUdeDescriptorConfiguration: + if (foundConfiguration || record->Index != 0 || + record->Length < sizeof(USB_CONFIGURATION_DESCRIPTOR) || + descriptor[0] != sizeof(USB_CONFIGURATION_DESCRIPTOR) || + descriptor[1] != USB_CONFIGURATION_DESCRIPTOR_TYPE || + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != record->Length) { + return FALSE; + } foundConfiguration = TRUE; + break; + case ViiperUdeDescriptorBos: + if (record->Index != 0 || descriptor[1] != USB_BOS_DESCRIPTOR_TYPE) { + return FALSE; + } + break; + case ViiperUdeDescriptorString: + if (record->Index > MAXUCHAR || record->Length > MAXUCHAR || + descriptor[0] != record->Length || descriptor[1] != USB_STRING_DESCRIPTOR_TYPE || + (record->Length & 1) != 0 || + (record->Index == 0 && record->LanguageId != 0) || + (record->Index != 0 && record->LanguageId == 0)) { + return FALSE; + } + break; + default: + return FALSE; } } return foundDevice && foundConfiguration; } +static +NTSTATUS +ViiperAddDeviceDescriptors( + _Inout_ PUDECXUSBDEVICE_INIT DeviceInit, + _In_ const VIIPER_UDE_CREATE_DEVICE *Input + ) +{ + const VIIPER_UDE_DESCRIPTOR_RECORD *records = + (const VIIPER_UDE_DESCRIPTOR_RECORD *) + ((const UCHAR *)Input + Input->DescriptorRecordsOffset); + const UCHAR *data = (const UCHAR *)Input + Input->DescriptorDataOffset; + ULONG index; + + for (index = 0; index < Input->DescriptorCount; ++index) { + const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; + PUCHAR descriptor = (PUCHAR)(data + record->Offset); + NTSTATUS status; + + switch (record->Kind) { + case ViiperUdeDescriptorDevice: + case ViiperUdeDescriptorConfiguration: + case ViiperUdeDescriptorBos: + status = UdecxUsbDeviceInitAddDescriptor( + DeviceInit, descriptor, (USHORT)record->Length); + break; + case ViiperUdeDescriptorString: + if (record->Index == 0) { + status = UdecxUsbDeviceInitAddDescriptorWithIndex( + DeviceInit, descriptor, (USHORT)record->Length, 0); + } else { + status = UdecxUsbDeviceInitAddStringDescriptorRaw( + DeviceInit, + descriptor, + (USHORT)record->Length, + (UCHAR)record->Index, + record->LanguageId); + } + break; + default: + status = STATUS_INVALID_PARAMETER; + break; + } + if (!NT_SUCCESS(status)) { + return status; + } + } + return STATUS_SUCCESS; +} + static NTSTATUS ViiperValidateOwner( @@ -243,6 +331,11 @@ ViiperCreateVirtualDevice( UdecxUsbDeviceInitSetStateChangeCallbacks(deviceInit, &callbacks); UdecxUsbDeviceInitSetSpeed(deviceInit, speed); UdecxUsbDeviceInitSetEndpointsType(deviceInit, UdecxEndpointTypeDynamic); + status = ViiperAddDeviceDescriptors(deviceInit, input); + if (!NT_SUCCESS(status)) { + UdecxUsbDeviceInitFree(deviceInit); + return status; + } WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); attributes.ParentObject = controller; @@ -366,7 +459,9 @@ ViiperDestroyVirtualDevice( return status; } if (inputLength < sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || - input->Header.Major != VIIPER_UDE_ABI_MAJOR || input->Header.Size != sizeof(*input) || + input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Size != sizeof(*input) || input->DeviceId == 0 || input->Generation == 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 7efae799..814dee76 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -11,6 +11,7 @@ ViiperValidateHeader( return BufferLength >= ExpectedSize && Header->Magic == VIIPER_UDE_MAGIC && Header->Major == VIIPER_UDE_ABI_MAJOR && + Header->Minor == VIIPER_UDE_ABI_MINOR && Header->Size == ExpectedSize; } From ac599a8a46e4dbc4145bb3395418cccf02ecdd25 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:03:54 -0500 Subject: [PATCH 018/240] Drain endpoint queues before acknowledging UdeCx purge Follow the UdeCx and usbip-win2 lifecycle contract: block endpoint admission, cancel broker-held operations, asynchronously purge the associated WDF queue, and acknowledge purge only after its completion callback. Keep admission closed until the matching endpoint-start callback. Also resolve the WDK descriptor-length signedness diagnostic. --- native/udecx/driver/Broker.c | 3 ++- native/udecx/driver/Device.c | 20 ++++++++++++++++---- native/udecx/driver/ViiperUde.h | 5 +++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 0a035f08..30204860 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -893,7 +893,8 @@ ViiperQueueUrb( BOOLEAN abortPending = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; - if (deviceContext->Purging || endpointContext->Purging) { + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { return STATUS_DEVICE_NOT_READY; } RtlZeroMemory(requestContext, sizeof(*requestContext)); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 8be07b8a..1ebc9f0e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -96,7 +96,7 @@ ViiperValidateCreateDevice( record->Length < sizeof(USB_CONFIGURATION_DESCRIPTOR) || descriptor[0] != sizeof(USB_CONFIGURATION_DESCRIPTOR) || descriptor[1] != USB_CONFIGURATION_DESCRIPTOR_TYPE || - ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != record->Length) { + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length) { return FALSE; } foundConfiguration = TRUE; @@ -688,17 +688,27 @@ ViiperEvtEndpointReset( WdfRequestComplete(Request, status); } +VOID +ViiperEvtEndpointQueuePurged( + _In_ WDFQUEUE Queue, + _In_ WDFCONTEXT Context + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + UNREFERENCED_PARAMETER(Queue); + UdecxUsbEndpointPurgeComplete(endpoint); +} + VOID ViiperEvtEndpointPurge( _In_ UDECXUSBENDPOINT Endpoint ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - endpointContext->Purging = TRUE; + InterlockedExchange(&endpointContext->Purging, TRUE); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - endpointContext->Purging = FALSE; - UdecxUsbEndpointPurgeComplete(Endpoint); + WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); } VOID @@ -707,6 +717,8 @@ ViiperEvtEndpointStart( ) { (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); + InterlockedExchange(&ViiperGetEndpointContext(Endpoint)->Purging, FALSE); + WdfIoQueueStart(ViiperGetEndpointContext(Endpoint)->Queue); } VOID diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index d86522eb..017f6f54 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -117,7 +117,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { ULONG Generation; ULONG Slot; BOOLEAN Plugged; - BOOLEAN Purging; + volatile LONG Purging; volatile LONG ActiveCounted; UDECXUSBENDPOINT DefaultEndpoint; volatile LONG64 EndpointSequences[256]; @@ -129,7 +129,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; USB_ENDPOINT_DESCRIPTOR Descriptor; - BOOLEAN Purging; + volatile LONG Purging; } VIIPER_UDE_ENDPOINT_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_ENDPOINT_CONTEXT, ViiperGetEndpointContext) @@ -153,6 +153,7 @@ EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; +EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); From 8da8ca9a9835952df01c3fcba31ca9e7a716b339 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:10:12 -0500 Subject: [PATCH 019/240] Complete native URBs on a dedicated DPC Route successful, failed, cancelled, and purged broker-owned URBs through one bounded controller completion DPC so every UdeCx completion occurs at DISPATCH_LEVEL and never synchronously on the endpoint callback stack. Preserve slot ownership until final completion and use a one-shot DPC only for pre-admission failures. This follows Microsoft's UDE completion contract and usbip-win2's dispatch-level behavior without adding a media queue. --- native/udecx/driver/Broker.c | 218 +++++++++++++++++++++++++++++--- native/udecx/driver/Device.c | 3 +- native/udecx/driver/ViiperUde.h | 12 +- 3 files changed, 211 insertions(+), 22 deletions(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 30204860..839b303d 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -12,6 +12,81 @@ EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; +typedef struct VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT { + WDFREQUEST Request; + NTSTATUS Status; +} VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME( + VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT, + ViiperGetOrphanCompletionContext) + +static EVT_WDF_DPC ViiperEvtOrphanCompletionDpc; + +static +VOID +ViiperEvtOrphanCompletionDpc( + _In_ WDFDPC Dpc + ) +{ + VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT *context = + ViiperGetOrphanCompletionContext(Dpc); + WDFREQUEST request = context->Request; + + UdecxUrbCompleteWithNtStatus(request, context->Status); + WdfObjectDereference(request); + WdfObjectDelete(Dpc); +} + +VOID +ViiperCompleteUnownedUrbAsync( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status + ) +{ + WDF_DPC_CONFIG config; + WDF_OBJECT_ATTRIBUTES attributes; + VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT *context; + WDFDPC dpc; + NTSTATUS createStatus; + + WDF_DPC_CONFIG_INIT(&config, ViiperEvtOrphanCompletionDpc); + config.AutomaticSerialization = FALSE; + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE( + &attributes, VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT); + attributes.ParentObject = Controller; + createStatus = WdfDpcCreate(&config, &attributes, &dpc); + if (!NT_SUCCESS(createStatus)) { + KIRQL previousIrql = KeGetCurrentIrql(); + if (previousIrql < DISPATCH_LEVEL) { + KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); + UdecxUrbCompleteWithNtStatus(Request, Status); + KeLowerIrql(previousIrql); + } else { + UdecxUrbCompleteWithNtStatus(Request, Status); + } + return; + } + + context = ViiperGetOrphanCompletionContext(dpc); + context->Request = Request; + context->Status = Status; + WdfObjectReference(Request); + if (!WdfDpcEnqueue(dpc)) { + KIRQL previousIrql = KeGetCurrentIrql(); + WdfObjectDereference(Request); + WdfObjectDelete(dpc); + if (previousIrql < DISPATCH_LEVEL) { + KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); + UdecxUrbCompleteWithNtStatus(Request, Status); + KeLowerIrql(previousIrql); + } else { + UdecxUrbCompleteWithNtStatus(Request, Status); + } + } +} + static BOOLEAN ViiperQueueCancelEventLocked( @@ -123,6 +198,9 @@ ViiperClearSlotLocked( pending->PublishedToOwner = FALSE; pending->EndpointAddress = 0; pending->AbortStatus = STATUS_SUCCESS; + pending->CompletionStatus = STATUS_SUCCESS; + pending->CompletionUsbdStatus = USBD_STATUS_SUCCESS; + pending->CompleteWithNtStatus = FALSE; InterlockedDecrement(&ControllerContext->PendingOperations); } @@ -170,6 +248,7 @@ ViiperInitializeBroker( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Device); WDF_OBJECT_ATTRIBUTES attributes; + WDF_DPC_CONFIG dpcConfig; NTSTATUS status; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -215,7 +294,67 @@ ViiperInitializeBroker( RtlZeroMemory( controllerContext->Notifications, sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS); - return STATUS_SUCCESS; + + WDF_DPC_CONFIG_INIT(&dpcConfig, ViiperEvtCompletionDpc); + dpcConfig.AutomaticSerialization = FALSE; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + return WdfDpcCreate(&dpcConfig, &attributes, &controllerContext->CompletionDpc); +} + +VOID +ViiperEvtCompletionDpc( + _In_ WDFDPC Dpc + ) +{ + WDFDEVICE controller = (WDFDEVICE)WdfDpcGetParentObject(Dpc); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + + for (;;) { + WDFREQUEST request = WDF_NO_HANDLE; + ULONGLONG token = 0; + ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + NTSTATUS completionStatus = STATUS_SUCCESS; + USBD_STATUS usbdStatus = USBD_STATUS_SUCCESS; + BOOLEAN completeWithNtStatus = FALSE; + ULONG index; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[index]; + if (pending->State != ViiperUdePendingDpcCompletion) { + continue; + } + request = pending->Request; + token = pending->Token; + slot = index; + completionStatus = pending->CompletionStatus; + usbdStatus = pending->CompletionUsbdStatus; + completeWithNtStatus = pending->CompleteWithNtStatus; + pending->State = ViiperUdePendingCompleting; + WdfObjectReference(request); + break; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (request == WDF_NO_HANDLE) { + break; + } + + if (completeWithNtStatus) { + UdecxUrbCompleteWithNtStatus(request, completionStatus); + } else { + UdecxUrbComplete(request, usbdStatus); + } + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && + controllerContext->PendingSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearSlotLocked(controllerContext, slot); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + WdfObjectDereference(request); + } } static @@ -399,7 +538,10 @@ ViiperEvtUrbCancel( &controllerContext->PendingSlots[requestContext->PendingSlot]; if (ViiperSlotMatches(pending, Request, requestContext->Token)) { notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); - ViiperClearSlotLocked(controllerContext, requestContext->PendingSlot); + pending->CompletionStatus = STATUS_CANCELLED; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + pending->State = ViiperUdePendingDpcCompletion; ownsRequest = TRUE; } } @@ -407,7 +549,7 @@ ViiperEvtUrbCancel( if (ownsRequest) { InterlockedIncrement64(&controllerContext->OperationsCancelled); - UdecxUrbCompleteWithNtStatus(Request, STATUS_CANCELLED); + (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); if (notifyOwner) { ViiperDispatchNotificationEvents(requestContext->Controller); } @@ -685,6 +827,36 @@ ViiperSerializeOperation( return STATUS_SUCCESS; } +static +VOID +ViiperQueueOwnedCompletion( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot, + _In_ WDFREQUEST Request, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus + ) +{ + BOOLEAN queued = FALSE; + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token) && + ControllerContext->PendingSlots[Slot].State == ViiperUdePendingCompleting) { + ControllerContext->PendingSlots[Slot].CompletionStatus = Status; + ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = UsbdStatus; + ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = CompleteWithNtStatus; + ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; + queued = TRUE; + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (queued) { + (VOID)WdfDpcEnqueue(ControllerContext->CompletionDpc); + } +} + static VOID ViiperRemovePublishingRequest( @@ -706,12 +878,15 @@ ViiperRemovePublishingRequest( notifyOwner = ViiperQueueCancelEventLocked( ControllerContext, &ControllerContext->PendingSlots[Slot]); } - ViiperClearSlotLocked(ControllerContext, Slot); + ControllerContext->PendingSlots[Slot].CompletionStatus = Status; + ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = USBD_STATUS_CANCELED; + ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = TRUE; + ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; ownsRequest = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (ownsRequest) { - UdecxUrbCompleteWithNtStatus(Request, Status); + (VOID)WdfDpcEnqueue(ControllerContext->CompletionDpc); if (notifyOwner) { ViiperDispatchNotificationEvents(ViiperGetRequestContext(Request)->Controller); } @@ -972,7 +1147,6 @@ ViiperCompleteOperation( ULONG index; ULONG isoPayloadLimit; NTSTATUS status; - BOOLEAN slotRemoved = FALSE; status = ViiperValidateBrokerOwner(controller, CompletionRequest); if (!NT_SUCCESS(status)) { @@ -1046,18 +1220,6 @@ ViiperCompleteOperation( InterlockedIncrement64(&controllerContext->LateCompletions); return status; } - WdfSpinLockAcquire(controllerContext->BrokerLock); - if (ViiperSlotMatches( - &controllerContext->PendingSlots[slot], urbRequest, completion->Token)) { - ViiperClearSlotLocked(controllerContext, slot); - slotRemoved = TRUE; - } - WdfSpinLockRelease(controllerContext->BrokerLock); - if (!slotRemoved) { - InterlockedIncrement64(&controllerContext->LateCompletions); - WdfObjectDereference(urbRequest); - return STATUS_NOT_FOUND; - } requestContext = ViiperGetRequestContext(urbRequest); urb = ViiperGetUrb(urbRequest); @@ -1106,13 +1268,27 @@ ViiperCompleteOperation( } UdecxUrbSetBytesCompleted(urbRequest, completion->TransferLength); - UdecxUrbComplete(urbRequest, (USBD_STATUS)completion->UsbdStatus); + ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + STATUS_SUCCESS, + (USBD_STATUS)completion->UsbdStatus, + FALSE); InterlockedIncrement64(&controllerContext->OperationsCompleted); WdfObjectDereference(urbRequest); return STATUS_SUCCESS; CompleteWithNtStatus: - UdecxUrbCompleteWithNtStatus(urbRequest, status); + ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE); WdfObjectDereference(urbRequest); return status; } @@ -1145,6 +1321,8 @@ ViiperAbortMatchingOperations( if (pending->State == ViiperUdePendingPublishing) { pending->AbortPending = TRUE; pending->AbortStatus = Status; + } else if (pending->State == ViiperUdePendingDpcCompletion) { + /* The request is already owned by the completion DPC. */ } else if (pending->State != ViiperUdePendingPreparing && pending->State != ViiperUdePendingCompleting) { request = pending->Request; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 1ebc9f0e..39409255 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -765,7 +765,8 @@ ViiperEvtEndpointIoInternalControl( if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB) { NTSTATUS status = ViiperQueueUrb(Queue, Request); if (status != STATUS_PENDING) { - UdecxUrbCompleteWithNtStatus(Request, status); + ViiperCompleteUnownedUrbAsync( + WdfIoQueueGetDevice(Queue), Request, status); } } else { WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 017f6f54..0ec5bfc2 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -24,7 +24,8 @@ typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingQueued, ViiperUdePendingPublishing, ViiperUdePendingInFlight, - ViiperUdePendingCompleting + ViiperUdePendingCompleting, + ViiperUdePendingDpcCompletion } VIIPER_UDE_PENDING_STATE; typedef struct VIIPER_UDE_PENDING_SLOT { @@ -39,6 +40,9 @@ typedef struct VIIPER_UDE_PENDING_SLOT { BOOLEAN PublishedToOwner; UCHAR EndpointAddress; NTSTATUS AbortStatus; + NTSTATUS CompletionStatus; + USBD_STATUS CompletionUsbdStatus; + BOOLEAN CompleteWithNtStatus; } VIIPER_UDE_PENDING_SLOT; typedef struct VIIPER_UDE_NOTIFICATION { @@ -73,6 +77,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { ULONG NextPendingSlot; WDFMEMORY NotificationStorage; VIIPER_UDE_NOTIFICATION *Notifications; + WDFDPC CompletionDpc; ULONG NotificationHead; ULONG NotificationTail; ULONG NotificationCount; @@ -154,6 +159,7 @@ EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; +EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); @@ -164,6 +170,10 @@ VOID ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT Own NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +VOID ViiperCompleteUnownedUrbAsync( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); NTSTATUS ViiperQueueEndpointLifecycleEvent( From 48c13807f32001051bd6d9294ddd32a2537ea0e5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:14:30 -0500 Subject: [PATCH 020/240] Separate broker cleanup from host controller I/O Keep the standard UDE host-controller default queue alive when the exclusive VIIPER broker exits. Only owner-specific pending operations and dequeue waiters are purged, preventing broker reconnects from disrupting Windows HCD clients. Continue completion scans from the last claimed slot so high-rate endpoint traffic does not repeatedly rescan the entire preallocated table. --- native/udecx/driver/Broker.c | 8 ++++++-- native/udecx/driver/Controller.c | 4 ---- native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 839b303d..9e0ba797 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -321,17 +321,21 @@ ViiperEvtCompletionDpc( WdfSpinLockAcquire(controllerContext->BrokerLock); for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { - VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[index]; + ULONG candidate = (controllerContext->NextCompletionSlot + index) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; if (pending->State != ViiperUdePendingDpcCompletion) { continue; } request = pending->Request; token = pending->Token; - slot = index; + slot = candidate; completionStatus = pending->CompletionStatus; usbdStatus = pending->CompletionUsbdStatus; completeWithNtStatus = pending->CompleteWithNtStatus; pending->State = ViiperUdePendingCompleting; + controllerContext->NextCompletionSlot = (candidate + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; WdfObjectReference(request); break; } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 3a4fb6b2..7223bbef 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -198,7 +198,6 @@ ViiperEvtFileCreate( } else { fileContext->BrokerOwner = TRUE; context->OwnerFile = FileObject; - WdfIoQueueStart(context->DefaultQueue); WdfIoQueueStart(context->WaitingDequeues); } WdfWaitLockRelease(context->OwnerLock); @@ -234,9 +233,6 @@ ViiperEvtFileCleanup( if (ownsController) { ViiperPurgeOwnerOperations(device, STATUS_FILE_CLOSED); - if (context->DefaultQueue != WDF_NO_HANDLE) { - WdfIoQueuePurgeSynchronously(context->DefaultQueue); - } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 0ec5bfc2..bbf68e5d 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -75,6 +75,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; + ULONG NextCompletionSlot; WDFMEMORY NotificationStorage; VIIPER_UDE_NOTIFICATION *Notifications; WDFDPC CompletionDpc; From 934c425d3eb884b8ec9dc3a38a930ba0d7497c48 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:17:41 -0500 Subject: [PATCH 021/240] Add transactional native UDE devnode installer Build a small x64 SetupAPI helper that creates at most one exact ROOT\\VIIPER\\UDE controller, validates the USB class, rolls back a newly registered node if driver update fails, verifies started state, and removes matching nodes idempotently. Package it beside the test-signed driver for clean-machine lifecycle testing. --- .github/workflows/native-ude.yml | 13 ++ native/udecx/README.md | 3 +- native/udecx/tools/ViiperUdeCtl.cpp | 351 ++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 native/udecx/tools/ViiperUdeCtl.cpp diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index cf3d9652..24d64837 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -47,6 +47,19 @@ jobs: $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Build x64 driver run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" + - name: Build transactional root-devnode helper + shell: pwsh + run: | + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw "Visual C++ toolchain was not found" } + $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" + $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path + $outputDir = Join-Path $PWD "native\udecx\x64\Release" + New-Item -ItemType Directory -Force $outputDir | Out-Null + $output = Join-Path $outputDir "ViiperUdeCtl.exe" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib" + cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } - uses: actions/upload-artifact@v4 with: name: ViiperUde-x64-test-signed diff --git a/native/udecx/README.md b/native/udecx/README.md index e2889a75..ab8fe12d 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -9,8 +9,9 @@ Directory contract: - `include/` is the stable C ABI shared by the driver and Go broker. - `driver/` is the KMDF/UdeCx controller driver. - `package/` contains INF and installation metadata. +- `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root + controller without creating duplicates or leaving a failed devnode behind. - `tests/` contains ABI, lifecycle, descriptor, cancellation, and fault tests. The design and release gates are in `docs/architecture/native-udecx.md`. - diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp new file mode 100644 index 00000000..ebd9e530 --- /dev/null +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2026 VIIPER Project contributors + * + * Root-devnode creation follows the SetupAPI sequence documented by the + * Microsoft DevCon sample and usbip-win2's BSD-2-Clause devnode utility. + * See ../THIRD_PARTY_NOTICES.md. + */ + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "Cfgmgr32.lib") +#pragma comment(lib, "Newdev.lib") +#pragma comment(lib, "Setupapi.lib") + +namespace { + +constexpr wchar_t kHardwareId[] = L"ROOT\\VIIPER\\UDE"; +constexpr wchar_t kEnumerator[] = L"ROOT"; + +class DeviceInfoSet final { +public: + explicit DeviceInfoSet(HDEVINFO value) noexcept : value_(value) {} + ~DeviceInfoSet() { + if (value_ != INVALID_HANDLE_VALUE) { + SetupDiDestroyDeviceInfoList(value_); + } + } + DeviceInfoSet(const DeviceInfoSet&) = delete; + DeviceInfoSet& operator=(const DeviceInfoSet&) = delete; + DeviceInfoSet(DeviceInfoSet&& other) noexcept : value_(other.value_) { + other.value_ = INVALID_HANDLE_VALUE; + } + DeviceInfoSet& operator=(DeviceInfoSet&& other) noexcept { + if (this != &other) { + if (value_ != INVALID_HANDLE_VALUE) { + SetupDiDestroyDeviceInfoList(value_); + } + value_ = other.value_; + other.value_ = INVALID_HANDLE_VALUE; + } + return *this; + } + HDEVINFO get() const noexcept { return value_; } + explicit operator bool() const noexcept { return value_ != INVALID_HANDLE_VALUE; } + +private: + HDEVINFO value_; +}; + +std::wstring FormatError(DWORD error) { + wchar_t* raw = nullptr; + const DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS; + const DWORD count = FormatMessageW( + flags, nullptr, error, 0, reinterpret_cast(&raw), 0, nullptr); + std::wstring message = count != 0 && raw != nullptr ? std::wstring(raw, count) : L"unknown error"; + if (raw != nullptr) { + LocalFree(raw); + } + while (!message.empty() && (message.back() == L'\r' || message.back() == L'\n' || + message.back() == L' ' || message.back() == L'.')) { + message.pop_back(); + } + return message; +} + +bool Fail(const wchar_t* operation, DWORD error = GetLastError()) { + std::wcerr << L"error: " << operation << L" failed (" << error << L"): " + << FormatError(error) << L"\n"; + return false; +} + +bool IsElevated() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + return false; + } + TOKEN_ELEVATION elevation{}; + DWORD returned = 0; + const BOOL ok = GetTokenInformation( + token, TokenElevation, &elevation, sizeof(elevation), &returned); + CloseHandle(token); + return ok && elevation.TokenIsElevated != 0; +} + +bool MultiSzContains(const std::vector& value, const wchar_t* expected) { + if (value.empty() || value.size() % sizeof(wchar_t) != 0) { + return false; + } + const auto* current = reinterpret_cast(value.data()); + const auto* end = current + value.size() / sizeof(wchar_t); + while (current < end && *current != L'\0') { + const size_t remaining = static_cast(end - current); + const size_t length = wcsnlen_s(current, remaining); + if (length == remaining) { + return false; + } + if (_wcsicmp(std::wstring(current, length).c_str(), expected) == 0) { + return true; + } + current += length + 1; + } + return false; +} + +bool HasExactHardwareId(HDEVINFO set, SP_DEVINFO_DATA& data) { + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, nullptr, 0, &required)) { + return false; + } + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || required == 0 || type != REG_MULTI_SZ) { + return false; + } + std::vector value(required); + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, value.data(), + static_cast(value.size()), nullptr)) { + return false; + } + return type == REG_MULTI_SZ && MultiSzContains(value, kHardwareId); +} + +struct DeviceMatch { + SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; + bool started = false; + ULONG problem = 0; +}; + +std::vector FindDevices(HDEVINFO set) { + std::vector matches; + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; + if (!SetupDiEnumDeviceInfo(set, index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + Fail(L"SetupDiEnumDeviceInfo"); + } + break; + } + if (!HasExactHardwareId(set, data)) { + continue; + } + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET result = CM_Get_DevNode_Status(&status, &problem, data.DevInst, 0); + matches.push_back(DeviceMatch{ + data, + result == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0, + result == CR_SUCCESS ? problem : static_cast(result), + }); + } + return matches; +} + +DeviceInfoSet OpenRootDevices() { + return DeviceInfoSet(SetupDiGetClassDevsW( + nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES | DIGCF_PRESENT)); +} + +bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired) { + BOOL reboot = FALSE; + if (!DiUninstallDevice(nullptr, set, &data, 0, &reboot)) { + return Fail(L"DiUninstallDevice"); + } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} + +bool RegisterRootDevice( + const GUID& classGuid, + const std::wstring& className, + DeviceInfoSet& set, + SP_DEVINFO_DATA* data) { + set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); + if (!set) { + return Fail(L"SetupDiCreateDeviceInfoList"); + } + *data = SP_DEVINFO_DATA{sizeof(SP_DEVINFO_DATA)}; + if (!SetupDiCreateDeviceInfoW( + set.get(), className.c_str(), &classGuid, nullptr, nullptr, + DICD_GENERATE_ID, data)) { + return Fail(L"SetupDiCreateDeviceInfo"); + } + const size_t idChars = std::size(kHardwareId) + 1; + std::vector ids(idChars, L'\0'); + std::copy(std::begin(kHardwareId), std::end(kHardwareId), ids.begin()); + if (!SetupDiSetDeviceRegistryPropertyW( + set.get(), data, SPDRP_HARDWAREID, + reinterpret_cast(ids.data()), + static_cast(ids.size() * sizeof(wchar_t)))) { + return Fail(L"SetupDiSetDeviceRegistryProperty(HardwareId)"); + } + if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set.get(), data)) { + return Fail(L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)"); + } + return true; +} + +bool Install(const wchar_t* rawInfPath) { + if (!IsElevated()) { + SetLastError(ERROR_ELEVATION_REQUIRED); + return Fail(L"administrator check"); + } + std::error_code pathError; + const std::filesystem::path infPath = std::filesystem::canonical(rawInfPath, pathError); + if (pathError || !std::filesystem::is_regular_file(infPath)) { + SetLastError(ERROR_FILE_NOT_FOUND); + return Fail(L"resolve INF path"); + } + + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW( + infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { + return Fail(L"SetupDiGetINFClass"); + } + if (!IsEqualGUID(classGuid, GUID_DEVCLASS_USB)) { + SetLastError(ERROR_CLASS_MISMATCH); + return Fail(L"validate INF class"); + } + + DeviceInfoSet existing = OpenRootDevices(); + if (!existing) { + return Fail(L"SetupDiGetClassDevs(ROOT)"); + } + const auto matches = FindDevices(existing.get()); + if (matches.size() > 1) { + SetLastError(ERROR_DUPLICATE_SERVICE_NAME); + return Fail(L"validate unique VIIPER UDE controller"); + } + + DeviceInfoSet created(INVALID_HANDLE_VALUE); + SP_DEVINFO_DATA createdData{sizeof(SP_DEVINFO_DATA)}; + bool createdHere = false; + if (matches.empty()) { + if (!RegisterRootDevice(classGuid, className, created, &createdData)) { + return false; + } + createdHere = true; + } + + BOOL rebootRequired = FALSE; + if (!UpdateDriverForPlugAndPlayDevicesW( + nullptr, kHardwareId, infPath.c_str(), INSTALLFLAG_FORCE, &rebootRequired)) { + const DWORD updateError = GetLastError(); + if (createdHere) { + bool ignoredReboot = false; + RemoveDevice(created.get(), createdData, &ignoredReboot); + } + return Fail(L"UpdateDriverForPlugAndPlayDevices", updateError); + } + + DeviceInfoSet verified = OpenRootDevices(); + if (!verified) { + return Fail(L"reopen VIIPER UDE controller"); + } + const auto installed = FindDevices(verified.get()); + if (installed.size() != 1) { + SetLastError(installed.empty() ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); + return Fail(L"verify installed VIIPER UDE controller"); + } + if (!rebootRequired && !installed[0].started) { + std::wcerr << L"error: VIIPER UDE controller did not start; problem=" + << installed[0].problem << L"\n"; + return false; + } + std::wcout << L"installed=1 started=" << (installed[0].started ? 1 : 0) + << L" rebootRequired=" << (rebootRequired ? 1 : 0) << L"\n"; + return true; +} + +bool Remove() { + if (!IsElevated()) { + SetLastError(ERROR_ELEVATION_REQUIRED); + return Fail(L"administrator check"); + } + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return Fail(L"SetupDiGetClassDevs(ROOT)"); + } + auto matches = FindDevices(set.get()); + bool rebootRequired = false; + for (auto& match : matches) { + if (!RemoveDevice(set.get(), match.data, &rebootRequired)) { + return false; + } + } + DeviceInfoSet verified = OpenRootDevices(); + if (!verified) { + return Fail(L"verify removed VIIPER UDE controller"); + } + if (!FindDevices(verified.get()).empty() && !rebootRequired) { + SetLastError(ERROR_DEVICE_IN_USE); + return Fail(L"verify removed VIIPER UDE controller"); + } + std::wcout << L"removed=" << matches.size() + << L" rebootRequired=" << (rebootRequired ? 1 : 0) << L"\n"; + return true; +} + +bool Status() { + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return Fail(L"SetupDiGetClassDevs(ROOT)"); + } + const auto matches = FindDevices(set.get()); + std::wcout << L"devices=" << matches.size(); + if (matches.size() == 1) { + std::wcout << L" started=" << (matches[0].started ? 1 : 0) + << L" problem=" << matches[0].problem; + } + std::wcout << L"\n"; + return matches.size() <= 1; +} + +void Usage() { + std::wcerr << L"usage:\n" + << L" ViiperUdeCtl.exe install \n" + << L" ViiperUdeCtl.exe remove\n" + << L" ViiperUdeCtl.exe status\n"; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + if (argc == 3 && _wcsicmp(argv[1], L"install") == 0) { + return Install(argv[2]) ? 0 : 1; + } + if (argc == 2 && _wcsicmp(argv[1], L"remove") == 0) { + return Remove() ? 0 : 1; + } + if (argc == 2 && _wcsicmp(argv[1], L"status") == 0) { + return Status() ? 0 : 1; + } + Usage(); + return 2; +} From cb9aa2668c47a51c225a7a3d1a8404be1933a2e1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:18:56 -0500 Subject: [PATCH 022/240] Expose the canonical UdeCx host interface Match the proven Microsoft-compatible UdeCx convention by leaving GUID_DEVINTERFACE_USB_HOST_CONTROLLER unqualified. Put the reference string on VIIPER's private interface instead, so broker opens remain distinguishable and exclusive without changing the standard host-controller device path consumed by Windows. --- native/udecx/driver/Controller.c | 25 +++++++++++++------------ native/udecx/driver/ViiperUde.h | 7 +++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 7223bbef..3bacb45e 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -54,7 +54,7 @@ ViiperEvtDeviceAdd( UDECX_WDF_DEVICE_CONFIG udeConfig; VIIPER_UDE_CONTROLLER_CONTEXT *context; UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); - UNICODE_STRING hostControllerReference; + UNICODE_STRING brokerReference; PAGED_CODE(); UNREFERENCED_PARAMETER(Driver); @@ -106,15 +106,16 @@ ViiperEvtDeviceAdd( return status; } - status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_VIIPER_UDE, NULL); + RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); + status = WdfDeviceCreateDeviceInterface( + device, &GUID_DEVINTERFACE_VIIPER_UDE, &brokerReference); if (!NT_SUCCESS(status)) { return status; } - RtlInitUnicodeString(&hostControllerReference, VIIPER_UDE_HOST_REFERENCE_STRING); status = WdfDeviceCreateDeviceInterface( device, (LPGUID)&GUID_DEVINTERFACE_USB_HOST_CONTROLLER, - &hostControllerReference); + NULL); if (!NT_SUCCESS(status)) { return status; } @@ -166,8 +167,8 @@ ViiperEvtFileCreate( VIIPER_UDE_CONTROLLER_CONTEXT *context; VIIPER_UDE_FILE_CONTEXT *fileContext; PUNICODE_STRING fileName; - UNICODE_STRING hostControllerReference; - BOOLEAN isHostControllerClient = FALSE; + UNICODE_STRING brokerReference; + BOOLEAN isBrokerClient = FALSE; NTSTATUS status = STATUS_SUCCESS; PAGED_CODE(); @@ -176,18 +177,18 @@ ViiperEvtFileCreate( RtlZeroMemory(fileContext, sizeof(*fileContext)); fileName = WdfFileObjectGetFileName(FileObject); - RtlInitUnicodeString(&hostControllerReference, VIIPER_UDE_HOST_REFERENCE_STRING); + RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); if (fileName != NULL && - fileName->Length == hostControllerReference.Length + sizeof(WCHAR) && + fileName->Length == brokerReference.Length + sizeof(WCHAR) && fileName->Buffer[0] == L'\\' && RtlEqualMemory( fileName->Buffer + 1, - hostControllerReference.Buffer, - hostControllerReference.Length)) { - isHostControllerClient = TRUE; + brokerReference.Buffer, + brokerReference.Length)) { + isBrokerClient = TRUE; } - if (isHostControllerClient) { + if (!isBrokerClient) { WdfRequestComplete(Request, STATUS_SUCCESS); return; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index bbf68e5d..762d64ce 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -13,10 +13,9 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; -// Reference string used by Microsoft's documented UDE host-controller sample. -// It lets the create callback distinguish standard HCD clients from the -// exclusive VIIPER broker interface. -#define VIIPER_UDE_HOST_REFERENCE_STRING L"GUID_DEVINTERFACE_USB_HOST_CONTROLLER" +// Only VIIPER's private interface receives a reference string. The standard +// host-controller interface must retain UdeCx's canonical unqualified path. +#define VIIPER_UDE_BROKER_REFERENCE_STRING L"broker" typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingEmpty = 0, From 43a7875cf052d6b3700a3b593e40702c4e5cec23 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:20:23 -0500 Subject: [PATCH 023/240] Link devnode helper token APIs --- .github/workflows/native-ude.yml | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 24d64837..8162b181 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -57,7 +57,7 @@ jobs: $outputDir = Join-Path $PWD "native\udecx\x64\Release" New-Item -ItemType Directory -Force $outputDir | Out-Null $output = Join-Path $outputDir "ViiperUdeCtl.exe" - $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib" cmd.exe /d /s /c $command if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } - uses: actions/upload-artifact@v4 diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index ebd9e530..0ca3b058 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -24,6 +24,7 @@ #pragma comment(lib, "Cfgmgr32.lib") #pragma comment(lib, "Newdev.lib") #pragma comment(lib, "Setupapi.lib") +#pragma comment(lib, "Advapi32.lib") namespace { From 63d1b51ceabcebb6eb75df4f117df57fab1aa9ff Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:23:18 -0500 Subject: [PATCH 024/240] Harden native device reconnect and link-power state Hold an explicit reference to each broker owner until asynchronous UdeCx child cleanup finishes. Permit a new generation to attach while the prior generation is asynchronously purging, skip purging generations during exact removal, reject duplicate descriptor roots, and preserve audio alternate settings across D0 link-power transitions while reanchoring only media clocks. --- internal/server/usb/native.go | 7 ++++- internal/server/usb/native_test.go | 34 ++++++++++++++++++++++ native/udecx/driver/Device.c | 46 +++++++++++++++++++++++------- native/udecx/driver/ViiperUde.h | 1 + 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index bdc00559..e663e08c 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -65,8 +65,13 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } - case udecx.OperationDeviceReset, udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: + case udecx.OperationDeviceReset: p.Reset(dev, identity) + case udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: + // A link-power transition is not a USB reset. Preserve the selected + // audio interfaces and controller state, but discard stale service-clock + // anchors so the first resumed transfer starts from the current time. + p.clearDeviceLanes(identity) case udecx.OperationSetInterface: if !descriptorHasInterfaceAlt(dev.GetDescriptor(), op.InterfaceNumber, op.InterfaceSetting) { return fmt.Errorf("native UDE selected invalid alternate setting %d for interface %d", diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index f5a2652c..f2a8cb69 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -5,6 +5,7 @@ import ( "context" "log/slog" "testing" + "time" "github.com/Alia5/VIIPER/internal/transport/udecx" usbdevice "github.com/Alia5/VIIPER/usb" @@ -73,6 +74,39 @@ func TestNativeProcessorAppliesUdeCxInterfaceSettingLifecycle(t *testing.T) { } } +func TestNativeProcessorPreservesAlternateSettingAcrossLinkPower(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 0}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 1}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.server.setInterfaceAlt(dev, 2, 1) + identity := udecx.DeviceIdentity{DeviceID: 4, Generation: 7} + key := nativeLaneKey{deviceID: identity.DeviceID, generation: identity.Generation, endpoint: 0x82} + processor.next[key] = time.Now() + processor.lastIn[key] = []byte{1, 2, 3} + + for _, kind := range []udecx.OperationKind{ + udecx.OperationDeviceD0Exit, udecx.OperationDeviceD0Entry, + } { + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, Kind: kind, + }); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("link-power event %d reset interface 2 alt to %d", kind, got) + } + if _, ok := processor.next[key]; ok { + t.Fatalf("link-power event %d retained stale service clock", kind) + } + } +} + func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { desc := &usbdevice.Descriptor{ Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 39409255..a10d2306 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -41,6 +41,9 @@ ViiperValidateCreateDevice( ULONG index; BOOLEAN foundDevice = FALSE; BOOLEAN foundConfiguration = FALSE; + BOOLEAN foundBos = FALSE; + BOOLEAN foundLanguageTable = FALSE; + BOOLEAN foundLocalizedString = FALSE; if (InputLength < sizeof(*Input) || InputLength > (size_t)VIIPER_UDE_MAX_DESCRIPTOR_BYTES * 2 + sizeof(*Input) || @@ -102,25 +105,37 @@ ViiperValidateCreateDevice( foundConfiguration = TRUE; break; case ViiperUdeDescriptorBos: - if (record->Index != 0 || descriptor[1] != USB_BOS_DESCRIPTOR_TYPE) { + if (foundBos || record->Index != 0 || + descriptor[1] != USB_BOS_DESCRIPTOR_TYPE) { return FALSE; } + foundBos = TRUE; break; case ViiperUdeDescriptorString: if (record->Index > MAXUCHAR || record->Length > MAXUCHAR || descriptor[0] != record->Length || descriptor[1] != USB_STRING_DESCRIPTOR_TYPE || (record->Length & 1) != 0 || (record->Index == 0 && record->LanguageId != 0) || + (record->Index == 0 && record->Length < 4) || (record->Index != 0 && record->LanguageId == 0)) { return FALSE; } + if (record->Index == 0) { + if (foundLanguageTable) { + return FALSE; + } + foundLanguageTable = TRUE; + } else { + foundLocalizedString = TRUE; + } break; default: return FALSE; } } - return foundDevice && foundConfiguration; + return foundDevice && foundConfiguration && + (!foundLocalizedString || foundLanguageTable); } static @@ -243,7 +258,9 @@ ViiperClaimDeviceSlot( } continue; } - if (ViiperGetDeviceContext(current)->DeviceId == DeviceId) { + if (ViiperGetDeviceContext(current)->DeviceId == DeviceId && + InterlockedCompareExchange( + &ViiperGetDeviceContext(current)->Purging, 0, 0) == 0) { status = STATUS_OBJECT_NAME_COLLISION; goto Exit; } @@ -353,6 +370,8 @@ ViiperCreateVirtualDevice( deviceContext->DeviceId = input->DeviceId; deviceContext->Generation = input->Generation; deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; + WdfObjectReference(ownerFile); + InterlockedExchange(&deviceContext->OwnerReferenced, 1); status = ViiperClaimDeviceSlot(controllerContext, device, input->DeviceId, &slot); if (!NT_SUCCESS(status)) { @@ -407,11 +426,10 @@ ViiperBeginRemoveDevice( (MatchGeneration && deviceContext->Generation != Generation)) { continue; } - if (deviceContext->Purging) { - status = STATUS_DEVICE_BUSY; - break; + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + continue; } - deviceContext->Purging = TRUE; + InterlockedExchange(&deviceContext->Purging, TRUE); *Device = current; status = STATUS_SUCCESS; break; @@ -430,7 +448,7 @@ ViiperCancelRemoveDevice( WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); if (ViiperGetDeviceContext(Device)->Slot < VIIPER_UDE_MAX_DEVICES && ControllerContext->Devices[ViiperGetDeviceContext(Device)->Slot] == Device) { - ViiperGetDeviceContext(Device)->Purging = FALSE; + InterlockedExchange(&ViiperGetDeviceContext(Device)->Purging, FALSE); } WdfWaitLockRelease(ControllerContext->DeviceLock); } @@ -499,7 +517,8 @@ ViiperDestroyOwnedDevices( device = controllerContext->Devices[index]; if (device != WDF_NO_HANDLE && ViiperGetDeviceContext(device)->OwnerFile == OwnerFile && - !ViiperGetDeviceContext(device)->Purging) { + InterlockedCompareExchange( + &ViiperGetDeviceContext(device)->Purging, 0, 0) == 0) { deviceId = ViiperGetDeviceContext(device)->DeviceId; break; } @@ -543,6 +562,9 @@ ViiperEvtVirtualDeviceCleanup( if (InterlockedExchange(&deviceContext->ActiveCounted, 0) != 0) { InterlockedDecrement(&controllerContext->ActiveDevices); } + if (InterlockedExchange(&deviceContext->OwnerReferenced, 0) != 0) { + WdfObjectDereference(deviceContext->OwnerFile); + } } NTSTATUS @@ -552,7 +574,8 @@ ViiperEvtUsbDeviceD0Entry( ) { UNREFERENCED_PARAMETER(Controller); - return ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); + (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); + return STATUS_SUCCESS; } NTSTATUS @@ -564,7 +587,8 @@ ViiperEvtUsbDeviceD0Exit( { UNREFERENCED_PARAMETER(Controller); UNREFERENCED_PARAMETER(WakeSetting); - return ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); + (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); + return STATUS_SUCCESS; } NTSTATUS diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 762d64ce..5e0dd474 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -124,6 +124,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { BOOLEAN Plugged; volatile LONG Purging; volatile LONG ActiveCounted; + volatile LONG OwnerReferenced; UDECXUSBENDPOINT DefaultEndpoint; volatile LONG64 EndpointSequences[256]; } VIIPER_UDE_DEVICE_CONTEXT; From 2522a74f262b015e1cb9a7674af105559dbdc237 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:28:26 -0500 Subject: [PATCH 025/240] Fail native UDE sessions closed on protocol corruption Make duplicate operation tokens, repeated or regressed endpoint sequences, reorder-window overflow, lifecycle failures, and completion-channel failures terminate the native host session instead of leaving a live kernel queue without a consumer. Clear per-session operation state during teardown so a restarted broker cannot inherit stale tokens. Add adversarial coverage and run each case repeatedly. --- internal/transport/udecx/host.go | 107 ++++++++++++++------- internal/transport/udecx/host_test.go | 133 +++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 36 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 708b8144..38e143df 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -82,6 +82,7 @@ type Host struct { lanes map[laneKey]*operationLane runCtx context.Context runCancel context.CancelFunc + fatal chan error running bool laneWG sync.WaitGroup operationMu sync.Mutex @@ -198,7 +199,8 @@ func (h *Host) Serve(ctx context.Context) error { return errors.New("native UDE host is already running") } runCtx, cancel := context.WithCancel(ctx) - h.runCtx, h.runCancel, h.running = runCtx, cancel, true + fatal := make(chan error, 1) + h.runCtx, h.runCancel, h.fatal, h.running = runCtx, cancel, fatal, true h.mu.Unlock() defer func() { cancel() @@ -207,9 +209,12 @@ func (h *Host) Serve(ctx context.Context) error { lane.cancel() delete(h.lanes, key) } - h.running, h.runCtx, h.runCancel = false, nil, nil h.mu.Unlock() h.laneWG.Wait() + h.cancelAllOperations() + h.mu.Lock() + h.running, h.runCtx, h.runCancel, h.fatal = false, nil, nil, nil + h.mu.Unlock() }() results := make(chan dequeueResult, h.workers*2) @@ -238,6 +243,10 @@ func (h *Host) Serve(ctx context.Context) error { case <-runCtx.Done(): workers.Wait() return nil + case err := <-fatal: + cancel() + workers.Wait() + return fmt.Errorf("native UDE host session failed: %w", err) case result := <-results: if result.err != nil { cancel() @@ -253,13 +262,16 @@ func (h *Host) Serve(ctx context.Context) error { } if !isLifecycleOperation(result.op.Kind) { if err := h.trackOperation(result.op); err != nil { - h.completeUntrackedFailure(runCtx, result.op) + h.reportFatal(fmt.Errorf("track operation token %d: %w", result.op.Token, err)) continue } } if err := h.dispatch(runCtx, result.op); err != nil { if !isLifecycleOperation(result.op.Kind) { - h.completeFailure(runCtx, result.op) + if completeErr := h.completeFailure(runCtx, result.op); completeErr != nil { + h.reportFatal(fmt.Errorf("reject operation token %d after dispatch failure %v: %w", + result.op.Token, err, completeErr)) + } } } } @@ -317,24 +329,19 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { return case op := <-lane.input: if op.EndpointSequence < expected { - if !isLifecycleOperation(op.Kind) { - h.completeFailure(lane.ctx, op) - } - continue + h.reportFatal(fmt.Errorf("endpoint 0x%02x sequence regressed from %d to %d", + lane.key.endpoint, expected, op.EndpointSequence)) + return } if _, duplicate := pending[op.EndpointSequence]; duplicate { - if !isLifecycleOperation(op.Kind) { - h.completeFailure(lane.ctx, op) - } - continue + h.reportFatal(fmt.Errorf("endpoint 0x%02x repeated pending sequence %d", + lane.key.endpoint, op.EndpointSequence)) + return } pending[op.EndpointSequence] = op if len(pending) > laneQueueDepth { - for _, queued := range pending { - if !isLifecycleOperation(queued.Kind) { - h.completeFailure(lane.ctx, queued) - } - } + h.reportFatal(fmt.Errorf("endpoint 0x%02x exceeded the %d-operation reorder bound while waiting for sequence %d", + lane.key.endpoint, laneQueueDepth, expected)) return } for { @@ -344,9 +351,17 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { } delete(pending, expected) if isLifecycleOperation(current.Kind) { - _ = h.processor.Lifecycle(lane.ctx, entry.device, current) + if err := h.processor.Lifecycle(lane.ctx, entry.device, current); err != nil { + h.reportFatal(fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", + lane.key.endpoint, current.EndpointSequence, err)) + return + } } else { - h.process(lane.ctx, entry.device, current) + if err := h.process(lane.ctx, entry.device, current); err != nil { + h.reportFatal(fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", + lane.key.endpoint, current.EndpointSequence, err)) + return + } } expected++ } @@ -364,11 +379,11 @@ func isLifecycleOperation(kind OperationKind) bool { } } -func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) { +func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) error { opCtx, cancel, active := h.beginOperation(ctx, op) if !active { h.finishOperation(op.Token) - return + return nil } defer cancel() @@ -378,32 +393,28 @@ func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) { } if h.operationCancelled(op.Token) { h.finishOperation(op.Token) - return + return nil } completion.Token = op.Token completion.DeviceID = op.DeviceID completion.Generation = op.Generation completionCtx, completionCancel := context.WithTimeout(ctx, completionTimeout) defer completionCancel() - _ = h.driver.Complete(completionCtx, completion) + err = h.driver.Complete(completionCtx, completion) h.finishOperation(op.Token) + return err } -func (h *Host) completeFailure(ctx context.Context, op Operation) { +func (h *Host) completeFailure(ctx context.Context, op Operation) error { if h.operationCancelled(op.Token) { h.finishOperation(op.Token) - return + return nil } completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) defer cancel() - _ = h.driver.Complete(completionCtx, failureCompletion(op)) + err := h.driver.Complete(completionCtx, failureCompletion(op)) h.finishOperation(op.Token) -} - -func (h *Host) completeUntrackedFailure(ctx context.Context, op Operation) { - completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) - defer cancel() - _ = h.driver.Complete(completionCtx, failureCompletion(op)) + return err } func (h *Host) trackOperation(op Operation) error { @@ -426,6 +437,38 @@ func (h *Host) trackOperation(op Operation) error { return nil } +func (h *Host) reportFatal(err error) { + if err == nil { + return + } + h.mu.RLock() + fatal := h.fatal + h.mu.RUnlock() + if fatal == nil { + return + } + select { + case fatal <- err: + default: + } +} + +func (h *Host) cancelAllOperations() { + var cancels []context.CancelFunc + h.operationMu.Lock() + for _, state := range h.operations { + if state.cancel != nil { + cancels = append(cancels, state.cancel) + } + } + h.operations = make(map[uint64]*operationState) + h.completed = nil + h.operationMu.Unlock() + for _, cancel := range cancels { + cancel() + } +} + func (h *Host) beginOperation(parent context.Context, op Operation) (context.Context, context.CancelFunc, bool) { h.operationMu.Lock() defer h.operationMu.Unlock() diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index b44ff2d6..748509b2 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -3,6 +3,7 @@ package udecx import ( "context" "errors" + "strings" "sync" "testing" "time" @@ -18,6 +19,7 @@ type fakeHostDriver struct { created []CreateDevice destroyed []DeviceIdentity destroyErr error + completeErr error } func newFakeHostDriver() *fakeHostDriver { @@ -46,6 +48,9 @@ func (d *fakeHostDriver) Dequeue(ctx context.Context, _ []byte) (Operation, erro } } func (d *fakeHostDriver) Complete(ctx context.Context, completion Completion) error { + if d.completeErr != nil { + return d.completeErr + } select { case d.completions <- completion: return nil @@ -56,9 +61,10 @@ func (d *fakeHostDriver) Complete(ctx context.Context, completion Completion) er func (d *fakeHostDriver) QueryStats(context.Context) (Stats, error) { return Stats{}, nil } type recordingProcessor struct { - processed chan uint64 - lifecycle chan uint64 - resets chan DeviceIdentity + processed chan uint64 + lifecycle chan uint64 + resets chan DeviceIdentity + lifecycleErr error } func (p *recordingProcessor) Process(_ context.Context, _ usb.Device, op Operation) (Completion, error) { @@ -69,7 +75,7 @@ func (p *recordingProcessor) Lifecycle(_ context.Context, _ usb.Device, op Opera if p.lifecycle != nil { p.lifecycle <- op.EndpointSequence } - return nil + return p.lifecycleErr } func (p *recordingProcessor) Reset(_ usb.Device, identity DeviceIdentity) { p.resets <- identity } @@ -349,3 +355,122 @@ func TestHostCancelInterruptsActiveProcessor(t *testing.T) { cancel() <-done } + +func TestHostDuplicateTokenFailsSessionWithoutCompletingWrongOperation(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 12, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + // Sequence 1 is deliberately absent, so the first token remains a valid, + // pending kernel request when the corrupt duplicate arrives. + for _, endpoint := range []uint8{0x81, 0x82} { + driver.operations <- Operation{ + Token: 77, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: endpoint, EndpointSequence: 2, Kind: OperationTransfer, + } + } + + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "reuses a completed or mismatched token") { + t.Fatalf("Serve error=%v, want duplicate-token session failure", err) + } + case <-time.After(time.Second): + t.Fatal("duplicate operation token did not fail the host session") + } + select { + case completion := <-driver.completions: + t.Fatalf("duplicate token completed an ambiguous kernel request: %+v", completion) + default: + } +} + +func TestHostDuplicateEndpointSequenceFailsSession(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 13, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + for token := uint64(1); token <= 2; token++ { + driver.operations <- Operation{ + Token: token, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationTransfer, + } + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "repeated pending sequence 2") { + t.Fatalf("Serve error=%v, want duplicate-sequence session failure", err) + } + case <-time.After(time.Second): + t.Fatal("duplicate endpoint sequence did not fail the host session") + } +} + +func TestHostLifecycleFailureFailsSession(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), lifecycleErr: errors.New("reset rejected"), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 14, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointReset, + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "reset rejected") { + t.Fatalf("Serve error=%v, want lifecycle session failure", err) + } + case <-time.After(time.Second): + t.Fatal("lifecycle failure did not fail the host session") + } +} + +func TestHostCompletionFailureFailsSession(t *testing.T) { + driver := newFakeHostDriver() + driver.completeErr = errors.New("completion handle lost") + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 15, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.processed: + case <-time.After(time.Second): + t.Fatal("processor did not receive transfer") + } + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "completion handle lost") { + t.Fatalf("Serve error=%v, want completion session failure", err) + } + case <-time.After(time.Second): + t.Fatal("completion failure did not fail the host session") + } +} From 644d5ef7ce643521c7506d51fefeec76dac86a2a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:30:23 -0500 Subject: [PATCH 026/240] Make native UDE devnode installation transactional Enumerate both present and phantom ROOT\\VIIPER\\UDE nodes, remove exact stale phantoms before install, reject duplicate present controllers, verify the selected ViiperUde service, and roll back newly-created devnodes when post-update validation or startup fails. Status and removal now cover non-present remnants instead of hiding them. --- native/udecx/tools/ViiperUdeCtl.cpp | 127 ++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 16 deletions(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 0ca3b058..f5a3194c 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -30,6 +31,7 @@ namespace { constexpr wchar_t kHardwareId[] = L"ROOT\\VIIPER\\UDE"; constexpr wchar_t kEnumerator[] = L"ROOT"; +constexpr wchar_t kServiceName[] = L"ViiperUde"; class DeviceInfoSet final { public: @@ -136,40 +138,77 @@ bool HasExactHardwareId(HDEVINFO set, SP_DEVINFO_DATA& data) { return type == REG_MULTI_SZ && MultiSzContains(value, kHardwareId); } +bool ReadDevicePresence(HDEVINFO set, SP_DEVINFO_DATA& data, bool* present) { + DEVPROPTYPE type = 0; + DEVPROP_BOOLEAN value = DEVPROP_FALSE; + DWORD required = 0; + if (!SetupDiGetDevicePropertyW( + set, &data, &DEVPKEY_Device_IsPresent, &type, + reinterpret_cast(&value), sizeof(value), &required, 0)) { + return Fail(L"SetupDiGetDeviceProperty(IsPresent)"); + } + if (type != DEVPROP_TYPE_BOOLEAN || required != sizeof(value)) { + SetLastError(ERROR_INVALID_DATA); + return Fail(L"validate DEVPKEY_Device_IsPresent"); + } + *present = value == DEVPROP_TRUE; + return true; +} + +bool HasExactService(HDEVINFO set, SP_DEVINFO_DATA& data) { + DWORD type = 0; + wchar_t value[128]{}; + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &type, reinterpret_cast(value), + sizeof(value), nullptr)) { + return false; + } + return type == REG_SZ && _wcsicmp(value, kServiceName) == 0; +} + struct DeviceMatch { SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; + bool present = false; bool started = false; + bool exactService = false; ULONG problem = 0; }; -std::vector FindDevices(HDEVINFO set) { - std::vector matches; +bool FindDevices(HDEVINFO set, std::vector* matches) { + matches->clear(); for (DWORD index = 0;; ++index) { SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; if (!SetupDiEnumDeviceInfo(set, index, &data)) { if (GetLastError() != ERROR_NO_MORE_ITEMS) { Fail(L"SetupDiEnumDeviceInfo"); + return false; } break; } if (!HasExactHardwareId(set, data)) { continue; } + bool present = false; + if (!ReadDevicePresence(set, data, &present)) { + return false; + } ULONG status = 0; ULONG problem = 0; const CONFIGRET result = CM_Get_DevNode_Status(&status, &problem, data.DevInst, 0); - matches.push_back(DeviceMatch{ + matches->push_back(DeviceMatch{ data, - result == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0, + present, + present && result == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0, + HasExactService(set, data), result == CR_SUCCESS ? problem : static_cast(result), }); } - return matches; + return true; } DeviceInfoSet OpenRootDevices() { return DeviceInfoSet(SetupDiGetClassDevsW( - nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES | DIGCF_PRESENT)); + nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES)); } bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired) { @@ -238,8 +277,27 @@ bool Install(const wchar_t* rawInfPath) { if (!existing) { return Fail(L"SetupDiGetClassDevs(ROOT)"); } - const auto matches = FindDevices(existing.get()); - if (matches.size() > 1) { + std::vector matches; + if (!FindDevices(existing.get(), &matches)) { + return false; + } + + size_t presentCount = 0; + bool staleRemovalNeedsReboot = false; + for (auto& match : matches) { + if (match.present) { + ++presentCount; + continue; + } + if (!RemoveDevice(existing.get(), match.data, &staleRemovalNeedsReboot)) { + return false; + } + } + if (staleRemovalNeedsReboot) { + std::wcerr << L"error: removing a stale VIIPER UDE devnode requires a restart\n"; + return false; + } + if (presentCount > 1) { SetLastError(ERROR_DUPLICATE_SERVICE_NAME); return Fail(L"validate unique VIIPER UDE controller"); } @@ -247,7 +305,7 @@ bool Install(const wchar_t* rawInfPath) { DeviceInfoSet created(INVALID_HANDLE_VALUE); SP_DEVINFO_DATA createdData{sizeof(SP_DEVINFO_DATA)}; bool createdHere = false; - if (matches.empty()) { + if (presentCount == 0) { if (!RegisterRootDevice(classGuid, className, created, &createdData)) { return false; } @@ -267,14 +325,39 @@ bool Install(const wchar_t* rawInfPath) { DeviceInfoSet verified = OpenRootDevices(); if (!verified) { + if (createdHere) { + bool ignoredReboot = false; + RemoveDevice(created.get(), createdData, &ignoredReboot); + } return Fail(L"reopen VIIPER UDE controller"); } - const auto installed = FindDevices(verified.get()); - if (installed.size() != 1) { - SetLastError(installed.empty() ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); + std::vector installed; + if (!FindDevices(verified.get(), &installed)) { + if (createdHere) { + bool ignoredReboot = false; + RemoveDevice(created.get(), createdData, &ignoredReboot); + } + return false; + } + if (installed.size() != 1 || !installed[0].present || !installed[0].exactService) { + if (createdHere) { + bool ignoredReboot = false; + RemoveDevice(created.get(), createdData, &ignoredReboot); + } + if (installed.empty() || (installed.size() == 1 && !installed[0].present)) { + SetLastError(ERROR_DEVICE_NOT_AVAILABLE); + } else if (installed.size() > 1) { + SetLastError(ERROR_DUPLICATE_SERVICE_NAME); + } else { + SetLastError(ERROR_SERVICE_NOT_FOUND); + } return Fail(L"verify installed VIIPER UDE controller"); } if (!rebootRequired && !installed[0].started) { + if (createdHere) { + bool ignoredReboot = false; + RemoveDevice(created.get(), createdData, &ignoredReboot); + } std::wcerr << L"error: VIIPER UDE controller did not start; problem=" << installed[0].problem << L"\n"; return false; @@ -293,7 +376,10 @@ bool Remove() { if (!set) { return Fail(L"SetupDiGetClassDevs(ROOT)"); } - auto matches = FindDevices(set.get()); + std::vector matches; + if (!FindDevices(set.get(), &matches)) { + return false; + } bool rebootRequired = false; for (auto& match : matches) { if (!RemoveDevice(set.get(), match.data, &rebootRequired)) { @@ -304,7 +390,11 @@ bool Remove() { if (!verified) { return Fail(L"verify removed VIIPER UDE controller"); } - if (!FindDevices(verified.get()).empty() && !rebootRequired) { + std::vector remaining; + if (!FindDevices(verified.get(), &remaining)) { + return false; + } + if (!remaining.empty() && !rebootRequired) { SetLastError(ERROR_DEVICE_IN_USE); return Fail(L"verify removed VIIPER UDE controller"); } @@ -318,10 +408,15 @@ bool Status() { if (!set) { return Fail(L"SetupDiGetClassDevs(ROOT)"); } - const auto matches = FindDevices(set.get()); + std::vector matches; + if (!FindDevices(set.get(), &matches)) { + return false; + } std::wcout << L"devices=" << matches.size(); if (matches.size() == 1) { - std::wcout << L" started=" << (matches[0].started ? 1 : 0) + std::wcout << L" present=" << (matches[0].present ? 1 : 0) + << L" started=" << (matches[0].started ? 1 : 0) + << L" exactService=" << (matches[0].exactService ? 1 : 0) << L" problem=" << matches[0].problem; } std::wcout << L"\n"; From 418501acc05f45899f1f37f4c12166a82f3c65f6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:32:30 -0500 Subject: [PATCH 027/240] Separate USB failures from native broker failures Treat a valid completion carrying a device NTSTATUS as an accepted broker message, so ordinary stalls and device errors cannot tear down the native host. Make endpoint purge and cancellation win atomically over an in-flight user reply, accept only the expected late-abort race idempotently, and continue rejecting duplicate or unknown completions. Return a hard error if a completion cannot be staged to the DISPATCH_LEVEL DPC. --- native/udecx/driver/Broker.c | 82 +++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 9e0ba797..109d573c 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -832,7 +832,7 @@ ViiperSerializeOperation( } static -VOID +BOOLEAN ViiperQueueOwnedCompletion( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ ULONG Slot, @@ -849,16 +849,49 @@ ViiperQueueOwnedCompletion( if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && ViiperSlotMatches(&ControllerContext->PendingSlots[Slot], Request, Token) && ControllerContext->PendingSlots[Slot].State == ViiperUdePendingCompleting) { - ControllerContext->PendingSlots[Slot].CompletionStatus = Status; - ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = UsbdStatus; - ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = CompleteWithNtStatus; - ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; + VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; + if (pending->AbortPending) { + pending->CompletionStatus = pending->AbortStatus; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + } else { + pending->CompletionStatus = Status; + pending->CompletionUsbdStatus = UsbdStatus; + pending->CompleteWithNtStatus = CompleteWithNtStatus; + } + pending->State = ViiperUdePendingDpcCompletion; queued = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (queued) { (VOID)WdfDpcEnqueue(ControllerContext->CompletionDpc); } + return queued; +} + +static +BOOLEAN +ViiperExpectedLateAbortLocked( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending, + _In_ ULONGLONG Token + ) +{ + NTSTATUS abortStatus; + + if (Pending->Token != Token || + (Pending->State != ViiperUdePendingCompleting && + Pending->State != ViiperUdePendingDpcCompletion) || + (!Pending->AbortPending && !Pending->CompleteWithNtStatus)) { + return FALSE; + } + + abortStatus = Pending->AbortPending + ? Pending->AbortStatus + : Pending->CompletionStatus; + return abortStatus == STATUS_CANCELLED || + abortStatus == STATUS_DEVICE_REMOVED || + abortStatus == STATUS_DEVICE_NOT_READY || + abortStatus == STATUS_FILE_CLOSED; } static @@ -1151,6 +1184,8 @@ ViiperCompleteOperation( ULONG index; ULONG isoPayloadLimit; NTSTATUS status; + BOOLEAN expectedLateAbort = FALSE; + BOOLEAN queued; status = ViiperValidateBrokerOwner(controller, CompletionRequest); if (!NT_SUCCESS(status)) { @@ -1211,11 +1246,14 @@ ViiperCompleteOperation( urbRequest = controllerContext->PendingSlots[slot].Request; controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; WdfObjectReference(urbRequest); + } else { + expectedLateAbort = ViiperExpectedLateAbortLocked( + &controllerContext->PendingSlots[slot], completion->Token); } WdfSpinLockRelease(controllerContext->BrokerLock); if (urbRequest == WDF_NO_HANDLE) { InterlockedIncrement64(&controllerContext->LateCompletions); - return STATUS_NOT_FOUND; + return expectedLateAbort ? STATUS_SUCCESS : STATUS_NOT_FOUND; } status = WdfRequestUnmarkCancelable(urbRequest); @@ -1243,7 +1281,21 @@ ViiperCompleteOperation( } if (!NT_SUCCESS((NTSTATUS)completion->Status)) { status = (NTSTATUS)completion->Status; - goto CompleteWithNtStatus; + queued = ViiperQueueOwnedCompletion( + controllerContext, + slot, + urbRequest, + completion->Token, + status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE); + WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + InterlockedIncrement64(&controllerContext->OperationsCompleted); + return STATUS_SUCCESS; } if (requestContext->DirectionIn && completion->PayloadLength > 0) { @@ -1272,7 +1324,7 @@ ViiperCompleteOperation( } UdecxUrbSetBytesCompleted(urbRequest, completion->TransferLength); - ViiperQueueOwnedCompletion( + queued = ViiperQueueOwnedCompletion( controllerContext, slot, urbRequest, @@ -1280,12 +1332,16 @@ ViiperCompleteOperation( STATUS_SUCCESS, (USBD_STATUS)completion->UsbdStatus, FALSE); - InterlockedIncrement64(&controllerContext->OperationsCompleted); WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + InterlockedIncrement64(&controllerContext->OperationsCompleted); return STATUS_SUCCESS; CompleteWithNtStatus: - ViiperQueueOwnedCompletion( + queued = ViiperQueueOwnedCompletion( controllerContext, slot, urbRequest, @@ -1294,6 +1350,10 @@ ViiperCompleteOperation( USBD_STATUS_INTERNAL_HC_ERROR, TRUE); WdfObjectDereference(urbRequest); + if (!queued) { + InterlockedIncrement64(&controllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } return status; } @@ -1331,6 +1391,8 @@ ViiperAbortMatchingOperations( pending->State != ViiperUdePendingCompleting) { request = pending->Request; token = pending->Token; + pending->AbortPending = TRUE; + pending->AbortStatus = Status; pending->State = ViiperUdePendingCompleting; WdfObjectReference(request); } else { From 17ab4a7dec365dbe28bd40b0327ec91982389f2b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:36:04 -0500 Subject: [PATCH 028/240] Instantiate setup property key in devnode helper Include initguid before devpkey so DEVPKEY_Device_IsPresent is defined in the standalone helper instead of leaving an unresolved external at link time. --- native/udecx/tools/ViiperUdeCtl.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index f5a3194c..9f76c6d1 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include From ff5336fe27384db7fa5cc047a94b36a53d4744fb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:37:05 -0500 Subject: [PATCH 029/240] Expand native UDE release validation Run the full VIIPER Go test and vet suites for every native-bus change instead of checking only the ABI package. Execute the compiled devnode helper's read-only status path on the clean Windows runner before publishing the test-signed artifact. --- .github/workflows/native-ude.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 8162b181..ccc60059 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -26,7 +26,10 @@ jobs: with: go-version: "1.26.5" cache: true - - run: go test ./internal/transport/udecx + - name: Test complete VIIPER tree + run: go test ./... + - name: Vet complete VIIPER tree + run: go vet ./... driver: runs-on: windows-2025-vs2026 @@ -60,6 +63,8 @@ jobs: $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib" cmd.exe /d /s /c $command if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } + & $output status + if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl status smoke test failed" } - uses: actions/upload-artifact@v4 with: name: ViiperUde-x64-test-signed From 7c967fb8e9ce593c85d848be676389e493dbf0d2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:39:15 -0500 Subject: [PATCH 030/240] Order native device unregister behind lane teardown Mark the generation's operations cancelled before stopping endpoint contexts, join every affected endpoint lane, and only then reset shared device state. This removes a normal-unplug race that could report a false completion failure or mutate a device while its processor was still active. Add a repeated teardown-order regression test. --- internal/transport/udecx/host.go | 28 ++++++++-- internal/transport/udecx/host_test.go | 73 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 38e143df..f844f8c7 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -56,6 +56,7 @@ type operationLane struct { ctx context.Context cancel context.CancelFunc input chan Operation + done chan struct{} } type operationState struct { @@ -174,15 +175,32 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { return errors.New("native UDE device changed during serialized removal") } delete(h.devices, identity.DeviceID) - entry.cancel() + stoppingLanes := make([]*operationLane, 0, 4) for key, lane := range h.lanes { if key.deviceID == identity.DeviceID && key.generation == identity.Generation { - lane.cancel() + stoppingLanes = append(stoppingLanes, lane) delete(h.lanes, key) } } h.mu.Unlock() + + // Mark operations cancelled before their processing contexts are stopped. + // This prevents a processor waking on lane cancellation and racing a + // completion through an intentionally cancelled driver handle. h.cancelDeviceOperations(identity) + entry.cancel() + for _, lane := range stoppingLanes { + lane.cancel() + } + for _, lane := range stoppingLanes { + select { + case <-lane.done: + case <-ctx.Done(): + h.reportFatal(fmt.Errorf("stop native UDE device %d generation %d lanes: %w", + identity.DeviceID, identity.Generation, ctx.Err())) + return ctx.Err() + } + } h.processor.Reset(entry.device, identity) return nil } @@ -302,7 +320,10 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { lane := h.lanes[key] if lane == nil { laneCtx, cancel := context.WithCancel(entry.ctx) - lane = &operationLane{key: key, ctx: laneCtx, cancel: cancel, input: make(chan Operation, laneQueueDepth)} + lane = &operationLane{ + key: key, ctx: laneCtx, cancel: cancel, + input: make(chan Operation, laneQueueDepth), done: make(chan struct{}), + } h.lanes[key] = lane h.laneWG.Add(1) go h.runLane(lane, entry) @@ -321,6 +342,7 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { defer h.laneWG.Done() + defer close(lane.done) expected := uint64(1) pending := make(map[uint64]Operation) for { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 748509b2..86870888 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -93,6 +93,28 @@ func (p *cancellableProcessor) Process(ctx context.Context, _ usb.Device, _ Oper func (*cancellableProcessor) Reset(usb.Device, DeviceIdentity) {} func (*cancellableProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +type unregisterProcessor struct { + started chan struct{} + cancelled chan struct{} + reset chan bool +} + +func (p *unregisterProcessor) Process(ctx context.Context, _ usb.Device, _ Operation) (Completion, error) { + close(p.started) + <-ctx.Done() + close(p.cancelled) + return Completion{}, ctx.Err() +} +func (p *unregisterProcessor) Reset(usb.Device, DeviceIdentity) { + select { + case <-p.cancelled: + p.reset <- true + default: + p.reset <- false + } +} +func (*unregisterProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } + func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ Device: usb.DeviceDescriptor{ @@ -356,6 +378,57 @@ func TestHostCancelInterruptsActiveProcessor(t *testing.T) { <-done } +func TestHostUnregisterCancelsAndJoinsLanesBeforeReset(t *testing.T) { + driver := newFakeHostDriver() + processor := &unregisterProcessor{ + started: make(chan struct{}), cancelled: make(chan struct{}), reset: make(chan bool, 1), + } + host, _ := NewHost(driver, processor, 2) + identity, err := host.Register(context.Background(), 16, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + serveCtx, stopServe := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(serveCtx) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + unregisterCtx, cancelUnregister := context.WithTimeout(context.Background(), time.Second) + defer cancelUnregister() + if err := host.Unregister(unregisterCtx, identity); err != nil { + t.Fatal(err) + } + select { + case cancelledFirst := <-processor.reset: + if !cancelledFirst { + t.Fatal("device reset raced ahead of its active endpoint lane") + } + case <-time.After(time.Second): + t.Fatal("device was not reset after unregister") + } + select { + case completion := <-driver.completions: + t.Fatalf("unregister completed an operation after cancellation: %+v", completion) + default: + } + stopServe() + select { + case err = <-done: + if err != nil { + t.Fatalf("ordinary unregister failed the host session: %v", err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostDuplicateTokenFailsSessionWithoutCompletingWrongOperation(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} From 9a4479f70988c6de31dce81d393ede6288db8d2f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:40:24 -0500 Subject: [PATCH 031/240] Hold native device tombstones through teardown Mark a generation stopping after UdeCx accepts plug-out, reject all later dispatch and registration for that ID, and remove the routing entry only after its endpoint lanes join. If a processor violates cancellation and teardown times out, fail the session while preserving the tombstone so no replacement can race the deleted child. Add deterministic timeout coverage. --- internal/transport/udecx/host.go | 12 +++++-- internal/transport/udecx/host_test.go | 52 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index f844f8c7..bf17bd94 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -43,6 +43,7 @@ type registeredDevice struct { device usb.Device ctx context.Context cancel context.CancelFunc + stopping bool } type laneKey struct { @@ -155,7 +156,7 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { h.mu.RLock() entry := h.devices[identity.DeviceID] - if entry == nil || entry.identity.Generation != identity.Generation { + if entry == nil || entry.stopping || entry.identity.Generation != identity.Generation { h.mu.RUnlock() return fmt.Errorf("native UDE device %d generation %d is not registered", identity.DeviceID, identity.Generation) @@ -174,7 +175,7 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { h.mu.Unlock() return errors.New("native UDE device changed during serialized removal") } - delete(h.devices, identity.DeviceID) + entry.stopping = true stoppingLanes := make([]*operationLane, 0, 4) for key, lane := range h.lanes { if key.deviceID == identity.DeviceID && key.generation == identity.Generation { @@ -201,6 +202,11 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { return ctx.Err() } } + h.mu.Lock() + if h.devices[identity.DeviceID] == entry { + delete(h.devices, identity.DeviceID) + } + h.mu.Unlock() h.processor.Reset(entry.device, identity) return nil } @@ -313,7 +319,7 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { h.mu.Lock() entry := h.devices[op.DeviceID] - if entry == nil || entry.identity.Generation != op.Generation { + if entry == nil || entry.stopping || entry.identity.Generation != op.Generation { h.mu.Unlock() return errors.New("native UDE operation targets a stale device generation") } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 86870888..c041925f 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -115,6 +115,19 @@ func (p *unregisterProcessor) Reset(usb.Device, DeviceIdentity) { } func (*unregisterProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +type stubbornProcessor struct { + started chan struct{} + release chan struct{} +} + +func (p *stubbornProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + close(p.started) + <-p.release + return Completion{}, context.Canceled +} +func (*stubbornProcessor) Reset(usb.Device, DeviceIdentity) {} +func (*stubbornProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } + func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ Device: usb.DeviceDescriptor{ @@ -429,6 +442,45 @@ func TestHostUnregisterCancelsAndJoinsLanesBeforeReset(t *testing.T) { } } +func TestHostUnregisterTimeoutKeepsStoppingTombstone(t *testing.T) { + driver := newFakeHostDriver() + processor := &stubbornProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 17, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor did not start") + } + unregisterCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + err = host.Unregister(unregisterCtx, identity) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Unregister error=%v want deadline", err) + } + if _, err = host.Register(context.Background(), identity.DeviceID, hostTestDevice()); err == nil { + t.Fatal("stopping device ID was reused after irreversible plug-out") + } + close(processor.release) + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "stop native UDE device") { + t.Fatalf("Serve error=%v want teardown-timeout session failure", err) + } + case <-time.After(time.Second): + t.Fatal("teardown timeout did not fail the host session") + } +} + func TestHostDuplicateTokenFailsSessionWithoutCompletingWrongOperation(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} From dcc99733172dbf903b20301cd4da09ad97d8c9dc Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:45:53 -0500 Subject: [PATCH 032/240] Route native UDE I/O through one completion port Replace the event plus waiter-goroutine created for every DeviceIoControl call with one Windows IOCP pump and pooled OVERLAPPED request records. Preserve context cancellation by cancelling the exact request and waiting for its completion packet before reuse. Close cancels all in-flight work, drains the completion port, and only then releases the controller and port handles. --- internal/transport/udecx/client_windows.go | 182 +++++++++++++++------ 1 file changed, 129 insertions(+), 53 deletions(-) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 0018bf10..18fcdbff 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -17,22 +17,23 @@ import ( ) const ( - crSuccess = 0 - crBufferSmall = 0x1a - cmGetDeviceInterfaceListPresent = 0 - fileDeviceUnknown = 0x22 - methodBuffered = 0 - methodInDirect = 1 - methodOutDirect = 2 - fileReadData = 1 - fileWriteData = 2 - ioctlBase = 0x900 - ioctlNegotiate = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 0) << 2) | methodBuffered - ioctlCreateDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 1) << 2) | methodBuffered - ioctlDestroyDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 2) << 2) | methodBuffered - ioctlDequeueOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 3) << 2) | methodOutDirect - ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect - ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered + crSuccess = 0 + crBufferSmall = 0x1a + cmGetDeviceInterfaceListPresent = 0 + fileDeviceUnknown = 0x22 + methodBuffered = 0 + methodInDirect = 1 + methodOutDirect = 2 + fileReadData = 1 + fileWriteData = 2 + ioctlBase = 0x900 + ioctlNegotiate = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 0) << 2) | methodBuffered + ioctlCreateDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 1) << 2) | methodBuffered + ioctlDestroyDevice = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 2) << 2) | methodBuffered + ioctlDequeueOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 3) << 2) | methodOutDirect + ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect + ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered + completionPortCloseKey uintptr = ^uintptr(0) ) var ( @@ -48,12 +49,29 @@ var ( ) type Client struct { - mu sync.RWMutex - inflight sync.WaitGroup - handle windows.Handle - driverNonce uint64 - capabilities Capabilities - limits NegotiateResponse + mu sync.RWMutex + inflight sync.WaitGroup + handle windows.Handle + completionPort windows.Handle + pumpDone chan struct{} + pumpErr error + requestPool sync.Pool + driverNonce uint64 + capabilities Capabilities + limits NegotiateResponse +} + +type ioCompletion struct { + transferred uint32 + err error +} + +// overlapped must remain the first field. Windows returns the exact pointer +// submitted to DeviceIoControl through the completion port, allowing the +// single completion pump to recover the owning request without a map or lock. +type ioRequest struct { + overlapped windows.Overlapped + done chan ioCompletion } func Open(ctx context.Context) (*Client, error) { @@ -84,9 +102,22 @@ func Open(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("open native UDE controller: %w", err) } - client := &Client{handle: handle} - if err = client.negotiate(ctx); err != nil { + completionPort, err := windows.CreateIoCompletionPort(handle, 0, 0, 0) + if err != nil { _ = windows.CloseHandle(handle) + return nil, fmt.Errorf("associate native UDE controller with I/O completion port: %w", err) + } + client := &Client{ + handle: handle, + completionPort: completionPort, + pumpDone: make(chan struct{}), + } + client.requestPool.New = func() any { + return &ioRequest{done: make(chan ioCompletion, 1)} + } + go client.runCompletionPort() + if err = client.negotiate(ctx); err != nil { + _ = client.Close() return nil, err } return client, nil @@ -99,12 +130,55 @@ func (c *Client) Close() error { return nil } handle := c.handle + completionPort := c.completionPort + pumpDone := c.pumpDone c.handle = windows.InvalidHandle + c.completionPort = windows.InvalidHandle c.mu.Unlock() _ = windows.CancelIoEx(handle, nil) c.inflight.Wait() - return windows.CloseHandle(handle) + if err := windows.PostQueuedCompletionStatus( + completionPort, 0, completionPortCloseKey, nil); err != nil { + // Closing the port is the documented escape hatch for a waiter when a + // sentinel cannot be posted. The pump records the abandoned wait. + _ = windows.CloseHandle(completionPort) + <-pumpDone + return errors.Join(windows.CloseHandle(handle), err) + } + <-pumpDone + return errors.Join(windows.CloseHandle(handle), windows.CloseHandle(completionPort)) +} + +func (c *Client) runCompletionPort() { + defer close(c.pumpDone) + for { + var transferred uint32 + var key uintptr + var overlapped *windows.Overlapped + err := windows.GetQueuedCompletionStatus( + c.completionPort, &transferred, &key, &overlapped, windows.INFINITE) + if overlapped == nil { + if key == completionPortCloseKey { + return + } + c.mu.Lock() + c.pumpErr = fmt.Errorf("native UDE I/O completion pump stopped: %w", err) + c.mu.Unlock() + return + } + request := (*ioRequest)(unsafe.Pointer(overlapped)) + request.done <- ioCompletion{transferred: transferred, err: err} + } +} + +func (c *Client) completionPumpError() error { + c.mu.RLock() + defer c.mu.RUnlock() + if c.pumpErr != nil { + return c.pumpErr + } + return windows.ERROR_INVALID_HANDLE } func (c *Client) Capabilities() Capabilities { @@ -233,12 +307,19 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( } defer c.inflight.Done() - event, err := windows.CreateEvent(nil, 1, 0, nil) - if err != nil { - return 0, err + request := c.requestPool.Get().(*ioRequest) + request.overlapped = windows.Overlapped{} + select { + case <-request.done: + panic("native UDE I/O request returned to pool with an unread completion") + default: } - defer windows.CloseHandle(event) - overlapped := windows.Overlapped{HEvent: event} + defer func() { + runtime.KeepAlive(input) + runtime.KeepAlive(output) + runtime.KeepAlive(request) + c.requestPool.Put(request) + }() var inputPointer *byte var outputPointer *byte if len(input) != 0 { @@ -252,34 +333,29 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( handle, code, inputPointer, uint32(len(input)), outputPointer, uint32(len(output)), - &immediate, &overlapped) - if err == nil { - var transferred uint32 - err = windows.GetOverlappedResult(handle, &overlapped, &transferred, false) - runtime.KeepAlive(input) - runtime.KeepAlive(output) - return transferred, err - } - if !errors.Is(err, windows.ERROR_IO_PENDING) { + &immediate, &request.overlapped) + if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { return 0, err } - done := make(chan struct{}) - var transferred uint32 - var resultErr error - go func() { - resultErr = windows.GetOverlappedResult(handle, &overlapped, &transferred, true) - close(done) - }() select { + case result := <-request.done: + return result.transferred, result.err case <-ctx.Done(): - _ = windows.CancelIoEx(handle, &overlapped) - <-done - return 0, ctx.Err() - case <-done: - runtime.KeepAlive(input) - runtime.KeepAlive(output) - return transferred, resultErr + _ = windows.CancelIoEx(handle, &request.overlapped) + select { + case <-request.done: + return 0, ctx.Err() + case <-c.pumpDone: + var transferred uint32 + _ = windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) + return 0, errors.Join(ctx.Err(), c.completionPumpError()) + } + case <-c.pumpDone: + _ = windows.CancelIoEx(handle, &request.overlapped) + var transferred uint32 + _ = windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) + return 0, c.completionPumpError() } } From 281fe2fd64329307515b2d26128c906a2cfcc625 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:47:39 -0500 Subject: [PATCH 033/240] Gate native UDE with race and CodeQL analysis Run repeated race-enabled host and USB processor tests, analyze the WDK driver and setup helper with the current CodeQL C/C++ action, and expand path coverage so native integration changes cannot bypass the release workflow. --- .github/workflows/native-ude.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index ccc60059..334e1484 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -6,16 +6,26 @@ on: paths: - "native/udecx/**" - "internal/transport/udecx/**" + - "internal/server/usb/**" + - "internal/cmd/**" + - "device/**" + - "usb/**" - ".github/workflows/native-ude.yml" pull_request: paths: - "native/udecx/**" - "internal/transport/udecx/**" + - "internal/server/usb/**" + - "internal/cmd/**" + - "device/**" + - "usb/**" - ".github/workflows/native-ude.yml" workflow_dispatch: permissions: + actions: read contents: read + security-events: write jobs: protocol: @@ -31,10 +41,26 @@ jobs: - name: Vet complete VIIPER tree run: go vet ./... + race: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.5" + cache: true + - name: Race-test native host and USB processor + run: go test -race -count=5 ./internal/transport/udecx ./internal/server/usb + driver: runs-on: windows-2025-vs2026 steps: - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v4 + with: + languages: c-cpp + build-mode: manual + queries: security-extended - uses: microsoft/setup-msbuild@v2 with: msbuild-architecture: x64 @@ -65,6 +91,10 @@ jobs: if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } & $output status if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl status smoke test failed" } + - name: Analyze native driver and setup helper + uses: github/codeql-action/analyze@v4 + with: + category: /language:c-cpp - uses: actions/upload-artifact@v4 with: name: ViiperUde-x64-test-signed From a79256d9d4680635d73ff0a1a47d1e6e1842f266 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:50:23 -0500 Subject: [PATCH 034/240] Fail native sessions on lifecycle queue loss Reserve a kernel notification slot for an explicit broker-fault record, reject new URBs and dequeue requests once any lifecycle or cancellation event would be lost, and teach the Go host to terminate that corrupted session immediately. Advance the exact-match ABI minor and cover the fail-closed path. --- internal/transport/udecx/host.go | 4 +++ internal/transport/udecx/host_test.go | 28 ++++++++++++++++ internal/transport/udecx/protocol.go | 3 +- native/udecx/driver/Broker.c | 42 ++++++++++++++++++++---- native/udecx/driver/Controller.c | 2 ++ native/udecx/driver/ViiperUde.h | 1 + native/udecx/include/ViiperUdeProtocol.h | 5 +-- 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index bf17bd94..7e082312 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -280,6 +280,10 @@ func (h *Host) Serve(ctx context.Context) error { } return fmt.Errorf("dequeue native UDE operation: %w", result.err) } + if result.op.Kind == OperationBrokerFault { + h.reportFatal(errors.New("native UDE kernel broker reported a lost lifecycle notification")) + continue + } if result.op.Kind == OperationCancel { h.cancelOperation(result.op) continue diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index c041925f..39e5ba3d 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -599,3 +599,31 @@ func TestHostCompletionFailureFailsSession(t *testing.T) { t.Fatal("completion failure did not fail the host session") } } + +func TestHostBrokerFaultFailsSessionWithoutDispatchingAnOperation(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{Kind: OperationBrokerFault} + + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "lost lifecycle notification") { + t.Fatalf("Serve error=%v, want broker-fault session failure", err) + } + case <-time.After(time.Second): + t.Fatal("kernel broker fault did not fail the host session") + } + select { + case sequence := <-processor.processed: + t.Fatalf("broker fault reached transfer processor with sequence %d", sequence) + case sequence := <-processor.lifecycle: + t.Fatalf("broker fault reached lifecycle processor with sequence %d", sequence) + default: + } +} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index f1620715..a25d26cf 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 2 + ABIMinor uint16 = 3 HeaderSize = 16 NegotiateRequestSize = 32 @@ -261,6 +261,7 @@ const ( OperationDeviceD0Entry OperationDeviceD0Exit OperationCancel + OperationBrokerFault ) type IsoPacket struct { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 109d573c..d41cb452 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -87,6 +87,31 @@ ViiperCompleteUnownedUrbAsync( } } +static +BOOLEAN +ViiperFaultBrokerLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + VIIPER_UDE_NOTIFICATION *event; + + InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); + if (InterlockedCompareExchange(&ControllerContext->BrokerFaulted, TRUE, FALSE) != FALSE) { + return FALSE; + } + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + return FALSE; + } + + event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; + RtlZeroMemory(event, sizeof(*event)); + event->Kind = ViiperUdeOperationBrokerFault; + ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; + ++ControllerContext->NotificationCount; + return TRUE; +} + static BOOLEAN ViiperQueueCancelEventLocked( @@ -99,9 +124,10 @@ ViiperQueueCancelEventLocked( if (!Pending->PublishedToOwner) { return FALSE; } - if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { - InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); - return FALSE; + // Keep one slot reserved for a broker-fault event. Losing cancellation or + // lifecycle state is not recoverable within the current owner session. + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + return ViiperFaultBrokerLocked(ControllerContext); } event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; @@ -374,8 +400,8 @@ ViiperQueueLifecycleEventLocked( { VIIPER_UDE_NOTIFICATION *event; - if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { - InterlockedIncrement64(&ControllerContext->NotificationEventOverflows); + if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + (VOID)ViiperFaultBrokerLocked(ControllerContext); return FALSE; } @@ -1078,6 +1104,9 @@ ViiperQueueDequeueOperation( if (!NT_SUCCESS(status)) { return status; } + if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + return STATUS_DATA_ERROR; + } status = WdfRequestForwardToIoQueue(Request, controllerContext->WaitingDequeues); if (!NT_SUCCESS(status)) { return status; @@ -1105,7 +1134,8 @@ ViiperQueueUrb( BOOLEAN abortPending = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; - if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { return STATUS_DEVICE_NOT_READY; } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 3bacb45e..47264a31 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -153,6 +153,7 @@ ViiperEvtControllerCleanup( context->NotificationHead = 0; context->NotificationTail = 0; context->NotificationCount = 0; + InterlockedExchange(&context->BrokerFaulted, FALSE); WdfSpinLockRelease(context->BrokerLock); } } @@ -199,6 +200,7 @@ ViiperEvtFileCreate( } else { fileContext->BrokerOwner = TRUE; context->OwnerFile = FileObject; + InterlockedExchange(&context->BrokerFaulted, FALSE); WdfIoQueueStart(context->WaitingDequeues); } WdfWaitLockRelease(context->OwnerLock); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 5e0dd474..e7390290 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -85,6 +85,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; BOOLEAN CleanupInProgress; + volatile LONG BrokerFaulted; volatile LONG ActiveDevices; volatile LONG PendingOperations; volatile LONG WaitingDequeueCount; diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 4cfb899a..0dce7c42 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(2) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(3) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) @@ -130,7 +130,8 @@ typedef enum VIIPER_UDE_OPERATION_KIND { ViiperUdeOperationSetInterface = 7, ViiperUdeOperationDeviceD0Entry = 8, ViiperUdeOperationDeviceD0Exit = 9, - ViiperUdeOperationCancel = 10 + ViiperUdeOperationCancel = 10, + ViiperUdeOperationBrokerFault = 11 } VIIPER_UDE_OPERATION_KIND; typedef struct VIIPER_UDE_ISO_PACKET { From d6564cdf8df217ee41b4452b6107cab0b2581069 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:52:54 -0500 Subject: [PATCH 035/240] Retry failed UdeCx owner teardown without orphaning Hold an explicit controller reference on the broker file object, retain owner exclusivity when UdecxUsbDevicePlugOutAndDelete fails, and retry cleanup from a passive one-shot WDF timer. Release ownership only after all children have accepted removal. Expose cleanup retries in the fixed-size stats ABI. --- internal/transport/udecx/protocol.go | 2 + native/udecx/driver/Controller.c | 98 ++++++++++++++++++++++-- native/udecx/driver/Device.c | 8 +- native/udecx/driver/Ioctl.c | 1 + native/udecx/driver/ViiperUde.h | 6 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- 6 files changed, 103 insertions(+), 14 deletions(-) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index a25d26cf..b07d4f58 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -368,6 +368,7 @@ type Stats struct { ActiveDevices uint32 PendingOperations uint32 WaitingDequeues uint32 + CleanupRetries uint32 } func ParseStats(src []byte) (Stats, error) { @@ -394,6 +395,7 @@ func ParseStats(src []byte) (Stats, error) { ActiveDevices: binary.LittleEndian.Uint32(src[112:116]), PendingOperations: binary.LittleEndian.Uint32(src[116:120]), WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), + CleanupRetries: binary.LittleEndian.Uint32(src[124:128]), }, nil } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 47264a31..2332a042 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -10,9 +10,63 @@ DEFINE_GUID( #pragma alloc_text(PAGE, ViiperEvtControllerCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) #pragma alloc_text(PAGE, ViiperEvtFileCleanup) +#pragma alloc_text(PAGE, ViiperEvtOwnerCleanupRetry) #pragma alloc_text(PAGE, ViiperCreateQueues) #endif +#define VIIPER_OWNER_CLEANUP_RETRY_MS 100 + +static +BOOLEAN +ViiperFinishOwnerCleanup( + _In_ WDFDEVICE Device, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + BOOLEAN releaseOwner = FALSE; + + PAGED_CODE(); + if (!ViiperDestroyOwnedDevices(Device, OwnerFile)) { + return FALSE; + } + + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile == OwnerFile && context->CleanupInProgress) { + context->OwnerFile = WDF_NO_HANDLE; + context->CleanupInProgress = FALSE; + releaseOwner = InterlockedExchange(&context->OwnerReferenced, FALSE) != FALSE; + } + WdfWaitLockRelease(context->OwnerLock); + if (releaseOwner) { + WdfObjectDereference(OwnerFile); + } + return TRUE; +} + +VOID +ViiperEvtOwnerCleanupRetry( + _In_ WDFTIMER Timer + ) +{ + WDFDEVICE device = (WDFDEVICE)WdfTimerGetParentObject(Timer); + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(device); + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; + + PAGED_CODE(); + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->CleanupInProgress && context->OwnerFile != WDF_NO_HANDLE) { + ownerFile = context->OwnerFile; + } + WdfWaitLockRelease(context->OwnerLock); + if (ownerFile == WDF_NO_HANDLE || ViiperFinishOwnerCleanup(device, ownerFile)) { + return; + } + + InterlockedIncrement(&context->CleanupRetries); + (VOID)WdfTimerStart(Timer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); +} + NTSTATUS ViiperEvtQueryUsbCapability( _In_ WDFDEVICE UdecxWdfDevice, @@ -51,6 +105,7 @@ ViiperEvtDeviceAdd( WDF_OBJECT_ATTRIBUTES fileAttributes; WDF_OBJECT_ATTRIBUTES requestAttributes; WDF_FILEOBJECT_CONFIG fileConfig; + WDF_TIMER_CONFIG timerConfig; UDECX_WDF_DEVICE_CONFIG udeConfig; VIIPER_UDE_CONTROLLER_CONTEXT *context; UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); @@ -106,6 +161,16 @@ ViiperEvtDeviceAdd( return status; } + WDF_TIMER_CONFIG_INIT(&timerConfig, ViiperEvtOwnerCleanupRetry); + timerConfig.AutomaticSerialization = FALSE; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + attributes.ExecutionLevel = WdfExecutionLevelPassive; + status = WdfTimerCreate(&timerConfig, &attributes, &context->OwnerCleanupTimer); + if (!NT_SUCCESS(status)) { + return status; + } + RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); status = WdfDeviceCreateDeviceInterface( device, &GUID_DEVINTERFACE_VIIPER_UDE, &brokerReference); @@ -140,6 +205,9 @@ ViiperEvtControllerCleanup( PAGED_CODE(); context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + if (context->OwnerCleanupTimer != WDF_NO_HANDLE) { + WdfTimerStop(context->OwnerCleanupTimer, TRUE); + } ViiperPurgeOwnerOperations((WDFDEVICE)ControllerObject, STATUS_DEVICE_REMOVED); if (context->DefaultQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->DefaultQueue); @@ -156,6 +224,20 @@ ViiperEvtControllerCleanup( InterlockedExchange(&context->BrokerFaulted, FALSE); WdfSpinLockRelease(context->BrokerLock); } + if (context->OwnerLock != WDF_NO_HANDLE) { + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; + BOOLEAN releaseOwner = FALSE; + + WdfWaitLockAcquire(context->OwnerLock, NULL); + ownerFile = context->OwnerFile; + context->OwnerFile = WDF_NO_HANDLE; + context->CleanupInProgress = FALSE; + releaseOwner = InterlockedExchange(&context->OwnerReferenced, FALSE) != FALSE; + WdfWaitLockRelease(context->OwnerLock); + if (releaseOwner && ownerFile != WDF_NO_HANDLE) { + WdfObjectDereference(ownerFile); + } + } } VOID @@ -199,6 +281,8 @@ ViiperEvtFileCreate( status = STATUS_SHARING_VIOLATION; } else { fileContext->BrokerOwner = TRUE; + WdfObjectReference(FileObject); + InterlockedExchange(&context->OwnerReferenced, TRUE); context->OwnerFile = FileObject; InterlockedExchange(&context->BrokerFaulted, FALSE); WdfIoQueueStart(context->WaitingDequeues); @@ -247,14 +331,12 @@ ViiperEvtFileCleanup( WdfSpinLockRelease(context->BrokerLock); } if (ownsController) { - ViiperDestroyOwnedDevices(device, FileObject); - } - - if (ownsController) { - WdfWaitLockAcquire(context->OwnerLock, NULL); - context->OwnerFile = WDF_NO_HANDLE; - context->CleanupInProgress = FALSE; - WdfWaitLockRelease(context->OwnerLock); + if (!ViiperFinishOwnerCleanup(device, FileObject)) { + InterlockedIncrement(&context->CleanupRetries); + (VOID)WdfTimerStart( + context->OwnerCleanupTimer, + WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); + } } } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index a10d2306..b170740c 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -497,7 +497,7 @@ ViiperDestroyVirtualDevice( return status; } -VOID +BOOLEAN ViiperDestroyOwnedDevices( _In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile @@ -525,18 +525,18 @@ ViiperDestroyOwnedDevices( } WdfWaitLockRelease(controllerContext->DeviceLock); if (deviceId == 0) { - break; + return TRUE; } if (!NT_SUCCESS(ViiperBeginRemoveDevice( controllerContext, OwnerFile, deviceId, 0, FALSE, &device))) { - continue; + return FALSE; } deviceContext = ViiperGetDeviceContext(device); if (deviceContext->Plugged) { if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { ViiperCancelRemoveDevice(controllerContext, device); - break; + return FALSE; } } else { WdfObjectDelete(device); diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 814dee76..0b6285d2 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -142,6 +142,7 @@ ViiperHandleQueryStats( output->ActiveDevices = (ULONG)InterlockedCompareExchange(&context->ActiveDevices, 0, 0); output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); + output->CleanupRetries = (ULONG)InterlockedCompareExchange(&context->CleanupRetries, 0, 0); WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index e7390290..07f2739c 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -84,8 +84,11 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE WaitingDequeues; + WDFTIMER OwnerCleanupTimer; BOOLEAN CleanupInProgress; volatile LONG BrokerFaulted; + volatile LONG OwnerReferenced; + volatile LONG CleanupRetries; volatile LONG ActiveDevices; volatile LONG PendingOperations; volatile LONG WaitingDequeueCount; @@ -148,6 +151,7 @@ EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtDriverCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtControllerCleanup; EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; +EVT_WDF_TIMER ViiperEvtOwnerCleanupRetry; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; @@ -168,7 +172,7 @@ NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); -VOID ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); +BOOLEAN ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 0dce7c42..3b24ffeb 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -196,7 +196,7 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT32 ActiveDevices; VIIPER_UDE_UINT32 PendingOperations; VIIPER_UDE_UINT32 WaitingDequeues; - VIIPER_UDE_UINT32 Reserved; + VIIPER_UDE_UINT32 CleanupRetries; } VIIPER_UDE_STATS; #pragma pack(pop) From 39a35465d115a1325e3e2bb0d199b73c85c869ed Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 20:56:16 -0500 Subject: [PATCH 036/240] Exercise native IOCP completion routing Verify the Windows completion pump routes the exact OVERLAPPED request and shuts down only on its sentinel while retaining the ABI and MULTI_SZ checks. --- .../transport/udecx/client_windows_test.go | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 6586925e..3bfd647f 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -2,7 +2,12 @@ package udecx -import "testing" +import ( + "testing" + "time" + + "golang.org/x/sys/windows" +) func TestIOCTLCodesMatchPackedHeader(t *testing.T) { wants := map[string]struct{ got, want uint32 }{ @@ -27,3 +32,37 @@ func TestParseMultiSZ(t *testing.T) { t.Fatalf("parseMultiSZ=%q", got) } } + +func TestCompletionPortRoutesExactOverlappedRequest(t *testing.T) { + port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 1) + if err != nil { + t.Fatal(err) + } + client := &Client{completionPort: port, pumpDone: make(chan struct{})} + go client.runCompletionPort() + + request := &ioRequest{done: make(chan ioCompletion, 1)} + if err := windows.PostQueuedCompletionStatus(port, 547, 0, &request.overlapped); err != nil { + t.Fatal(err) + } + select { + case completion := <-request.done: + if completion.err != nil || completion.transferred != 547 { + t.Fatalf("completion=%+v want 547 successful bytes", completion) + } + case <-time.After(time.Second): + t.Fatal("completion pump did not route the exact OVERLAPPED request") + } + + if err := windows.PostQueuedCompletionStatus(port, 0, completionPortCloseKey, nil); err != nil { + t.Fatal(err) + } + select { + case <-client.pumpDone: + case <-time.After(time.Second): + t.Fatal("completion pump did not stop on its sentinel") + } + if err := windows.CloseHandle(port); err != nil { + t.Fatal(err) + } +} From ed5b23b31392694c21793de942c4d5d3cdec23a1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:02:59 -0500 Subject: [PATCH 037/240] Validate native UDE negotiation and device topology Require the isochronous and lifecycle capabilities, reject impossible or exceeded negotiated limits before issuing kernel I/O, and snapshot every production VIIPER controller topology through the native create-device ABI. --- internal/transport/udecx/client_windows.go | 36 ++++++++-- .../transport/udecx/client_windows_test.go | 52 ++++++++++++++ .../udecx/descriptors_integration_test.go | 69 +++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 internal/transport/udecx/descriptors_integration_test.go diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 18fcdbff..1f606d91 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -34,6 +34,7 @@ const ( ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered completionPortCloseKey uintptr = ^uintptr(0) + requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle ) var ( @@ -204,7 +205,7 @@ func (c *Client) negotiate(ctx context.Context) error { } request, err := (NegotiateRequest{ ClientNonce: nonce, - RequestedCapabilities: CapabilityIsochronous | CapabilityDeviceLifecycle, + RequestedCapabilities: requiredCapabilities, }).MarshalBinary() if err != nil { return err @@ -221,21 +222,42 @@ func (c *Client) negotiate(ctx context.Context) error { if err != nil { return fmt.Errorf("validate native UDE negotiation: %w", err) } + if err := validateNegotiation(negotiated, nonce); err != nil { + return err + } + c.driverNonce = negotiated.DriverNonce + c.capabilities = negotiated.Capabilities + c.limits = negotiated + return nil +} + +func validateNegotiation(negotiated NegotiateResponse, nonce uint64) error { if negotiated.ClientNonce != nonce || negotiated.DriverNonce == 0 { return errors.New("validate native UDE negotiation: session nonce mismatch") } + if negotiated.Capabilities&requiredCapabilities != requiredCapabilities { + return fmt.Errorf("validate native UDE negotiation: required capabilities %#x, driver returned %#x", + requiredCapabilities, negotiated.Capabilities) + } if negotiated.MaxDevices == 0 || negotiated.MaxDescriptorBytes == 0 || negotiated.MaxTransferBytes == 0 || negotiated.MaxIsoPackets == 0 || negotiated.MaxPendingOperations == 0 { return errors.New("validate native UDE negotiation: driver returned a zero limit") } - c.driverNonce = negotiated.DriverNonce - c.capabilities = negotiated.Capabilities - c.limits = negotiated + if negotiated.MaxDevices > MaxDevices || negotiated.MaxDescriptorBytes > MaxDescriptorBytes || + negotiated.MaxTransferBytes > MaxTransferBytes || negotiated.MaxIsoPackets > MaxIsoPackets || + negotiated.MaxPendingOperations > MaxPendingOperations { + return errors.New("validate native UDE negotiation: driver limits exceed this client's ABI bounds") + } return nil } func (c *Client) CreateDevice(ctx context.Context, device CreateDevice) error { + limits := c.Limits() + if uint32(len(device.DescriptorData)) > limits.MaxDescriptorBytes || + device.MaxPendingOperations > limits.MaxPendingOperations { + return ErrLimitExceeded + } request, err := device.MarshalBinary() if err != nil { return err @@ -268,6 +290,12 @@ func (c *Client) Dequeue(ctx context.Context, buffer []byte) (Operation, error) } func (c *Client) Complete(ctx context.Context, completion Completion) error { + limits := c.Limits() + if uint32(len(completion.Payload)) > limits.MaxTransferBytes || + uint32(len(completion.IsoPackets)) > limits.MaxIsoPackets || + completion.TransferLength > limits.MaxTransferBytes { + return ErrLimitExceeded + } request, err := completion.MarshalBinary() if err != nil { return err diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 3bfd647f..5ac0b708 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -3,12 +3,64 @@ package udecx import ( + "context" + "errors" "testing" "time" "golang.org/x/sys/windows" ) +func validTestNegotiation() NegotiateResponse { + return NegotiateResponse{ + ClientNonce: 7, + DriverNonce: 8, + Capabilities: requiredCapabilities, + MaxDevices: MaxDevices, + MaxDescriptorBytes: MaxDescriptorBytes, + MaxTransferBytes: MaxTransferBytes, + MaxIsoPackets: MaxIsoPackets, + MaxPendingOperations: MaxPendingOperations, + } +} + +func TestNegotiationRejectsMissingCapabilitiesAndImpossibleLimits(t *testing.T) { + valid := validTestNegotiation() + if err := validateNegotiation(valid, valid.ClientNonce); err != nil { + t.Fatal(err) + } + + missingCapability := valid + missingCapability.Capabilities &^= CapabilityIsochronous + if err := validateNegotiation(missingCapability, valid.ClientNonce); err == nil { + t.Fatal("negotiation accepted a driver without isochronous support") + } + + oversized := valid + oversized.MaxTransferBytes++ + if err := validateNegotiation(oversized, valid.ClientNonce); err == nil { + t.Fatal("negotiation accepted a driver limit outside the client ABI") + } +} + +func TestClientRejectsRequestsOutsideNegotiatedLimitsBeforeKernelIO(t *testing.T) { + client := &Client{limits: validTestNegotiation()} + client.limits.MaxDescriptorBytes = 1 + if err := client.CreateDevice(context.Background(), CreateDevice{ + DescriptorData: []byte{1, 2}, + }); !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("CreateDevice error=%v want ErrLimitExceeded", err) + } + + client.limits = validTestNegotiation() + client.limits.MaxTransferBytes = 1 + if err := client.Complete(context.Background(), Completion{ + Payload: []byte{1, 2}, + }); !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("Complete error=%v want ErrLimitExceeded", err) + } +} + func TestIOCTLCodesMatchPackedHeader(t *testing.T) { wants := map[string]struct{ got, want uint32 }{ "negotiate": {ioctlNegotiate, 0x22e400}, diff --git a/internal/transport/udecx/descriptors_integration_test.go b/internal/transport/udecx/descriptors_integration_test.go new file mode 100644 index 00000000..ccc0ea35 --- /dev/null +++ b/internal/transport/udecx/descriptors_integration_test.go @@ -0,0 +1,69 @@ +package udecx_test + +import ( + "encoding/binary" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/usb" +) + +func TestSnapshotDeviceCoversEveryProductionControllerTopology(t *testing.T) { + tests := []struct { + name string + new func() (usb.Device, error) + }{ + {"Xbox360", func() (usb.Device, error) { return xbox360.New(nil) }}, + {"DualShock4", func() (usb.Device, error) { return dualshock4.New(nil) }}, + {"DualSense", func() (usb.Device, error) { return dualsense.New(nil) }}, + {"DualSenseEdge", func() (usb.Device, error) { return dualsense.NewEdge(nil) }}, + {"Switch2Pro", func() (usb.Device, error) { return ns2pro.New(nil) }}, + {"Keyboard", func() (usb.Device, error) { return keyboard.New(nil) }}, + {"Mouse", func() (usb.Device, error) { return mouse.New(nil) }}, + } + + for index, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dev, err := tc.new() + if err != nil { + t.Fatal(err) + } + desc := dev.GetDescriptor() + if desc == nil || len(desc.Interfaces) == 0 { + t.Fatal("production controller has no USB interfaces") + } + snapshot, err := udecx.SnapshotDevice(uint64(index+1), 1, dev) + if err != nil { + t.Fatal(err) + } + raw, err := snapshot.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(raw[8:12]); got != uint32(len(raw)) { + t.Fatalf("native create size=%d want=%d", got, len(raw)) + } + + configuration, err := desc.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + var nativeConfiguration []byte + for _, record := range snapshot.Descriptors { + if record.Kind == udecx.DescriptorConfiguration { + nativeConfiguration = snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + break + } + } + if string(nativeConfiguration) != string(configuration) { + t.Fatal("native UDE snapshot changed the production USB topology") + } + }) + } +} From 0f8d6453fcbe91af20e5c9063a40cfd384cbd542 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:15:17 -0500 Subject: [PATCH 038/240] Complete interrupt input through a native UDE fast lane Mirror ViGEm's manual pending-input queue for interrupt-IN endpoints while preserving the ordered broker for output, control, lifecycle, speaker, microphone, feedback, and haptics. Extend ABI v1.4 with a bounded direct input-report call, validate owner/device/generation/endpoint/sequence identity in kernel, and complete parked UdeCx URBs without hot-path allocation. Drive publishers from endpoint and D0 lifecycle notifications, preserve sequence across restarts, cancel before removal, and restore active publishers after failed transactional teardown. Add IOCP/client ABI tests, host fast-lane lifecycle tests, stats, documentation, and strict capability negotiation. --- docs/architecture/native-udecx.md | 36 ++- internal/transport/udecx/client_windows.go | 12 +- .../transport/udecx/client_windows_test.go | 1 + internal/transport/udecx/host.go | 201 ++++++++++++++++- internal/transport/udecx/host_test.go | 100 +++++++++ internal/transport/udecx/protocol.go | 48 +++- internal/transport/udecx/protocol_test.go | 25 ++- native/udecx/driver/Broker.c | 3 - native/udecx/driver/Device.c | 208 +++++++++++++++++- native/udecx/driver/Ioctl.c | 11 +- native/udecx/driver/ViiperUde.h | 15 ++ native/udecx/include/ViiperUdeProtocol.h | 24 +- 12 files changed, 652 insertions(+), 32 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 8ccb4e19..c4784d2b 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -64,16 +64,29 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. ## Kernel/user transport -The first implementation uses a cancel-safe inverted-call model. VIIPER posts -multiple `DEQUEUE_OPERATION` requests. When UdeCx delivers an endpoint request, -the driver pairs it with a waiting user request and returns an immutable -operation record. VIIPER processes it through the existing `usb.Device` -interface and submits `COMPLETE_OPERATION`. - -This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping -before introducing a shared-memory optimization. Once correctness gates pass, -high-rate media payloads may move to a preallocated ring while keeping the same -token/generation lifecycle. Control and lifecycle operations remain IOCTL based. +The transport is intentionally split by USB semantics: + +- interrupt-IN input reports use the ViGEm-style manual-queue fast path. The + Windows poll stays parked in the endpoint queue; one versioned + `SUBMIT_INPUT_REPORT` call copies the already encoded report into that URB + and completes it without an allocation or broker round trip; +- control, interrupt-OUT, isochronous speaker/microphone/haptics, feedback, and + every lifecycle transition use the cancel-safe ordered inverted-call broker. + VIIPER posts multiple `DEQUEUE_OPERATION` requests, processes each immutable + operation through the existing `usb.Device` interface, then submits + `COMPLETE_OPERATION`. + +Input publishers start and stop from UdeCx endpoint lifecycle notifications, +retain their sequence across a purge/start cycle, and are cancelled before +device removal. Failed removal restores the active publishers so a retry does +not strand the current generation. + +This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping. +The direct input lane removes the highest-frequency HID broker path without +mixing report ownership into the proven PlayStation media/state transport. +Once correctness gates pass, high-rate media payloads may move to a +preallocated ring while keeping the same token/generation lifecycle. Control +and lifecycle operations remain IOCTL based. ### Operation identity @@ -120,6 +133,8 @@ unplug all converge on the same idempotent purge path. - A controller-level lock protects the device table and owner registration. - Each device has a short-held state lock and independent endpoint queues. - Media callbacks do not take the controller lock. +- Interrupt-IN queues are manual and completed from fresh input snapshots; + output and media endpoints retain independent ordered queues. - UDE callbacks never wait on user mode while holding a WDF lock. - Blocking work is represented by cancelable WDF requests, not sleeping kernel threads. @@ -159,4 +174,3 @@ request queues while accounting for UdeCx's endpoint-specific purge contract. - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance - diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 1f606d91..aefb55f6 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -33,8 +33,9 @@ const ( ioctlDequeueOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 3) << 2) | methodOutDirect ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered + ioctlSubmitInputReport = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 6) << 2) | methodInDirect completionPortCloseKey uintptr = ^uintptr(0) - requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle + requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports ) var ( @@ -306,6 +307,15 @@ func (c *Client) Complete(ctx context.Context, completion Completion) error { return err } +func (c *Client) SubmitInputReport(ctx context.Context, report InputReport) error { + request, err := report.MarshalBinary() + if err != nil { + return err + } + _, err = c.ioctl(ctx, ioctlSubmitInputReport, request[:InputReportSize], request[InputReportSize:]) + return err +} + func (c *Client) QueryStats(ctx context.Context) (Stats, error) { buffer := make([]byte, StatsSize) written, err := c.ioctl(ctx, ioctlQueryStats, nil, buffer) diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 5ac0b708..6de7dc68 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -69,6 +69,7 @@ func TestIOCTLCodesMatchPackedHeader(t *testing.T) { "dequeue": {ioctlDequeueOperation, 0x22e40e}, "complete": {ioctlCompleteOperation, 0x22e411}, "stats": {ioctlQueryStats, 0x226414}, + "input": {ioctlSubmitInputReport, 0x22e419}, } for name, pair := range wants { if pair.got != pair.want { diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 7e082312..4da9cf48 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -8,6 +8,7 @@ import ( "time" "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" ) const ( @@ -29,6 +30,13 @@ type Driver interface { QueryStats(context.Context) (Stats, error) } +// InputReportDriver is an optional, version-negotiated extension used only +// for interrupt-IN reports. Keeping it separate preserves the ordered broker +// contract for control, output, feedback, audio, and lifecycle traffic. +type InputReportDriver interface { + SubmitInputReport(context.Context, InputReport) error +} + // OperationProcessor translates one native USB operation through VIIPER's // existing usb.Device engines. Implementations must not retain operation // payload slices after Process returns. @@ -39,11 +47,22 @@ type OperationProcessor interface { } type registeredDevice struct { - identity DeviceIdentity - device usb.Device - ctx context.Context + identity DeviceIdentity + device usb.Device + ctx context.Context + cancel context.CancelFunc + stopping bool + publisherStopping bool + fastInput map[uint8]struct{} + publishers map[uint8]*inputPublisher + activeInput map[uint8]bool + inputSequences map[uint8]uint64 +} + +type inputPublisher struct { + endpoint uint8 cancel context.CancelFunc - stopping bool + done chan struct{} } type laneKey struct { @@ -74,6 +93,7 @@ type operationState struct { // across endpoints while preserving strict FIFO within each endpoint. type Host struct { driver Driver + input InputReportDriver processor OperationProcessor workers int @@ -99,13 +119,30 @@ func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, e if workers <= 0 { workers = defaultDequeueWorkers } - return &Host{ + host := &Host{ driver: driver, processor: processor, workers: workers, devices: make(map[uint64]*registeredDevice), generations: make(map[uint64]uint32), lanes: make(map[laneKey]*operationLane), operations: make(map[uint64]*operationState), - }, nil + } + host.input, _ = driver.(InputReportDriver) + return host, nil +} + +func fastInputEndpoints(dev usb.Device) map[uint8]struct{} { + result := make(map[uint8]struct{}) + if dev == nil || dev.GetDescriptor() == nil { + return result + } + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress&0x80 != 0 && endpoint.BMAttributes&0x03 == 0x03 { + result[endpoint.BEndpointAddress] = struct{}{} + } + } + } + return result } // Register publishes a USB device using a fresh generation. The routing entry @@ -129,7 +166,11 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D } identity := DeviceIdentity{DeviceID: deviceID, Generation: generation} deviceCtx, cancel := context.WithCancel(context.Background()) - entry := ®isteredDevice{identity: identity, device: dev, ctx: deviceCtx, cancel: cancel} + entry := ®isteredDevice{ + identity: identity, device: dev, ctx: deviceCtx, cancel: cancel, + fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), + activeInput: make(map[uint8]bool), inputSequences: make(map[uint8]uint64), + } h.devices[deviceID] = entry h.generations[deviceID] = generation h.mu.Unlock() @@ -163,10 +204,22 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { } h.mu.RUnlock() + h.mu.Lock() + entry.publisherStopping = true + h.mu.Unlock() + activePublishers := h.activeInputEndpoints(entry) + h.stopAllInputPublishers(entry) + // Keep routing live until the driver has transactionally unplugged the // child. If unplug fails, callers can retry without losing the generation, // endpoint lanes, or the ability to complete already-issued Windows URBs. if err := h.driver.DestroyDevice(ctx, identity); err != nil { + h.mu.Lock() + entry.publisherStopping = false + h.mu.Unlock() + for _, endpoint := range activePublishers { + h.startInputPublisher(entry, endpoint) + } return err } @@ -211,6 +264,104 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { return nil } +func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { + if h.input == nil { + return + } + h.mu.Lock() + if !h.running || entry.stopping || entry.publisherStopping || h.devices[entry.identity.DeviceID] != entry { + h.mu.Unlock() + return + } + if _, fast := entry.fastInput[endpoint]; !fast || entry.publishers[endpoint] != nil { + h.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(entry.ctx) + publisher := &inputPublisher{endpoint: endpoint, cancel: cancel, done: make(chan struct{})} + entry.publishers[endpoint] = publisher + h.mu.Unlock() + + go h.runInputPublisher(ctx, entry, publisher) +} + +func (h *Host) stopInputPublisher(entry *registeredDevice, endpoint uint8) bool { + h.mu.Lock() + publisher := entry.publishers[endpoint] + if publisher != nil { + delete(entry.publishers, endpoint) + publisher.cancel() + } + h.mu.Unlock() + if publisher == nil { + return false + } + <-publisher.done + return true +} + +func (h *Host) stopAllInputPublishers(entry *registeredDevice) []uint8 { + h.mu.RLock() + endpoints := make([]uint8, 0, len(entry.publishers)) + for endpoint := range entry.publishers { + endpoints = append(endpoints, endpoint) + } + h.mu.RUnlock() + for _, endpoint := range endpoints { + h.stopInputPublisher(entry, endpoint) + } + return endpoints +} + +func (h *Host) activeInputEndpoints(entry *registeredDevice) []uint8 { + h.mu.RLock() + defer h.mu.RUnlock() + endpoints := make([]uint8, 0, len(entry.activeInput)) + for endpoint, active := range entry.activeInput { + if active { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + +func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { + defer close(publisher.done) + var sequence uint64 + for { + payload := entry.device.HandleTransfer( + ctx, uint32(publisher.endpoint&0x0f), usbip.DirIn, nil) + if ctx.Err() != nil { + return + } + if len(payload) == 0 { + h.reportFatal(fmt.Errorf( + "device %d returned an empty interrupt-IN report for endpoint 0x%02x", + entry.identity.DeviceID, publisher.endpoint)) + return + } + h.mu.Lock() + sequence = entry.inputSequences[publisher.endpoint] + 1 + if sequence == 0 { + sequence = 1 + } + entry.inputSequences[publisher.endpoint] = sequence + h.mu.Unlock() + if err := h.input.SubmitInputReport(ctx, InputReport{ + DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, + EndpointAddress: publisher.endpoint, Sequence: sequence, Payload: payload, + }); err != nil { + if ctx.Err() != nil { + return + } + h.reportFatal(fmt.Errorf( + "submit native UDE input report for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + } +} + type dequeueResult struct { op Operation err error @@ -225,7 +376,16 @@ func (h *Host) Serve(ctx context.Context) error { runCtx, cancel := context.WithCancel(ctx) fatal := make(chan error, 1) h.runCtx, h.runCancel, h.fatal, h.running = runCtx, cancel, fatal, true + entries := make([]*registeredDevice, 0, len(h.devices)) + for _, entry := range h.devices { + entries = append(entries, entry) + } h.mu.Unlock() + for _, entry := range entries { + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + } defer func() { cancel() h.mu.Lock() @@ -237,8 +397,15 @@ func (h *Host) Serve(ctx context.Context) error { h.laneWG.Wait() h.cancelAllOperations() h.mu.Lock() + entries = entries[:0] + for _, entry := range h.devices { + entries = append(entries, entry) + } h.running, h.runCtx, h.runCancel, h.fatal = false, nil, nil, nil h.mu.Unlock() + for _, entry := range entries { + h.stopAllInputPublishers(entry) + } }() results := make(chan dequeueResult, h.workers*2) @@ -383,11 +550,31 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { } delete(pending, expected) if isLifecycleOperation(current.Kind) { + switch current.Kind { + case OperationEndpointPurge: + h.mu.Lock() + entry.activeInput[current.EndpointAddress] = false + h.mu.Unlock() + h.stopInputPublisher(entry, current.EndpointAddress) + case OperationDeviceD0Exit: + h.stopAllInputPublishers(entry) + } if err := h.processor.Lifecycle(lane.ctx, entry.device, current); err != nil { h.reportFatal(fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", lane.key.endpoint, current.EndpointSequence, err)) return } + switch current.Kind { + case OperationEndpointStart: + h.mu.Lock() + entry.activeInput[current.EndpointAddress] = true + h.mu.Unlock() + h.startInputPublisher(entry, current.EndpointAddress) + case OperationDeviceD0Entry: + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + } } else { if err := h.process(lane.ctx, entry.device, current); err != nil { h.reportFatal(fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 39e5ba3d..4b2750e8 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -22,6 +22,21 @@ type fakeHostDriver struct { completeErr error } +type fastInputDriver struct { + *fakeHostDriver + reports chan InputReport +} + +func (d *fastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { + report.Payload = append([]byte(nil), report.Payload...) + select { + case d.reports <- report: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func newFakeHostDriver() *fakeHostDriver { return &fakeHostDriver{ operations: make(chan Operation, 16), completions: make(chan Completion, 16), @@ -142,6 +157,91 @@ func hostTestDevice() usb.Device { }} } +type inputPublisherTestDevice struct { + descriptor usb.Descriptor + reports chan []byte +} + +func newInputPublisherTestDevice() *inputPublisherTestDevice { + base := hostTestDevice().GetDescriptor() + return &inputPublisherTestDevice{descriptor: *base, reports: make(chan []byte, 4)} +} + +func (d *inputPublisherTestDevice) HandleTransfer( + ctx context.Context, _ uint32, _ uint32, _ []byte, +) []byte { + select { + case report := <-d.reports: + return report + case <-ctx.Done(): + return nil + } +} + +func (d *inputPublisherTestDevice) GetDescriptor() *usb.Descriptor { return &d.descriptor } +func (*inputPublisherTestDevice) GetDeviceSpecificArgs() map[string]any { + return nil +} + +func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 44, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1, 2, 3, 4} + select { + case report := <-driver.reports: + if report.DeviceID != identity.DeviceID || report.Generation != identity.Generation || + report.EndpointAddress != 0x81 || report.Sequence != 1 || + string(report.Payload) != string([]byte{1, 2, 3, 4}) { + t.Fatalf("unexpected direct input report: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("interrupt-IN report did not use the direct publisher") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 2), resets: make(chan DeviceIdentity, 1)} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index b07d4f58..be37c8a3 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 3 + ABIMinor uint16 = 4 HeaderSize = 16 NegotiateRequestSize = 32 @@ -24,12 +24,14 @@ const ( IsoPacketSize = 16 OperationSize = 96 CompletionSize = 72 - StatsSize = 128 + InputReportSize = 48 + StatsSize = 144 MaxDevices = 32 MaxDescriptorBytes = 256 * 1024 MaxTransferBytes = 1024 * 1024 MaxIsoPackets = 1024 + MaxInputReportBytes = 4096 MaxPendingOperations = 4096 ) @@ -49,6 +51,7 @@ const ( CapabilityIsochronous Capabilities = 1 << iota CapabilityStreams CapabilityDeviceLifecycle + CapabilityInputReports ) type Header struct { @@ -352,6 +355,43 @@ type Completion struct { Payload []byte } +// InputReport is the ViGEm-style fast path for interrupt-IN endpoints. The +// host parks the Windows polling request in the kernel and user mode submits +// only a fresh, already encoded report. Audio, control, output, and lifecycle +// traffic deliberately remain on the ordered operation broker. +type InputReport struct { + DeviceID uint64 + Generation uint32 + EndpointAddress uint8 + Sequence uint64 + Payload []byte +} + +func (m InputReport) MarshalBinary() ([]byte, error) { + if m.DeviceID == 0 || m.Generation == 0 || m.EndpointAddress&0x80 == 0 || + m.Sequence == 0 || m.Sequence > math.MaxInt64 { + return nil, fmt.Errorf("%w: invalid input-report identity", ErrInvalidRange) + } + if len(m.Payload) == 0 || len(m.Payload) > MaxInputReportBytes { + return nil, ErrLimitExceeded + } + total := InputReportSize + len(m.Payload) + h, err := NewHeader(total) + if err != nil { + return nil, err + } + dst := make([]byte, total) + putHeader(dst, h) + binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) + binary.LittleEndian.PutUint32(dst[24:28], m.Generation) + dst[28] = m.EndpointAddress + binary.LittleEndian.PutUint32(dst[32:36], InputReportSize) + binary.LittleEndian.PutUint32(dst[36:40], uint32(len(m.Payload))) + binary.LittleEndian.PutUint64(dst[40:48], m.Sequence) + copy(dst[InputReportSize:], m.Payload) + return dst, nil +} + type Stats struct { OperationsDequeued uint64 OperationsCompleted uint64 @@ -369,6 +409,8 @@ type Stats struct { PendingOperations uint32 WaitingDequeues uint32 CleanupRetries uint32 + InputReportsSubmitted uint64 + InputReportsCompleted uint64 } func ParseStats(src []byte) (Stats, error) { @@ -396,6 +438,8 @@ func ParseStats(src []byte) (Stats, error) { PendingOperations: binary.LittleEndian.Uint32(src[116:120]), WaitingDequeues: binary.LittleEndian.Uint32(src[120:124]), CleanupRetries: binary.LittleEndian.Uint32(src[124:128]), + InputReportsSubmitted: binary.LittleEndian.Uint64(src[128:136]), + InputReportsCompleted: binary.LittleEndian.Uint64(src[136:144]), }, nil } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7e668dbf..f4aca7c5 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -12,7 +12,8 @@ func TestABISizes(t *testing.T) { "negotiate response": NegotiateResponseSize, "descriptor": DescriptorRecordSize, "create device": CreateDeviceSize, "identity": DeviceIdentitySize, "iso packet": IsoPacketSize, "operation": OperationSize, - "completion": CompletionSize, "stats": StatsSize, + "completion": CompletionSize, "input report": InputReportSize, + "stats": StatsSize, } { if got%8 != 0 { t.Fatalf("%s ABI size %d is not 8-byte aligned", name, got) @@ -132,6 +133,23 @@ func TestCompletionMarshalling(t *testing.T) { } } +func TestInputReportMarshalling(t *testing.T) { + raw, err := (InputReport{ + DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + Sequence: 11, Payload: []byte{1, 2, 3}, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if len(raw) != InputReportSize+3 || + binary.LittleEndian.Uint32(raw[32:36]) != InputReportSize || + binary.LittleEndian.Uint32(raw[36:40]) != 3 || + binary.LittleEndian.Uint64(raw[40:48]) != 11 || + string(raw[InputReportSize:]) != string([]byte{1, 2, 3}) { + t.Fatalf("invalid input-report wire layout: %x", raw) + } +} + func TestIdentityAndStatsLayout(t *testing.T) { identity, err := (DeviceIdentity{DeviceID: 0x1122334455667788, Generation: 7}).MarshalBinary() if err != nil { @@ -151,12 +169,15 @@ func TestIdentityAndStatsLayout(t *testing.T) { binary.LittleEndian.PutUint32(raw[112:116], 3) binary.LittleEndian.PutUint32(raw[116:120], 5) binary.LittleEndian.PutUint32(raw[120:124], 7) + binary.LittleEndian.PutUint64(raw[128:136], 37) + binary.LittleEndian.PutUint64(raw[136:144], 41) stats, err := ParseStats(raw) if err != nil { t.Fatal(err) } if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.NotificationEvents != 31 || - stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 { + stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 || + stats.InputReportsSubmitted != 37 || stats.InputReportsCompleted != 41 { t.Fatalf("unexpected stats: %+v", stats) } } diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index d41cb452..7cc84ca6 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -242,7 +242,6 @@ ViiperSlotMatches( Pending->State != ViiperUdePendingEmpty; } -static NTSTATUS ViiperValidateBrokerOwner( _In_ WDFDEVICE Controller, @@ -586,7 +585,6 @@ ViiperEvtUrbCancel( } } -static PURB ViiperGetUrb( _In_ WDFREQUEST Request @@ -620,7 +618,6 @@ ViiperGetTransferMdl( } } -static NTSTATUS ViiperCopyTransferBuffer( _In_ WDFREQUEST Request, diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index b170740c..c6dae091 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -16,6 +16,7 @@ #pragma alloc_text(PAGE, ViiperEvtEndpointAdd) #pragma alloc_text(PAGE, ViiperEvtDefaultEndpointAdd) #pragma alloc_text(PAGE, ViiperEvtVirtualDeviceCleanup) +#pragma alloc_text(PAGE, ViiperEvtEndpointCleanup) #endif static @@ -622,7 +623,9 @@ ViiperCreateEndpointQueue( WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, DispatchType); queueConfig.PowerManaged = WdfFalse; - queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; + if (DispatchType != WdfIoQueueDispatchManual) { + queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; + } WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, UDECXUSBENDPOINT); attributes.ParentObject = Endpoint; attributes.ExecutionLevel = WdfExecutionLevelPassive; @@ -636,6 +639,34 @@ ViiperCreateEndpointQueue( return STATUS_SUCCESS; } +VOID +ViiperEvtEndpointCleanup( + _In_ WDFOBJECT EndpointObject + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)EndpointObject; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + UCHAR address; + + PAGED_CODE(); + if (endpointContext->Device == WDF_NO_HANDLE) { + return; + } + deviceContext = ViiperGetDeviceContext(endpointContext->Device); + if (deviceContext->Controller == WDF_NO_HANDLE) { + return; + } + controllerContext = ViiperGetControllerContext(deviceContext->Controller); + address = endpointContext->Descriptor.bEndpointAddress; + WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + if (deviceContext->Endpoints[address] == endpoint) { + deviceContext->Endpoints[address] = WDF_NO_HANDLE; + } + WdfWaitLockRelease(controllerContext->DeviceLock); +} + NTSTATUS ViiperEvtEndpointAdd( _In_ UDECXUSBDEVICE Device, @@ -668,6 +699,8 @@ ViiperEvtEndpointAdd( WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_ENDPOINT_CONTEXT); attributes.ParentObject = Device; + attributes.EvtCleanupCallback = ViiperEvtEndpointCleanup; + attributes.ExecutionLevel = WdfExecutionLevelPassive; status = UdecxUsbEndpointCreate(&EndpointData->UdecxUsbEndpointInit, &attributes, &endpoint); if (!NT_SUCCESS(status)) { return status; @@ -679,10 +712,27 @@ ViiperEvtEndpointAdd( if (descriptor.bEndpointAddress == 0) { ViiperGetDeviceContext(Device)->DefaultEndpoint = endpoint; dispatchType = WdfIoQueueDispatchSequential; + } else if ((descriptor.bEndpointAddress & USB_ENDPOINT_DIRECTION_MASK) != 0 && + (descriptor.bmAttributes & USB_ENDPOINT_TYPE_MASK) == USB_ENDPOINT_TYPE_INTERRUPT) { + endpointContext->FastInput = TRUE; + dispatchType = WdfIoQueueDispatchManual; } else { dispatchType = WdfIoQueueDispatchParallel; } - return ViiperCreateEndpointQueue(endpoint, dispatchType); + status = ViiperCreateEndpointQueue(endpoint, dispatchType); + if (!NT_SUCCESS(status)) { + return status; + } + + { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + deviceContext->Endpoints[descriptor.bEndpointAddress] = endpoint; + WdfWaitLockRelease(controllerContext->DeviceLock); + } + return STATUS_SUCCESS; } NTSTATUS @@ -699,6 +749,160 @@ ViiperEvtDefaultEndpointAdd( return ViiperEvtEndpointAdd(Device, &endpointData); } +static +VOID +ViiperCompleteRetrievedInputUrb( + _In_ WDFREQUEST Request, + _In_ NTSTATUS Status + ) +{ + KIRQL previousIrql = KeGetCurrentIrql(); + BOOLEAN raised = previousIrql < DISPATCH_LEVEL; + + // The URB was parked in a manual queue and is therefore completed from a + // different call path than UdeCx's submit callback. Match usbip-win2's + // documented compatibility pattern without allocating a DPC per report. + if (raised) { + KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); + } + if (NT_SUCCESS(Status)) { + UdecxUrbComplete(Request, USBD_STATUS_SUCCESS); + } else { + UdecxUrbCompleteWithNtStatus(Request, Status); + } + if (raised) { + KeLowerIrql(previousIrql); + } +} + +NTSTATUS +ViiperSubmitInputReport( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + VIIPER_UDE_INPUT_REPORT *input; + UCHAR *payload; + size_t inputLength; + size_t payloadLength; + WDFFILEOBJECT ownerFile; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + WDFREQUEST urbRequest = WDF_NO_HANDLE; + PURB urb; + ULONG transferLength; + ULONG index; + NTSTATUS status; + + status = ViiperValidateBrokerOwner(controller, Request); + if (!NT_SUCCESS(status)) { + return status; + } + ownerFile = WdfRequestGetFileObject(Request); + status = WdfRequestRetrieveInputBuffer( + Request, sizeof(*input), (PVOID *)&input, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + status = WdfRequestRetrieveOutputBuffer( + Request, 1, (PVOID *)&payload, &payloadLength); + if (!NT_SUCCESS(status)) { + return status; + } + if (inputLength != sizeof(*input) || + input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Size != sizeof(*input) + input->PayloadLength || + input->DeviceId == 0 || input->Generation == 0 || input->Sequence == 0 || + input->Sequence > MAXLONGLONG || + (input->EndpointAddress & USB_ENDPOINT_DIRECTION_MASK) == 0 || + input->PayloadOffset != sizeof(*input) || input->PayloadLength == 0 || + input->PayloadLength > VIIPER_UDE_MAX_INPUT_REPORT_BYTES || + payloadLength != input->PayloadLength || + input->Reserved1[0] != 0 || input->Reserved1[1] != 0 || input->Reserved1[2] != 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + + WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE device = controllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->OwnerFile != ownerFile || + deviceContext->DeviceId != input->DeviceId || + deviceContext->Generation != input->Generation || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + continue; + } + endpoint = deviceContext->Endpoints[input->EndpointAddress]; + if (endpoint != WDF_NO_HANDLE) { + WdfObjectReference(endpoint); + } + break; + } + WdfWaitLockRelease(controllerContext->DeviceLock); + if (endpoint == WDF_NO_HANDLE) { + return STATUS_NOT_FOUND; + } + + endpointContext = ViiperGetEndpointContext(endpoint); + if (!endpointContext->FastInput || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->LastInputSequence, 0, 0)) { + WdfObjectDereference(endpoint); + return STATUS_INVALID_DEVICE_STATE; + } + + InterlockedIncrement64(&controllerContext->InputReportsSubmitted); + status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); + if (!NT_SUCCESS(status)) { + WdfObjectDereference(endpoint); + // A producer update is allowed to arrive before Windows posts its + // next interrupt poll. This is normal latest-state coalescing, not a + // session fault; the following report services the following poll. + return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; + } + urb = ViiperGetUrb(urbRequest); + if (urb == NULL || + (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && + urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || + (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { + ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_INVALID_DEVICE_REQUEST); + WdfObjectDereference(endpoint); + return STATUS_INVALID_DEVICE_REQUEST; + } + transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + if (input->PayloadLength > transferLength) { + ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_BUFFER_TOO_SMALL); + WdfObjectDereference(endpoint); + return STATUS_BUFFER_TOO_SMALL; + } + status = ViiperCopyTransferBuffer( + urbRequest, urb, payload, input->PayloadLength, TRUE); + if (!NT_SUCCESS(status)) { + ViiperCompleteRetrievedInputUrb(urbRequest, status); + WdfObjectDereference(endpoint); + return status; + } + + urb->UrbBulkOrInterruptTransfer.TransferBufferLength = input->PayloadLength; + UdecxUrbSetBytesCompleted(urbRequest, input->PayloadLength); + InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); + InterlockedAdd64(&controllerContext->BytesFromDevice, input->PayloadLength); + InterlockedIncrement64(&controllerContext->InputReportsCompleted); + ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_SUCCESS); + WdfObjectDereference(endpoint); + return STATUS_SUCCESS; +} + VOID ViiperEvtEndpointReset( _In_ UDECXUSBENDPOINT Endpoint, diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 0b6285d2..5dd994cb 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -84,7 +84,9 @@ ViiperHandleNegotiate( output->Header.Size = sizeof(*output); output->ClientNonce = fileContext->ClientNonce; output->DriverNonce = fileContext->DriverNonce; - output->Capabilities = VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE; + output->Capabilities = VIIPER_UDE_CAP_ISOCHRONOUS | + VIIPER_UDE_CAP_DEVICE_LIFECYCLE | + VIIPER_UDE_CAP_INPUT_REPORTS; output->MaxDevices = VIIPER_UDE_MAX_DEVICES; output->MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; output->MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; @@ -143,6 +145,10 @@ ViiperHandleQueryStats( output->PendingOperations = (ULONG)InterlockedCompareExchange(&context->PendingOperations, 0, 0); output->WaitingDequeues = (ULONG)InterlockedCompareExchange(&context->WaitingDequeueCount, 0, 0); output->CleanupRetries = (ULONG)InterlockedCompareExchange(&context->CleanupRetries, 0, 0); + output->InputReportsSubmitted = + (ULONGLONG)ViiperReadCounter(&context->InputReportsSubmitted); + output->InputReportsCompleted = + (ULONGLONG)ViiperReadCounter(&context->InputReportsCompleted); WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } @@ -180,6 +186,9 @@ ViiperEvtIoDeviceControl( case IOCTL_VIIPER_UDE_COMPLETE_OPERATION: status = ViiperCompleteOperation(Queue, Request); break; + case IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT: + status = ViiperSubmitInputReport(Queue, Request); + break; default: status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) ? STATUS_PENDING diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 07f2739c..a25539de 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -101,6 +101,8 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 QueueExhaustions; volatile LONG64 NotificationEventsDelivered; volatile LONG64 NotificationEventOverflows; + volatile LONG64 InputReportsSubmitted; + volatile LONG64 InputReportsCompleted; volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; @@ -130,6 +132,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { volatile LONG ActiveCounted; volatile LONG OwnerReferenced; UDECXUSBENDPOINT DefaultEndpoint; + UDECXUSBENDPOINT Endpoints[256]; volatile LONG64 EndpointSequences[256]; } VIIPER_UDE_DEVICE_CONTEXT; @@ -140,6 +143,8 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { WDFQUEUE Queue; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; + volatile LONG64 LastInputSequence; + BOOLEAN FastInput; } VIIPER_UDE_ENDPOINT_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_ENDPOINT_CONTEXT, ViiperGetEndpointContext) @@ -167,6 +172,7 @@ EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; +EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; NTSTATUS ViiperCreateQueues(_In_ WDFDEVICE Device); NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); @@ -180,6 +186,15 @@ VOID ViiperCompleteUnownedUrbAsync( _In_ WDFDEVICE Controller, _In_ WDFREQUEST Request, _In_ NTSTATUS Status); +NTSTATUS ViiperSubmitInputReport(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); +NTSTATUS ViiperValidateBrokerOwner(_In_ WDFDEVICE Controller, _In_ WDFREQUEST Request); +PURB ViiperGetUrb(_In_ WDFREQUEST Request); +NTSTATUS ViiperCopyTransferBuffer( + _In_ WDFREQUEST Request, + _In_ PURB Urb, + _Inout_updates_bytes_(Length) UCHAR *Buffer, + _In_ ULONG Length, + _In_ BOOLEAN ToUrb); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); NTSTATUS ViiperQueueEndpointLifecycleEvent( diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 3b24ffeb..185cccb9 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,17 +33,19 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(3) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(4) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) #define VIIPER_UDE_MAX_TRANSFER_BYTES VIIPER_UDE_UINT32_C(1048576) #define VIIPER_UDE_MAX_ISO_PACKETS VIIPER_UDE_UINT32_C(1024) +#define VIIPER_UDE_MAX_INPUT_REPORT_BYTES VIIPER_UDE_UINT32_C(4096) #define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) #define VIIPER_UDE_CAP_ISOCHRONOUS VIIPER_UDE_UINT32_C(0x00000001) #define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) #define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) +#define VIIPER_UDE_CAP_INPUT_REPORTS VIIPER_UDE_UINT32_C(0x00000008) #if defined(_WIN32) #define VIIPER_UDE_IOCTL_BASE 0x900 @@ -53,6 +55,7 @@ typedef int32_t VIIPER_UDE_INT32; #define IOCTL_VIIPER_UDE_DEQUEUE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 3, METHOD_OUT_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_VIIPER_UDE_COMPLETE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 4, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_VIIPER_UDE_QUERY_STATS CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 5, METHOD_BUFFERED, FILE_READ_DATA) +#define IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 6, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) #endif #pragma pack(push, 1) @@ -179,6 +182,17 @@ typedef struct VIIPER_UDE_COMPLETION { VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_COMPLETION; +typedef struct VIIPER_UDE_INPUT_REPORT { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Reserved1[3]; + VIIPER_UDE_UINT32 PayloadOffset; + VIIPER_UDE_UINT32 PayloadLength; + VIIPER_UDE_UINT64 Sequence; +} VIIPER_UDE_INPUT_REPORT; + typedef struct VIIPER_UDE_STATS { VIIPER_UDE_HEADER Header; VIIPER_UDE_UINT64 OperationsDequeued; @@ -197,6 +211,8 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT32 PendingOperations; VIIPER_UDE_UINT32 WaitingDequeues; VIIPER_UDE_UINT32 CleanupRetries; + VIIPER_UDE_UINT64 InputReportsSubmitted; + VIIPER_UDE_UINT64 InputReportsCompleted; } VIIPER_UDE_STATS; #pragma pack(pop) @@ -211,7 +227,8 @@ static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENT static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -static_assert(sizeof(VIIPER_UDE_STATS) == 128, "VIIPER_UDE_STATS ABI drift"); +static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L _Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); @@ -222,5 +239,6 @@ _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDEN _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); _Static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_STATS) == 128, "VIIPER_UDE_STATS ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); #endif From 6d65d696d878e7e54efbed9fbc058ef7008fa7ee Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:19:38 -0500 Subject: [PATCH 039/240] Remove input-report wire copies from the hot path Encode the fixed UDE input metadata directly into a stack-sized buffer and pass the controller-owned report payload to METHOD_IN_DIRECT without concatenating or copying it. Preserve the allocating MarshalBinary form for protocol tests and add a zero-allocation metadata regression gate. --- internal/transport/udecx/client_windows.go | 6 +++--- internal/transport/udecx/protocol.go | 22 +++++++++++++++++----- internal/transport/udecx/protocol_test.go | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index aefb55f6..adcf05f1 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -308,11 +308,11 @@ func (c *Client) Complete(ctx context.Context, completion Completion) error { } func (c *Client) SubmitInputReport(ctx context.Context, report InputReport) error { - request, err := report.MarshalBinary() - if err != nil { + var metadata [InputReportSize]byte + if err := report.marshalMetadata(metadata[:]); err != nil { return err } - _, err = c.ioctl(ctx, ioctlSubmitInputReport, request[:InputReportSize], request[InputReportSize:]) + _, err := c.ioctl(ctx, ioctlSubmitInputReport, metadata[:], report.Payload) return err } diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index be37c8a3..1ee0e430 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -367,20 +367,22 @@ type InputReport struct { Payload []byte } -func (m InputReport) MarshalBinary() ([]byte, error) { +func (m InputReport) marshalMetadata(dst []byte) error { if m.DeviceID == 0 || m.Generation == 0 || m.EndpointAddress&0x80 == 0 || m.Sequence == 0 || m.Sequence > math.MaxInt64 { - return nil, fmt.Errorf("%w: invalid input-report identity", ErrInvalidRange) + return fmt.Errorf("%w: invalid input-report identity", ErrInvalidRange) } if len(m.Payload) == 0 || len(m.Payload) > MaxInputReportBytes { - return nil, ErrLimitExceeded + return ErrLimitExceeded } total := InputReportSize + len(m.Payload) h, err := NewHeader(total) if err != nil { - return nil, err + return err + } + if len(dst) != InputReportSize { + return ErrInvalidSize } - dst := make([]byte, total) putHeader(dst, h) binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) binary.LittleEndian.PutUint32(dst[24:28], m.Generation) @@ -388,6 +390,16 @@ func (m InputReport) MarshalBinary() ([]byte, error) { binary.LittleEndian.PutUint32(dst[32:36], InputReportSize) binary.LittleEndian.PutUint32(dst[36:40], uint32(len(m.Payload))) binary.LittleEndian.PutUint64(dst[40:48], m.Sequence) + return nil +} + +func (m InputReport) MarshalBinary() ([]byte, error) { + var metadata [InputReportSize]byte + if err := m.marshalMetadata(metadata[:]); err != nil { + return nil, err + } + dst := make([]byte, InputReportSize+len(m.Payload)) + copy(dst[:InputReportSize], metadata[:]) copy(dst[InputReportSize:], m.Payload) return dst, nil } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index f4aca7c5..c43e4153 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -150,6 +150,22 @@ func TestInputReportMarshalling(t *testing.T) { } } +func TestInputReportMetadataEncodingDoesNotAllocate(t *testing.T) { + report := InputReport{ + DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + Sequence: 11, Payload: []byte{1, 2, 3}, + } + var metadata [InputReportSize]byte + allocations := testing.AllocsPerRun(1000, func() { + if err := report.marshalMetadata(metadata[:]); err != nil { + panic(err) + } + }) + if allocations != 0 { + t.Fatalf("input-report metadata encoding allocated %.2f objects per call", allocations) + } +} + func TestIdentityAndStatsLayout(t *testing.T) { identity, err := (DeviceIdentity{DeviceID: 0x1122334455667788, Generation: 7}).MarshalBinary() if err != nil { From db2effd023e03f805879a29fc77f3620b9cc3eb1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:23:50 -0500 Subject: [PATCH 040/240] Prove native input lifecycle recovery Exercise the interrupt-IN fast lane across endpoint purge/restart, device D0 exit/entry, and failed transactional removal. Verify that no input escapes while inactive, publishers resume only after the matching lifecycle event, and report sequences remain monotonic across every recovery path. --- internal/transport/udecx/host_test.go | 223 ++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 4b2750e8..727dc53b 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -242,6 +242,229 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { } } +func TestHostRestoresInputPublisherAfterFailedTransactionalRemoval(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 45, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.mu.Lock() + driver.destroyErr = errors.New("plug-out still pending") + driver.mu.Unlock() + if err = host.Unregister(context.Background(), identity); err == nil { + t.Fatal("failed removal unexpectedly succeeded") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher was not restored after failed removal") + } + + driver.mu.Lock() + driver.destroyErr = nil + driver.mu.Unlock() + if err = host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 46, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, Kind: OperationDeviceD0Exit, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 exit was not processed") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("report submitted while device was outside D0: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 2, Kind: OperationDeviceD0Entry, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 entry was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("D0-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after D0 entry") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 2) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 47, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + <-processor.lifecycle + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("report submitted while endpoint was purged: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("endpoint-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint restart") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 2), resets: make(chan DeviceIdentity, 1)} From e05613c508a96fe5a1b230f428821bf3d190305d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:26:16 -0500 Subject: [PATCH 041/240] Isolate native input from broker head-of-line stalls Turn the default KMDF queue into a parallel router, keep mutation and broker operations on a serialized control queue, and dispatch direct interrupt-IN submissions through an independent parallel queue. This preserves lifecycle ordering while preventing large media completions from delaying fresh input reports. Purge every queue during controller teardown. --- docs/architecture/native-udecx.md | 4 +++ native/udecx/driver/Controller.c | 27 ++++++++++++++-- native/udecx/driver/Ioctl.c | 52 ++++++++++++++++++++++++++++++- native/udecx/driver/ViiperUde.h | 4 +++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index c4784d2b..a04aadfa 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -132,6 +132,10 @@ unplug all converge on the same idempotent purge path. - A controller-level lock protects the device table and owner registration. - Each device has a short-held state lock and independent endpoint queues. +- The controller's default KMDF queue only routes requests: interrupt-IN + submissions run on an independent parallel queue, while mutation, broker, + and lifecycle IOCTLs retain their serialized control queue. Large media + completions therefore cannot head-of-line block fresh controller input. - Media callbacks do not take the controller lock. - Interrupt-IN queues are manual and completed from fresh input snapshots; output and media endpoints retain independent ordered queues. diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 2332a042..20f5c5fc 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -212,6 +212,12 @@ ViiperEvtControllerCleanup( if (context->DefaultQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->DefaultQueue); } + if (context->ControlQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->ControlQueue); + } + if (context->InputQueue != WDF_NO_HANDLE) { + WdfIoQueuePurgeSynchronously(context->InputQueue); + } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); @@ -354,15 +360,32 @@ ViiperCreateQueues( WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = Device; attributes.ExecutionLevel = WdfExecutionLevelPassive; + attributes.SynchronizationScope = WdfSynchronizationScopeNone; - WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchSequential); + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel); queueConfig.PowerManaged = WdfFalse; - queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl; + queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControlRoute; status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->DefaultQueue); if (!NT_SUCCESS(status)) { return status; } + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchSequential); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl; + status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->ControlQueue); + if (!NT_SUCCESS(status)) { + return status; + } + + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchParallel); + queueConfig.PowerManaged = WdfFalse; + queueConfig.EvtIoDeviceControl = ViiperEvtInputIoDeviceControl; + status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->InputQueue); + if (!NT_SUCCESS(status)) { + return status; + } + WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual); queueConfig.PowerManaged = WdfFalse; return WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->WaitingDequeues); diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 5dd994cb..0bef1318 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -153,6 +153,54 @@ ViiperHandleQueryStats( return STATUS_SUCCESS; } +VOID +ViiperEvtIoDeviceControlRoute( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + WDFQUEUE destination = IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT + ? context->InputQueue + : context->ControlQueue; + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + // The default queue performs routing only. Keeping it parallel prevents a + // large media completion or lifecycle mutation on the serialized control + // queue from delaying an already encoded interrupt-IN report. + status = WdfRequestForwardToIoQueue(Request, destination); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } +} + +VOID +ViiperEvtInputIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode + ) +{ + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + status = IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT + ? ViiperSubmitInputReport(Queue, Request) + : STATUS_INVALID_DEVICE_REQUEST; + WdfRequestComplete(Request, status); +} + VOID ViiperEvtIoDeviceControl( _In_ WDFQUEUE Queue, @@ -187,7 +235,9 @@ ViiperEvtIoDeviceControl( status = ViiperCompleteOperation(Queue, Request); break; case IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT: - status = ViiperSubmitInputReport(Queue, Request); + // The router sends this IOCTL to the independent parallel input queue. + // Reject it here rather than silently restoring head-of-line blocking. + status = STATUS_INVALID_DEVICE_REQUEST; break; default: status = UdecxWdfDeviceTryHandleUserIoctl(WdfIoQueueGetDevice(Queue), Request) diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index a25539de..10916d21 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -83,6 +83,8 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { ULONG NotificationCount; WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; + WDFQUEUE ControlQueue; + WDFQUEUE InputQueue; WDFQUEUE WaitingDequeues; WDFTIMER OwnerCleanupTimer; BOOLEAN CleanupInProgress; @@ -157,7 +159,9 @@ EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtControllerCleanup; EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; EVT_WDF_TIMER ViiperEvtOwnerCleanupRetry; +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControlRoute; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtInputIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; From 06f8a8ce0996e8c4a136f7faee1a3fafade07944 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:27:34 -0500 Subject: [PATCH 042/240] Make the native IOCP pump handle immutable Pass the completion-port handle into the pump instead of rereading the Client field while Close invalidates it. This removes a Windows-only data race from session shutdown without changing the exact-overlapped routing contract. --- internal/transport/udecx/client_windows.go | 6 +++--- internal/transport/udecx/client_windows_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index adcf05f1..f3a30435 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -117,7 +117,7 @@ func Open(ctx context.Context) (*Client, error) { client.requestPool.New = func() any { return &ioRequest{done: make(chan ioCompletion, 1)} } - go client.runCompletionPort() + go client.runCompletionPort(completionPort) if err = client.negotiate(ctx); err != nil { _ = client.Close() return nil, err @@ -152,14 +152,14 @@ func (c *Client) Close() error { return errors.Join(windows.CloseHandle(handle), windows.CloseHandle(completionPort)) } -func (c *Client) runCompletionPort() { +func (c *Client) runCompletionPort(completionPort windows.Handle) { defer close(c.pumpDone) for { var transferred uint32 var key uintptr var overlapped *windows.Overlapped err := windows.GetQueuedCompletionStatus( - c.completionPort, &transferred, &key, &overlapped, windows.INFINITE) + completionPort, &transferred, &key, &overlapped, windows.INFINITE) if overlapped == nil { if key == completionPortCloseKey { return diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 6de7dc68..0d6133e4 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -92,7 +92,7 @@ func TestCompletionPortRoutesExactOverlappedRequest(t *testing.T) { t.Fatal(err) } client := &Client{completionPort: port, pumpDone: make(chan struct{})} - go client.runCompletionPort() + go client.runCompletionPort(port) request := &ioRequest{done: make(chan ioCompletion, 1)} if err := windows.PostQueuedCompletionStatus(port, 547, 0, &request.overlapped); err != nil { From 47f4953c1b052a2cbd34ba3e03ae589e324c037c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:29:50 -0500 Subject: [PATCH 043/240] Serialize direct input per native endpoint Keep the controller-wide input queue parallel, but give each fast interrupt-IN endpoint its own passive lock. Claim input sequences at acceptance so concurrent same-pad submissions cannot complete out of order and coalesced reports cannot be replayed, while different controllers remain fully concurrent. --- docs/architecture/native-udecx.md | 3 +++ native/udecx/driver/Device.c | 28 +++++++++++++++++++++++++--- native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index a04aadfa..4913763c 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -136,6 +136,9 @@ unplug all converge on the same idempotent purge path. submissions run on an independent parallel queue, while mutation, broker, and lifecycle IOCTLs retain their serialized control queue. Large media completions therefore cannot head-of-line block fresh controller input. +- Each fast interrupt-IN endpoint has its own passive lock. Different + controllers publish concurrently, while accidental concurrent submissions + for one endpoint cannot reorder reports or replay a coalesced sequence. - Media callbacks do not take the controller lock. - Interrupt-IN queues are manual and completed from fresh input snapshots; output and media endpoints retain independent ordered queues. diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index c6dae091..fd94a92c 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -716,6 +716,12 @@ ViiperEvtEndpointAdd( (descriptor.bmAttributes & USB_ENDPOINT_TYPE_MASK) == USB_ENDPOINT_TYPE_INTERRUPT) { endpointContext->FastInput = TRUE; dispatchType = WdfIoQueueDispatchManual; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWaitLockCreate(&attributes, &endpointContext->InputLock); + if (!NT_SUCCESS(status)) { + return status; + } } else { dispatchType = WdfIoQueueDispatchParallel; } @@ -854,16 +860,29 @@ ViiperSubmitInputReport( endpointContext = ViiperGetEndpointContext(endpoint); if (!endpointContext->FastInput || - InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + endpointContext->InputLock == WDF_NO_HANDLE) { + WdfObjectDereference(endpoint); + return STATUS_INVALID_DEVICE_STATE; + } + + // InputQueue is parallel so independent controllers never block one + // another. Serialize only this endpoint, preserving report order even if + // a faulty or hostile owner submits concurrent updates for the same pad. + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( &endpointContext->LastInputSequence, 0, 0)) { + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_INVALID_DEVICE_STATE; } - + // Claim the sequence when the report is accepted, including when no host + // poll is parked. That makes latest-state coalescing replay-safe. + InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); InterlockedIncrement64(&controllerContext->InputReportsSubmitted); status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); if (!NT_SUCCESS(status)) { + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); // A producer update is allowed to arrive before Windows posts its // next interrupt poll. This is normal latest-state coalescing, not a @@ -876,12 +895,14 @@ ViiperSubmitInputReport( urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_INVALID_DEVICE_REQUEST); + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_INVALID_DEVICE_REQUEST; } transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; if (input->PayloadLength > transferLength) { ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_BUFFER_TOO_SMALL); + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_BUFFER_TOO_SMALL; } @@ -889,16 +910,17 @@ ViiperSubmitInputReport( urbRequest, urb, payload, input->PayloadLength, TRUE); if (!NT_SUCCESS(status)) { ViiperCompleteRetrievedInputUrb(urbRequest, status); + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return status; } urb->UrbBulkOrInterruptTransfer.TransferBufferLength = input->PayloadLength; UdecxUrbSetBytesCompleted(urbRequest, input->PayloadLength); - InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); InterlockedAdd64(&controllerContext->BytesFromDevice, input->PayloadLength); InterlockedIncrement64(&controllerContext->InputReportsCompleted); ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_SUCCESS); + WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 10916d21..2a31d688 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -143,6 +143,7 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceCon typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; + WDFWAITLOCK InputLock; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; volatile LONG64 LastInputSequence; From eb6872e666a82bf485677a3847be18d4ea2c3867 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:36:18 -0500 Subject: [PATCH 044/240] Preserve native endpoint admission order --- docs/architecture/native-udecx.md | 9 +++++++ native/udecx/driver/Broker.c | 44 ++++++++++++++++++++++++++++++- native/udecx/driver/Device.c | 25 +++++++++++++++--- native/udecx/driver/ViiperUde.h | 2 ++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 4913763c..bb913222 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -139,9 +139,18 @@ unplug all converge on the same idempotent purge path. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. +- Parallel media callbacks receive a per-endpoint admission sequence under the + broker lock. An URB cannot publish ahead of an earlier live unpublished + admission; cancellation retires the admission before dispatch resumes, so + the public endpoint sequence remains contiguous without limiting media to + one in-flight URB. - Media callbacks do not take the controller lock. - Interrupt-IN queues are manual and completed from fresh input snapshots; output and media endpoints retain independent ordered queues. +- A direct input report that was already submitted when D0 exit, unplug, or + endpoint purge begins is acknowledged and discarded at that exact lifecycle + boundary. Stale generations and replayed sequences remain hard failures, so + normal teardown cannot fault the exclusive broker session. - UDE callbacks never wait on user mode while holding a WDF lock. - Blocking work is represented by cancelable WDF requests, not sleeping kernel threads. diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 7cc84ca6..7a25f815 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -12,6 +12,8 @@ EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; +static VOID ViiperDispatchAvailable(_In_ WDFDEVICE Controller); + typedef struct VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT { WDFREQUEST Request; NTSTATUS Status; @@ -218,6 +220,7 @@ ViiperClearSlotLocked( pending->Endpoint = WDF_NO_HANDLE; pending->Token = 0; pending->DeviceId = 0; + pending->AdmissionSequence = 0; pending->DeviceGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->AbortPending = FALSE; @@ -529,6 +532,11 @@ ViiperAllocatePendingSlot( pending->Endpoint = Endpoint; pending->Token = ((ULONGLONG)pending->Generation << 32) | (index + 1); pending->DeviceId = deviceContext->DeviceId; + ++endpointContext->NextAdmissionSequence; + if (endpointContext->NextAdmissionSequence == 0) { + ++endpointContext->NextAdmissionSequence; + } + pending->AdmissionSequence = endpointContext->NextAdmissionSequence; pending->DeviceGeneration = deviceContext->Generation; pending->State = ViiperUdePendingPreparing; pending->AbortPending = FALSE; @@ -550,6 +558,39 @@ ViiperAllocatePendingSlot( return status; } +static +BOOLEAN +ViiperHasEarlierUnpublishedAdmissionLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG CandidateSlot + ) +{ + const VIIPER_UDE_PENDING_SLOT *candidate = + &ControllerContext->PendingSlots[CandidateSlot]; + ULONG index; + + for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { + const VIIPER_UDE_PENDING_SLOT *other; + if (index == CandidateSlot) { + continue; + } + other = &ControllerContext->PendingSlots[index]; + if (other->State == ViiperUdePendingEmpty || other->PublishedToOwner || + other->AbortPending || + other->State == ViiperUdePendingCompleting || + other->State == ViiperUdePendingDpcCompletion || + other->DeviceId != candidate->DeviceId || + other->DeviceGeneration != candidate->DeviceGeneration || + other->EndpointAddress != candidate->EndpointAddress || + other->AdmissionSequence == 0 || + other->AdmissionSequence >= candidate->AdmissionSequence) { + continue; + } + return TRUE; + } + return FALSE; +} + VOID ViiperEvtUrbCancel( _In_ WDFREQUEST Request @@ -979,7 +1020,8 @@ ViiperDispatchAvailable( ULONG candidate = (controllerContext->NextPendingSlot + index) % VIIPER_UDE_MAX_PENDING_OPERATIONS; VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; - if (pending->State == ViiperUdePendingQueued) { + if (pending->State == ViiperUdePendingQueued && + !ViiperHasEarlierUnpublishedAdmissionLocked(controllerContext, candidate)) { status = WdfIoQueueRetrieveNextRequest( controllerContext->WaitingDequeues, &dequeueRequest); if (!NT_SUCCESS(status)) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index fd94a92c..165b4f6e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -801,6 +801,7 @@ ViiperSubmitInputReport( ULONG transferLength; ULONG index; NTSTATUS status; + BOOLEAN lifecycleDrop = FALSE; status = ViiperValidateBrokerOwner(controller, Request); if (!NT_SUCCESS(status)) { @@ -843,10 +844,13 @@ ViiperSubmitInputReport( deviceContext = ViiperGetDeviceContext(device); if (deviceContext->OwnerFile != ownerFile || deviceContext->DeviceId != input->DeviceId || - deviceContext->Generation != input->Generation || - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + deviceContext->Generation != input->Generation) { continue; } + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + lifecycleDrop = TRUE; + break; + } endpoint = deviceContext->Endpoints[input->EndpointAddress]; if (endpoint != WDF_NO_HANDLE) { WdfObjectReference(endpoint); @@ -854,6 +858,13 @@ ViiperSubmitInputReport( break; } WdfWaitLockRelease(controllerContext->DeviceLock); + if (lifecycleDrop) { + // A report already submitted by the owner may cross the D0/unplug + // boundary before the ordered lifecycle notification cancels its + // publisher. It is stale latest-state data, not a broken owner + // session. Acknowledge and discard it exactly at that boundary. + return STATUS_SUCCESS; + } if (endpoint == WDF_NO_HANDLE) { return STATUS_NOT_FOUND; } @@ -869,8 +880,14 @@ ViiperSubmitInputReport( // another. Serialize only this endpoint, preserving report order even if // a faulty or hostile owner submits concurrent updates for the same pad. WdfWaitLockAcquire(endpointContext->InputLock, NULL); - if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || - input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( + if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { + WdfWaitLockRelease(endpointContext->InputLock); + WdfObjectDereference(endpoint); + // Endpoint purge and restart preserve the device generation. Do not + // turn the one report racing purge into a fatal user-mode session. + return STATUS_SUCCESS; + } + if (input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( &endpointContext->LastInputSequence, 0, 0)) { WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 2a31d688..41e459e4 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -32,6 +32,7 @@ typedef struct VIIPER_UDE_PENDING_SLOT { UDECXUSBENDPOINT Endpoint; ULONGLONG Token; ULONGLONG DeviceId; + ULONGLONG AdmissionSequence; ULONG Generation; ULONG DeviceGeneration; VIIPER_UDE_PENDING_STATE State; @@ -147,6 +148,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; volatile LONG64 LastInputSequence; + ULONGLONG NextAdmissionSequence; BOOLEAN FastInput; } VIIPER_UDE_ENDPOINT_CONTEXT; From 0e39f06e7c41ebc774e27c87d7c848006672a720 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:41:15 -0500 Subject: [PATCH 045/240] Harden native UDE protocol boundaries --- .github/workflows/native-ude.yml | 2 + internal/transport/udecx/protocol.go | 3 ++ internal/transport/udecx/protocol_test.go | 19 +++++++++ native/udecx/driver/Broker.c | 20 ++++++++- native/udecx/driver/Device.c | 50 ++++++++++++++++++++--- native/udecx/driver/Ioctl.c | 8 +++- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 334e1484..70649c1e 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -40,6 +40,8 @@ jobs: run: go test ./... - name: Vet complete VIIPER tree run: go vet ./... + - name: Fuzz native protocol decoders + run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=20s ./internal/transport/udecx race: runs-on: ubuntu-latest diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 1ee0e430..d4a4045b 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -89,6 +89,9 @@ func ParseHeader(src []byte) (Header, error) { if h.Minor != ABIMinor { return Header{}, fmt.Errorf("%w: driver=%d client=%d", ErrIncompatibleMinor, h.Minor, ABIMinor) } + if h.Flags != 0 { + return Header{}, fmt.Errorf("%w: unsupported header flags %#x", ErrInvalidRange, h.Flags) + } if h.Size < HeaderSize || uint64(h.Size) > uint64(len(src)) { return Header{}, ErrInvalidSize } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index c43e4153..7b98fb7d 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -38,6 +38,7 @@ func TestHeaderRejectsMalformedInput(t *testing.T) { {"magic", func(b []byte) []byte { binary.LittleEndian.PutUint32(b, 0); return b }, ErrBadMagic}, {"major", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[4:6], ABIMajor+1); return b }, ErrIncompatibleMajor}, {"minor", func(b []byte) []byte { binary.LittleEndian.PutUint16(b[6:8], ABIMinor+1); return b }, ErrIncompatibleMinor}, + {"flags", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[12:16], 1); return b }, ErrInvalidRange}, {"size below header", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 15); return b }, ErrInvalidSize}, {"size beyond buffer", func(b []byte) []byte { binary.LittleEndian.PutUint32(b[8:12], 17); return b }, ErrInvalidSize}, } @@ -210,3 +211,21 @@ func FuzzParseOperation(f *testing.F) { _, _ = ParseOperation(raw) }) } + +func FuzzProtocolDecoders(f *testing.F) { + f.Add([]byte{}) + negotiation := make([]byte, NegotiateResponseSize) + h, _ := NewHeader(len(negotiation)) + putHeader(negotiation, h) + f.Add(negotiation) + stats := make([]byte, StatsSize) + h, _ = NewHeader(len(stats)) + putHeader(stats, h) + f.Add(stats) + f.Fuzz(func(t *testing.T, raw []byte) { + _, _ = ParseHeader(raw) + _, _ = ParseNegotiateResponse(raw) + _, _ = ParseStats(raw) + _, _ = ParseOperation(raw) + }) +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 7a25f815..1b7c52b6 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1252,6 +1252,8 @@ ViiperCompleteOperation( ULONG slot; ULONG index; ULONG isoPayloadLimit; + ULONG isoBytes; + ULONG expectedSize; NTSTATUS status; BOOLEAN expectedLateAbort = FALSE; BOOLEAN queued; @@ -1268,11 +1270,25 @@ ViiperCompleteOperation( if (inputLength != sizeof(*completion) || completion->Header.Magic != VIIPER_UDE_MAGIC || completion->Header.Major != VIIPER_UDE_ABI_MAJOR || completion->Header.Minor != VIIPER_UDE_ABI_MINOR || + completion->Header.Flags != 0 || completion->Header.Size < sizeof(*completion) || completion->Token == 0 || completion->DeviceId == 0 || completion->Generation == 0 || completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || completion->PayloadLength > VIIPER_UDE_MAX_TRANSFER_BYTES || - completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS) { + completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS || + completion->Reserved != 0) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + isoBytes = completion->IsoPacketCount * sizeof(VIIPER_UDE_ISO_PACKET); + if (completion->PayloadLength > MAXULONG - sizeof(*completion) - isoBytes) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INTEGER_OVERFLOW; + } + expectedSize = sizeof(*completion) + isoBytes + completion->PayloadLength; + if (completion->Header.Size != expectedSize || + completion->IsoPacketsOffset != sizeof(*completion) || + completion->PayloadOffset != sizeof(*completion) + isoBytes) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } @@ -1286,7 +1302,7 @@ ViiperCompleteOperation( } if (!ViiperRangeValid( completion->IsoPacketsOffset, - completion->IsoPacketCount * sizeof(VIIPER_UDE_ISO_PACKET), + isoBytes, completion->Header.Size) || !ViiperRangeValid( completion->PayloadOffset, completion->PayloadLength, completion->Header.Size) || diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 165b4f6e..e3d9281d 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -30,6 +30,33 @@ ViiperRangeValid( return Offset <= Total && Length <= Total - Offset; } +static +BOOLEAN +ViiperValidateDescriptorChain( + _In_reads_bytes_(Length) const UCHAR *Descriptor, + _In_ ULONG Length, + _In_ UCHAR ExpectedType + ) +{ + ULONG offset = 0; + + if (Length < 2 || Descriptor[1] != ExpectedType) { + return FALSE; + } + while (offset < Length) { + ULONG itemLength; + if (Length - offset < 2) { + return FALSE; + } + itemLength = Descriptor[offset]; + if (itemLength < 2 || itemLength > Length - offset) { + return FALSE; + } + offset += itemLength; + } + return offset == Length; +} + static BOOLEAN ViiperValidateCreateDevice( @@ -51,14 +78,17 @@ ViiperValidateCreateDevice( Input->Header.Magic != VIIPER_UDE_MAGIC || Input->Header.Major != VIIPER_UDE_ABI_MAJOR || Input->Header.Minor != VIIPER_UDE_ABI_MINOR || + Input->Header.Flags != 0 || Input->Header.Size != InputLength || Input->DeviceId == 0 || Input->Generation == 0 || + Input->Speed < 1 || Input->Speed > 4 || Input->DescriptorCount == 0 || Input->DescriptorCount > VIIPER_UDE_MAX_DESCRIPTOR_BYTES / sizeof(*records) || Input->DescriptorDataLength == 0 || Input->DescriptorDataLength > VIIPER_UDE_MAX_DESCRIPTOR_BYTES || Input->MaxPendingOperations == 0 || - Input->MaxPendingOperations > VIIPER_UDE_MAX_PENDING_OPERATIONS) { + Input->MaxPendingOperations > VIIPER_UDE_MAX_PENDING_OPERATIONS || + Input->Reserved != 0) { return FALSE; } @@ -81,6 +111,7 @@ ViiperValidateCreateDevice( const VIIPER_UDE_DESCRIPTOR_RECORD *record = &records[index]; const UCHAR *descriptor; if (record->Length < 2 || record->Length > MAXUSHORT || + record->Reserved != 0 || !ViiperRangeValid(record->Offset, record->Length, Input->DescriptorDataLength)) { return FALSE; } @@ -100,14 +131,21 @@ ViiperValidateCreateDevice( record->Length < sizeof(USB_CONFIGURATION_DESCRIPTOR) || descriptor[0] != sizeof(USB_CONFIGURATION_DESCRIPTOR) || descriptor[1] != USB_CONFIGURATION_DESCRIPTOR_TYPE || - ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length) { + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length || + !ViiperValidateDescriptorChain( + descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE)) { return FALSE; } foundConfiguration = TRUE; break; case ViiperUdeDescriptorBos: if (foundBos || record->Index != 0 || - descriptor[1] != USB_BOS_DESCRIPTOR_TYPE) { + record->Length < sizeof(USB_BOS_DESCRIPTOR) || + descriptor[0] != sizeof(USB_BOS_DESCRIPTOR) || + descriptor[1] != USB_BOS_DESCRIPTOR_TYPE || + ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length || + !ViiperValidateDescriptorChain( + descriptor, record->Length, USB_BOS_DESCRIPTOR_TYPE)) { return FALSE; } foundBos = TRUE; @@ -477,11 +515,12 @@ ViiperDestroyVirtualDevice( if (!NT_SUCCESS(status)) { return status; } - if (inputLength < sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || + if (inputLength != sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || input->Header.Major != VIIPER_UDE_ABI_MAJOR || input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Flags != 0 || input->Header.Size != sizeof(*input) || - input->DeviceId == 0 || input->Generation == 0) { + input->DeviceId == 0 || input->Generation == 0 || input->Reserved != 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } @@ -822,6 +861,7 @@ ViiperSubmitInputReport( input->Header.Magic != VIIPER_UDE_MAGIC || input->Header.Major != VIIPER_UDE_ABI_MAJOR || input->Header.Minor != VIIPER_UDE_ABI_MINOR || + input->Header.Flags != 0 || input->Header.Size != sizeof(*input) + input->PayloadLength || input->DeviceId == 0 || input->Generation == 0 || input->Sequence == 0 || input->Sequence > MAXLONGLONG || diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 0bef1318..aaa2383f 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -8,10 +8,11 @@ ViiperValidateHeader( _In_ size_t ExpectedSize ) { - return BufferLength >= ExpectedSize && + return BufferLength == ExpectedSize && Header->Magic == VIIPER_UDE_MAGIC && Header->Major == VIIPER_UDE_ABI_MAJOR && Header->Minor == VIIPER_UDE_ABI_MINOR && + Header->Flags == 0 && Header->Size == ExpectedSize; } @@ -50,7 +51,10 @@ ViiperHandleNegotiate( return status; } if (!ViiperValidateHeader(&input->Header, inputLength, sizeof(*input)) || - input->ClientNonce == 0) { + input->ClientNonce == 0 || input->Reserved != 0 || + (input->RequestedCapabilities & ~(VIIPER_UDE_CAP_ISOCHRONOUS | + VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | + VIIPER_UDE_CAP_INPUT_REPORTS)) != 0) { return STATUS_INVALID_PARAMETER; } From 0043856d3d0506f5584eb91bfed006e766048029 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:45:32 -0500 Subject: [PATCH 046/240] Make native cancellation ownership final --- docs/architecture/native-udecx.md | 4 ++ native/udecx/driver/Broker.c | 65 ++++++++++++++++++++++--------- native/udecx/driver/Controller.c | 6 +-- native/udecx/driver/Device.c | 3 +- native/udecx/driver/Ioctl.c | 12 +++--- native/udecx/driver/ViiperUde.h | 6 +-- 6 files changed, 65 insertions(+), 31 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bb913222..2122ea3e 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -154,6 +154,10 @@ unplug all converge on the same idempotent purge path. - UDE callbacks never wait on user mode while holding a WDF lock. - Blocking work is represented by cancelable WDF requests, not sleeping kernel threads. +- Every mark-cancelable transition revalidates its prior state under the broker + lock. If KMDF invokes cancellation before that lock is reacquired, the cancel + callback's DPC ownership is final and cannot be overwritten by admission or + publication. - Completion lookup is keyed by `(device ID, generation, token)`. This follows the useful ViGEmBus pattern of per-target ownership and manual diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 1b7c52b6..2208d386 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -262,7 +262,8 @@ ViiperValidateBrokerOwner( fileContext = ViiperGetFileContext(fileObject); WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || - !fileContext->Negotiated || fileContext->Closing) { + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; } WdfWaitLockRelease(controllerContext->OwnerLock); @@ -1012,6 +1013,7 @@ ViiperDispatchAvailable( ULONG index; NTSTATUS status; BOOLEAN abortPending = FALSE; + BOOLEAN cancelClaimed = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; ViiperDispatchNotificationEvents(Controller); @@ -1096,23 +1098,35 @@ ViiperDispatchAvailable( if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && ViiperSlotMatches(&controllerContext->PendingSlots[slot], urbRequest, token)) { VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; - abortPending = pending->AbortPending; - abortStatus = pending->AbortStatus; - pending->State = abortPending - ? ViiperUdePendingCompleting - : ViiperUdePendingInFlight; - if (!abortPending) { - serializedOperation->EndpointSequence = - (ULONGLONG)InterlockedIncrement64( - &ViiperGetDeviceContext( - ViiperGetEndpointContext(endpoint)->Device)->EndpointSequences[ - ViiperGetEndpointContext(endpoint)->Descriptor.bEndpointAddress]); - pending->PublishedToOwner = TRUE; + if (pending->State != ViiperUdePendingPublishing) { + // MarkCancelableEx may invoke the cancel callback before this + // thread reacquires BrokerLock. That callback owns the URB and + // its completion state must never be resurrected here. + cancelClaimed = TRUE; + } else { + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + pending->State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingInFlight; + if (!abortPending) { + serializedOperation->EndpointSequence = + (ULONGLONG)InterlockedIncrement64( + &ViiperGetDeviceContext( + ViiperGetEndpointContext(endpoint)->Device)->EndpointSequences[ + ViiperGetEndpointContext(endpoint)->Descriptor.bEndpointAddress]); + pending->PublishedToOwner = TRUE; + } } } else { - status = STATUS_CANCELLED; + cancelClaimed = TRUE; } WdfSpinLockRelease(controllerContext->BrokerLock); + if (cancelClaimed) { + WdfRequestComplete(dequeueRequest, STATUS_CANCELLED); + WdfObjectDereference(urbRequest); + continue; + } if (!NT_SUCCESS(status) || abortPending) { NTSTATUS completionStatus = abortPending ? abortStatus : STATUS_CANCELLED; NTSTATUS unmarkStatus = WdfRequestUnmarkCancelable(urbRequest); @@ -1171,6 +1185,7 @@ ViiperQueueUrb( ULONGLONG token; NTSTATUS status; BOOLEAN abortPending = FALSE; + BOOLEAN cancelClaimed = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || @@ -1195,19 +1210,31 @@ ViiperQueueUrb( if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && ViiperSlotMatches(&controllerContext->PendingSlots[slot], Request, token)) { if (NT_SUCCESS(status)) { - abortPending = controllerContext->PendingSlots[slot].AbortPending; - abortStatus = controllerContext->PendingSlots[slot].AbortStatus; - controllerContext->PendingSlots[slot].State = abortPending - ? ViiperUdePendingCompleting - : ViiperUdePendingQueued; + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + if (pending->State != ViiperUdePendingPreparing) { + // An immediate cancel callback already moved this slot to its + // DPC completion state and owns the request. + cancelClaimed = TRUE; + } else { + abortPending = pending->AbortPending; + abortStatus = pending->AbortStatus; + pending->State = abortPending + ? ViiperUdePendingCompleting + : ViiperUdePendingQueued; + } } else { ViiperClearSlotLocked(controllerContext, slot); } + } else if (NT_SUCCESS(status)) { + cancelClaimed = TRUE; } WdfSpinLockRelease(controllerContext->BrokerLock); if (!NT_SUCCESS(status)) { return STATUS_CANCELLED; } + if (cancelClaimed) { + return STATUS_PENDING; + } if (abortPending) { status = WdfRequestUnmarkCancelable(Request); if (NT_SUCCESS(status)) { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 20f5c5fc..9758a19c 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -286,7 +286,7 @@ ViiperEvtFileCreate( if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { status = STATUS_SHARING_VIOLATION; } else { - fileContext->BrokerOwner = TRUE; + InterlockedExchange(&fileContext->BrokerOwner, TRUE); WdfObjectReference(FileObject); InterlockedExchange(&context->OwnerReferenced, TRUE); context->OwnerFile = FileObject; @@ -311,9 +311,9 @@ ViiperEvtFileCleanup( device = WdfFileObjectGetDevice(FileObject); context = ViiperGetControllerContext(device); fileContext = ViiperGetFileContext(FileObject); - fileContext->Closing = TRUE; + InterlockedExchange(&fileContext->Closing, TRUE); - if (!fileContext->BrokerOwner) { + if (InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0) { return; } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index e3d9281d..598d7acb 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -245,7 +245,8 @@ ViiperValidateOwner( fileContext = ViiperGetFileContext(fileObject); WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || - !fileContext->Negotiated || fileContext->Closing) { + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; } WdfWaitLockRelease(controllerContext->OwnerLock); diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index aaa2383f..48bbcbc0 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -63,14 +63,15 @@ ViiperHandleNegotiate( return STATUS_INVALID_HANDLE; } fileContext = ViiperGetFileContext(fileObject); - if (fileContext->Closing) { + if (InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { return STATUS_FILE_CLOSED; } - if (fileContext->Negotiated && fileContext->ClientNonce != input->ClientNonce) { + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0 && + fileContext->ClientNonce != input->ClientNonce) { return STATUS_INVALID_DEVICE_STATE; } - if (!fileContext->Negotiated) { + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0) { ticks = KeQueryPerformanceCounter(NULL); fileContext->ClientNonce = input->ClientNonce; fileContext->DriverNonce = ((ULONGLONG)ticks.QuadPart) ^ @@ -78,7 +79,7 @@ ViiperHandleNegotiate( if (fileContext->DriverNonce == 0) { fileContext->DriverNonce = 1; } - fileContext->Negotiated = TRUE; + InterlockedExchange(&fileContext->Negotiated, TRUE); } RtlZeroMemory(output, sizeof(*output)); @@ -117,7 +118,8 @@ ViiperHandleQueryStats( return STATUS_INVALID_HANDLE; } fileContext = ViiperGetFileContext(fileObject); - if (!fileContext->Negotiated || fileContext->Closing) { + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { return STATUS_INVALID_DEVICE_STATE; } status = WdfRequestRetrieveOutputBuffer( diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 41e459e4..73f9c2e8 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -115,9 +115,9 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) typedef struct VIIPER_UDE_FILE_CONTEXT { - BOOLEAN Negotiated; - BOOLEAN Closing; - BOOLEAN BrokerOwner; + volatile LONG Negotiated; + volatile LONG Closing; + volatile LONG BrokerOwner; ULONGLONG ClientNonce; ULONGLONG DriverNonce; } VIIPER_UDE_FILE_CONTEXT; From a24bca8151d25a4d15bc4513b40f426b7ce8229b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:52:46 -0500 Subject: [PATCH 047/240] Invalidate UDE handles before asynchronous removal Revoke a virtual device from the controller table before calling UdecxUsbDevicePlugOutAndDelete, reserve its root-port slot until object cleanup, and hold broker ownership until every asynchronous removal completes. The UdeCx contract makes the device handle unusable after PlugOutAndDelete returns, so neither success nor failure restores or dereferences it. --- docs/architecture/native-udecx.md | 5 ++++ native/udecx/driver/Device.c | 39 +++++++++++++------------------ native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 2122ea3e..108870ab 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -131,6 +131,11 @@ unplug all converge on the same idempotent purge path. ## Synchronization model - A controller-level lock protects the device table and owner registration. +- Removal atomically revokes the UDE handle from the device table before + `UdecxUsbDevicePlugOutAndDelete`; that slot remains reserved until the + asynchronous object cleanup runs. Once that API returns, success or failure, + no path dereferences or restores the invalidated UDE handle and the broker + owner cannot be released while its reserved removal slot remains. - Each device has a short-held state lock and independent endpoint queues. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 598d7acb..84ab3856 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -293,7 +293,8 @@ ViiperClaimDeviceSlot( for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; if (current == WDF_NO_HANDLE) { - if (freeSlot == VIIPER_UDE_MAX_DEVICES) { + if (!ControllerContext->RemovingSlots[index] && + freeSlot == VIIPER_UDE_MAX_DEVICES) { freeSlot = index; } continue; @@ -325,8 +326,13 @@ ViiperReleaseDeviceSlot( ) { WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); - if (Slot < VIIPER_UDE_MAX_DEVICES && ControllerContext->Devices[Slot] == Device) { - ControllerContext->Devices[Slot] = WDF_NO_HANDLE; + if (Slot < VIIPER_UDE_MAX_DEVICES) { + if (ControllerContext->Devices[Slot] == Device) { + ControllerContext->Devices[Slot] = WDF_NO_HANDLE; + } + if (ControllerContext->Devices[Slot] == WDF_NO_HANDLE) { + ControllerContext->RemovingSlots[Slot] = FALSE; + } } WdfWaitLockRelease(ControllerContext->DeviceLock); } @@ -470,6 +476,8 @@ ViiperBeginRemoveDevice( continue; } InterlockedExchange(&deviceContext->Purging, TRUE); + ControllerContext->Devices[index] = WDF_NO_HANDLE; + ControllerContext->RemovingSlots[index] = TRUE; *Device = current; status = STATUS_SUCCESS; break; @@ -478,21 +486,6 @@ ViiperBeginRemoveDevice( return status; } -static -VOID -ViiperCancelRemoveDevice( - _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, - _In_ UDECXUSBDEVICE Device - ) -{ - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); - if (ViiperGetDeviceContext(Device)->Slot < VIIPER_UDE_MAX_DEVICES && - ControllerContext->Devices[ViiperGetDeviceContext(Device)->Slot] == Device) { - InterlockedExchange(&ViiperGetDeviceContext(Device)->Purging, FALSE); - } - WdfWaitLockRelease(ControllerContext->DeviceLock); -} - NTSTATUS ViiperDestroyVirtualDevice( _In_ WDFQUEUE Queue, @@ -532,9 +525,6 @@ ViiperDestroyVirtualDevice( return status; } status = UdecxUsbDevicePlugOutAndDelete(device); - if (!NT_SUCCESS(status)) { - ViiperCancelRemoveDevice(controllerContext, device); - } return status; } @@ -551,6 +541,7 @@ ViiperDestroyOwnedDevices( UDECXUSBDEVICE device; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; ULONGLONG deviceId = 0; + BOOLEAN removalPending = FALSE; ULONG index; WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); @@ -563,10 +554,13 @@ ViiperDestroyOwnedDevices( deviceId = ViiperGetDeviceContext(device)->DeviceId; break; } + if (controllerContext->RemovingSlots[index]) { + removalPending = TRUE; + } } WdfWaitLockRelease(controllerContext->DeviceLock); if (deviceId == 0) { - return TRUE; + return !removalPending; } if (!NT_SUCCESS(ViiperBeginRemoveDevice( @@ -576,7 +570,6 @@ ViiperDestroyOwnedDevices( deviceContext = ViiperGetDeviceContext(device); if (deviceContext->Plugged) { if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { - ViiperCancelRemoveDevice(controllerContext, device); return FALSE; } } else { diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 73f9c2e8..aeba8e99 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -110,6 +110,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; + BOOLEAN RemovingSlots[VIIPER_UDE_MAX_DEVICES]; } VIIPER_UDE_CONTROLLER_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) From f55426aa6e323b1f65af6d8bf772b554417cafa1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:55:59 -0500 Subject: [PATCH 048/240] Close broker admission before owner cleanup Serialize dequeue owner validation, pending-count admission, and the manual-queue handoff under the same owner lock used to begin file cleanup. This prevents an already-validated inverted call from landing behind a completed cleanup purge and makes WaitingDequeueCount exact under concurrent notification dispatch. --- docs/architecture/native-udecx.md | 4 ++++ native/udecx/driver/Broker.c | 35 ++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 108870ab..f5c9271e 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -163,6 +163,10 @@ unplug all converge on the same idempotent purge path. lock. If KMDF invokes cancellation before that lock is reacquired, the cancel callback's DPC ownership is final and cannot be overwritten by admission or publication. +- Broker dequeue validation, wait-count admission, and transfer into the + manual inverted-call queue share the owner lock with file cleanup. No close + can finish purging that queue and then have an already-validated request + appear behind the purge boundary. - Completion lookup is keyed by `(device ID, generation, token)`. This follows the useful ViGEmBus pattern of per-target ownership and manual diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 2208d386..c7be41df 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1152,19 +1152,40 @@ ViiperQueueDequeueOperation( { WDFDEVICE controller = WdfIoQueueGetDevice(Queue); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); - NTSTATUS status = ViiperValidateBrokerOwner(controller, Request); + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + VIIPER_UDE_FILE_CONTEXT *fileContext; + NTSTATUS status = STATUS_SUCCESS; - if (!NT_SUCCESS(status)) { - return status; + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; } - if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { - return STATUS_DATA_ERROR; + fileContext = ViiperGetFileContext(fileObject); + + // File cleanup closes admission and purges WaitingDequeues while holding + // OwnerLock. Keep validation, accounting, and the manual-queue handoff in + // that same ownership transaction so a request cannot be forwarded after + // cleanup has already finished purging the queue. + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (controllerContext->OwnerFile != fileObject || + controllerContext->CleanupInProgress || + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + status = STATUS_INVALID_DEVICE_STATE; + } else if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + status = STATUS_DATA_ERROR; + } else { + InterlockedIncrement(&controllerContext->WaitingDequeueCount); + status = WdfRequestForwardToIoQueue(Request, controllerContext->WaitingDequeues); + if (!NT_SUCCESS(status)) { + InterlockedDecrement(&controllerContext->WaitingDequeueCount); + } } - status = WdfRequestForwardToIoQueue(Request, controllerContext->WaitingDequeues); + WdfWaitLockRelease(controllerContext->OwnerLock); if (!NT_SUCCESS(status)) { return status; } - InterlockedIncrement(&controllerContext->WaitingDequeueCount); + ViiperDispatchAvailable(controller); return STATUS_PENDING; } From 214f31523013ff4c35399f9170812c58ec0fe5f7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:57:01 -0500 Subject: [PATCH 049/240] Validate native transfer completions by outcome Allow failed isochronous operations to complete without fabricating the successful packet table, while validating successful completion shape against the owning URB. Canonicalize OUT payloads, ISO reserved fields, packet bounds, and actual-byte totals before completing UdeCx requests. --- docs/architecture/native-udecx.md | 4 ++++ native/udecx/driver/Broker.c | 35 ++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f5c9271e..68af4841 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -168,6 +168,10 @@ unplug all converge on the same idempotent purge path. can finish purging that queue and then have an already-validated request appear behind the purge boundary. - Completion lookup is keyed by `(device ID, generation, token)`. +- Failed transfers do not need to fabricate a successful ISO packet table. + Successful completions are canonical: OUT replies carry no payload, every + ISO reserved field is zero, packet extents stay inside the transfer buffer, + and the sum of actual packet lengths equals the reported completed bytes. This follows the useful ViGEmBus pattern of per-target ownership and manual request queues while accounting for UdeCx's endpoint-specific purge contract. diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index c7be41df..7f7f2c94 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1302,6 +1302,7 @@ ViiperCompleteOperation( ULONG isoPayloadLimit; ULONG isoBytes; ULONG expectedSize; + ULONG packetTotal = 0; NTSTATUS status; BOOLEAN expectedLateAbort = FALSE; BOOLEAN queued; @@ -1402,12 +1403,7 @@ ViiperCompleteOperation( ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->DeviceId || completion->Generation != ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->Generation || - completion->TransferLength > requestContext->TransferLength || - completion->IsoPacketCount != requestContext->IsoPacketCount || - (requestContext->DirectionIn && completion->IsoPacketCount == 0 && - completion->PayloadLength != completion->TransferLength) || - (requestContext->DirectionIn && completion->IsoPacketCount != 0 && - completion->PayloadLength > requestContext->TransferLength)) { + completion->TransferLength > requestContext->TransferLength) { status = STATUS_INVALID_PARAMETER; InterlockedIncrement64(&controllerContext->InvalidMessages); goto CompleteWithNtStatus; @@ -1431,6 +1427,20 @@ ViiperCompleteOperation( return STATUS_SUCCESS; } + if (completion->IsoPacketCount != requestContext->IsoPacketCount || + (completion->IsoPacketCount == 0 && requestContext->DirectionIn && + completion->PayloadLength != completion->TransferLength) || + (completion->IsoPacketCount == 0 && !requestContext->DirectionIn && + completion->PayloadLength != 0) || + (completion->IsoPacketCount != 0 && requestContext->DirectionIn && + completion->PayloadLength > requestContext->TransferLength) || + (completion->IsoPacketCount != 0 && !requestContext->DirectionIn && + completion->PayloadLength != 0)) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } + if (requestContext->DirectionIn && completion->PayloadLength > 0) { status = ViiperCopyTransferBuffer( urbRequest, urb, payload, completion->PayloadLength, TRUE); @@ -1444,15 +1454,24 @@ ViiperCompleteOperation( ? completion->PayloadLength : requestContext->TransferLength; for (index = 0; index < completion->IsoPacketCount; ++index) { - if (packets[index].Offset > isoPayloadLimit || - packets[index].Length > isoPayloadLimit - packets[index].Offset) { + if (packets[index].Reserved != 0 || + packets[index].Offset > isoPayloadLimit || + packets[index].Length > isoPayloadLimit - packets[index].Offset || + packets[index].Length > MAXULONG - packetTotal) { status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); goto CompleteWithNtStatus; } + packetTotal += packets[index].Length; urb->UrbIsochronousTransfer.IsoPacket[index].Offset = packets[index].Offset; urb->UrbIsochronousTransfer.IsoPacket[index].Length = packets[index].Length; urb->UrbIsochronousTransfer.IsoPacket[index].Status = packets[index].Status; } + if (packetTotal != completion->TransferLength) { + status = STATUS_INVALID_PARAMETER; + InterlockedIncrement64(&controllerContext->InvalidMessages); + goto CompleteWithNtStatus; + } InterlockedAdd64(&controllerContext->IsoPackets, completion->IsoPacketCount); } From 7091dd217635d51a18a2c945b40e6822c0eaccc6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 21:58:24 -0500 Subject: [PATCH 050/240] Make UdeCx removal ownership irreversible Define DestroyDevice errors as pre-transfer rejection only. After PlugOutAndDelete consumes a child handle, accept the removal at the VIIPER ABI boundary; if UdeCx reports a terminal fault, request a PnP controller restart and keep owner cleanup closed rather than resurrecting publishers or retrying an invalid handle. --- docs/architecture/native-udecx.md | 5 +++++ internal/transport/udecx/host.go | 10 +++++++--- native/udecx/driver/Device.c | 11 ++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 68af4841..e47ca97d 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -136,6 +136,11 @@ unplug all converge on the same idempotent purge path. asynchronous object cleanup runs. Once that API returns, success or failure, no path dereferences or restores the invalidated UDE handle and the broker owner cannot be released while its reserved removal slot remains. +- A post-transfer UdeCx removal failure is terminal for the controller, not + retryable for the child. The kernel accepts the broker's removal request, + requests a PnP controller restart, and keeps owner cleanup closed until + object teardown completes. User mode can retry only failures returned before + ownership reached UdeCx. - Each device has a short-held state lock and independent endpoint queues. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 4da9cf48..8be98c0a 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -24,6 +24,10 @@ const ( // and stale-generation behavior testable without loading a kernel driver. type Driver interface { CreateDevice(context.Context, CreateDevice) error + // DestroyDevice returns an error only if removal was rejected before the + // kernel transferred ownership to UdeCx. Once accepted, any terminal + // UdeCx removal fault is recovered by restarting the controller and the + // call succeeds so callers never resurrect an invalid device generation. DestroyDevice(context.Context, DeviceIdentity) error Dequeue(context.Context, []byte) (Operation, error) Complete(context.Context, Completion) error @@ -210,9 +214,9 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { activePublishers := h.activeInputEndpoints(entry) h.stopAllInputPublishers(entry) - // Keep routing live until the driver has transactionally unplugged the - // child. If unplug fails, callers can retry without losing the generation, - // endpoint lanes, or the ability to complete already-issued Windows URBs. + // Keep routing live until the driver has transactionally accepted removal. + // Errors occur before UdeCx consumes the device handle, so callers can + // retry without losing the generation or its endpoint lanes. if err := h.driver.DestroyDevice(ctx, identity); err != nil { h.mu.Lock() entry.publisherStopping = false diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 84ab3856..2a8b4f1d 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -525,7 +525,15 @@ ViiperDestroyVirtualDevice( return status; } status = UdecxUsbDevicePlugOutAndDelete(device); - return status; + if (!NT_SUCCESS(status)) { + // PlugOutAndDelete consumes the UDE handle even when it reports a + // failure. The request was nevertheless accepted at our ABI boundary; + // attempting to restore or retry this handle would be a use-after- + // invalidation. Restart the controller so PnP owns final recovery. + WdfDeviceSetFailed(controller, WdfDeviceFailedAttemptRestart); + return STATUS_SUCCESS; + } + return STATUS_SUCCESS; } BOOLEAN @@ -570,6 +578,7 @@ ViiperDestroyOwnedDevices( deviceContext = ViiperGetDeviceContext(device); if (deviceContext->Plugged) { if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { + WdfDeviceSetFailed(Controller, WdfDeviceFailedAttemptRestart); return FALSE; } } else { From fd07a2f2cb89fcd2c7cabd3ed08fdcd7b857260c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:02:43 -0500 Subject: [PATCH 051/240] Make native UDE owner sessions one-shot A stopped host may already own dequeued kernel requests that cannot be reconstructed. Reject Serve restarts and post-stop registration so callers must close the file owner and negotiate a fresh Client/Host session, matching ViGEm-style per-file child ownership. Document the lifecycle rule and add a deterministic restart rejection test. --- docs/architecture/native-udecx.md | 13 ++++++-- internal/transport/udecx/host.go | 15 +++++++++- internal/transport/udecx/host_test.go | 43 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index e47ca97d..f3c019e5 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -78,8 +78,17 @@ The transport is intentionally split by USB semantics: Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before -device removal. Failed removal restores the active publishers so a retry does -not strand the current generation. +device removal. Removal rejected before UdeCx takes the child restores the +active publishers, so a retry does not strand the current generation. Once +ownership transfers, a terminal UdeCx removal fault restarts the controller +and the removed generation remains closed. + +The broker owner session is deliberately one-shot. Stopping the user-mode host +cancels endpoint lanes that may already own dequeued kernel requests; those +requests cannot be reconstructed safely in a restarted goroutine. VIIPER must +close that driver handle and negotiate a fresh `Client`/`Host` session, matching +ViGEmBus's file-session ownership model, rather than guessing a new endpoint +sequence baseline and risking an abandoned USB request. This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping. The direct input lane removes the highest-frequency HID broker path without diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 8be98c0a..b8c3b2d6 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -109,6 +109,7 @@ type Host struct { runCtx context.Context runCancel context.CancelFunc fatal chan error + started bool running bool laneWG sync.WaitGroup operationMu sync.Mutex @@ -160,6 +161,14 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D } h.mu.Lock() + // One driver file owner is one native UDE host session. Once Serve has + // stopped, operations already dequeued into user mode cannot be replayed or + // reconstructed safely. A fresh Client/Host pair is therefore required + // instead of publishing a child into a terminal owner session. + if h.started && (!h.running || h.runCtx == nil || h.runCtx.Err() != nil) { + h.mu.Unlock() + return DeviceIdentity{}, errors.New("native UDE host session has stopped; open a fresh driver session") + } if _, exists := h.devices[deviceID]; exists { h.mu.Unlock() return DeviceIdentity{}, fmt.Errorf("native UDE device %d is already registered", deviceID) @@ -377,9 +386,13 @@ func (h *Host) Serve(ctx context.Context) error { h.mu.Unlock() return errors.New("native UDE host is already running") } + if h.started { + h.mu.Unlock() + return errors.New("native UDE host sessions are one-shot; open a fresh driver session") + } runCtx, cancel := context.WithCancel(ctx) fatal := make(chan error, 1) - h.runCtx, h.runCancel, h.fatal, h.running = runCtx, cancel, fatal, true + h.runCtx, h.runCancel, h.fatal, h.started, h.running = runCtx, cancel, fatal, true, true entries := make([]*registeredDevice, 0, len(h.devices)) for _, entry := range h.devices { entries = append(entries, entry) diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 727dc53b..c57f723d 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -510,6 +510,49 @@ func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { } } +func TestHostSessionCannotRestartAfterOperationsWereDequeued(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 19, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.processed: + case <-time.After(time.Second): + t.Fatal("first host session did not process its operation") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("first host session did not stop") + } + + if err = host.Serve(context.Background()); err == nil || !strings.Contains(err.Error(), "one-shot") { + t.Fatalf("second Serve error=%v, want one-shot session rejection", err) + } + if _, err = host.Register(context.Background(), 20, hostTestDevice()); err == nil || + !strings.Contains(err.Error(), "fresh driver session") { + t.Fatalf("Register after Serve error=%v, want terminal session rejection", err) + } +} + func TestHostOrdersLifecycleBeforeFollowingTransfer(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{ From f9e8c7417817e1e9da8b4e945c1946b6437c092a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:04:58 -0500 Subject: [PATCH 052/240] Survive dynamic endpoint cleanup races Keep a per-address retired-endpoint tombstone within each device generation. A fast interrupt-IN report that crosses asynchronous UdeCx endpoint cleanup is now acknowledged and dropped as stale, while reports for endpoints that never existed remain rejected. Re-adding the address clears the tombstone atomically under DeviceLock. --- docs/architecture/native-udecx.md | 6 ++++++ native/udecx/driver/Device.c | 9 +++++++++ native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 16 insertions(+) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f3c019e5..bd4bf985 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -83,6 +83,12 @@ active publishers, so a retry does not strand the current generation. Once ownership transfers, a terminal UdeCx removal fault restarts the controller and the removed generation remains closed. +Dynamic endpoint cleanup leaves an address-scoped retirement tombstone for +the current device generation. This lets the kernel acknowledge and discard +the single latest-state input report that can cross asynchronous cleanup before +user mode consumes the ordered purge event, without accepting reports for an +endpoint that was never configured. + The broker owner session is deliberately one-shot. Stopping the user-mode host cancels endpoint lanes that may already own dequeued kernel requests; those requests cannot be reconstructed safely in a restarted goroutine. VIIPER must diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 2a8b4f1d..d7ae6f09 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -705,6 +705,12 @@ ViiperEvtEndpointCleanup( WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); if (deviceContext->Endpoints[address] == endpoint) { deviceContext->Endpoints[address] = WDF_NO_HANDLE; + // The user-mode latest-state publisher is stopped by the ordered + // endpoint-purge notification. It can race this asynchronous object + // cleanup by one already-built report. Preserve an address-scoped + // tombstone so that report is distinguishable from a report for an + // endpoint that never existed in this device generation. + deviceContext->RetiredEndpoints[address] = TRUE; } WdfWaitLockRelease(controllerContext->DeviceLock); } @@ -778,6 +784,7 @@ ViiperEvtEndpointAdd( ViiperGetControllerContext(deviceContext->Controller); WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); deviceContext->Endpoints[descriptor.bEndpointAddress] = endpoint; + deviceContext->RetiredEndpoints[descriptor.bEndpointAddress] = FALSE; WdfWaitLockRelease(controllerContext->DeviceLock); } return STATUS_SUCCESS; @@ -897,6 +904,8 @@ ViiperSubmitInputReport( endpoint = deviceContext->Endpoints[input->EndpointAddress]; if (endpoint != WDF_NO_HANDLE) { WdfObjectReference(endpoint); + } else if (deviceContext->RetiredEndpoints[input->EndpointAddress]) { + lifecycleDrop = TRUE; } break; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index aeba8e99..5e60286e 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -137,6 +137,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { volatile LONG OwnerReferenced; UDECXUSBENDPOINT DefaultEndpoint; UDECXUSBENDPOINT Endpoints[256]; + BOOLEAN RetiredEndpoints[256]; volatile LONG64 EndpointSequences[256]; } VIIPER_UDE_DEVICE_CONTEXT; From 7b89143a5c517201e16caf633ea455be03841a5f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:08:27 -0500 Subject: [PATCH 053/240] Preserve host-owned isochronous packet geometry Keep sparse ISO payload span independent from the completed-byte count, reject user-mode attempts to rewrite host packet offsets or exceed packet slots, and derive the URB ErrorCount from returned packet statuses. This closes a C/Go ABI ambiguity that could corrupt partial microphone/audio completions while retaining the controller's original service layout. --- docs/architecture/native-udecx.md | 4 ++++ internal/transport/udecx/protocol.go | 8 +++++++- internal/transport/udecx/protocol_test.go | 21 +++++++++++++++++++++ native/udecx/driver/Broker.c | 15 ++++++++++++++- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bd4bf985..62c8b2f6 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -192,6 +192,10 @@ unplug all converge on the same idempotent purge path. Successful completions are canonical: OUT replies carry no payload, every ISO reserved field is zero, packet extents stay inside the transfer buffer, and the sum of actual packet lengths equals the reported completed bytes. + The host-owned packet offsets are immutable across the broker boundary; + user mode may return only each packet's actual length and status. Sparse IN + payload span is independent of the completed-byte total, and the kernel + derives the URB error count from the returned per-packet statuses. This follows the useful ViGEmBus pattern of per-target ownership and manual request queues while accounting for UdeCx's endpoint-specific purge contract. diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index d4a4045b..a7fff4d8 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -466,7 +466,13 @@ func (m Completion) MarshalBinary() ([]byte, error) { return nil, ErrLimitExceeded } transferLength := m.TransferLength - if transferLength == 0 && len(m.Payload) != 0 { + // Non-isochronous IN completions historically infer the completed byte + // count from their contiguous payload. Isochronous payloads are different: + // the buffer preserves the host packet offsets, including sparse gaps, while + // TransferLength is the sum of the packets' actual lengths. In particular, + // an all-zero ISO completion can legitimately carry a full sparse buffer and + // still complete zero bytes. + if transferLength == 0 && len(m.Payload) != 0 && len(m.IsoPackets) == 0 { transferLength = uint32(len(m.Payload)) } if transferLength > MaxTransferBytes { diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7b98fb7d..f37bf544 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -134,6 +134,27 @@ func TestCompletionMarshalling(t *testing.T) { } } +func TestCompletionMarshallingPreservesZeroLengthSparseISO(t *testing.T) { + payload := make([]byte, 64) + raw, err := (Completion{ + Token: 3, + DeviceID: 9, + Generation: 4, + TransferLength: 0, + IsoPackets: []IsoPacket{{Offset: 0, Length: 0}}, + Payload: payload, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(raw[44:48]); got != 0 { + t.Fatalf("transfer length=%d want=0", got) + } + if got := binary.LittleEndian.Uint32(raw[56:60]); got != uint32(len(payload)) { + t.Fatalf("payload length=%d want=%d", got, len(payload)) + } +} + func TestInputReportMarshalling(t *testing.T) { raw, err := (InputReport{ DeviceID: 5, Generation: 7, EndpointAddress: 0x81, diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 7f7f2c94..f91d663c 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1303,6 +1303,7 @@ ViiperCompleteOperation( ULONG isoBytes; ULONG expectedSize; ULONG packetTotal = 0; + ULONG isoErrorCount = 0; NTSTATUS status; BOOLEAN expectedLateAbort = FALSE; BOOLEAN queued; @@ -1454,24 +1455,36 @@ ViiperCompleteOperation( ? completion->PayloadLength : requestContext->TransferLength; for (index = 0; index < completion->IsoPacketCount; ++index) { + ULONG originalOffset = urb->UrbIsochronousTransfer.IsoPacket[index].Offset; + ULONG nextOriginalOffset = index + 1 < completion->IsoPacketCount + ? urb->UrbIsochronousTransfer.IsoPacket[index + 1].Offset + : requestContext->TransferLength; + if (packets[index].Reserved != 0 || + originalOffset > nextOriginalOffset || + nextOriginalOffset > requestContext->TransferLength || + packets[index].Offset != originalOffset || packets[index].Offset > isoPayloadLimit || packets[index].Length > isoPayloadLimit - packets[index].Offset || + packets[index].Length > nextOriginalOffset - originalOffset || packets[index].Length > MAXULONG - packetTotal) { status = STATUS_INVALID_PARAMETER; InterlockedIncrement64(&controllerContext->InvalidMessages); goto CompleteWithNtStatus; } packetTotal += packets[index].Length; - urb->UrbIsochronousTransfer.IsoPacket[index].Offset = packets[index].Offset; urb->UrbIsochronousTransfer.IsoPacket[index].Length = packets[index].Length; urb->UrbIsochronousTransfer.IsoPacket[index].Status = packets[index].Status; + if ((USBD_STATUS)packets[index].Status != USBD_STATUS_SUCCESS) { + ++isoErrorCount; + } } if (packetTotal != completion->TransferLength) { status = STATUS_INVALID_PARAMETER; InterlockedIncrement64(&controllerContext->InvalidMessages); goto CompleteWithNtStatus; } + urb->UrbIsochronousTransfer.ErrorCount = isoErrorCount; InterlockedAdd64(&controllerContext->IsoPackets, completion->IsoPacketCount); } From 28feeb7f16edb5e7332d6b26d3b5cddcbe8fbdbd Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:11:56 -0500 Subject: [PATCH 054/240] Preserve Microsoft OS descriptor parity on native UDE Publish the reserved 0xEE Microsoft OS 1.0 string through UdeCx, prevent ordinary strings from shadowing it, and retain the zero language identifier required by the probe. This keeps the Switch 2 Pro vendor interface eligible for the same WinUSB binding as the USB/IP path. --- docs/architecture/native-udecx.md | 4 +- internal/transport/udecx/descriptors.go | 13 ++++++ internal/transport/udecx/descriptors_test.go | 42 ++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 62c8b2f6..8944ed6b 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -122,7 +122,9 @@ device, configuration, BOS, language/string records, and device-speed policy. The driver validates all offsets and lengths before constructing a UDE device. Descriptor normalization required by UdeCx is a named policy, not an implicit mutation: high-speed bulk packets are 512 bytes and interval conversion is -covered by descriptor tests. +covered by descriptor tests. The reserved Microsoft OS 1.0 `0xEE` string is +published explicitly when a controller exposes one, preserving WinUSB binding +for vendor interfaces such as the Switch 2 Pro path. ## Lifecycle diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go index 679ac266..ea7113be 100644 --- a/internal/transport/udecx/descriptors.go +++ b/internal/transport/udecx/descriptors.go @@ -48,6 +48,12 @@ func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateD sort.Ints(indices) for _, value := range indices { index := uint8(value) + // The Microsoft OS 1.0 descriptor owns the reserved 0xEE string + // exactly as it does on the USB/IP control path. Never publish a + // conflicting ordinary string at that index. + if index == 0xEE && desc.MicrosoftOS10 != nil { + continue + } languageID := uint16(0x0409) if index == 0 { languageID = 0 @@ -56,6 +62,13 @@ func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateD DescriptorString, uint16(index), languageID, usb.EncodeStringDescriptor(desc.Strings[index])) } + if desc.MicrosoftOS10 != nil { + appendDescriptor( + DescriptorString, + 0xEE, + 0, + desc.MicrosoftOS10.StringDescriptor()) + } if _, err := message.MarshalBinary(); err != nil { return CreateDevice{}, fmt.Errorf("snapshot native UDE descriptors: %w", err) diff --git a/internal/transport/udecx/descriptors_test.go b/internal/transport/udecx/descriptors_test.go index 816cf681..d1f0188a 100644 --- a/internal/transport/udecx/descriptors_test.go +++ b/internal/transport/udecx/descriptors_test.go @@ -52,3 +52,45 @@ func TestSnapshotDevicePreservesDescriptorBytes(t *testing.T) { t.Fatalf("configuration total length=%d want=%d", got, len(config)) } } + +func TestSnapshotDevicePublishesMicrosoftOS10ReservedString(t *testing.T) { + msOS := &usb.MicrosoftOS10Descriptor{VendorCode: 0x20, CompatibleID: "WINUSB"} + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x057e, + IDProduct: 0x2073, BNumConfigurations: 1, Speed: uint32(DeviceSpeedHigh), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0}, + }}, + MicrosoftOS10: msOS, + Strings: map[uint8]string{ + 0: "\u0409", + 1: "Nintendo", + 0xEE: "must not shadow the Microsoft descriptor", + }, + }} + + snapshot, err := SnapshotDevice(8, 2, dev) + if err != nil { + t.Fatal(err) + } + var matches []DescriptorRecord + for _, record := range snapshot.Descriptors { + if record.Kind == DescriptorString && record.Index == 0xEE { + matches = append(matches, record) + } + } + if len(matches) != 1 { + t.Fatalf("Microsoft OS string count=%d want=1", len(matches)) + } + record := matches[0] + if record.LanguageID != 0 { + t.Fatalf("Microsoft OS string language=%#x want=0", record.LanguageID) + } + got := snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + want := msOS.StringDescriptor() + if string(got) != string(want) { + t.Fatalf("Microsoft OS string=%x want=%x", got, want) + } +} From 8b47f6b67008751856205fea44c76304688da813 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:14:31 -0500 Subject: [PATCH 055/240] Make native child publication transactional Serialize bus, native-driver, and identity-table lifecycle changes so removal cannot cross a UdeCx plug-in before its identity is committed. Hold the native registry lock across driver create/destroy calls and cover the former orphan race with a deterministic blocked-create test. --- internal/server/usb/native_transport_test.go | 80 ++++++++++++++++++++ internal/server/usb/server.go | 52 +++++++++---- 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/internal/server/usb/native_transport_test.go b/internal/server/usb/native_transport_test.go index f1b7e3ec..3a643e31 100644 --- a/internal/server/usb/native_transport_test.go +++ b/internal/server/usb/native_transport_test.go @@ -18,6 +18,24 @@ type nativeTransportTestDriver struct { destroyed []udecx.DeviceIdentity } +type blockingNativeTransportTestDriver struct { + nativeTransportTestDriver + createStarted chan struct{} + allowCreate chan struct{} +} + +func (d *blockingNativeTransportTestDriver) CreateDevice( + ctx context.Context, device udecx.CreateDevice, +) error { + close(d.createStarted) + select { + case <-d.allowCreate: + case <-ctx.Done(): + return ctx.Err() + } + return d.nativeTransportTestDriver.CreateDevice(ctx, device) +} + func (d *nativeTransportTestDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) error { d.created = append(d.created, device) return d.createErr @@ -115,3 +133,65 @@ func TestNativeTransportRollsBackVirtualBusWhenPlugInFails(t *testing.T) { t.Fatal("failed native plug-in leaked a virtual bus device") } } + +func TestNativeTransportRemovalCannotRaceUncommittedPlugIn(t *testing.T) { + driver := &blockingNativeTransportTestDriver{ + createStarted: make(chan struct{}), + allowCreate: make(chan struct{}), + } + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ConnectionTimeout: time.Second}, slog.Default(), nil) + if err = server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98103) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err = server.AddBus(bus); err != nil { + t.Fatal(err) + } + + addDone := make(chan error, 1) + go func() { + _, addErr := server.AddDeviceToBus( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + addDone <- addErr + }() + <-driver.createStarted + + removeStarted := make(chan struct{}) + removeDone := make(chan error, 1) + go func() { + close(removeStarted) + removeDone <- server.RemoveDeviceByID(bus.BusID(), "1") + }() + <-removeStarted + select { + case err = <-removeDone: + t.Fatalf("remove crossed uncommitted native plug-in: %v", err) + case <-time.After(25 * time.Millisecond): + } + + close(driver.allowCreate) + if err = <-addDone; err != nil { + t.Fatal(err) + } + if err = <-removeDone; err != nil { + t.Fatal(err) + } + if len(driver.created) != 1 || len(driver.destroyed) != 1 || len(bus.Devices()) != 0 { + t.Fatalf("created=%d destroyed=%d bus devices=%d want 1/1/0", + len(driver.created), len(driver.destroyed), len(bus.Devices())) + } + server.nativeMu.Lock() + remaining := len(server.nativeIDs) + server.nativeMu.Unlock() + if remaining != 0 { + t.Fatalf("native identity table retained %d entries", remaining) + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 2695dab0..a84ab72b 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -199,16 +199,21 @@ type Server struct { config *ServerConfig logger *slog.Logger rawLogger log.RawLogger - busses map[uint32]*virtualbus.VirtualBus - busesMu sync.Mutex - alts map[usb.Device]map[uint8]uint8 - altsMu sync.Mutex - ready chan struct{} - readyOnce sync.Once - ln net.Listener - nativeMu sync.Mutex - native *udecx.Host - nativeIDs map[nativeDeviceKey]udecx.DeviceIdentity + // lifecycleMu makes publication into the in-memory bus, native UdeCx + // driver, and native identity table one transaction. Device creation and + // removal are infrequent control-plane operations; serializing them avoids + // an orphaned child if removal races a driver plug-in or bus teardown. + lifecycleMu sync.Mutex + busses map[uint32]*virtualbus.VirtualBus + busesMu sync.Mutex + alts map[usb.Device]map[uint8]uint8 + altsMu sync.Mutex + ready chan struct{} + readyOnce sync.Once + ln net.Listener + nativeMu sync.Mutex + native *udecx.Host + nativeIDs map[nativeDeviceKey]udecx.DeviceIdentity } type nativeDeviceKey struct { @@ -234,6 +239,8 @@ func (s *Server) EnableNativeTransport(host *udecx.Host) error { if host == nil { return errors.New("native UDE host is nil") } + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() s.nativeMu.Lock() defer s.nativeMu.Unlock() if s.native != nil { @@ -256,6 +263,9 @@ func nativeDeviceID(busID, devID uint32) uint64 { // AddDeviceToBus publishes a device transactionally. A failed native plug-in // rolls the in-memory bus back before the device becomes visible to clients. func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Device) (context.Context, error) { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + bus := s.GetBus(busID) if bus == nil { return nil, fmt.Errorf("bus %d not found", busID) @@ -272,16 +282,16 @@ func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Devic s.nativeMu.Lock() host := s.native - s.nativeMu.Unlock() if host == nil { + s.nativeMu.Unlock() return deviceCtx, nil } identity, err := host.Register(ctx, nativeDeviceID(busID, meta.DevID), dev) if err != nil { + s.nativeMu.Unlock() _ = bus.Remove(dev) return nil, fmt.Errorf("plug native UDE device: %w", err) } - s.nativeMu.Lock() s.nativeIDs[nativeDeviceKey{busID: busID, devID: meta.DevID}] = identity s.nativeMu.Unlock() return deviceCtx, nil @@ -290,6 +300,8 @@ func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Devic // AddBus registers a bus with the server. If the bus number is already present, // an error is returned. func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() s.busesMu.Lock() defer s.busesMu.Unlock() if bus == nil { @@ -304,6 +316,12 @@ func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { // RemoveBus unregisters a bus from the server. func (s *Server) RemoveBus(busID uint32) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeBus(busID) +} + +func (s *Server) removeBus(busID uint32) error { s.busesMu.Lock() bus, ok := s.busses[busID] if !ok { @@ -332,6 +350,9 @@ func (s *Server) RemoveBus(busID uint32) error { // RemoveDeviceByID removes a device by busId and cancels its connections. func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.busesMu.Lock() bus, ok := s.busses[busID] s.busesMu.Unlock() @@ -368,7 +389,7 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { } else { s.logger.Debug("No bus empty context; Cleaning bus immediately") if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { + if err := s.removeBus(busID); err != nil { s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) } else { s.logger.Info("timeout: removed empty bus", "busID", busID) @@ -394,7 +415,6 @@ func (s *Server) removeDevice(busID, deviceID uint32, requireBus bool) error { s.nativeMu.Lock() host := s.native identity, registered := s.nativeIDs[key] - s.nativeMu.Unlock() if host != nil && registered { timeout := s.config.ConnectionTimeout if timeout <= 0 { @@ -404,12 +424,12 @@ func (s *Server) removeDevice(busID, deviceID uint32, requireBus bool) error { err := host.Unregister(ctx, identity) cancel() if err != nil { + s.nativeMu.Unlock() return fmt.Errorf("unplug native UDE device: %w", err) } - s.nativeMu.Lock() delete(s.nativeIDs, key) - s.nativeMu.Unlock() } + s.nativeMu.Unlock() return bus.RemoveDeviceByID(strconv.FormatUint(uint64(deviceID), 10)) } From 67218696e0f031ecce93cb78c1393fb6f5179ffb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:17:02 -0500 Subject: [PATCH 056/240] Verify native controller topology and control parity Use each supported controller implementation to prove that native snapshots retain exact device and configuration bytes, exercise the Microsoft OS feature response through the native control processor, and fix the fragmented legacy control-URB MDL accessor so CONTROL_TRANSFER is never interpreted as CONTROL_TRANSFER_EX. --- internal/server/usb/native_test.go | 20 ++++++ .../udecx/controller_descriptors_test.go | 66 +++++++++++++++++++ native/udecx/driver/Broker.c | 1 + 3 files changed, 87 insertions(+) create mode 100644 internal/transport/udecx/controller_descriptors_test.go diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index f2a8cb69..d47900d7 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -37,6 +37,26 @@ func TestNativeProcessorServesControlDescriptor(t *testing.T) { } } +func TestNativeProcessorServesSwitchMicrosoftOS10FeatureDescriptor(t *testing.T) { + msOS := &usbdevice.MicrosoftOS10Descriptor{ + VendorCode: 0x20, InterfaceNumber: 1, CompatibleID: "WINUSB", + } + dev := &altSettingTestDevice{desc: &usbdevice.Descriptor{MicrosoftOS10: msOS}} + op := udecx.Operation{ + Token: 2, DeviceID: 1, Generation: 1, Kind: udecx.OperationControl, + Direction: 1, TransferLength: 40, + SetupPacket: [8]byte{0xC0, msOS.EffectiveVendorCode(), 0, 0, 4, 0, 40, 0}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + want := msOS.CompatibleIDDescriptor() + if completion.TransferLength != uint32(len(want)) || !bytes.Equal(completion.Payload, want) { + t.Fatalf("native Microsoft OS feature response=%x want=%x", completion.Payload, want) + } +} + func TestNativeProcessorAppliesUdeCxInterfaceSettingLifecycle(t *testing.T) { desc := &usbdevice.Descriptor{ Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, diff --git a/internal/transport/udecx/controller_descriptors_test.go b/internal/transport/udecx/controller_descriptors_test.go new file mode 100644 index 00000000..7e39346a --- /dev/null +++ b/internal/transport/udecx/controller_descriptors_test.go @@ -0,0 +1,66 @@ +package udecx_test + +import ( + "bytes" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/usb" +) + +func TestNativeSnapshotsPreserveSupportedControllerTopologies(t *testing.T) { + type factory func() (usb.Device, error) + tests := map[string]factory{ + "DualSense": func() (usb.Device, error) { return dualsense.New(nil) }, + "DualSense Edge": func() (usb.Device, error) { + return dualsense.NewEdge(nil) + }, + "DualShock 4": func() (usb.Device, error) { return dualshock4.New(nil) }, + "Xbox 360": func() (usb.Device, error) { return xbox360.New(nil) }, + "Switch 2 Pro": func() (usb.Device, error) { + return ns2pro.New(nil) + }, + } + + for name, construct := range tests { + t.Run(name, func(t *testing.T) { + dev, err := construct() + if err != nil { + t.Fatal(err) + } + desc := dev.GetDescriptor() + if desc == nil { + t.Fatal("controller returned no descriptor") + } + wantConfig, err := desc.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + snapshot, err := udecx.SnapshotDevice(0x100, 7, dev) + if err != nil { + t.Fatal(err) + } + + var gotDevice, gotConfig []byte + for _, record := range snapshot.Descriptors { + payload := snapshot.DescriptorData[record.Offset : record.Offset+record.Length] + switch record.Kind { + case udecx.DescriptorDevice: + gotDevice = payload + case udecx.DescriptorConfiguration: + gotConfig = payload + } + } + if !bytes.Equal(gotDevice, desc.Bytes()) { + t.Fatalf("native device descriptor changed: got=%x want=%x", gotDevice, desc.Bytes()) + } + if !bytes.Equal(gotConfig, wantConfig) { + t.Fatalf("native configuration changed: got=%x want=%x", gotConfig, wantConfig) + } + }) + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index f91d663c..edc64a97 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -653,6 +653,7 @@ ViiperGetTransferMdl( case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: return Urb->UrbIsochronousTransfer.TransferBufferMDL; case URB_FUNCTION_CONTROL_TRANSFER: + return Urb->UrbControlTransfer.TransferBufferMDL; case URB_FUNCTION_CONTROL_TRANSFER_EX: return Urb->UrbControlTransferEx.TransferBufferMDL; default: From 4b37f194d5acf27c8a3960c7ee3e8c1766a0be0c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:19:52 -0500 Subject: [PATCH 057/240] Honor URB transfer capacity in native UDE Use the function-specific URB TransferBufferLength as the authoritative capacity for bulk, interrupt, isochronous, legacy control, and extended control requests. UdeCx can expose a valid contiguous buffer while reporting a mapped span smaller than that declared request capacity; usbip-win2 explicitly handles the same condition. Keep a strict requested-length bound before copying so malformed broker completions cannot overrun the host-owned URB. --- native/udecx/driver/Broker.c | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index edc64a97..10ac6ae5 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -661,6 +661,27 @@ ViiperGetTransferMdl( } } +ULONG +ViiperGetTransferBufferLength( + _In_ PURB Urb + ) +{ + switch (Urb->UrbHeader.Function) { + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: + case URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + case URB_FUNCTION_ISOCH_TRANSFER: + case URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: + return Urb->UrbIsochronousTransfer.TransferBufferLength; + case URB_FUNCTION_CONTROL_TRANSFER: + return Urb->UrbControlTransfer.TransferBufferLength; + case URB_FUNCTION_CONTROL_TRANSFER_EX: + return Urb->UrbControlTransferEx.TransferBufferLength; + default: + return 0; + } +} + NTSTATUS ViiperCopyTransferBuffer( _In_ WDFREQUEST Request, @@ -672,6 +693,7 @@ ViiperCopyTransferBuffer( { UCHAR *contiguous = NULL; ULONG contiguousLength = 0; + ULONG transferBufferLength; PMDL mdl; ULONG copied = 0; NTSTATUS status; @@ -680,8 +702,16 @@ ViiperCopyTransferBuffer( return STATUS_SUCCESS; } + transferBufferLength = ViiperGetTransferBufferLength(Urb); + if (Length > transferBufferLength) { + return STATUS_BUFFER_TOO_SMALL; + } + status = UdecxUrbRetrieveBuffer(Request, &contiguous, &contiguousLength); - if (NT_SUCCESS(status) && contiguous != NULL && contiguousLength >= Length) { + if (NT_SUCCESS(status) && contiguous != NULL) { + // UdeCx can report a mapped span smaller than the URB's declared + // TransferBufferLength. The URB field is the authoritative transfer + // capacity for this request; usbip-win2 follows the same rule. if (ToUrb) { RtlCopyMemory(contiguous, Buffer, Length); } else { From 1079a9e60aaa9b799b1618c53b3fe9f1b4be4d15 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:26:57 -0500 Subject: [PATCH 058/240] Return canonical ISO start frames from native UDE Reserve one monotonic virtual USB frame window per isochronous endpoint, using the endpoint interval and negotiated device speed. Return the actual reserved StartFrame for USBD_START_ISO_TRANSFER_ASAP, as required by the Windows URB contract, and reset scheduling ownership at endpoint reset/purge/start boundaries. This prevents stale caller frame values and previous pipe lifetimes from leaking into PlayStation speaker, haptics, or microphone streams. --- docs/architecture/native-udecx.md | 6 +++ native/udecx/driver/Broker.c | 80 +++++++++++++++++++++++++++++++ native/udecx/driver/Device.c | 9 +++- native/udecx/driver/ViiperUde.h | 3 ++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 8944ed6b..ae43ae4c 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -198,6 +198,12 @@ unplug all converge on the same idempotent purge path. user mode may return only each packet's actual length and status. Sparse IN payload span is independent of the completed-byte total, and the kernel derives the URB error count from the returned per-packet statuses. +- Each isochronous endpoint owns a virtual USB frame reservation clock. ASAP + URBs reserve the first frame after the current or previously queued window, + and the driver returns that actual frame in `StartFrame` as required by the + Windows USB contract. Explicit schedules advance the same endpoint clock; + reset, purge, and start clear it so an old pipe lifetime cannot skew a new + media stream. This follows the useful ViGEmBus pattern of per-target ownership and manual request queues while accounting for UdeCx's endpoint-specific purge contract. diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 10ac6ae5..202f409b 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -682,6 +682,77 @@ ViiperGetTransferBufferLength( } } +static +ULONG +ViiperIsoFrameSpan( + _In_ const VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext, + _In_ ULONG PacketCount + ) +{ + UCHAR interval = EndpointContext->Descriptor.bInterval; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(EndpointContext->Device); + ULONGLONG span; + + if (PacketCount == 0 || interval == 0 || interval > 16) { + return PacketCount == 0 ? 1 : PacketCount; + } + if (deviceContext->Speed == UdecxUsbHighSpeed || + deviceContext->Speed == UdecxUsbSuperSpeed) { + // High/SuperSpeed bInterval is an exponent in 125-us microframes, + // while URB StartFrame is expressed in one-millisecond USB frames. + span = (ULONGLONG)PacketCount * (1UL << (interval - 1)); + span = (span + 7) / 8; + } else { + span = (ULONGLONG)PacketCount * interval; + } + if (span == 0) { + return 1; + } + return span > MAXULONG ? MAXULONG : (ULONG)span; +} + +static +ULONG +ViiperReserveIsoStartFrame( + _In_ VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext, + _In_ ULONG TransferFlags, + _In_ ULONG RequestedStartFrame, + _In_ ULONG PacketCount + ) +{ + LONG64 observed; + ULONG currentFrame; + ULONG startFrame; + ULONG nextFrame; + ULONG span; + + span = ViiperIsoFrameSpan(EndpointContext, PacketCount); + if ((TransferFlags & USBD_START_ISO_TRANSFER_ASAP) == 0) { + InterlockedExchange64( + &EndpointContext->NextIsoStartFrame, + (LONG64)(ULONGLONG)(RequestedStartFrame + span)); + return RequestedStartFrame; + } + + currentFrame = (ULONG)(KeQueryInterruptTime() / 10000ULL); + for (;;) { + observed = InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, 0, 0); + startFrame = (ULONG)observed; + if (observed == 0 || (LONG)(startFrame - currentFrame) <= 0) { + startFrame = currentFrame + 1; + } + nextFrame = startFrame + span; + if (InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, + (LONG64)(ULONGLONG)nextFrame, + observed) == observed) { + return startFrame; + } + } +} + NTSTATUS ViiperCopyTransferBuffer( _In_ WDFREQUEST Request, @@ -856,6 +927,10 @@ ViiperSerializeOperation( if (!NT_SUCCESS(status)) { return status; } + if (packetCount != 0) { + startFrame = ViiperReserveIsoStartFrame( + endpointContext, transferFlags, startFrame, packetCount); + } isoBytes = packetCount * sizeof(VIIPER_UDE_ISO_PACKET); payloadLength = directionIn ? 0 : transferLength; @@ -917,6 +992,7 @@ ViiperSerializeOperation( requestContext->TransferLength = transferLength; requestContext->IsoPacketCount = packetCount; + requestContext->IsoStartFrame = startFrame; requestContext->DirectionIn = directionIn; WdfRequestSetInformation(DequeueRequest, totalLength); InterlockedIncrement64(&ControllerContext->OperationsDequeued); @@ -1516,6 +1592,10 @@ ViiperCompleteOperation( goto CompleteWithNtStatus; } urb->UrbIsochronousTransfer.ErrorCount = isoErrorCount; + if ((urb->UrbIsochronousTransfer.TransferFlags & + USBD_START_ISO_TRANSFER_ASAP) != 0) { + urb->UrbIsochronousTransfer.StartFrame = requestContext->IsoStartFrame; + } InterlockedAdd64(&controllerContext->IsoPackets, completion->IsoPacketCount); } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index d7ae6f09..e7c7b93f 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -416,6 +416,7 @@ ViiperCreateVirtualDevice( deviceContext->DeviceId = input->DeviceId; deviceContext->Generation = input->Generation; deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; + deviceContext->Speed = speed; WdfObjectReference(ownerFile); InterlockedExchange(&deviceContext->OwnerReferenced, 1); @@ -1002,6 +1003,7 @@ ViiperEvtEndpointReset( { NTSTATUS status; + InterlockedExchange64(&ViiperGetEndpointContext(Endpoint)->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); status = ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointReset); WdfRequestComplete(Request, status); @@ -1025,6 +1027,7 @@ ViiperEvtEndpointPurge( { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); InterlockedExchange(&endpointContext->Purging, TRUE); + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); @@ -1035,9 +1038,11 @@ ViiperEvtEndpointStart( _In_ UDECXUSBENDPOINT Endpoint ) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); - InterlockedExchange(&ViiperGetEndpointContext(Endpoint)->Purging, FALSE); - WdfIoQueueStart(ViiperGetEndpointContext(Endpoint)->Queue); + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + InterlockedExchange(&endpointContext->Purging, FALSE); + WdfIoQueueStart(endpointContext->Queue); } VOID diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 5e60286e..e7141fdb 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -63,6 +63,7 @@ typedef struct VIIPER_UDE_REQUEST_CONTEXT { ULONGLONG Token; ULONG TransferLength; ULONG IsoPacketCount; + ULONG IsoStartFrame; BOOLEAN DirectionIn; } VIIPER_UDE_REQUEST_CONTEXT; @@ -131,6 +132,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { ULONGLONG DeviceId; ULONG Generation; ULONG Slot; + UDECX_USB_DEVICE_SPEED Speed; BOOLEAN Plugged; volatile LONG Purging; volatile LONG ActiveCounted; @@ -150,6 +152,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; volatile LONG64 LastInputSequence; + volatile LONG64 NextIsoStartFrame; ULONGLONG NextAdmissionSequence; BOOLEAN FastInput; } VIIPER_UDE_ENDPOINT_CONTEXT; From 50c0c0d80f5e55bd88c23c91d256bb621e61a963 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:34:26 -0500 Subject: [PATCH 059/240] Complete native UDE host capability and reset contracts Report SuperSpeed compatibility alongside the USB 3 root ports, following the documented UdeCx capability contract and usbip-win2 reference implementation. Forward post-enumeration per-child resets to the generation-owned user-mode lifecycle instead of leaving controller state stale after a host reset. Keep controller-wide resets rejected because the configured policy is reset-each-child. Correct the ISO frame-span shift to compile cleanly under the WDK /W4 /WX gate and preserve valid full-speed bInterval values above 16. --- docs/architecture/native-udecx.md | 4 ++++ native/udecx/driver/Broker.c | 9 +++++++-- native/udecx/driver/Controller.c | 4 ++++ native/udecx/driver/Device.c | 24 ++++++++++++++++++++++++ native/udecx/driver/ViiperUde.h | 1 + 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index ae43ae4c..909f7394 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -17,6 +17,10 @@ after transfer ordering, cancellation, teardown, and recovery are proven. cancelled before `UdecxUsbEndpointPurgeComplete` is called. - The local usbip-win2 0.9.7.8 reference proves that UdeCx can expose VIIPER's bidirectional isochronous PlayStation audio topology on Windows. +- Its controller contract also reports chained-MDL, high-speed, and SuperSpeed + compatibility for a root controller with USB 2 and USB 3 ports. VIIPER + mirrors that capability set and explicitly forwards post-enumeration child + resets into the generation-owned lifecycle stream. - ViGEmBus provides the lifecycle north star: explicit protocol negotiation, handle-scoped ownership, bounded manual queues, cancel-safe requests, generation-aware target teardown, and synchronization per target rather than diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 202f409b..f78e39e9 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -694,14 +694,19 @@ ViiperIsoFrameSpan( ViiperGetDeviceContext(EndpointContext->Device); ULONGLONG span; - if (PacketCount == 0 || interval == 0 || interval > 16) { + if (PacketCount == 0 || interval == 0) { return PacketCount == 0 ? 1 : PacketCount; } if (deviceContext->Speed == UdecxUsbHighSpeed || deviceContext->Speed == UdecxUsbSuperSpeed) { + if (interval > 16) { + // UdeCx should reject an invalid high-speed descriptor before an + // URB reaches us. Keep the fallback bounded if it does not. + return PacketCount; + } // High/SuperSpeed bInterval is an exponent in 125-us microframes, // while URB StartFrame is expressed in one-millisecond USB frames. - span = (ULONGLONG)PacketCount * (1UL << (interval - 1)); + span = (ULONGLONG)PacketCount * ((ULONGLONG)1 << (interval - 1)); span = (span + 7) / 8; } else { span = (ULONGLONG)PacketCount * interval; diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 9758a19c..06e7536d 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -86,6 +86,10 @@ ViiperEvtQueryUsbCapability( RtlEqualMemory( CapabilityType, &GUID_USB_CAPABILITY_DEVICE_CONNECTION_HIGH_SPEED_COMPATIBLE, + sizeof(GUID)) || + RtlEqualMemory( + CapabilityType, + &GUID_USB_CAPABILITY_DEVICE_CONNECTION_SUPER_SPEED_COMPATIBLE, sizeof(GUID))) { return STATUS_SUCCESS; } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index e7c7b93f..beed403d 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -384,6 +384,7 @@ ViiperCreateVirtualDevice( UDECX_USB_DEVICE_CALLBACKS_INIT(&callbacks); callbacks.EvtUsbDeviceLinkPowerEntry = ViiperEvtUsbDeviceD0Entry; callbacks.EvtUsbDeviceLinkPowerExit = ViiperEvtUsbDeviceD0Exit; + callbacks.EvtUsbDeviceReset = ViiperEvtUsbDeviceReset; if (speed == UdecxUsbSuperSpeed) { callbacks.EvtUsbDeviceSetFunctionSuspendAndWake = ViiperEvtUsbDeviceSetFunctionSuspendAndWake; @@ -650,6 +651,29 @@ ViiperEvtUsbDeviceSetFunctionSuspendAndWake( return STATUS_SUCCESS; } +VOID +ViiperEvtUsbDeviceReset( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ BOOLEAN AllDevicesReset + ) +{ + NTSTATUS status; + + UNREFERENCED_PARAMETER(Controller); + if (AllDevicesReset) { + // The controller uses UdecxWdfDeviceResetActionResetEachUsbDevice, + // so UdeCx must deliver one callback per child. Accepting a controller- + // wide reset here would make the owner lose the affected generation. + WdfRequestComplete(Request, STATUS_NOT_SUPPORTED); + return; + } + + status = ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceReset); + WdfRequestComplete(Request, status); +} + static NTSTATUS ViiperCreateEndpointQueue( diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index e7141fdb..103d4948 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -174,6 +174,7 @@ EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; EVT_UDECX_USB_DEVICE_SET_FUNCTION_SUSPEND_AND_WAKE ViiperEvtUsbDeviceSetFunctionSuspendAndWake; +EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET ViiperEvtUsbDeviceReset; EVT_UDECX_USB_DEVICE_DEFAULT_ENDPOINT_ADD ViiperEvtDefaultEndpointAdd; EVT_UDECX_USB_DEVICE_ENDPOINT_ADD ViiperEvtEndpointAdd; EVT_UDECX_USB_DEVICE_ENDPOINTS_CONFIGURE ViiperEvtEndpointsConfigure; From d0d9ef3e866dd21da54d9b9ac0f6188c97fcb327 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:39:11 -0500 Subject: [PATCH 060/240] Prove native transport parity with production controllers Exercise the native UDE processor through the actual DualSense, DualShock 4, Xbox 360, and Switch 2 Pro engines. Verify output reports and rumble remain byte-accurate, and verify bidirectional DualSense/DS4 isochronous speaker and microphone media survives interface activation, packet geometry, and native completion without mutation. --- internal/server/usb/native_production_test.go | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 internal/server/usb/native_production_test.go diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go new file mode 100644 index 00000000..7ac90716 --- /dev/null +++ b/internal/server/usb/native_production_test.go @@ -0,0 +1,230 @@ +package usb_test + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +func TestNativeProcessorPreservesProductionControllerOutputReports(t *testing.T) { + t.Run("DualSense", func(t *testing.T) { + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + var got dualsense.OutputState + dev.SetOutputCallback(func(state dualsense.OutputState) { got = state }) + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[3], report[4] = dualsense.ReportIDOutput, 0x03, 0x31, 0x92 + processNativeOutput(t, dev, dualsense.EndpointOut, report) + if got.RumbleSmall != 0x31 || got.RumbleLarge != 0x92 || + !bytes.Equal(got.RawOutputReport[:], report) { + t.Fatalf("DualSense output changed across native transport: %+v", got) + } + }) + + t.Run("DualShock4", func(t *testing.T) { + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + var got dualshock4.OutputState + dev.SetOutputCallback(func(state dualshock4.OutputState) { got = state }) + report := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x22, 0xe1, 1, 2, 3, 4, 5} + processNativeOutput(t, dev, dualshock4.EndpointOut, report) + if got != (dualshock4.OutputState{ + RumbleSmall: 0x22, RumbleLarge: 0xe1, + LedRed: 1, LedGreen: 2, LedBlue: 3, FlashOn: 4, FlashOff: 5, + }) { + t.Fatalf("DualShock 4 output changed across native transport: %+v", got) + } + }) + + t.Run("Xbox360", func(t *testing.T) { + dev, err := xbox360.New(nil) + if err != nil { + t.Fatal(err) + } + var got xbox360.XRumbleState + dev.SetRumbleCallback(func(state xbox360.XRumbleState) { got = state }) + processNativeOutput(t, dev, 0x01, []byte{0x00, 0x08, 0x00, 0x74, 0x29, 0, 0, 0}) + if got != (xbox360.XRumbleState{LeftMotor: 0x74, RightMotor: 0x29}) { + t.Fatalf("Xbox 360 rumble changed across native transport: %+v", got) + } + }) + + t.Run("Switch2Pro", func(t *testing.T) { + dev, err := ns2pro.New(nil) + if err != nil { + t.Fatal(err) + } + var got ns2pro.OutputState + clear := dev.SetOutputCallback(func(state ns2pro.OutputState) { got = state }) + defer clear() + report := make([]byte, ns2pro.OutputReportSize) + report[0] = ns2pro.ReportIDOutput + for i := range 16 { + report[1+i] = byte(i + 1) + report[17+i] = byte(0x80 + i) + } + processNativeOutput(t, dev, ns2pro.EndpointHIDOut, report) + if got.Flags != ns2pro.OutputFlagRumble || + !bytes.Equal(got.LeftRumble[:], report[1:17]) || + !bytes.Equal(got.RightRumble[:], report[17:33]) { + t.Fatalf("Switch 2 Pro rumble changed across native transport: %+v", got) + } + }) +} + +func processNativeOutput(t *testing.T, dev usbdevice.Device, endpoint uint8, payload []byte) { + t.Helper() + server := serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + op := udecx.Operation{ + Token: 99, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, Direction: 0, + TransferLength: uint32(len(payload)), Payload: payload, + } + completion, err := processor.Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native OUT completion=%+v", completion) + } +} + +func TestNativeProcessorPreservesPlayStationIsochronousMedia(t *testing.T) { + t.Run("DualSense", func(t *testing.T) { + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + setNativeInterface(t, processor, dev, dualsense.InterfaceHapticsAudio, 1) + setNativeInterface(t, processor, dev, dualsense.InterfaceMicrophone, 1) + + var speaker []byte + dev.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, pcm []byte) { + speaker = append([]byte(nil), pcm...) + }) + usbPCM := make([]byte, 480*dualsense.USBHapticsAudioFrameSize) + for i := range usbPCM { + usbPCM[i] = byte(i*37 + 11) + } + completion := processNativeIso(t, processor, dev, dualsense.EndpointHapticsAudioOut, + false, usbPCM, 10, dualsense.USBHapticsAudioPacketSize) + if completion.TransferLength != uint32(len(usbPCM)) || len(speaker) != 480*4 { + t.Fatalf("DualSense speaker completion=%d callback=%d", completion.TransferLength, len(speaker)) + } + + microphoneFrame := make([]byte, dualsense.USBMicrophoneClientFrameSize) + for i := range microphoneFrame { + microphoneFrame[i] = byte(i*13 + 7) + } + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + completion = processNativeIso(t, processor, dev, dualsense.EndpointMicrophoneIn, + true, nil, 10, dualsense.USBMicrophonePacketSize) + if !bytes.Equal(completion.Payload, microphoneFrame) { + t.Fatal("DualSense microphone PCM changed across native transport") + } + }) + + t.Run("DualShock4", func(t *testing.T) { + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + setNativeInterface(t, processor, dev, dualshock4.InterfaceSpeaker, 1) + setNativeInterface(t, processor, dev, dualshock4.InterfaceMicrophone, 1) + + speakerPCM := make([]byte, 128) + for i := range speakerPCM { + speakerPCM[i] = byte(i*19 + 3) + } + var speaker []byte + dev.SetSpeakerCallback(func(pcm []byte) { speaker = append([]byte(nil), pcm...) }) + completion := processNativeIso(t, processor, dev, dualshock4.EndpointAudioOut, + false, speakerPCM, 1, uint32(len(speakerPCM))) + if completion.TransferLength != uint32(len(speakerPCM)) || !bytes.Equal(speaker, speakerPCM) { + t.Fatal("DualShock 4 speaker PCM changed across native transport") + } + + microphoneFrame := make([]byte, dualshock4.USBMicrophoneClientFrameSize) + for i := range microphoneFrame { + microphoneFrame[i] = byte(i*23 + 5) + } + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + completion = processNativeIso(t, processor, dev, dualshock4.EndpointMicrophoneIn, + true, nil, 10, dualshock4.USBMicrophonePacketSize) + if !bytes.Equal(completion.Payload, microphoneFrame) { + t.Fatal("DualShock 4 microphone PCM changed across native transport") + } + }) +} + +func newProductionProcessor(t *testing.T) *serverusb.NativeProcessor { + t.Helper() + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + return processor +} + +func setNativeInterface(t *testing.T, processor *serverusb.NativeProcessor, + dev usbdevice.Device, iface, alt uint8) { + t.Helper() + err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationSetInterface, + InterfaceNumber: iface, InterfaceSetting: alt, + }) + if err != nil { + t.Fatal(err) + } +} + +func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, + dev usbdevice.Device, endpoint uint8, input bool, payload []byte, + packetCount int, packetLength uint32) udecx.Completion { + t.Helper() + packets := make([]udecx.IsoPacket, packetCount) + for i := range packets { + packets[i] = udecx.IsoPacket{Offset: uint32(i) * packetLength, Length: packetLength} + } + transferLength := uint32(packetCount) * packetLength + op := udecx.Operation{ + Token: 100, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, TransferLength: transferLength, + IsoPackets: packets, Payload: payload, + } + if input { + op.Direction = 1 + } + completion, err := processor.Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if len(completion.IsoPackets) != packetCount { + t.Fatalf("native ISO completion has %d packets, want %d", len(completion.IsoPackets), packetCount) + } + return completion +} From 546dc9c27670a5c15871e93c337ca66bec0ebcfb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:43:27 -0500 Subject: [PATCH 061/240] Close native UDE sessions without handle races --- docs/architecture/native-udecx.md | 6 +++ internal/cmd/native_transport.go | 30 +++++++++++ internal/cmd/native_transport_test.go | 68 ++++++++++++++++++++++++ internal/cmd/native_transport_windows.go | 26 ++------- internal/cmd/server.go | 3 +- 5 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 internal/cmd/native_transport_test.go diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 909f7394..ce204527 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -100,6 +100,12 @@ close that driver handle and negotiate a fresh `Client`/`Host` session, matching ViGEmBus's file-session ownership model, rather than guessing a new endpoint sequence baseline and risking an abandoned USB request. +Session shutdown owns a cancellation context before `Serve` is scheduled, so +even an immediate stop cannot miss host cancellation. The client waits for all +dequeue workers, endpoint lanes, input publishers, and their completions to +finish before cancelling overlapped kernel I/O and closing the exclusive broker +handle. The handle is therefore always the last object released. + This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping. The direct input lane removes the highest-frequency HID broker path without mixing report ownership into the proven PlayStation media/state transport. diff --git a/internal/cmd/native_transport.go b/internal/cmd/native_transport.go index b793c2de..c9d58d4c 100644 --- a/internal/cmd/native_transport.go +++ b/internal/cmd/native_transport.go @@ -1,6 +1,36 @@ package cmd +import ( + "context" + "errors" + "sync" +) + type nativeUDETransport interface { Done() <-chan error Close() error } + +// nativeUDETransportSession owns the lifetime boundary between the Go host +// and the kernel broker handle. Cancellation is session-owned rather than +// delegated to Host.Close so shutdown is safe even if it races the Serve +// goroutine's first instruction. The broker handle is closed only after every +// dequeue worker, endpoint lane, and input publisher has stopped using it. +type nativeUDETransportSession struct { + cancel context.CancelFunc + closeClient func() error + done chan error + closeOnce sync.Once + closeErr error +} + +func (s *nativeUDETransportSession) Done() <-chan error { return s.done } + +func (s *nativeUDETransportSession) Close() error { + s.closeOnce.Do(func() { + s.cancel() + serveErr := <-s.done + s.closeErr = errors.Join(serveErr, s.closeClient()) + }) + return s.closeErr +} diff --git a/internal/cmd/native_transport_test.go b/internal/cmd/native_transport_test.go new file mode 100644 index 00000000..9b26087c --- /dev/null +++ b/internal/cmd/native_transport_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + var hostStopped atomic.Bool + var clientClosed atomic.Bool + orderingErr := errors.New("kernel client closed before native host stopped") + session := &nativeUDETransportSession{ + cancel: cancel, + done: done, + closeClient: func() error { + if !hostStopped.Load() { + return orderingErr + } + clientClosed.Store(true) + return nil + }, + } + go func() { + <-ctx.Done() + hostStopped.Store(true) + done <- nil + close(done) + }() + + closed := make(chan error, 1) + go func() { closed <- session.Close() }() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("native transport shutdown did not complete") + } + if !clientClosed.Load() { + t.Fatal("kernel client was not closed") + } + if err := session.Close(); err != nil { + t.Fatalf("idempotent Close returned %v", err) + } +} + +func TestNativeUDETransportClosePreservesHostAndClientErrors(t *testing.T) { + hostErr := errors.New("host failed") + clientErr := errors.New("client close failed") + done := make(chan error, 1) + done <- hostErr + close(done) + session := &nativeUDETransportSession{ + cancel: func() {}, + done: done, + closeClient: func() error { return clientErr }, + } + err := session.Close() + if !errors.Is(err, hostErr) || !errors.Is(err, clientErr) { + t.Fatalf("Close error=%v, want joined host and client errors", err) + } +} diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go index ae273f90..f57e03f7 100644 --- a/internal/cmd/native_transport_windows.go +++ b/internal/cmd/native_transport_windows.go @@ -4,20 +4,11 @@ package cmd import ( "context" - "sync" serverusb "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/transport/udecx" ) -type windowsNativeUDETransport struct { - host *udecx.Host - client *udecx.Client - done chan error - closeOnce sync.Once - closeErr error -} - func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nativeUDETransport, error) { client, err := udecx.Open(ctx) if err != nil { @@ -37,22 +28,13 @@ func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nat _ = client.Close() return nil, err } - session := &windowsNativeUDETransport{ - host: host, client: client, done: make(chan error, 1), + sessionCtx, cancel := context.WithCancel(ctx) + session := &nativeUDETransportSession{ + cancel: cancel, closeClient: client.Close, done: make(chan error, 1), } go func() { - session.done <- host.Serve(ctx) + session.done <- host.Serve(sessionCtx) close(session.done) }() return session, nil } - -func (s *windowsNativeUDETransport) Done() <-chan error { return s.done } - -func (s *windowsNativeUDETransport) Close() error { - s.closeOnce.Do(func() { - s.host.Close() - s.closeErr = s.client.Close() - }) - return s.closeErr -} diff --git a/internal/cmd/server.go b/internal/cmd/server.go index b565a086..160d73d9 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -60,7 +60,8 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger s.APIServerConfig.ConnectionTimeout = s.ConnectionTimeout s.USBServerConfig.BusCleanupTimeout = s.APIServerConfig.DeviceHandlerConnectTimeout - logger.Info("Starting VIIPER USB-IP server", "addr", s.USBServerConfig.Addr) + logger.Info("Starting VIIPER virtual USB server", "transport", transport, + "usbipAddr", s.USBServerConfig.Addr) keyFileDir, err := configpaths.KeyFileDir() if err != nil { From 21b89e494e66a7fd315b78c12961c36d3507743d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 22:55:08 -0500 Subject: [PATCH 062/240] Derive native audio lifecycle from endpoint identity UdeCx reports unreliable composite interface/alternate values. Carry the authoritative endpoint descriptor signature through ABI v1.5 and derive PlayStation audio activation from endpoint start, purge, and the first ISO URB. This avoids usbip-win2's system-wide root-hub filter while closing the cross-worker start race. Production DualSense and DualShock 4 media tests now exercise the descriptor-derived path. --- docs/architecture/native-udecx.md | 19 ++ internal/server/usb/native.go | 189 ++++++++++++++++-- internal/server/usb/native_production_test.go | 48 +++-- internal/server/usb/native_test.go | 75 +++++-- internal/transport/udecx/protocol.go | 70 ++++--- internal/transport/udecx/protocol_test.go | 6 +- native/udecx/README.md | 3 +- native/udecx/driver/Broker.c | 23 ++- native/udecx/driver/ViiperUde.h | 3 + native/udecx/include/ViiperUdeProtocol.h | 6 +- 10 files changed, 364 insertions(+), 78 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index ce204527..092382b1 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -120,6 +120,8 @@ Every operation carries: - device ID and generation; - a globally unique token for that generation; - endpoint address and transfer direction; +- endpoint attributes, interval, and maximum packet size copied from the + UdeCx endpoint descriptor; - operation kind and URB function; - transfer flags, setup packet, and start frame where applicable; - ordered isochronous packet metadata; @@ -155,6 +157,23 @@ Absent -> Creating -> Enumerating -> Active Power loss, owner process exit, DS4Windows restart, VIIPER restart, and explicit unplug all converge on the same idempotent purge path. +### Composite alternate-setting identity + +The usbip-win2 0.9.7.8 UdeCx implementation documents that UdeCx can report +incorrect `InterfaceNumber` and `NewInterfaceSetting` values for composite +device alternate-setting changes. usbip-win2 compensates with an upper filter +on every USB 3 root hub. VIIPER does not install that system-wide filter. + +Every endpoint callback already supplies the authoritative endpoint descriptor. +The kernel copies its address, attributes, interval, and maximum packet size +into the versioned broker operation. User mode matches that complete signature +against the immutable controller descriptor and derives the owning interface +and alternate setting. Endpoint start activates it; purge returns it to zero +only after the last endpoint belonging to the active alternate is gone. The +first ISO URB is also authoritative activation, closing the cross-worker race +where media reaches user mode before its start notification. Numeric UdeCx +interface fields are only hints for alternates that contain no endpoints. + ## Synchronization model - A controller-level lock protects the device table and owner registration. diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index e663e08c..fd1e546e 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -18,14 +18,28 @@ type nativeLaneKey struct { endpoint uint8 } +type nativeSessionKey struct { + deviceID uint64 + generation uint32 +} + +type nativeEndpointSignature struct { + address uint8 + attributes uint8 + interval uint8 + maxPacket uint16 +} + // NativeProcessor adapts the native UdeCx broker to the same control and // transfer engine used by USB/IP. Transport-specific clocks live here; device // state, feedback, HID, audio, and descriptor behavior remain in usb.Device. type NativeProcessor struct { - server *Server - mu sync.Mutex - next map[nativeLaneKey]time.Time - lastIn map[nativeLaneKey][]byte + server *Server + mu sync.Mutex + lifecycleMu sync.Mutex + next map[nativeLaneKey]time.Time + lastIn map[nativeLaneKey][]byte + active map[nativeSessionKey]map[nativeEndpointSignature]struct{} } func NewNativeProcessor(server *Server) (*NativeProcessor, error) { @@ -36,10 +50,17 @@ func NewNativeProcessor(server *Server) (*NativeProcessor, error) { server: server, next: make(map[nativeLaneKey]time.Time), lastIn: make(map[nativeLaneKey][]byte), + active: make(map[nativeSessionKey]map[nativeEndpointSignature]struct{}), }, nil } func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdentity) { + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + p.resetDeviceLocked(dev, identity) +} + +func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity) { p.server.resetInterfaceAlts(dev) p.mu.Lock() for key := range p.next { @@ -49,6 +70,7 @@ func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdent } } p.mu.Unlock() + delete(p.active, nativeSessionKey{deviceID: identity.DeviceID, generation: identity.Generation}) } func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op udecx.Operation) error { @@ -60,7 +82,14 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op switch op.Kind { case udecx.OperationEndpointStart: p.clearLane(key) - case udecx.OperationEndpointPurge, udecx.OperationEndpointReset: + p.activateEndpoint(dev, op) + case udecx.OperationEndpointPurge: + p.clearLane(key) + if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { + resetter.ResetEndpoint(op.EndpointAddress) + } + p.deactivateEndpoint(dev, op) + case udecx.OperationEndpointReset: p.clearLane(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) @@ -73,19 +102,149 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op // anchors so the first resumed transfer starts from the current time. p.clearDeviceLanes(identity) case udecx.OperationSetInterface: - if !descriptorHasInterfaceAlt(dev.GetDescriptor(), op.InterfaceNumber, op.InterfaceSetting) { - return fmt.Errorf("native UDE selected invalid alternate setting %d for interface %d", - op.InterfaceSetting, op.InterfaceNumber) - } - p.clearDeviceLanes(identity) - p.server.setInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) - p.server.notifyInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) + // UdeCx is documented by usbip-win2 0.9.7.8 to return incorrect + // interface/alternate values for some composite devices. Treat this as + // a hint only. Interfaces with endpoint-bearing alternate settings are + // driven by the exact endpoint descriptors carried by start/purge and + // transfer operations instead. + p.applyInterfaceHint(dev, op) default: return fmt.Errorf("unsupported native UDE lifecycle operation %d", op.Kind) } return nil } +func signatureFromOperation(op udecx.Operation) nativeEndpointSignature { + return nativeEndpointSignature{ + address: op.EndpointAddress, attributes: op.EndpointAttributes, + interval: op.EndpointInterval, maxPacket: op.EndpointMaxPacketSize, + } +} + +func signatureFromDescriptor(endpoint usbdevice.EndpointDescriptor) nativeEndpointSignature { + return nativeEndpointSignature{ + address: endpoint.BEndpointAddress, attributes: endpoint.BMAttributes, + interval: endpoint.BInterval, maxPacket: endpoint.WMaxPacketSize, + } +} + +func descriptorInterfaceAltForEndpoint(desc *usbdevice.Descriptor, + signature nativeEndpointSignature) (uint8, uint8, bool) { + if desc == nil || signature.address == 0 { + return 0, 0, false + } + var interfaceNumber, alternateSetting uint8 + found := false + for _, iface := range desc.Interfaces { + if iface.Descriptor.BAlternateSetting == 0 { + continue + } + for _, endpoint := range iface.Endpoints { + if signatureFromDescriptor(endpoint) != signature { + continue + } + candidateInterface := iface.Descriptor.BInterfaceNumber + candidateAlt := iface.Descriptor.BAlternateSetting + if found && (candidateInterface != interfaceNumber || candidateAlt != alternateSetting) { + return 0, 0, false + } + interfaceNumber, alternateSetting, found = candidateInterface, candidateAlt, true + } + } + return interfaceNumber, alternateSetting, found +} + +func descriptorInterfaceUsesEndpointLifecycle(desc *usbdevice.Descriptor, interfaceNumber uint8) bool { + if desc == nil { + return false + } + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber == interfaceNumber && + iface.Descriptor.BAlternateSetting != 0 && len(iface.Endpoints) != 0 { + return true + } + } + return false +} + +func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, alternateSetting uint8, + active map[nativeEndpointSignature]struct{}) bool { + if desc == nil { + return false + } + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != interfaceNumber || + iface.Descriptor.BAlternateSetting != alternateSetting { + continue + } + for _, endpoint := range iface.Endpoints { + if _, ok := active[signatureFromDescriptor(endpoint)]; ok { + return true + } + } + } + return false +} + +func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operation) { + signature := signatureFromOperation(op) + interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( + dev.GetDescriptor(), signature) + if !ok { + return + } + identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + active := p.active[identity] + if active == nil { + active = make(map[nativeEndpointSignature]struct{}) + p.active[identity] = active + } + active[signature] = struct{}{} + if p.server.getInterfaceAlt(dev, interfaceNumber) != alternateSetting { + p.server.setInterfaceAlt(dev, interfaceNumber, alternateSetting) + p.server.notifyInterfaceAlt(dev, interfaceNumber, alternateSetting) + } +} + +func (p *NativeProcessor) deactivateEndpoint(dev usbdevice.Device, op udecx.Operation) { + signature := signatureFromOperation(op) + interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( + dev.GetDescriptor(), signature) + if !ok { + return + } + identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + active := p.active[identity] + delete(active, signature) + if len(active) == 0 { + delete(p.active, identity) + } + if p.server.getInterfaceAlt(dev, interfaceNumber) == alternateSetting && + !descriptorInterfaceAltIsActive(dev.GetDescriptor(), interfaceNumber, alternateSetting, active) { + p.server.setInterfaceAlt(dev, interfaceNumber, 0) + p.server.notifyInterfaceAlt(dev, interfaceNumber, 0) + } +} + +func (p *NativeProcessor) applyInterfaceHint(dev usbdevice.Device, op udecx.Operation) { + desc := dev.GetDescriptor() + if !descriptorHasInterfaceAlt(desc, op.InterfaceNumber, op.InterfaceSetting) || + descriptorInterfaceUsesEndpointLifecycle(desc, op.InterfaceNumber) { + return + } + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + if p.server.getInterfaceAlt(dev, op.InterfaceNumber) == op.InterfaceSetting { + return + } + p.server.setInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) + p.server.notifyInterfaceAlt(dev, op.InterfaceNumber, op.InterfaceSetting) +} + func (p *NativeProcessor) clearDeviceLanes(identity udecx.DeviceIdentity) { p.mu.Lock() for key := range p.next { @@ -117,6 +276,12 @@ func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op dir = usbip.DirIn } key := nativeLaneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} + if len(op.IsoPackets) != 0 { + // The first ISO URB is itself authoritative proof that Windows activated + // this endpoint. This also closes the scheduling race where a transfer is + // dequeued before the endpoint-start notification reaches another worker. + p.activateEndpoint(dev, op) + } switch { case op.Kind == udecx.OperationControl: diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go index 7ac90716..f06e07d9 100644 --- a/internal/server/usb/native_production_test.go +++ b/internal/server/usb/native_production_test.go @@ -113,8 +113,8 @@ func TestNativeProcessorPreservesPlayStationIsochronousMedia(t *testing.T) { t.Fatal(err) } processor := newProductionProcessor(t) - setNativeInterface(t, processor, dev, dualsense.InterfaceHapticsAudio, 1) - setNativeInterface(t, processor, dev, dualsense.InterfaceMicrophone, 1) + startNativeEndpoint(t, processor, dev, dualsense.EndpointHapticsAudioOut) + startNativeEndpoint(t, processor, dev, dualsense.EndpointMicrophoneIn) var speaker []byte dev.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, pcm []byte) { @@ -150,8 +150,8 @@ func TestNativeProcessorPreservesPlayStationIsochronousMedia(t *testing.T) { t.Fatal(err) } processor := newProductionProcessor(t) - setNativeInterface(t, processor, dev, dualshock4.InterfaceSpeaker, 1) - setNativeInterface(t, processor, dev, dualshock4.InterfaceMicrophone, 1) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointAudioOut) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointMicrophoneIn) speakerPCM := make([]byte, 128) for i := range speakerPCM { @@ -190,16 +190,31 @@ func newProductionProcessor(t *testing.T) *serverusb.NativeProcessor { return processor } -func setNativeInterface(t *testing.T, processor *serverusb.NativeProcessor, - dev usbdevice.Device, iface, alt uint8) { +func startNativeEndpoint(t *testing.T, processor *serverusb.NativeProcessor, + dev usbdevice.Device, endpointAddress uint8) { t.Helper() - err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ - DeviceID: 1, Generation: 1, Kind: udecx.OperationSetInterface, - InterfaceNumber: iface, InterfaceSetting: alt, - }) - if err != nil { - t.Fatal(err) + for _, iface := range dev.GetDescriptor().Interfaces { + if iface.Descriptor.BAlternateSetting == 0 { + continue + } + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress != endpointAddress { + continue + } + err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationEndpointStart, + EndpointAddress: endpoint.BEndpointAddress, + EndpointAttributes: endpoint.BMAttributes, + EndpointInterval: endpoint.BInterval, + EndpointMaxPacketSize: endpoint.WMaxPacketSize, + }) + if err != nil { + t.Fatal(err) + } + return + } } + t.Fatalf("endpoint %#x has no nonzero alternate setting", endpointAddress) } func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, @@ -216,6 +231,15 @@ func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, EndpointAddress: endpoint, TransferLength: transferLength, IsoPackets: packets, Payload: payload, } + for _, iface := range dev.GetDescriptor().Interfaces { + for _, descEndpoint := range iface.Endpoints { + if descEndpoint.BEndpointAddress == endpoint { + op.EndpointAttributes = descEndpoint.BMAttributes + op.EndpointInterval = descEndpoint.BInterval + op.EndpointMaxPacketSize = descEndpoint.WMaxPacketSize + } + } + } if input { op.Direction = 1 } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index d47900d7..dd9dfb66 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -57,7 +57,7 @@ func TestNativeProcessorServesSwitchMicrosoftOS10FeatureDescriptor(t *testing.T) } } -func TestNativeProcessorAppliesUdeCxInterfaceSettingLifecycle(t *testing.T) { +func TestNativeProcessorDerivesInterfaceSettingFromEndpointLifecycle(t *testing.T) { desc := &usbdevice.Descriptor{ Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, Interfaces: []usbdevice.InterfaceConfig{ @@ -65,32 +65,83 @@ func TestNativeProcessorAppliesUdeCxInterfaceSettingLifecycle(t *testing.T) { BInterfaceNumber: 2, BAlternateSetting: 0, }}, {Descriptor: usbdevice.InterfaceDescriptor{ - BInterfaceNumber: 2, BAlternateSetting: 1, - }}, + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, }, } dev := &altSettingTestDevice{desc: desc} processor := nativeProcessorForTest(t) - op := udecx.Operation{ + // UdeCx supplies incorrect numeric interface fields for some composite + // devices. An endpoint-bearing alternate must therefore ignore this hint. + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ DeviceID: 1, Generation: 1, Kind: udecx.OperationSetInterface, - InterfaceNumber: 2, InterfaceSetting: 1, + InterfaceNumber: 0, InterfaceSetting: 0, + }); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("unreliable interface hint changed interface 2 alt to %d", got) + } + + op := udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x82, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, } if err := processor.Lifecycle(context.Background(), dev, op); err != nil { t.Fatal(err) } if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { - t.Fatalf("interface 2 alt=%d want 1", got) + t.Fatalf("interface 2 alt=%d want 1 after endpoint start", got) + } + + op.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatal(err) } - if len(dev.altEvents) != 1 || dev.altEvents[0] != [2]uint8{2, 1} { - t.Fatalf("device alternate-setting events=%v want [[2 1]]", dev.altEvents) + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("interface 2 alt=%d want 0 after endpoint purge", got) } + if want := [][2]uint8{{2, 1}, {2, 0}}; !bytes.Equal(flattenAltEvents(dev.altEvents), flattenAltEvents(want)) { + t.Fatalf("device alternate-setting events=%v want %v", dev.altEvents, want) + } +} + +func flattenAltEvents(events [][2]uint8) []byte { + result := make([]byte, 0, len(events)*2) + for _, event := range events { + result = append(result, event[0], event[1]) + } + return result +} - op.InterfaceSetting = 3 - if err := processor.Lifecycle(context.Background(), dev, op); err == nil { - t.Fatal("invalid native alternate setting unexpectedly succeeded") +func TestNativeProcessorFirstISOTransferClosesEndpointStartRace(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, + }} + dev := &isoOutRecordingDevice{desc: desc} + processor := nativeProcessorForTest(t) + _, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 1, DeviceID: 3, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, + TransferLength: 4, Payload: []byte{1, 2, 3, 4}, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 4}}, + }) + if err != nil { + t.Fatal(err) } if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { - t.Fatalf("invalid transition changed interface 2 alt to %d", got) + t.Fatalf("first ISO transfer left interface 2 at alt %d", got) } } diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index a7fff4d8..7a324659 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 4 + ABIMinor uint16 = 5 HeaderSize = 16 NegotiateRequestSize = 32 @@ -277,22 +277,25 @@ type IsoPacket struct { } type Operation struct { - Token uint64 - DeviceID uint64 - Generation uint32 - Kind OperationKind - EndpointAddress uint8 - Direction uint8 - InterfaceNumber uint8 - InterfaceSetting uint8 - URBFunction uint32 - TransferFlags uint32 - StartFrame uint32 - TransferLength uint32 - SetupPacket [8]byte - IsoPackets []IsoPacket - Payload []byte - EndpointSequence uint64 + Token uint64 + DeviceID uint64 + Generation uint32 + Kind OperationKind + EndpointAddress uint8 + Direction uint8 + InterfaceNumber uint8 + InterfaceSetting uint8 + EndpointAttributes uint8 + EndpointInterval uint8 + EndpointMaxPacketSize uint16 + URBFunction uint32 + TransferFlags uint32 + StartFrame uint32 + TransferLength uint32 + SetupPacket [8]byte + IsoPackets []IsoPacket + Payload []byte + EndpointSequence uint64 } func ParseOperation(src []byte) (Operation, error) { @@ -316,21 +319,24 @@ func ParseOperation(src []byte) (Operation, error) { return Operation{}, ErrInvalidRange } op := Operation{ - Token: binary.LittleEndian.Uint64(src[16:24]), - DeviceID: binary.LittleEndian.Uint64(src[24:32]), - Generation: binary.LittleEndian.Uint32(src[32:36]), - Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), - EndpointAddress: src[40], - Direction: src[41], - InterfaceNumber: src[42], - InterfaceSetting: src[43], - URBFunction: binary.LittleEndian.Uint32(src[44:48]), - TransferFlags: binary.LittleEndian.Uint32(src[48:52]), - StartFrame: binary.LittleEndian.Uint32(src[52:56]), - TransferLength: transferLength, - EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), - IsoPackets: make([]IsoPacket, int(packetCount)), - Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), + Token: binary.LittleEndian.Uint64(src[16:24]), + DeviceID: binary.LittleEndian.Uint64(src[24:32]), + Generation: binary.LittleEndian.Uint32(src[32:36]), + Kind: OperationKind(binary.LittleEndian.Uint32(src[36:40])), + EndpointAddress: src[40], + Direction: src[41], + InterfaceNumber: src[42], + InterfaceSetting: src[43], + EndpointAttributes: src[84], + EndpointInterval: src[85], + EndpointMaxPacketSize: binary.LittleEndian.Uint16(src[86:88]), + URBFunction: binary.LittleEndian.Uint32(src[44:48]), + TransferFlags: binary.LittleEndian.Uint32(src[48:52]), + StartFrame: binary.LittleEndian.Uint32(src[52:56]), + TransferLength: transferLength, + EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), + IsoPackets: make([]IsoPacket, int(packetCount)), + Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), } copy(op.SetupPacket[:], src[76:84]) for i := range op.IsoPackets { diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index f37bf544..ef738d83 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -93,6 +93,8 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) raw[40], raw[41] = 0x84, 1 raw[42], raw[43] = 2, 1 + raw[84], raw[85] = 0x05, 4 + binary.LittleEndian.PutUint16(raw[86:88], 196) binary.LittleEndian.PutUint32(raw[56:60], 1) binary.LittleEndian.PutUint32(raw[60:64], uint32(len(payload))) binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize) @@ -109,7 +111,9 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { } if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || op.EndpointSequence != 17 || op.InterfaceNumber != 2 || - op.InterfaceSetting != 1 || len(op.IsoPackets) != 1 { + op.InterfaceSetting != 1 || op.EndpointAttributes != 0x05 || + op.EndpointInterval != 4 || op.EndpointMaxPacketSize != 196 || + len(op.IsoPackets) != 1 { t.Fatalf("unexpected operation: %+v", op) } raw[len(raw)-1] = 0xff diff --git a/native/udecx/README.md b/native/udecx/README.md index ab8fe12d..dc4058f8 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -11,7 +11,8 @@ Directory contract: - `package/` contains INF and installation metadata. - `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root controller without creating duplicates or leaving a failed devnode behind. -- `tests/` contains ABI, lifecycle, descriptor, cancellation, and fault tests. +- ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go + broker packages and in the native-driver CI gates. The design and release gates are in `docs/architecture/native-udecx.md`. diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index f78e39e9..429769d8 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -200,6 +200,9 @@ ViiperDispatchNotificationEvents( operation->EndpointAddress = event.EndpointAddress; operation->InterfaceNumber = event.InterfaceNumber; operation->InterfaceSetting = event.InterfaceSetting; + operation->EndpointAttributes = event.EndpointAttributes; + operation->EndpointInterval = event.EndpointInterval; + operation->EndpointMaxPacketSize = event.EndpointMaxPacketSize; operation->EndpointSequence = event.EndpointSequence; WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); @@ -395,7 +398,7 @@ BOOLEAN ViiperQueueLifecycleEventLocked( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext, - _In_ UCHAR EndpointAddress, + _In_opt_ const USB_ENDPOINT_DESCRIPTOR *EndpointDescriptor, _In_ VIIPER_UDE_OPERATION_KIND Kind, _In_ UCHAR InterfaceNumber, _In_ UCHAR InterfaceSetting @@ -413,11 +416,16 @@ ViiperQueueLifecycleEventLocked( event->DeviceId = DeviceContext->DeviceId; event->Generation = DeviceContext->Generation; event->Kind = Kind; - event->EndpointAddress = EndpointAddress; + if (EndpointDescriptor != NULL) { + event->EndpointAddress = EndpointDescriptor->bEndpointAddress; + event->EndpointAttributes = EndpointDescriptor->bmAttributes; + event->EndpointInterval = EndpointDescriptor->bInterval; + event->EndpointMaxPacketSize = EndpointDescriptor->wMaxPacketSize; + } event->InterfaceNumber = InterfaceNumber; event->InterfaceSetting = InterfaceSetting; event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( - &DeviceContext->EndpointSequences[EndpointAddress]); + &DeviceContext->EndpointSequences[event->EndpointAddress]); ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ++ControllerContext->NotificationCount; @@ -440,7 +448,7 @@ ViiperQueueEndpointLifecycleEvent( queued = ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, - endpointContext->Descriptor.bEndpointAddress, + &endpointContext->Descriptor, Kind, 0, 0); @@ -465,7 +473,7 @@ ViiperQueueDeviceLifecycleEvent( WdfSpinLockAcquire(controllerContext->BrokerLock); queued = ViiperQueueLifecycleEventLocked( - controllerContext, deviceContext, 0, Kind, 0, 0); + controllerContext, deviceContext, NULL, Kind, 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -490,7 +498,7 @@ ViiperQueueInterfaceLifecycleEvent( queued = ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, - 0, + NULL, ViiperUdeOperationSetInterface, InterfaceNumber, InterfaceSetting); @@ -962,6 +970,9 @@ ViiperSerializeOperation( urb->UrbHeader.Function == URB_FUNCTION_CONTROL_TRANSFER_EX) ? ViiperUdeOperationControl : ViiperUdeOperationTransfer; operation->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; + operation->EndpointAttributes = endpointContext->Descriptor.bmAttributes; + operation->EndpointInterval = endpointContext->Descriptor.bInterval; + operation->EndpointMaxPacketSize = endpointContext->Descriptor.wMaxPacketSize; operation->Direction = directionIn ? 1 : 0; operation->UrbFunction = urb->UrbHeader.Function; operation->TransferFlags = transferFlags; diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 103d4948..9767d9da 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -54,6 +54,9 @@ typedef struct VIIPER_UDE_NOTIFICATION { UCHAR EndpointAddress; UCHAR InterfaceNumber; UCHAR InterfaceSetting; + UCHAR EndpointAttributes; + UCHAR EndpointInterval; + USHORT EndpointMaxPacketSize; } VIIPER_UDE_NOTIFICATION; typedef struct VIIPER_UDE_REQUEST_CONTEXT { diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 185cccb9..c813f79c 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(4) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(5) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) @@ -163,7 +163,9 @@ typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_UINT32 PayloadLength; VIIPER_UDE_UINT32 IsoPacketsOffset; VIIPER_UDE_UINT8 SetupPacket[8]; - VIIPER_UDE_UINT32 Reserved1; + VIIPER_UDE_UINT8 EndpointAttributes; + VIIPER_UDE_UINT8 EndpointInterval; + VIIPER_UDE_UINT16 EndpointMaxPacketSize; VIIPER_UDE_UINT64 EndpointSequence; } VIIPER_UDE_OPERATION; From 45bba16872b84d64cb1334051fe90cbe5b05d0f1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:00:06 -0500 Subject: [PATCH 063/240] Drain forwarded URBs before endpoint purge completes UdeCx requires every request forwarded out of an endpoint queue to finish before UdecxUsbEndpointPurgeComplete. Track both brokered transfers and direct interrupt-IN ownership with a per-endpoint drain event, close admission under the broker lock, and finish purge from a passive work item only after the final URB completion. This prevents restart/delete from crossing a live media or input request. --- docs/architecture/native-udecx.md | 6 +++++ native/udecx/driver/Broker.c | 40 +++++++++++++++++++++++++++++-- native/udecx/driver/Device.c | 40 +++++++++++++++++++++++++++++++ native/udecx/driver/ViiperUde.h | 6 +++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 092382b1..d17f8686 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -188,6 +188,12 @@ interface fields are only hints for alternates that contain no endpoints. object teardown completes. User mode can retry only failures returned before ownership reached UdeCx. - Each device has a short-held state lock and independent endpoint queues. +- Each endpoint owns a drain event covering both broker-forwarded URBs and the + direct interrupt-IN fast path. UdeCx purge first closes admission, cancels + those owners, and purges the framework queue; a passive work item calls + `UdecxUsbEndpointPurgeComplete` only after the last forwarded URB has actually + completed. A pipe can therefore never restart or disappear across a live + request. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, and lifecycle IOCTLs retain their serialized control queue. Large media diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 429769d8..bddbdf5c 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -218,6 +218,7 @@ ViiperClearSlotLocked( ) { VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; + UDECXUSBENDPOINT endpoint = pending->Endpoint; pending->Request = WDF_NO_HANDLE; pending->Endpoint = WDF_NO_HANDLE; @@ -234,6 +235,35 @@ ViiperClearSlotLocked( pending->CompletionUsbdStatus = USBD_STATUS_SUCCESS; pending->CompleteWithNtStatus = FALSE; InterlockedDecrement(&ControllerContext->PendingOperations); + if (endpoint != WDF_NO_HANDLE) { + ViiperEndpointOperationCompleted(endpoint); + } +} + +VOID +ViiperEndpointOperationStarted( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + // Callers serialize admission for one endpoint. Clear before publishing + // the increment so a concurrent purge worker can never observe the old + // signaled state between a 0 -> 1 transition and KeClearEvent. + KeClearEvent(&endpointContext->OperationsDrained); + (VOID)InterlockedIncrement(&endpointContext->ActiveOperations); +} + +VOID +ViiperEndpointOperationCompleted( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + LONG remaining = InterlockedDecrement(&endpointContext->ActiveOperations); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE); + } } static @@ -526,7 +556,12 @@ ViiperAllocatePendingSlot( NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; WdfSpinLockAcquire(ControllerContext->BrokerLock); - for (offset = 0; offset < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++offset) { + if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_NOT_READY; + } + for (offset = 0; status == STATUS_INSUFFICIENT_RESOURCES && + offset < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++offset) { ULONG index = (ControllerContext->NextPendingSlot + offset) % VIIPER_UDE_MAX_PENDING_OPERATIONS; VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[index]; @@ -553,6 +588,7 @@ ViiperAllocatePendingSlot( pending->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; pending->AbortStatus = STATUS_SUCCESS; ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; + ViiperEndpointOperationStarted(Endpoint); InterlockedIncrement(&ControllerContext->PendingOperations); *Slot = index; *Token = pending->Token; @@ -561,7 +597,7 @@ ViiperAllocatePendingSlot( } WdfSpinLockRelease(ControllerContext->BrokerLock); - if (!NT_SUCCESS(status)) { + if (status == STATUS_INSUFFICIENT_RESOURCES) { InterlockedIncrement64(&ControllerContext->QueueExhaustions); } return status; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index beed403d..9a94a0e3 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -748,6 +748,7 @@ ViiperEvtEndpointAdd( { USB_ENDPOINT_DESCRIPTOR descriptor; UDECX_USB_ENDPOINT_CALLBACKS callbacks; + WDF_WORKITEM_CONFIG workItemConfig; WDF_OBJECT_ATTRIBUTES attributes; UDECXUSBENDPOINT endpoint; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; @@ -782,6 +783,15 @@ ViiperEvtEndpointAdd( RtlZeroMemory(endpointContext, sizeof(*endpointContext)); endpointContext->Device = Device; endpointContext->Descriptor = descriptor; + KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->PurgeWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } if (descriptor.bEndpointAddress == 0) { ViiperGetDeviceContext(Device)->DefaultEndpoint = endpoint; dispatchType = WdfIoQueueDispatchSequential; @@ -957,7 +967,9 @@ ViiperSubmitInputReport( // another. Serialize only this endpoint, preserving report order even if // a faulty or hostile owner submits concurrent updates for the same pad. WdfWaitLockAcquire(endpointContext->InputLock, NULL); + ViiperEndpointOperationStarted(endpoint); if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); // Endpoint purge and restart preserve the device generation. Do not @@ -966,6 +978,7 @@ ViiperSubmitInputReport( } if (input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( &endpointContext->LastInputSequence, 0, 0)) { + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_INVALID_DEVICE_STATE; @@ -976,6 +989,7 @@ ViiperSubmitInputReport( InterlockedIncrement64(&controllerContext->InputReportsSubmitted); status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); if (!NT_SUCCESS(status)) { + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); // A producer update is allowed to arrive before Windows posts its @@ -989,6 +1003,7 @@ ViiperSubmitInputReport( urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_INVALID_DEVICE_REQUEST); + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_INVALID_DEVICE_REQUEST; @@ -996,6 +1011,7 @@ ViiperSubmitInputReport( transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; if (input->PayloadLength > transferLength) { ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_BUFFER_TOO_SMALL); + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_BUFFER_TOO_SMALL; @@ -1004,6 +1020,7 @@ ViiperSubmitInputReport( urbRequest, urb, payload, input->PayloadLength, TRUE); if (!NT_SUCCESS(status)) { ViiperCompleteRetrievedInputUrb(urbRequest, status); + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return status; @@ -1014,6 +1031,7 @@ ViiperSubmitInputReport( InterlockedAdd64(&controllerContext->BytesFromDevice, input->PayloadLength); InterlockedIncrement64(&controllerContext->InputReportsCompleted); ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_SUCCESS); + ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return STATUS_SUCCESS; @@ -1040,7 +1058,29 @@ ViiperEvtEndpointQueuePurged( ) { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); UNREFERENCED_PARAMETER(Queue); + WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); +} + +VOID +ViiperEvtEndpointPurgeWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + + PAGED_CODE(); + // UdeCx requires every request forwarded out of the endpoint queue to be + // completed before PurgeComplete. The broker DPC and the direct input path + // signal this event only after their last owned URB has been completed. + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); UdecxUsbEndpointPurgeComplete(endpoint); } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 9767d9da..09620063 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -152,8 +152,11 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; WDFWAITLOCK InputLock; + WDFWORKITEM PurgeWorkItem; + KEVENT OperationsDrained; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; + volatile LONG ActiveOperations; volatile LONG64 LastInputSequence; volatile LONG64 NextIsoStartFrame; ULONGLONG NextAdmissionSequence; @@ -186,6 +189,7 @@ EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; +EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; @@ -212,6 +216,8 @@ NTSTATUS ViiperCopyTransferBuffer( _In_ ULONG Length, _In_ BOOLEAN ToUrb); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); +VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); +VOID ViiperEndpointOperationCompleted(_In_ UDECXUSBENDPOINT Endpoint); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); NTSTATUS ViiperQueueEndpointLifecycleEvent( _In_ UDECXUSBENDPOINT Endpoint, From f1dac787eab6ff76ed76589d1cc9fda0cfd973cb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:02:39 -0500 Subject: [PATCH 064/240] Soak native lifecycle and generation cleanup Stress concurrent ISO processing against endpoint start/purge transitions and verify reset removes every service clock, cached report, and active endpoint. Repeat 512 create/remove generations and assert that only monotonic generation history remains, with no devices, lanes, operations, or unbalanced driver lifecycle calls. --- internal/server/usb/native_test.go | 96 +++++++++++++++++++++++++++ internal/transport/udecx/host_test.go | 45 +++++++++++++ 2 files changed, 141 insertions(+) diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index dd9dfb66..edff8f30 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "log/slog" + "sync" + "sync/atomic" "testing" "time" @@ -242,3 +244,97 @@ func TestNativeProcessorCompletesIsoOutWithoutEchoPayload(t *testing.T) { t.Fatalf("unexpected ISO OUT completion: %+v captured=%x", completion, dev.payload) } } + +type concurrentNativeTestDevice struct { + desc *usbdevice.Descriptor + mu sync.Mutex + altEvents [][2]uint8 + transfers atomic.Uint64 +} + +func (d *concurrentNativeTestDevice) HandleTransfer( + _ context.Context, _, _ uint32, _ []byte, +) []byte { + d.transfers.Add(1) + return nil +} + +func (d *concurrentNativeTestDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*concurrentNativeTestDevice) GetDeviceSpecificArgs() map[string]any { return nil } +func (d *concurrentNativeTestDevice) SetInterfaceAltSetting(iface, alt uint8) { + d.mu.Lock() + d.altEvents = append(d.altEvents, [2]uint8{iface, alt}) + d.mu.Unlock() +} + +func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + dev := &concurrentNativeTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + identity := udecx.DeviceIdentity{DeviceID: 91, Generation: 14} + base := udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + } + + var wg sync.WaitGroup + for worker := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for iteration := range 25 { + if (worker+iteration)%2 == 0 { + op := base + op.Kind = udecx.OperationEndpointStart + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Errorf("endpoint start: %v", err) + return + } + } else { + op := base + op.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, op); err != nil { + t.Errorf("endpoint purge: %v", err) + return + } + } + + op := base + op.Token = uint64(worker*25 + iteration + 1) + op.Kind = udecx.OperationTransfer + op.TransferLength = 4 + op.Payload = []byte{1, 2, 3, 4} + op.IsoPackets = []udecx.IsoPacket{{Offset: 0, Length: 4}} + if _, err := processor.Process(context.Background(), dev, op); err != nil { + t.Errorf("ISO transfer: %v", err) + return + } + } + }() + } + wg.Wait() + processor.Reset(dev, identity) + + if got := dev.transfers.Load(); got != 100 { + t.Fatalf("processed %d transfers, want 100", got) + } + processor.mu.Lock() + defer processor.mu.Unlock() + if len(processor.next) != 0 || len(processor.lastIn) != 0 { + t.Fatalf("reset retained clocks=%d cached-input=%d", len(processor.next), len(processor.lastIn)) + } + processor.lifecycleMu.Lock() + defer processor.lifecycleMu.Unlock() + if len(processor.active) != 0 { + t.Fatalf("reset retained %d active native sessions", len(processor.active)) + } +} diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index c57f723d..fdc50314 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -143,6 +143,14 @@ func (p *stubbornProcessor) Process(context.Context, usb.Device, Operation) (Com func (*stubbornProcessor) Reset(usb.Device, DeviceIdentity) {} func (*stubbornProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +type noopProcessor struct{} + +func (*noopProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (*noopProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} + func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ Device: usb.DeviceDescriptor{ @@ -157,6 +165,43 @@ func hostTestDevice() usb.Device { }} } +func TestHostRepeatedCreateRemoveLeavesOnlyGenerationHistory(t *testing.T) { + driver := newFakeHostDriver() + host, err := NewHost(driver, &noopProcessor{}, 4) + if err != nil { + t.Fatal(err) + } + const cycles = 512 + for cycle := 1; cycle <= cycles; cycle++ { + identity, registerErr := host.Register(context.Background(), 72, hostTestDevice()) + if registerErr != nil { + t.Fatalf("cycle %d register: %v", cycle, registerErr) + } + if identity.Generation != uint32(cycle) { + t.Fatalf("cycle %d generation=%d", cycle, identity.Generation) + } + if unregisterErr := host.Unregister(context.Background(), identity); unregisterErr != nil { + t.Fatalf("cycle %d unregister: %v", cycle, unregisterErr) + } + } + + host.mu.RLock() + devices, lanes := len(host.devices), len(host.lanes) + generation := host.generations[72] + host.mu.RUnlock() + host.operationMu.Lock() + operations := len(host.operations) + host.operationMu.Unlock() + driver.mu.Lock() + created, destroyed := len(driver.created), len(driver.destroyed) + driver.mu.Unlock() + if devices != 0 || lanes != 0 || operations != 0 || generation != cycles || + created != cycles || destroyed != cycles { + t.Fatalf("devices=%d lanes=%d operations=%d generation=%d created=%d destroyed=%d", + devices, lanes, operations, generation, created, destroyed) + } +} + type inputPublisherTestDevice struct { descriptor usb.Descriptor reports chan []byte From fe197aace2f502c4537e8e4f364e2ea2ebb3d130 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:15:58 -0500 Subject: [PATCH 065/240] Acknowledge native UDE reset and configuration lifecycle Keep UdeCx reset and endpoint-configuration requests pending until the Go controller engine applies the corresponding generation-bound lifecycle operation. Add a disjoint preallocated management-token pool so lifecycle acknowledgements cannot collide with or serialize steady-state URB traffic. Stop manually starting and purging UdeCx-owned endpoint queues. Purge now drains only requests forwarded into VIIPER-owned broker and direct-input paths before reporting UdecxUsbEndpointPurgeComplete, matching the documented UDE queue contract. --- docs/architecture/native-udecx.md | 12 +- internal/transport/udecx/host.go | 31 ++- internal/transport/udecx/host_test.go | 89 ++++++- internal/transport/udecx/protocol.go | 2 +- native/udecx/driver/Broker.c | 286 ++++++++++++++++++++++- native/udecx/driver/Device.c | 44 ++-- native/udecx/driver/ViiperUde.h | 30 ++- native/udecx/include/ViiperUdeProtocol.h | 2 +- 8 files changed, 463 insertions(+), 33 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index d17f8686..e90783fe 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -189,11 +189,19 @@ interface fields are only hints for alternates that contain no endpoints. ownership reached UdeCx. - Each device has a short-held state lock and independent endpoint queues. - Each endpoint owns a drain event covering both broker-forwarded URBs and the - direct interrupt-IN fast path. UdeCx purge first closes admission, cancels - those owners, and purges the framework queue; a passive work item calls + direct interrupt-IN fast path. UdeCx itself owns and purges the framework + endpoint queue; VIIPER never starts or purges that queue. The purge callback + closes admission and cancels only the requests already forwarded into + VIIPER-owned paths; a passive work item calls `UdecxUsbEndpointPurgeComplete` only after the last forwarded URB has actually completed. A pipe can therefore never restart or disappear across a live request. +- Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx + management requests, not notifications. ABI 1.6 gives only those lifecycle + operations a generation-bound management token. Windows receives the request + completion only after the Go controller engine has applied the reset or + alternate-setting transition. Start, purge, and power notifications remain + unacknowledged and cannot add a media round trip. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, and lifecycle IOCTLs retain their serialized control queue. Large media diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index b8c3b2d6..3bfc2890 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -479,7 +479,12 @@ func (h *Host) Serve(ctx context.Context) error { } } if err := h.dispatch(runCtx, result.op); err != nil { - if !isLifecycleOperation(result.op.Kind) { + if isLifecycleOperation(result.op.Kind) && result.op.Token != 0 { + if completeErr := h.completeLifecycle(runCtx, result.op, statusUnsuccessful); completeErr != nil { + h.reportFatal(fmt.Errorf("reject lifecycle token %d after dispatch failure %v: %w", + result.op.Token, err, completeErr)) + } + } else if !isLifecycleOperation(result.op.Kind) { if completeErr := h.completeFailure(runCtx, result.op); completeErr != nil { h.reportFatal(fmt.Errorf("reject operation token %d after dispatch failure %v: %w", result.op.Token, err, completeErr)) @@ -576,9 +581,21 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { case OperationDeviceD0Exit: h.stopAllInputPublishers(entry) } - if err := h.processor.Lifecycle(lane.ctx, entry.device, current); err != nil { + lifecycleErr := h.processor.Lifecycle(lane.ctx, entry.device, current) + if current.Token != 0 { + status := int32(0) + if lifecycleErr != nil { + status = statusUnsuccessful + } + if err := h.completeLifecycle(lane.ctx, current, status); err != nil { + h.reportFatal(fmt.Errorf("endpoint 0x%02x acknowledge lifecycle sequence %d: %w", + current.EndpointAddress, current.EndpointSequence, err)) + return + } + } + if lifecycleErr != nil { h.reportFatal(fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", - lane.key.endpoint, current.EndpointSequence, err)) + lane.key.endpoint, current.EndpointSequence, lifecycleErr)) return } switch current.Kind { @@ -615,6 +632,14 @@ func isLifecycleOperation(kind OperationKind) bool { } } +func (h *Host) completeLifecycle(ctx context.Context, op Operation, status int32) error { + completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) + defer cancel() + return h.driver.Complete(completionCtx, Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, Status: status, + }) +} + func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) error { opCtx, cancel, active := h.beginOperation(ctx, op) if !active { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index fdc50314..b927f715 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -645,6 +645,84 @@ func TestHostOrdersLifecycleBeforeFollowingTransfer(t *testing.T) { <-done } +func TestHostAcknowledgesLifecycleOnlyAfterProcessorAppliesIt(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 73, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + const token = uint64(0x0000000180000001) + driver.operations <- Operation{ + Token: token, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointReset, + } + select { + case sequence := <-processor.lifecycle: + if sequence != 1 { + t.Fatalf("lifecycle endpoint sequence=%d want=1", sequence) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for acknowledged lifecycle operation") + } + select { + case completion := <-driver.completions: + if completion.Token != token || completion.DeviceID != identity.DeviceID || + completion.Generation != identity.Generation || completion.Status != 0 || + completion.TransferLength != 0 || len(completion.Payload) != 0 || + len(completion.IsoPackets) != 0 { + t.Fatalf("lifecycle acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for lifecycle acknowledgement") + } + cancel() + <-done +} + +func TestHostDoesNotCompleteAdvisoryLifecycleNotification(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 74, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("timed out waiting for advisory lifecycle operation") + } + select { + case completion := <-driver.completions: + t.Fatalf("advisory lifecycle notification was completed: %+v", completion) + case <-time.After(20 * time.Millisecond): + } + cancel() + <-done +} + func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { driver := newFakeHostDriver() driver.createErr = errors.New("plug failed") @@ -968,10 +1046,19 @@ func TestHostLifecycleFailureFailsSession(t *testing.T) { done := make(chan error, 1) go func() { done <- host.Serve(context.Background()) }() driver.operations <- Operation{ - DeviceID: identity.DeviceID, Generation: identity.Generation, + Token: 0x0000000180000001, DeviceID: identity.DeviceID, Generation: identity.Generation, EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointReset, } select { + case completion := <-driver.completions: + if completion.Status != statusUnsuccessful { + t.Fatalf("failed lifecycle completion status=%d want=%d", + completion.Status, statusUnsuccessful) + } + case <-time.After(time.Second): + t.Fatal("failed lifecycle was not acknowledged to the driver") + } + select { case err = <-done: if err == nil || !strings.Contains(err.Error(), "reset rejected") { t.Fatalf("Serve error=%v, want lifecycle session failure", err) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 7a324659..8f704733 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 5 + ABIMinor uint16 = 6 HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index bddbdf5c..8df7c4db 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -180,6 +180,19 @@ ViiperDispatchNotificationEvents( controllerContext->NotificationHead = (controllerContext->NotificationHead + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; --controllerContext->NotificationCount; + if (((ULONG)event.Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) != 0) { + ULONG managementSlot = ((ULONG)event.Token & + ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; + if (managementSlot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || + controllerContext->ManagementSlots[managementSlot].Token != event.Token || + controllerContext->ManagementSlots[managementSlot].State != + ViiperUdePendingQueued) { + status = STATUS_INVALID_DEVICE_STATE; + } else { + controllerContext->ManagementSlots[managementSlot].State = + ViiperUdePendingInFlight; + } + } } WdfSpinLockRelease(controllerContext->BrokerLock); @@ -210,6 +223,25 @@ ViiperDispatchNotificationEvents( } } +static +VOID +ViiperClearManagementSlotLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONG Slot + ) +{ + VIIPER_UDE_MANAGEMENT_SLOT *pending = &ControllerContext->ManagementSlots[Slot]; + + pending->Request = WDF_NO_HANDLE; + pending->Token = 0; + pending->DeviceId = 0; + pending->DeviceGeneration = 0; + pending->State = ViiperUdePendingEmpty; + pending->Kind = 0; + pending->EndpointAddress = 0; + InterlockedDecrement(&ControllerContext->PendingOperations); +} + static VOID ViiperClearSlotLocked( @@ -357,6 +389,24 @@ ViiperInitializeBroker( controllerContext->Notifications, sizeof(VIIPER_UDE_NOTIFICATION) * VIIPER_UDE_MAX_PENDING_OPERATIONS); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Device; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495544, + sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT, + &controllerContext->ManagementStorage, + (PVOID *)&controllerContext->ManagementSlots); + if (!NT_SUCCESS(status)) { + controllerContext->ManagementStorage = WDF_NO_HANDLE; + controllerContext->ManagementSlots = NULL; + return status; + } + RtlZeroMemory( + controllerContext->ManagementSlots, + sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT); + WDF_DPC_CONFIG_INIT(&dpcConfig, ViiperEvtCompletionDpc); dpcConfig.AutomaticSerialization = FALSE; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -431,7 +481,8 @@ ViiperQueueLifecycleEventLocked( _In_opt_ const USB_ENDPOINT_DESCRIPTOR *EndpointDescriptor, _In_ VIIPER_UDE_OPERATION_KIND Kind, _In_ UCHAR InterfaceNumber, - _In_ UCHAR InterfaceSetting + _In_ UCHAR InterfaceSetting, + _In_ ULONGLONG Token ) { VIIPER_UDE_NOTIFICATION *event; @@ -443,6 +494,7 @@ ViiperQueueLifecycleEventLocked( event = &ControllerContext->Notifications[ControllerContext->NotificationTail]; RtlZeroMemory(event, sizeof(*event)); + event->Token = Token; event->DeviceId = DeviceContext->DeviceId; event->Generation = DeviceContext->Generation; event->Kind = Kind; @@ -481,6 +533,7 @@ ViiperQueueEndpointLifecycleEvent( &endpointContext->Descriptor, Kind, 0, + 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { @@ -503,7 +556,7 @@ ViiperQueueDeviceLifecycleEvent( WdfSpinLockAcquire(controllerContext->BrokerLock); queued = ViiperQueueLifecycleEventLocked( - controllerContext, deviceContext, NULL, Kind, 0, 0); + controllerContext, deviceContext, NULL, Kind, 0, 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -531,7 +584,8 @@ ViiperQueueInterfaceLifecycleEvent( NULL, ViiperUdeOperationSetInterface, InterfaceNumber, - InterfaceSetting); + InterfaceSetting, + 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -540,6 +594,136 @@ ViiperQueueInterfaceLifecycleEvent( return STATUS_SUCCESS; } +static +NTSTATUS +ViiperQueueAcknowledgedLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + const USB_ENDPOINT_DESCRIPTOR *descriptor = NULL; + ULONG offset; + NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; + BOOLEAN canAllocate = TRUE; + + if (Endpoint != WDF_NO_HANDLE) { + descriptor = &ViiperGetEndpointContext(Endpoint)->Descriptor; + } + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; + } else if (controllerContext->NotificationCount >= + VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { + (VOID)ViiperFaultBrokerLocked(controllerContext); + status = STATUS_INSUFFICIENT_RESOURCES; + canAllocate = FALSE; + } + for (offset = 0; canAllocate && status == STATUS_INSUFFICIENT_RESOURCES && + offset < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++offset) { + ULONG index = (controllerContext->NextManagementSlot + offset) % + VIIPER_UDE_MAX_PENDING_MANAGEMENT; + VIIPER_UDE_MANAGEMENT_SLOT *pending = &controllerContext->ManagementSlots[index]; + ULONGLONG token; + + if (pending->State != ViiperUdePendingEmpty) { + continue; + } + ++pending->Generation; + if (pending->Generation == 0) { + ++pending->Generation; + } + token = ((ULONGLONG)pending->Generation << 32) | + VIIPER_UDE_MANAGEMENT_SLOT_FLAG | (index + 1); + pending->Request = Request; + pending->Token = token; + pending->DeviceId = deviceContext->DeviceId; + pending->DeviceGeneration = deviceContext->Generation; + pending->State = ViiperUdePendingQueued; + pending->Kind = Kind; + pending->EndpointAddress = descriptor != NULL ? descriptor->bEndpointAddress : 0; + if (!ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + descriptor, + Kind, + InterfaceNumber, + InterfaceSetting, + token)) { + pending->Request = WDF_NO_HANDLE; + pending->Token = 0; + pending->DeviceId = 0; + pending->DeviceGeneration = 0; + pending->State = ViiperUdePendingEmpty; + pending->Kind = 0; + pending->EndpointAddress = 0; + break; + } + controllerContext->NextManagementSlot = (index + 1) % + VIIPER_UDE_MAX_PENDING_MANAGEMENT; + InterlockedIncrement(&controllerContext->PendingOperations); + status = STATUS_SUCCESS; + break; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (status == STATUS_INSUFFICIENT_RESOURCES) { + InterlockedIncrement64(&controllerContext->QueueExhaustions); + } + if (NT_SUCCESS(status)) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } + return status; +} + +NTSTATUS +ViiperQueueAcknowledgedEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + ViiperGetEndpointContext(Endpoint)->Device, Endpoint, Request, Kind, 0, 0); +} + +NTSTATUS +ViiperQueueAcknowledgedDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + Device, WDF_NO_HANDLE, Request, Kind, 0, 0); +} + +NTSTATUS +ViiperQueueAcknowledgedInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting + ) +{ + return ViiperQueueAcknowledgedLifecycleEvent( + Device, + WDF_NO_HANDLE, + Request, + ViiperUdeOperationSetInterface, + InterfaceNumber, + InterfaceSetting); +} + static NTSTATUS ViiperAllocatePendingSlot( @@ -1439,6 +1623,54 @@ ViiperRangeValid( return Offset <= Total && Length <= Total - Offset; } +static +NTSTATUS +ViiperCompleteManagementOperation( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ const VIIPER_UDE_COMPLETION *Completion + ) +{ + ULONG encodedSlot = (ULONG)Completion->Token; + ULONG slot = (encodedSlot & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; + WDFREQUEST request = WDF_NO_HANDLE; + + if ((encodedSlot & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 || + slot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || + Completion->TransferLength != 0 || Completion->IsoPacketCount != 0 || + Completion->PayloadLength != 0 || Completion->UsbdStatus != 0 || + (NTSTATUS)Completion->Status == STATUS_PENDING) { + InterlockedIncrement64(&ControllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } + + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingInFlight && + ControllerContext->ManagementSlots[slot].DeviceId == Completion->DeviceId && + ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation) { + request = ControllerContext->ManagementSlots[slot].Request; + ControllerContext->ManagementSlots[slot].State = ViiperUdePendingCompleting; + WdfObjectReference(request); + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + if (request == WDF_NO_HANDLE) { + InterlockedIncrement64(&ControllerContext->LateCompletions); + return STATUS_NOT_FOUND; + } + + WdfRequestComplete(request, (NTSTATUS)Completion->Status); + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Request == request && + ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked(ControllerContext, slot); + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + WdfObjectDereference(request); + InterlockedIncrement64(&ControllerContext->OperationsCompleted); + return STATUS_SUCCESS; +} + NTSTATUS ViiperCompleteOperation( _In_ WDFQUEUE Queue, @@ -1527,6 +1759,9 @@ ViiperCompleteOperation( if (completion->PayloadLength != 0) { payload = tail + completion->PayloadOffset - sizeof(*completion); } + if (((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) != 0) { + return ViiperCompleteManagementOperation(controllerContext, completion); + } slot = (ULONG)(completion->Token & MAXULONG); if (slot == 0 || slot > VIIPER_UDE_MAX_PENDING_OPERATIONS) { @@ -1743,6 +1978,50 @@ ViiperAbortMatchingOperations( } } +static +VOID +ViiperAbortManagementOperations( + _In_ WDFDEVICE Controller, + _In_ NTSTATUS Status + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + ULONG index; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->ManagementSlots == NULL) { + return; + } + for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { + WDFREQUEST request = WDF_NO_HANDLE; + ULONGLONG token = 0; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].State != ViiperUdePendingEmpty && + controllerContext->ManagementSlots[index].State != ViiperUdePendingCompleting) { + request = controllerContext->ManagementSlots[index].Request; + token = controllerContext->ManagementSlots[index].Token; + controllerContext->ManagementSlots[index].State = ViiperUdePendingCompleting; + WdfObjectReference(request); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (request == WDF_NO_HANDLE) { + continue; + } + + WdfRequestComplete(request, Status); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].Request == request && + controllerContext->ManagementSlots[index].Token == token && + controllerContext->ManagementSlots[index].State == ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked(controllerContext, index); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + WdfObjectDereference(request); + InterlockedIncrement64(&controllerContext->OperationsPurged); + } +} + VOID ViiperPurgeEndpointOperations( _In_ UDECXUSBENDPOINT Endpoint, @@ -1761,4 +2040,5 @@ ViiperPurgeOwnerOperations( ) { ViiperAbortMatchingOperations(Controller, WDF_NO_HANDLE, Status); + ViiperAbortManagementOperations(Controller, Status); } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 9a94a0e3..9de0412e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -670,8 +670,11 @@ ViiperEvtUsbDeviceReset( return; } - status = ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceReset); - WdfRequestComplete(Request, status); + status = ViiperQueueAcknowledgedDeviceLifecycleEvent( + Device, Request, ViiperUdeOperationDeviceReset); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } } static @@ -1047,20 +1050,11 @@ ViiperEvtEndpointReset( InterlockedExchange64(&ViiperGetEndpointContext(Endpoint)->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); - status = ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointReset); - WdfRequestComplete(Request, status); -} - -VOID -ViiperEvtEndpointQueuePurged( - _In_ WDFQUEUE Queue, - _In_ WDFCONTEXT Context - ) -{ - UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; - VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); - UNREFERENCED_PARAMETER(Queue); - WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); + status = ViiperQueueAcknowledgedEndpointLifecycleEvent( + Endpoint, Request, ViiperUdeOperationEndpointReset); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } } VOID @@ -1094,7 +1088,10 @@ ViiperEvtEndpointPurge( InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); + // UdeCx owns the state of the endpoint queue. We only drain requests that + // were already forwarded to the broker/direct-input paths, then report + // purge completion from the passive work item. + WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); } VOID @@ -1106,7 +1103,6 @@ ViiperEvtEndpointStart( (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); InterlockedExchange(&endpointContext->Purging, FALSE); - WdfIoQueueStart(endpointContext->Queue); } VOID @@ -1121,21 +1117,27 @@ ViiperEvtEndpointsConfigure( switch (ConfigureParams->ConfigureType) { case UdecxEndpointsConfigureTypeDeviceInitialize: case UdecxEndpointsConfigureTypeDeviceConfigurationChange: - status = ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceReset); + status = ViiperQueueAcknowledgedDeviceLifecycleEvent( + Device, Request, ViiperUdeOperationDeviceReset); break; case UdecxEndpointsConfigureTypeInterfaceSettingChange: - status = ViiperQueueInterfaceLifecycleEvent( + status = ViiperQueueAcknowledgedInterfaceLifecycleEvent( Device, + Request, ConfigureParams->InterfaceNumber, ConfigureParams->NewInterfaceSetting); break; case UdecxEndpointsConfigureTypeEndpointsReleasedOnly: + WdfRequestComplete(Request, STATUS_SUCCESS); + return; break; default: status = STATUS_INVALID_PARAMETER; break; } - WdfRequestComplete(Request, status); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } } VOID diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 09620063..49db0ae4 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -16,6 +16,8 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; // Only VIIPER's private interface receives a reference string. The standard // host-controller interface must retain UdeCx's canonical unqualified path. #define VIIPER_UDE_BROKER_REFERENCE_STRING L"broker" +#define VIIPER_UDE_MAX_PENDING_MANAGEMENT 256 +#define VIIPER_UDE_MANAGEMENT_SLOT_FLAG 0x80000000UL typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingEmpty = 0, @@ -59,6 +61,17 @@ typedef struct VIIPER_UDE_NOTIFICATION { USHORT EndpointMaxPacketSize; } VIIPER_UDE_NOTIFICATION; +typedef struct VIIPER_UDE_MANAGEMENT_SLOT { + WDFREQUEST Request; + ULONGLONG Token; + ULONGLONG DeviceId; + ULONG Generation; + ULONG DeviceGeneration; + VIIPER_UDE_PENDING_STATE State; + ULONG Kind; + UCHAR EndpointAddress; +} VIIPER_UDE_MANAGEMENT_SLOT; + typedef struct VIIPER_UDE_REQUEST_CONTEXT { WDFDEVICE Controller; UDECXUSBENDPOINT Endpoint; @@ -80,8 +93,11 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; ULONG NextCompletionSlot; + ULONG NextManagementSlot; WDFMEMORY NotificationStorage; VIIPER_UDE_NOTIFICATION *Notifications; + WDFMEMORY ManagementStorage; + VIIPER_UDE_MANAGEMENT_SLOT *ManagementSlots; WDFDPC CompletionDpc; ULONG NotificationHead; ULONG NotificationTail; @@ -188,7 +204,6 @@ EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; -EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; @@ -225,6 +240,19 @@ NTSTATUS ViiperQueueEndpointLifecycleEvent( NTSTATUS ViiperQueueDeviceLifecycleEvent( _In_ UDECXUSBDEVICE Device, _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedEndpointLifecycleEvent( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedDeviceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ VIIPER_UDE_OPERATION_KIND Kind); +NTSTATUS ViiperQueueAcknowledgedInterfaceLifecycleEvent( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request, + _In_ UCHAR InterfaceNumber, + _In_ UCHAR InterfaceSetting); NTSTATUS ViiperQueueInterfaceLifecycleEvent( _In_ UDECXUSBDEVICE Device, _In_ UCHAR InterfaceNumber, diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index c813f79c..67ad44d5 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(5) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(6) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) From 800fec6f4cf82f76ad1a9c4dfce26a4fa657c85b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:26:16 -0500 Subject: [PATCH 066/240] Gate native UDE signing package and media allocations --- .github/workflows/native-ude.yml | 9 + docs/architecture/native-udecx-signing.md | 70 +++++++ docs/architecture/native-udecx.md | 4 + internal/transport/udecx/protocol_test.go | 89 +++++++++ native/udecx/README.md | 8 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 188 ++++++++++++++++++ .../tools/Test-ViiperUdeSignedPackage.ps1 | 47 +++++ 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/native-udecx-signing.md create mode 100644 native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 create mode 100644 native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 70649c1e..5a785361 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -93,6 +93,15 @@ jobs: if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } & $output status if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl status smoke test failed" } + - name: Validate Hardware Dev Center CAB structure + shell: pwsh + run: | + ./native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 ` + -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` + -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -CatalogPath native/udecx/x64/Release/ViiperUde/viiperude.cat ` + -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@v4 with: diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md new file mode 100644 index 00000000..5cfbdbb0 --- /dev/null +++ b/docs/architecture/native-udecx-signing.md @@ -0,0 +1,70 @@ +# Native UDE driver signing and release contract + +The native VIIPER bus is a kernel driver. Shipping an Authenticode-signed EXE +does not make the driver loadable on modern Secure Boot Windows. A production +package must be signed by Microsoft through Hardware Dev Center. + +## Supported release paths + +### Desktop preview: attestation signing + +Attestation signing is the shortest supported path for a Windows 10/11 Desktop +preview. It is not Windows Certified, cannot be distributed to retail users by +Windows Update, and does not support Windows Server 2016 or later. + +1. Build the exact x64 Release driver, INF, PDB, and catalog. +2. Run `native/udecx/tools/New-ViiperUdeAttestationPackage.ps1` with explicit + paths to those four artifacts. The script validates the INF contract, + creates the required non-root `ViiperUde` folder in the CAB, re-extracts the + CAB, verifies every SHA-256 hash, and writes a sidecar hash manifest. +3. Sign the CAB with a SHA-256 code-signing certificate registered to the + organization's Hardware Dev Center account. Establishing that account and + submitting attestation packages requires a currently valid EV certificate. +4. Submit the signed CAB in Partner Center with test-signing options disabled. +5. Download Microsoft's returned package and run + `native/udecx/tools/Test-ViiperUdeSignedPackage.ps1`. It requires valid + Microsoft kernel-policy signatures on both the SYS and catalog and reruns + INF verification when the WDK tool is available. +6. Hash-lock only that validated Microsoft-signed package into the installer. + +The structural CAB produced by CI is not installable production media. It has +not been EV-signed, submitted to Microsoft, or returned with Microsoft's +signature. CI names it accordingly and never promotes it as a release driver. + +### Production certification: HLK/WHCP + +HLK/WHCP is the production target. It covers Windows Server and is the route +required for retail Windows Update publication. Run the controller and child +devices through the applicable Device Fundamentals, USB, HID, audio, power, +reliability, and security playlists, submit the resulting HLKX package, and +validate the dashboard-signed result with the same local validation script. + +## Package invariants + +- The CAB has no files at its root. Its only driver folder is `ViiperUde`. +- The package contains exactly one `ViiperUde.inf`, `ViiperUde.sys`, + `ViiperUde.pdb`, and `ViiperUde.cat` selected by explicit path. +- The INF targets only `ROOT\VIIPER\UDE`, copies only `ViiperUde.sys`, and + names only `ViiperUde.cat`. +- The build and submission hash manifests identify the exact reviewed bits. +- Test certificates, test-signing state, or disabled Secure Boot are never a + release prerequisite. +- The installer refuses an unsigned, test-signed, mismatched, downgraded, or + non-Microsoft driver package before any driver-store mutation. +- Updating a live kernel package remains a reboot-safe transaction; it is not + overwritten in place. + +## Current release gate + +The branch currently proves compilation, static analysis, ABI/lifecycle tests, +fuzzing, race tests, deterministic package structure, and payload hashing. A +native driver is not production-ready until the Microsoft-signed package also +passes Driver Verifier, HLK or the scoped attestation test matrix, repeated +install/update/rollback, process crash, sleep/resume, and multi-controller +media soak on a disposable test machine. + +## Primary Microsoft references + +- [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) +- [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) +- [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index e90783fe..39f27cf1 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -276,6 +276,10 @@ request queues while accounting for UdeCx's endpoint-specific purge contract. - Installation is signed, reversible, version-gated, and never replaces a live kernel driver across an unsafe reboot boundary. +The exact attestation/HLK boundary, CAB construction, and Microsoft-signature +validation contract is documented in +[`native-udecx-signing.md`](native-udecx-signing.md). + ## Primary documentation - Microsoft, *Write a UDE client driver* diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index ef738d83..5e7af4b3 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -254,3 +254,92 @@ func FuzzProtocolDecoders(f *testing.F) { _, _ = ParseOperation(raw) }) } + +func dualSenseIsoOperationFixture(packetCount, packetLength int) []byte { + total := OperationSize + packetCount*IsoPacketSize + packetCount*packetLength + h, _ := NewHeader(total) + raw := make([]byte, total) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 1) + binary.LittleEndian.PutUint64(raw[24:32], 2) + binary.LittleEndian.PutUint32(raw[32:36], 3) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationTransfer)) + raw[40], raw[41], raw[84], raw[85] = 0x04, 0, 0x05, 4 + binary.LittleEndian.PutUint16(raw[86:88], uint16(packetLength)) + binary.LittleEndian.PutUint32(raw[56:60], uint32(packetCount)) + binary.LittleEndian.PutUint32(raw[60:64], uint32(packetCount*packetLength)) + binary.LittleEndian.PutUint32(raw[64:68], uint32(OperationSize+packetCount*IsoPacketSize)) + binary.LittleEndian.PutUint32(raw[68:72], uint32(packetCount*packetLength)) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint64(raw[88:96], 1) + for index := 0; index < packetCount; index++ { + offset := OperationSize + index*IsoPacketSize + binary.LittleEndian.PutUint32(raw[offset:offset+4], uint32(index*packetLength)) + binary.LittleEndian.PutUint32(raw[offset+4:offset+8], uint32(packetLength)) + } + return raw +} + +func BenchmarkParseDualSenseIsoOperation(b *testing.B) { + raw := dualSenseIsoOperationFixture(4, 196) + b.ReportAllocs() + b.SetBytes(int64(len(raw))) + b.ResetTimer() + for range b.N { + operation, err := ParseOperation(raw) + if err != nil || len(operation.Payload) != 4*196 || len(operation.IsoPackets) != 4 { + b.Fatalf("ParseOperation: operation=%+v err=%v", operation, err) + } + } +} + +func BenchmarkMarshalDualSenseIsoCompletion(b *testing.B) { + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + b.ReportAllocs() + b.SetBytes(int64(CompletionSize + len(completion.IsoPackets)*IsoPacketSize + len(completion.Payload))) + b.ResetTimer() + for range b.N { + raw, err := completion.MarshalBinary() + if err != nil || len(raw) != CompletionSize+4*IsoPacketSize+4*196 { + b.Fatalf("MarshalBinary: bytes=%d err=%v", len(raw), err) + } + } +} + +func TestDualSenseIsoProtocolAllocationBudget(t *testing.T) { + raw := dualSenseIsoOperationFixture(4, 196) + parseAllocations := testing.AllocsPerRun(1000, func() { + operation, err := ParseOperation(raw) + if err != nil || len(operation.Payload) != 4*196 { + panic("parse representative DualSense ISO operation") + } + }) + if parseAllocations > 2 { + t.Fatalf("DualSense ISO parse allocated %.2f objects, budget is 2", parseAllocations) + } + + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + marshalAllocations := testing.AllocsPerRun(1000, func() { + encoded, err := completion.MarshalBinary() + if err != nil || len(encoded) != CompletionSize+4*IsoPacketSize+4*196 { + panic("marshal representative DualSense ISO completion") + } + }) + if marshalAllocations > 1 { + t.Fatalf("DualSense ISO completion allocated %.2f objects, budget is 1", marshalAllocations) + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index dc4058f8..d758ec88 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -11,8 +11,14 @@ Directory contract: - `package/` contains INF and installation metadata. - `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root controller without creating duplicates or leaving a failed devnode behind. +- `tools/New-ViiperUdeAttestationPackage.ps1` creates and hash-verifies the + exact Hardware Dev Center CAB structure; it does not pretend that an + unsigned CI artifact is a production driver. +- `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned + driver and catalog against kernel signing policy. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. The design and release gates are in -`docs/architecture/native-udecx.md`. +`docs/architecture/native-udecx.md`. The Microsoft signing boundary is in +`docs/architecture/native-udecx-signing.md`. diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 new file mode 100644 index 00000000..0e174005 --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -0,0 +1,188 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InfPath, + + [Parameter(Mandatory = $true)] + [string]$SysPath, + + [Parameter(Mandatory = $true)] + [string]$PdbPath, + + [Parameter(Mandatory = $true)] + [string]$CatalogPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-RequiredFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$ExpectedExtension + ) + + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + $item = Get-Item -LiteralPath $resolved.Path -Force + if (-not $item.PSIsContainer -and $item.Extension -ieq $ExpectedExtension -and $item.Length -gt 0) { + return $item + } + throw "Expected a nonempty $ExpectedExtension file at '$Path'." +} + +function Assert-InfContract { + param([Parameter(Mandatory = $true)][string]$Path) + + $contents = Get-Content -LiteralPath $Path -Raw + $required = @( + '(?im)^\s*Class\s*=\s*USB\s*$', + '(?im)^\s*ClassGuid\s*=\s*\{36FC9E60-C465-11CF-8056-444553540000\}\s*$', + '(?im)^\s*CatalogFile\s*=\s*ViiperUde\.cat\s*$', + '(?im)^\s*CopyFiles\s*=\s*@ViiperUde\.sys\s*$', + '(?im)^\s*%DeviceName%\s*=\s*ViiperUde_Install\s*,\s*ROOT\\VIIPER\\UDE\s*$' + ) + foreach ($pattern in $required) { + if ($contents -notmatch $pattern) { + throw "The INF does not satisfy the native VIIPER package contract: $pattern" + } + } +} + +$inf = Resolve-RequiredFile -Path $InfPath -ExpectedExtension '.inf' +$sys = Resolve-RequiredFile -Path $SysPath -ExpectedExtension '.sys' +$pdb = Resolve-RequiredFile -Path $PdbPath -ExpectedExtension '.pdb' +$cat = Resolve-RequiredFile -Path $CatalogPath -ExpectedExtension '.cat' +Assert-InfContract -Path $inf.FullName + +$makeCab = Get-Command makecab.exe -ErrorAction Stop +$expand = Get-Command expand.exe -ErrorAction Stop +$outputFullPath = [System.IO.Path]::GetFullPath($OutputPath) +if ([System.IO.Path]::GetExtension($outputFullPath) -ine '.cab') { + throw "The output path must end in .cab." +} +$outputDirectory = [System.IO.Path]::GetDirectoryName($outputFullPath) +if ([string]::IsNullOrWhiteSpace($outputDirectory)) { + throw "The output path must include a directory." +} +New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +if (Test-Path -LiteralPath $outputFullPath) { + if (-not $Force) { + throw "The output CAB already exists. Pass -Force to replace '$outputFullPath'." + } + Remove-Item -LiteralPath $outputFullPath -Force +} + +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ViiperUdeCab" + [Guid]::NewGuid().ToString('N')) +$stage = Join-Path $workRoot 'stage' +$verify = Join-Path $workRoot 'verify' +$packageFolder = 'ViiperUde' +$cabName = [System.IO.Path]::GetFileName($outputFullPath) + +try { + New-Item -ItemType Directory -Path $stage, $verify -Force | Out-Null + $sourceByName = [ordered]@{ + 'ViiperUde.inf' = $inf.FullName + 'ViiperUde.sys' = $sys.FullName + 'ViiperUde.pdb' = $pdb.FullName + 'ViiperUde.cat' = $cat.FullName + } + foreach ($entry in $sourceByName.GetEnumerator()) { + Copy-Item -LiteralPath $entry.Value -Destination (Join-Path $stage $entry.Key) + } + + $infVerif = Get-Command infverif.exe -ErrorAction SilentlyContinue + if ($null -ne $infVerif) { + & $infVerif.Source /v (Join-Path $stage 'ViiperUde.inf') + if ($LASTEXITCODE -ne 0) { + throw "InfVerif rejected the staged VIIPER INF with exit code $LASTEXITCODE." + } + } + + $ddfPath = Join-Path $workRoot 'ViiperUde.ddf' + $ddfLines = @( + '.OPTION EXPLICIT', + '.Set CabinetFileCountThreshold=0', + '.Set FolderFileCountThreshold=0', + '.Set FolderSizeThreshold=0', + '.Set MaxCabinetSize=0', + '.Set MaxDiskFileCount=0', + '.Set MaxDiskSize=0', + '.Set CompressionType=MSZIP', + '.Set Cabinet=on', + '.Set Compress=on', + ".Set CabinetNameTemplate=$cabName", + ".Set DiskDirectoryTemplate=$outputDirectory", + ".Set DestinationDir=$packageFolder" + ) + foreach ($name in $sourceByName.Keys) { + $ddfLines += ('"{0}" "{1}"' -f (Join-Path $stage $name), $name) + } + Set-Content -LiteralPath $ddfPath -Value $ddfLines -Encoding ascii + + Push-Location -LiteralPath $workRoot + try { + & $makeCab.Source /V1 /F $ddfPath + } + finally { + Pop-Location + } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $outputFullPath)) { + throw "MakeCab failed to create '$outputFullPath' (exit code $LASTEXITCODE)." + } + + & $expand.Source -R '-F:*' $outputFullPath $verify | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Expand failed to verify '$outputFullPath' (exit code $LASTEXITCODE)." + } + foreach ($name in $sourceByName.Keys) { + $expanded = @(Get-ChildItem -LiteralPath $verify -Recurse -File -Filter $name) + if ($expanded.Count -ne 1) { + throw "The CAB must contain exactly one '$name'; found $($expanded.Count)." + } + $expectedHash = (Get-FileHash -LiteralPath (Join-Path $stage $name) -Algorithm SHA256).Hash + $actualHash = (Get-FileHash -LiteralPath $expanded[0].FullName -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { + throw "The expanded '$name' does not match the staged input." + } + } + + $manifest = [ordered]@{ + schema = 1 + purpose = 'Microsoft Hardware Dev Center attestation submission' + cabinet = [System.IO.Path]::GetFileName($outputFullPath) + cabinetSha256 = (Get-FileHash -LiteralPath $outputFullPath -Algorithm SHA256).Hash + packageFolder = $packageFolder + files = @( + foreach ($name in $sourceByName.Keys) { + $path = Join-Path $stage $name + [ordered]@{ + name = $name + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + } + } + ) + } + $manifestPath = "$outputFullPath.sha256.json" + [System.IO.File]::WriteAllText( + $manifestPath, + ($manifest | ConvertTo-Json -Depth 5), + [System.Text.UTF8Encoding]::new($false)) + + Write-Host "Created exact VIIPER attestation package: $outputFullPath" + Write-Host "Hash manifest: $manifestPath" + Write-Host 'The CAB is not production-loadable yet. EV-sign it, submit it to Microsoft Hardware Dev Center, and validate the Microsoft-signed result.' +} +finally { + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force + } +} diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 new file mode 100644 index 00000000..d9c6fca6 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackageDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = Resolve-Path -LiteralPath $PackageDirectory -ErrorAction Stop +if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { + throw "The signed package path must be a directory." +} + +$expected = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$files = @{} +foreach ($name in $expected) { + $matches = @(Get-ChildItem -LiteralPath $root.Path -Recurse -File -Filter $name) + if ($matches.Count -ne 1) { + throw "The signed package must contain exactly one '$name'; found $($matches.Count)." + } + $files[$name] = $matches[0].FullName +} + +$signTool = Get-Command signtool.exe -ErrorAction Stop +foreach ($name in @('ViiperUde.sys', 'ViiperUde.cat')) { + & $signTool.Source verify /kp /v $files[$name] + if ($LASTEXITCODE -ne 0) { + throw "Kernel-policy signature validation failed for '$name' with exit code $LASTEXITCODE." + } + $signature = Get-AuthenticodeSignature -LiteralPath $files[$name] + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Subject -notmatch '(?i)Microsoft') { + throw "'$name' does not have a valid Microsoft production signature." + } +} + +$infVerif = Get-Command infverif.exe -ErrorAction SilentlyContinue +if ($null -ne $infVerif) { + & $infVerif.Source /v $files['ViiperUde.inf'] + if ($LASTEXITCODE -ne 0) { + throw "InfVerif rejected the Microsoft-signed package with exit code $LASTEXITCODE." + } +} + +Write-Host "Validated Microsoft-signed VIIPER native UDE package at '$($root.Path)'." From edda2a3f05fc9de82b19ee1e1d58d3f63b39effd Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:36:13 -0500 Subject: [PATCH 067/240] Harden native UDE mapped buffers and cleanup ownership Respect the mapped span returned by UdecxUrbRetrieveBuffer and fall back to the bounded MDL-chain walker when a transfer spans mappings. Pin the owner WDFFILEOBJECT across unlocked process-death cleanup retries so concurrent teardown cannot leave the timer with a stale handle. Document both lifetime and buffer invariants. --- docs/architecture/native-udecx.md | 9 +++++++++ native/udecx/driver/Broker.c | 11 +++++++---- native/udecx/driver/Controller.c | 14 +++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 39f27cf1..bf29ccc2 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -215,6 +215,11 @@ interface fields are only hints for alternates that contain no endpoints. the public endpoint sequence remains contiguous without limiting media to one in-flight URB. - Media callbacks do not take the controller lock. +- Transfer buffers obey both dimensions of the Windows USB contract: the URB + declares the total transfer length, while a pointer returned by + `UdecxUrbRetrieveBuffer` is used only within its separately reported mapped + span. Chained or short mappings fall through to a bounded MDL-chain walk; + the driver never treats the URB length as permission to overrun one mapping. - Interrupt-IN queues are manual and completed from fresh input snapshots; output and media endpoints retain independent ordered queues. - A direct input report that was already submitted when D0 exit, unplug, or @@ -232,6 +237,10 @@ interface fields are only hints for alternates that contain no endpoints. manual inverted-call queue share the owner lock with file cleanup. No close can finish purging that queue and then have an already-validated request appear behind the purge boundary. +- The process-death cleanup timer takes its own temporary reference to the + owner file object before dropping the owner lock. Concurrent cleanup can + release the controller's long-lived reference without leaving the retry path + with a stale WDF handle. - Completion lookup is keyed by `(device ID, generation, token)`. - Failed transfers do not need to fabricate a successful ISO packet table. Successful completions are canonical: OUT replies carry no payload, every diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 8df7c4db..7635e872 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1012,10 +1012,13 @@ ViiperCopyTransferBuffer( } status = UdecxUrbRetrieveBuffer(Request, &contiguous, &contiguousLength); - if (NT_SUCCESS(status) && contiguous != NULL) { - // UdeCx can report a mapped span smaller than the URB's declared - // TransferBufferLength. The URB field is the authoritative transfer - // capacity for this request; usbip-win2 follows the same rule. + if (NT_SUCCESS(status) && contiguous != NULL && contiguousLength >= Length) { + // The pointer returned by UdecxUrbRetrieveBuffer is valid for exactly + // the reported span. A chained MDL can legitimately expose a first + // mapped segment that is shorter than the URB's total transfer length; + // in that case use the MDL walk below instead of copying beyond this + // mapping. The URB length remains the transfer contract, but it does + // not enlarge an individual mapped buffer. if (ToUrb) { RtlCopyMemory(contiguous, Buffer, Length); } else { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 06e7536d..50afa9f3 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -57,14 +57,26 @@ ViiperEvtOwnerCleanupRetry( WdfWaitLockAcquire(context->OwnerLock, NULL); if (context->CleanupInProgress && context->OwnerFile != WDF_NO_HANDLE) { ownerFile = context->OwnerFile; + // Pin the file object across the unlocked cleanup attempt. Another + // cleanup path can finish device removal and release the controller's + // long-lived owner reference immediately after OwnerLock is dropped. + // Without this temporary reference the timer could dereference a + // deleted WDFFILEOBJECT while retrying process-death cleanup. + WdfObjectReference(ownerFile); } WdfWaitLockRelease(context->OwnerLock); - if (ownerFile == WDF_NO_HANDLE || ViiperFinishOwnerCleanup(device, ownerFile)) { + if (ownerFile == WDF_NO_HANDLE) { + return; + } + + if (ViiperFinishOwnerCleanup(device, ownerFile)) { + WdfObjectDereference(ownerFile); return; } InterlockedIncrement(&context->CleanupRetries); (VOID)WdfTimerStart(Timer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); + WdfObjectDereference(ownerFile); } NTSTATUS From 4ee9f6e5a77e1774d6e0b35f6bc95458bbb46aa9 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:38:49 -0500 Subject: [PATCH 068/240] Close native child creation against owner cleanup Protect UdeCx child creation with an owner-admission barrier shared with file cleanup. Cleanup now waits for every admitted create and PlugIn transaction before enumerating children, preventing a newly published device from escaping a closing broker session without holding OwnerLock across UdeCx callbacks. --- docs/architecture/native-udecx.md | 5 ++ native/udecx/driver/Controller.c | 11 +++++ native/udecx/driver/Device.c | 76 +++++++++++++++++++++++++++---- native/udecx/driver/ViiperUde.h | 1 + 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bf29ccc2..5270d226 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -241,6 +241,11 @@ interface fields are only hints for alternates that contain no endpoints. owner file object before dropping the owner lock. Concurrent cleanup can release the controller's long-lived reference without leaving the retry path with a stale WDF handle. +- Child creation is protected by an owner-admission barrier. Cleanup closes + admission under the owner lock and waits for every admitted UdeCx create and + PlugIn transaction before enumerating owned children. UdeCx calls run without + the owner lock held, avoiding callback deadlocks while preventing an orphaned + child from being published behind cleanup's enumeration boundary. - Completion lookup is keyed by `(device ID, generation, token)`. - Failed transfers do not need to fabricate a successful ISO packet table. Successful completions are canonical: OUT replies carry no payload, every diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 50afa9f3..b103194c 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -27,6 +27,17 @@ ViiperFinishOwnerCleanup( BOOLEAN releaseOwner = FALSE; PAGED_CODE(); + WdfWaitLockAcquire(context->OwnerLock, NULL); + if (context->OwnerFile != OwnerFile || !context->CleanupInProgress) { + WdfWaitLockRelease(context->OwnerLock); + return TRUE; + } + if (InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) != 0) { + WdfWaitLockRelease(context->OwnerLock); + return FALSE; + } + WdfWaitLockRelease(context->OwnerLock); + if (!ViiperDestroyOwnedDevices(Device, OwnerFile)) { return FALSE; } diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 9de0412e..585398ab 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -256,6 +256,57 @@ ViiperValidateOwner( return status; } +static +NTSTATUS +ViiperBeginOwnerAdmission( + _In_ WDFDEVICE Controller, + _In_ WDFREQUEST Request, + _Out_ WDFFILEOBJECT *OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + NTSTATUS status = STATUS_SUCCESS; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + status = STATUS_INVALID_DEVICE_STATE; + } else { + // Keep both the owner object and cleanup boundary alive while a child + // is being built. UdeCx creation and PlugIn may invoke asynchronous + // callbacks, so do not hold OwnerLock across those calls. + WdfObjectReference(fileObject); + InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions); + *OwnerFile = fileObject; + } + WdfWaitLockRelease(controllerContext->OwnerLock); + return status; +} + +static +VOID +ViiperEndOwnerAdmission( + _In_ WDFDEVICE Controller, + _In_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + LONG remaining; + + WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + remaining = InterlockedDecrement(&controllerContext->ActiveOwnerAdmissions); + NT_ASSERT(remaining >= 0); + WdfWaitLockRelease(controllerContext->OwnerLock); + WdfObjectDereference(OwnerFile); +} + static UDECX_USB_DEVICE_SPEED ViiperMapSpeed( @@ -359,10 +410,6 @@ ViiperCreateVirtualDevice( ULONG slot; PAGED_CODE(); - status = ViiperValidateOwner(controller, Request, &ownerFile); - if (!NT_SUCCESS(status)) { - return status; - } status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); if (!NT_SUCCESS(status)) { return status; @@ -375,10 +422,15 @@ ViiperCreateVirtualDevice( if (speed == (UDECX_USB_DEVICE_SPEED)0) { return STATUS_NOT_SUPPORTED; } + status = ViiperBeginOwnerAdmission(controller, Request, &ownerFile); + if (!NT_SUCCESS(status)) { + return status; + } deviceInit = UdecxUsbDeviceInitAllocate(controller); if (deviceInit == NULL) { - return STATUS_INSUFFICIENT_RESOURCES; + status = STATUS_INSUFFICIENT_RESOURCES; + goto ExitAdmission; } UDECX_USB_DEVICE_CALLBACKS_INIT(&callbacks); @@ -398,7 +450,7 @@ ViiperCreateVirtualDevice( status = ViiperAddDeviceDescriptors(deviceInit, input); if (!NT_SUCCESS(status)) { UdecxUsbDeviceInitFree(deviceInit); - return status; + goto ExitAdmission; } WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); @@ -407,7 +459,7 @@ ViiperCreateVirtualDevice( status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); if (!NT_SUCCESS(status)) { UdecxUsbDeviceInitFree(deviceInit); - return status; + goto ExitAdmission; } deviceContext = ViiperGetDeviceContext(device); @@ -424,7 +476,7 @@ ViiperCreateVirtualDevice( status = ViiperClaimDeviceSlot(controllerContext, device, input->DeviceId, &slot); if (!NT_SUCCESS(status)) { WdfObjectDelete(device); - return status; + goto ExitAdmission; } deviceContext->Slot = slot; @@ -438,14 +490,18 @@ ViiperCreateVirtualDevice( if (!NT_SUCCESS(status)) { ViiperReleaseDeviceSlot(controllerContext, device, slot); WdfObjectDelete(device); - return status; + goto ExitAdmission; } deviceContext->Plugged = TRUE; InterlockedExchange(&deviceContext->ActiveCounted, 1); InterlockedIncrement(&controllerContext->ActiveDevices); WdfRequestSetInformation(Request, 0); - return STATUS_SUCCESS; + status = STATUS_SUCCESS; + +ExitAdmission: + ViiperEndOwnerAdmission(controller, ownerFile); + return status; } static diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 49db0ae4..d1fcb533 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -111,6 +111,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { BOOLEAN CleanupInProgress; volatile LONG BrokerFaulted; volatile LONG OwnerReferenced; + volatile LONG ActiveOwnerAdmissions; volatile LONG CleanupRetries; volatile LONG ActiveDevices; volatile LONG PendingOperations; From cf512af86cf28ae5b4c15e5d962da4a7c0978a58 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:41:46 -0500 Subject: [PATCH 069/240] Preserve native IOCTL outcomes across cancellation races Follow the documented CancelIoEx contract: cancellation is advisory, and the completion packet decides whether an operation succeeded, was aborted, or failed. Preserve successful create/destroy completions that race a context deadline so user mode and the kernel cannot disagree about native device existence. --- docs/architecture/native-udecx.md | 5 +++++ internal/transport/udecx/client_windows.go | 17 ++++++++++++-- .../transport/udecx/client_windows_test.go | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 5270d226..f56de040 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -246,6 +246,11 @@ interface fields are only hints for alternates that contain no endpoints. PlugIn transaction before enumerating owned children. UdeCx calls run without the owner lock held, avoiding callback deadlocks while preventing an orphaned child from being published behind cleanup's enumeration boundary. +- Overlapped cancellation is outcome-based rather than intent-based. After + `CancelIoEx`, the completion packet decides whether the operation completed + normally, was actually aborted, or failed. A successful create/destroy can + therefore never be reported as cancelled merely because the context deadline + raced its completion. - Completion lookup is keyed by `(device ID, generation, token)`. - Failed transfers do not need to fabricate a successful ISO packet table. Successful completions are canonical: OUT replies carry no payload, every diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index f3a30435..3d88db12 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -183,6 +183,19 @@ func (c *Client) completionPumpError() error { return windows.ERROR_INVALID_HANDLE } +func completionAfterCancel(result ioCompletion, contextErr error) (uint32, error) { + // CancelIoEx is advisory: Microsoft explicitly permits the operation to + // complete normally when cancellation loses the race. Preserve that kernel + // outcome so create/destroy state cannot diverge across the ABI boundary. + if result.err == nil { + return result.transferred, nil + } + if errors.Is(result.err, windows.ERROR_OPERATION_ABORTED) { + return 0, contextErr + } + return result.transferred, errors.Join(contextErr, result.err) +} + func (c *Client) Capabilities() Capabilities { c.mu.RLock() defer c.mu.RUnlock() @@ -382,8 +395,8 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( case <-ctx.Done(): _ = windows.CancelIoEx(handle, &request.overlapped) select { - case <-request.done: - return 0, ctx.Err() + case result := <-request.done: + return completionAfterCancel(result, ctx.Err()) case <-c.pumpDone: var transferred uint32 _ = windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 0d6133e4..6e41edb2 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -11,6 +11,28 @@ import ( "golang.org/x/sys/windows" ) +func TestCompletionAfterCancelPreservesKernelOutcome(t *testing.T) { + t.Parallel() + + transferred, err := completionAfterCancel(ioCompletion{transferred: 547}, context.Canceled) + if err != nil || transferred != 547 { + t.Fatalf("normal completion after cancellation = (%d, %v), want (547, nil)", transferred, err) + } + + transferred, err = completionAfterCancel( + ioCompletion{err: windows.ERROR_OPERATION_ABORTED}, context.DeadlineExceeded) + if transferred != 0 || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cancelled completion = (%d, %v), want deadline exceeded", transferred, err) + } + + transportErr := windows.ERROR_INVALID_DATA + transferred, err = completionAfterCancel( + ioCompletion{transferred: 17, err: transportErr}, context.Canceled) + if transferred != 17 || !errors.Is(err, context.Canceled) || !errors.Is(err, transportErr) { + t.Fatalf("failed completion = (%d, %v), want joined context and transport errors", transferred, err) + } +} + func validTestNegotiation() NegotiateResponse { return NegotiateResponse{ ClientNonce: 7, From 424703af73c2deff192ddeda111502296141a00f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:47:13 -0500 Subject: [PATCH 070/240] Order native power transitions across endpoint lanes Add a per-device publication sequence to ABI 1.7 so overlapped dequeue workers cannot apply a delayed endpoint start after D0 exit. Keep endpoint lanes concurrent, gate direct input on the newest power transition, and prove resumption preserves the queued report and monotonic input sequence. --- docs/architecture/native-udecx.md | 7 +- internal/transport/udecx/host.go | 32 +++++++-- internal/transport/udecx/host_test.go | 82 ++++++++++++++++++++++- internal/transport/udecx/protocol.go | 6 +- internal/transport/udecx/protocol_test.go | 3 +- native/udecx/driver/Broker.c | 8 +++ native/udecx/driver/ViiperUde.h | 2 + native/udecx/include/ViiperUdeProtocol.h | 7 +- 8 files changed, 133 insertions(+), 14 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f56de040..8f0484f6 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -197,7 +197,7 @@ interface fields are only hints for alternates that contain no endpoints. completed. A pipe can therefore never restart or disappear across a live request. - Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx - management requests, not notifications. ABI 1.6 gives only those lifecycle + management requests, not notifications. ABI 1.7 gives only those lifecycle operations a generation-bound management token. Windows receives the request completion only after the Go controller engine has applied the reset or alternate-setting transition. Start, purge, and power notifications remain @@ -209,6 +209,11 @@ interface fields are only hints for alternates that contain no endpoints. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. +- Every published operation also carries a per-device publication sequence. + Endpoint lanes remain independent, but device-wide D0 transitions use that + sequence to reject a delayed pre-exit start notification. Multiple overlapped + dequeue workers therefore cannot resurrect an input publisher outside D0 or + consume a physical state snapshot behind a power boundary. - Parallel media callbacks receive a per-endpoint admission sequence under the broker lock. An URB cannot publish ahead of an earlier live unpublished admission; cancellation retires the admission before dispatch resumes, so diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 3bfc2890..f69917ec 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -61,6 +61,8 @@ type registeredDevice struct { publishers map[uint8]*inputPublisher activeInput map[uint8]bool inputSequences map[uint8]uint64 + inD0 bool + powerSequence uint64 } type inputPublisher struct { @@ -182,7 +184,7 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D entry := ®isteredDevice{ identity: identity, device: dev, ctx: deviceCtx, cancel: cancel, fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), - activeInput: make(map[uint8]bool), inputSequences: make(map[uint8]uint64), + activeInput: make(map[uint8]bool), inputSequences: make(map[uint8]uint64), inD0: true, } h.devices[deviceID] = entry h.generations[deviceID] = generation @@ -282,7 +284,8 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { return } h.mu.Lock() - if !h.running || entry.stopping || entry.publisherStopping || h.devices[entry.identity.DeviceID] != entry { + if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || + h.devices[entry.identity.DeviceID] != entry { h.mu.Unlock() return } @@ -572,6 +575,7 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { } delete(pending, expected) if isLifecycleOperation(current.Kind) { + applyPowerTransition := false switch current.Kind { case OperationEndpointPurge: h.mu.Lock() @@ -579,7 +583,16 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { h.mu.Unlock() h.stopInputPublisher(entry, current.EndpointAddress) case OperationDeviceD0Exit: - h.stopAllInputPublishers(entry) + h.mu.Lock() + if current.DeviceSequence > entry.powerSequence { + entry.powerSequence = current.DeviceSequence + entry.inD0 = false + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + h.stopAllInputPublishers(entry) + } } lifecycleErr := h.processor.Lifecycle(lane.ctx, entry.device, current) if current.Token != 0 { @@ -605,8 +618,17 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { h.mu.Unlock() h.startInputPublisher(entry, current.EndpointAddress) case OperationDeviceD0Entry: - for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) + h.mu.Lock() + if current.DeviceSequence > entry.powerSequence { + entry.powerSequence = current.DeviceSequence + entry.inD0 = true + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } } } } else { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index b927f715..4760656d 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -391,7 +391,8 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0, EndpointSequence: 1, Kind: OperationDeviceD0Exit, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceD0Exit, } select { case <-processor.lifecycle: @@ -407,7 +408,8 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0, EndpointSequence: 2, Kind: OperationDeviceD0Entry, + EndpointAddress: 0, EndpointSequence: 2, DeviceSequence: 3, + Kind: OperationDeviceD0Entry, } select { case <-processor.lifecycle: @@ -434,6 +436,82 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T } } +func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), + resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 47, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + // Multiple dequeue workers may deliver the device-wide D0 exit before an + // older endpoint-start notification. DeviceSequence must prevent that old + // start from resurrecting the publisher while the child is outside D0. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceD0Exit, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 exit was not processed") + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("older endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + t.Fatalf("stale endpoint start resurrected input outside D0: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 2, DeviceSequence: 3, + Kind: OperationDeviceD0Entry, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("D0 entry was not processed") + } + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{1}) { + t.Fatalf("D0-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after the ordered D0 entry") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 8f704733..3a9e3594 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 6 + ABIMinor uint16 = 7 HeaderSize = 16 NegotiateRequestSize = 32 @@ -22,7 +22,7 @@ const ( CreateDeviceSize = 56 DeviceIdentitySize = 32 IsoPacketSize = 16 - OperationSize = 96 + OperationSize = 104 CompletionSize = 72 InputReportSize = 48 StatsSize = 144 @@ -296,6 +296,7 @@ type Operation struct { IsoPackets []IsoPacket Payload []byte EndpointSequence uint64 + DeviceSequence uint64 } func ParseOperation(src []byte) (Operation, error) { @@ -335,6 +336,7 @@ func ParseOperation(src []byte) (Operation, error) { StartFrame: binary.LittleEndian.Uint32(src[52:56]), TransferLength: transferLength, EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), + DeviceSequence: binary.LittleEndian.Uint64(src[96:104]), IsoPackets: make([]IsoPacket, int(packetCount)), Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 5e7af4b3..0542607f 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -101,6 +101,7 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { binary.LittleEndian.PutUint32(raw[68:72], uint32(len(payload))) binary.LittleEndian.PutUint32(raw[72:76], OperationSize) binary.LittleEndian.PutUint64(raw[88:96], 17) + binary.LittleEndian.PutUint64(raw[96:104], 23) binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 0) binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], uint32(len(payload))) copy(raw[OperationSize+IsoPacketSize:], payload) @@ -110,7 +111,7 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { t.Fatal(err) } if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || - op.EndpointSequence != 17 || op.InterfaceNumber != 2 || + op.EndpointSequence != 17 || op.DeviceSequence != 23 || op.InterfaceNumber != 2 || op.InterfaceSetting != 1 || op.EndpointAttributes != 0x05 || op.EndpointInterval != 4 || op.EndpointMaxPacketSize != 196 || len(op.IsoPackets) != 1 { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 7635e872..e0549aff 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -136,6 +136,7 @@ ViiperQueueCancelEventLocked( event->Token = Pending->Token; event->DeviceId = Pending->DeviceId; event->EndpointSequence = 0; + event->DeviceSequence = 0; event->Generation = Pending->DeviceGeneration; event->Kind = ViiperUdeOperationCancel; event->EndpointAddress = Pending->EndpointAddress; @@ -217,6 +218,7 @@ ViiperDispatchNotificationEvents( operation->EndpointInterval = event.EndpointInterval; operation->EndpointMaxPacketSize = event.EndpointMaxPacketSize; operation->EndpointSequence = event.EndpointSequence; + operation->DeviceSequence = event.DeviceSequence; WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); @@ -508,6 +510,8 @@ ViiperQueueLifecycleEventLocked( event->InterfaceSetting = InterfaceSetting; event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( &DeviceContext->EndpointSequences[event->EndpointAddress]); + event->DeviceSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->DeviceSequence); ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ++ControllerContext->NotificationCount; @@ -1461,6 +1465,10 @@ ViiperDispatchAvailable( &ViiperGetDeviceContext( ViiperGetEndpointContext(endpoint)->Device)->EndpointSequences[ ViiperGetEndpointContext(endpoint)->Descriptor.bEndpointAddress]); + serializedOperation->DeviceSequence = + (ULONGLONG)InterlockedIncrement64( + &ViiperGetDeviceContext( + ViiperGetEndpointContext(endpoint)->Device)->DeviceSequence); pending->PublishedToOwner = TRUE; } } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index d1fcb533..8ad9c177 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -51,6 +51,7 @@ typedef struct VIIPER_UDE_NOTIFICATION { ULONGLONG Token; ULONGLONG DeviceId; ULONGLONG EndpointSequence; + ULONGLONG DeviceSequence; ULONG Generation; ULONG Kind; UCHAR EndpointAddress; @@ -161,6 +162,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { UDECXUSBENDPOINT Endpoints[256]; BOOLEAN RetiredEndpoints[256]; volatile LONG64 EndpointSequences[256]; + volatile LONG64 DeviceSequence; } VIIPER_UDE_DEVICE_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceContext) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 67ad44d5..338f87cb 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(6) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(7) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) @@ -167,6 +167,7 @@ typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_UINT8 EndpointInterval; VIIPER_UDE_UINT16 EndpointMaxPacketSize; VIIPER_UDE_UINT64 EndpointSequence; + VIIPER_UDE_UINT64 DeviceSequence; } VIIPER_UDE_OPERATION; typedef struct VIIPER_UDE_COMPLETION { @@ -227,7 +228,7 @@ static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); +static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); @@ -239,7 +240,7 @@ _Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTO _Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 96, "VIIPER_UDE_OPERATION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); _Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); From 019677784c69fd1b1b574594b8fb7dfe4d14d72f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:48:14 -0500 Subject: [PATCH 071/240] Close direct input at the UdeCx D0 boundary Track the child power state in kernel context and reject coalesced interrupt input synchronously from the UdeCx D0-exit callback. This removes the scheduler window before the advisory owner notification while preserving stale-generation and replay failures. --- docs/architecture/native-udecx.md | 6 ++++-- native/udecx/driver/Device.c | 10 +++++++++- native/udecx/driver/ViiperUde.h | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 8f0484f6..a971aeee 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -229,8 +229,10 @@ interface fields are only hints for alternates that contain no endpoints. output and media endpoints retain independent ordered queues. - A direct input report that was already submitted when D0 exit, unplug, or endpoint purge begins is acknowledged and discarded at that exact lifecycle - boundary. Stale generations and replayed sequences remain hard failures, so - normal teardown cannot fault the exclusive broker session. + boundary. The kernel closes D0 admission in the UdeCx callback itself rather + than waiting for the advisory user-mode notification. Stale generations and + replayed sequences remain hard failures, so normal teardown cannot fault the + exclusive broker session. - UDE callbacks never wait on user mode while holding a WDF lock. - Blocking work is represented by cancelable WDF requests, not sleeping kernel threads. diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 585398ab..94f4bd90 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -675,6 +675,9 @@ ViiperEvtUsbDeviceD0Entry( ) { UNREFERENCED_PARAMETER(Controller); + // This callback is the exact UdeCx power boundary. Open direct input + // admission before publishing the ordered advisory event to user mode. + InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, TRUE); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); return STATUS_SUCCESS; } @@ -688,6 +691,10 @@ ViiperEvtUsbDeviceD0Exit( { UNREFERENCED_PARAMETER(Controller); UNREFERENCED_PARAMETER(WakeSetting); + // Close direct input admission synchronously. Waiting for the user-mode + // notification would leave a scheduler window in which a fresh report + // could complete a Windows poll after the child had left D0. + InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, FALSE); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); return STATUS_SUCCESS; } @@ -991,7 +998,8 @@ ViiperSubmitInputReport( deviceContext->Generation != input->Generation) { continue; } - if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { lifecycleDrop = TRUE; break; } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 8ad9c177..9559679b 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -155,6 +155,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { ULONG Slot; UDECX_USB_DEVICE_SPEED Speed; BOOLEAN Plugged; + volatile LONG InD0; volatile LONG Purging; volatile LONG ActiveCounted; volatile LONG OwnerReferenced; From de06c327f715ddc33ae4d7c158d6b955387b61ca Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 9 Aug 2026 23:55:12 -0500 Subject: [PATCH 072/240] Enforce native UDE per-device backpressure Honor each child's negotiated pending-operation limit in the kernel broker instead of allowing one high-bandwidth device to consume the controller-wide slot pool. Account admission and release under the broker lock so media, HID, cancellation, purge, and DPC completion all share one exact quota. --- docs/architecture/native-udecx.md | 6 ++++-- native/udecx/driver/Broker.c | 15 ++++++++++++++- native/udecx/driver/Device.c | 1 + native/udecx/driver/ViiperUde.h | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index a971aeee..9eebdea0 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -59,8 +59,10 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. 5. Purge stops admission, cancels queued and in-flight work, waits for ownership to settle, then acknowledges UdeCx. 6. Driver unload and file cleanup leave no UDE device, request, or worker alive. -7. Endpoint queues are bounded. Saturation is observable and never overwrites - live media or state silently. +7. Endpoint queues are bounded. The broker enforces both its controller-wide + ceiling and each child's negotiated pending-operation quota, so one busy + media device cannot starve another controller. Saturation is observable and + never overwrites live media or state silently. 8. Shared report state is snapshotted atomically before encoding. Media and state never share mutable buffers. 9. No raw user pointer crosses the ABI. diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index e0549aff..90a202ba 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -253,6 +253,11 @@ ViiperClearSlotLocked( { VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[Slot]; UDECXUSBENDPOINT endpoint = pending->Endpoint; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; + + if (endpoint != WDF_NO_HANDLE) { + deviceContext = ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device); + } pending->Request = WDF_NO_HANDLE; pending->Endpoint = WDF_NO_HANDLE; @@ -269,6 +274,9 @@ ViiperClearSlotLocked( pending->CompletionUsbdStatus = USBD_STATUS_SUCCESS; pending->CompleteWithNtStatus = FALSE; InterlockedDecrement(&ControllerContext->PendingOperations); + if (deviceContext != NULL) { + InterlockedDecrement(&deviceContext->PendingOperations); + } if (endpoint != WDF_NO_HANDLE) { ViiperEndpointOperationCompleted(endpoint); } @@ -747,6 +755,10 @@ ViiperAllocatePendingSlot( if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { status = STATUS_DEVICE_NOT_READY; + } else if ((ULONG)InterlockedCompareExchange( + &deviceContext->PendingOperations, 0, 0) >= + deviceContext->MaxPendingOperations) { + status = STATUS_QUOTA_EXCEEDED; } for (offset = 0; status == STATUS_INSUFFICIENT_RESOURCES && offset < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++offset) { @@ -778,6 +790,7 @@ ViiperAllocatePendingSlot( ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ViiperEndpointOperationStarted(Endpoint); InterlockedIncrement(&ControllerContext->PendingOperations); + InterlockedIncrement(&deviceContext->PendingOperations); *Slot = index; *Token = pending->Token; status = STATUS_SUCCESS; @@ -785,7 +798,7 @@ ViiperAllocatePendingSlot( } WdfSpinLockRelease(ControllerContext->BrokerLock); - if (status == STATUS_INSUFFICIENT_RESOURCES) { + if (status == STATUS_INSUFFICIENT_RESOURCES || status == STATUS_QUOTA_EXCEEDED) { InterlockedIncrement64(&ControllerContext->QueueExhaustions); } return status; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 94f4bd90..1420a3d8 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -470,6 +470,7 @@ ViiperCreateVirtualDevice( deviceContext->Generation = input->Generation; deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; deviceContext->Speed = speed; + deviceContext->MaxPendingOperations = input->MaxPendingOperations; WdfObjectReference(ownerFile); InterlockedExchange(&deviceContext->OwnerReferenced, 1); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 9559679b..828fc745 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -159,6 +159,8 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { volatile LONG Purging; volatile LONG ActiveCounted; volatile LONG OwnerReferenced; + ULONG MaxPendingOperations; + volatile LONG PendingOperations; UDECXUSBENDPOINT DefaultEndpoint; UDECXUSBENDPOINT Endpoints[256]; BOOLEAN RetiredEndpoints[256]; From 9fda57c9f9dab89524550def9bf7ce7ddfc71286 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:01:46 -0500 Subject: [PATCH 073/240] Gate native input across USB device reset Close direct interrupt-IN admission at the exact UdeCx reset callback, pause and join all user-mode input publishers before resetting controller state, and reopen both kernel and host paths only at the acknowledged reset boundary. Add a repeated race regression proving queued input resumes without crossing reset. --- docs/architecture/native-udecx.md | 12 ++-- internal/transport/udecx/host.go | 23 ++++++- internal/transport/udecx/host_test.go | 97 +++++++++++++++++++++++++++ native/udecx/driver/Broker.c | 40 +++++++++++ native/udecx/driver/Device.c | 7 ++ native/udecx/driver/ViiperUde.h | 1 + 6 files changed, 175 insertions(+), 5 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 9eebdea0..a1f3cc6a 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -204,6 +204,10 @@ interface fields are only hints for alternates that contain no endpoints. completion only after the Go controller engine has applied the reset or alternate-setting transition. Start, purge, and power notifications remain unacknowledged and cannot add a media round trip. +- Device reset closes direct input admission in the kernel callback and pauses + every user-mode publisher before controller state is cleared. Admission and + the active publishers reopen only after the generation-bound reset request + has been acknowledged, so no HID snapshot can cross the reset boundary. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, and lifecycle IOCTLs retain their serialized control queue. Large media @@ -229,10 +233,10 @@ interface fields are only hints for alternates that contain no endpoints. the driver never treats the URB length as permission to overrun one mapping. - Interrupt-IN queues are manual and completed from fresh input snapshots; output and media endpoints retain independent ordered queues. -- A direct input report that was already submitted when D0 exit, unplug, or - endpoint purge begins is acknowledged and discarded at that exact lifecycle - boundary. The kernel closes D0 admission in the UdeCx callback itself rather - than waiting for the advisory user-mode notification. Stale generations and +- A direct input report that was already submitted when D0 exit, device reset, + unplug, or endpoint purge begins is acknowledged and discarded at that exact + lifecycle boundary. The kernel closes admission in the UdeCx callback itself + rather than waiting for the user-mode notification. Stale generations and replayed sequences remain hard failures, so normal teardown cannot fault the exclusive broker session. - UDE callbacks never wait on user mode while holding a WDF lock. diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index f69917ec..5b8e7cc6 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -62,6 +62,7 @@ type registeredDevice struct { activeInput map[uint8]bool inputSequences map[uint8]uint64 inD0 bool + resetting bool powerSequence uint64 } @@ -284,7 +285,7 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { return } h.mu.Lock() - if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || + if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || entry.resetting || h.devices[entry.identity.DeviceID] != entry { h.mu.Unlock() return @@ -576,6 +577,7 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { delete(pending, expected) if isLifecycleOperation(current.Kind) { applyPowerTransition := false + applyDeviceReset := false switch current.Kind { case OperationEndpointPurge: h.mu.Lock() @@ -593,6 +595,16 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { if applyPowerTransition { h.stopAllInputPublishers(entry) } + case OperationDeviceReset: + h.mu.Lock() + if !entry.resetting { + entry.resetting = true + applyDeviceReset = true + } + h.mu.Unlock() + if applyDeviceReset { + h.stopAllInputPublishers(entry) + } } lifecycleErr := h.processor.Lifecycle(lane.ctx, entry.device, current) if current.Token != 0 { @@ -630,6 +642,15 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { h.startInputPublisher(entry, endpoint) } } + case OperationDeviceReset: + if applyDeviceReset { + h.mu.Lock() + entry.resetting = false + h.mu.Unlock() + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + } } } else { if err := h.process(lane.ctx, entry.device, current); err != nil { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 4760656d..2c67091e 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -151,6 +151,28 @@ func (*noopProcessor) Process(context.Context, usb.Device, Operation) (Completio func (*noopProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} +type resetGateProcessor struct { + started chan struct{} + release chan struct{} +} + +func (*resetGateProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (p *resetGateProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + if op.Kind != OperationDeviceReset { + return nil + } + close(p.started) + select { + case <-p.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (*resetGateProcessor) Reset(usb.Device, DeviceIdentity) {} + func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ Device: usb.DeviceDescriptor{ @@ -512,6 +534,81 @@ func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { } } +func TestHostPausesDirectInputAcrossAcknowledgedDeviceReset(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &resetGateProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 48, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 0x0000000180000001, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("device reset did not reach processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an unacknowledged device reset: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.release) + select { + case completion := <-driver.completions: + if completion.Token != 0x0000000180000001 || completion.Status != 0 { + t.Fatalf("device reset acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("device reset was not acknowledged") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("reset-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after device reset acknowledgement") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 90a202ba..c5efe6bd 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -244,6 +244,34 @@ ViiperClearManagementSlotLocked( InterlockedDecrement(&ControllerContext->PendingOperations); } +static +VOID +ViiperSetDeviceResettingByIdentity( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ LONG Value + ) +{ + ULONG index; + + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE device = ControllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->DeviceId == DeviceId && + deviceContext->Generation == Generation) { + InterlockedExchange(&deviceContext->Resetting, Value); + break; + } + } + WdfWaitLockRelease(ControllerContext->DeviceLock); +} + static VOID ViiperClearSlotLocked( @@ -753,6 +781,7 @@ ViiperAllocatePendingSlot( WdfSpinLockAcquire(ControllerContext->BrokerLock); if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { status = STATUS_DEVICE_NOT_READY; } else if ((ULONG)InterlockedCompareExchange( @@ -1577,6 +1606,7 @@ ViiperQueueUrb( NTSTATUS abortStatus = STATUS_CANCELLED; if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { return STATUS_DEVICE_NOT_READY; @@ -1657,6 +1687,7 @@ ViiperCompleteManagementOperation( ULONG encodedSlot = (ULONG)Completion->Token; ULONG slot = (encodedSlot & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; WDFREQUEST request = WDF_NO_HANDLE; + ULONG kind = 0; if ((encodedSlot & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 || slot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || @@ -1673,6 +1704,7 @@ ViiperCompleteManagementOperation( ControllerContext->ManagementSlots[slot].DeviceId == Completion->DeviceId && ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation) { request = ControllerContext->ManagementSlots[slot].Request; + kind = ControllerContext->ManagementSlots[slot].Kind; ControllerContext->ManagementSlots[slot].State = ViiperUdePendingCompleting; WdfObjectReference(request); } @@ -1682,6 +1714,14 @@ ViiperCompleteManagementOperation( return STATUS_NOT_FOUND; } + if (kind == ViiperUdeOperationDeviceReset) { + // User mode has stopped every direct-input publisher before issuing + // this acknowledgement. Reopen kernel admission immediately before + // completing the UdeCx reset request so any synchronously resumed URB + // sees the post-reset state, while no direct report can cross early. + ViiperSetDeviceResettingByIdentity( + ControllerContext, Completion->DeviceId, Completion->Generation, FALSE); + } WdfRequestComplete(request, (NTSTATUS)Completion->Status); WdfSpinLockAcquire(ControllerContext->BrokerLock); if (ControllerContext->ManagementSlots[slot].Request == request && diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 1420a3d8..2c590471 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -723,6 +723,7 @@ ViiperEvtUsbDeviceReset( _In_ BOOLEAN AllDevicesReset ) { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); NTSTATUS status; UNREFERENCED_PARAMETER(Controller); @@ -734,9 +735,14 @@ ViiperEvtUsbDeviceReset( return; } + // Direct interrupt-IN bypasses the ordinary endpoint broker. Close that + // admission path at the exact asynchronous UdeCx reset boundary instead + // of waiting for user mode to observe the lifecycle operation. + InterlockedExchange(&deviceContext->Resetting, TRUE); status = ViiperQueueAcknowledgedDeviceLifecycleEvent( Device, Request, ViiperUdeOperationDeviceReset); if (!NT_SUCCESS(status)) { + InterlockedExchange(&deviceContext->Resetting, FALSE); WdfRequestComplete(Request, status); } } @@ -1000,6 +1006,7 @@ ViiperSubmitInputReport( continue; } if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { lifecycleDrop = TRUE; break; diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 828fc745..f4eabfd9 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -156,6 +156,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { UDECX_USB_DEVICE_SPEED Speed; BOOLEAN Plugged; volatile LONG InD0; + volatile LONG Resetting; volatile LONG Purging; volatile LONG ActiveCounted; volatile LONG OwnerReferenced; From aa12d3b809739c05637354eb366c9487fdc29432 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:05:28 -0500 Subject: [PATCH 074/240] Open native endpoint admission before resume notification --- docs/architecture/native-udecx.md | 4 ++++ native/udecx/driver/Device.c | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index a1f3cc6a..7587f5a2 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -215,6 +215,10 @@ interface fields are only hints for alternates that contain no endpoints. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. +- Endpoint start opens the kernel admission gate before publishing the ordered + start notification. The first fresh input snapshot after resume therefore + cannot consume its sequence against a still-purged kernel endpoint; the + callback itself is the single UdeCx restart boundary for both paths. - Every published operation also carries a per-device publication sequence. Endpoint lanes remain independent, but device-wide D0 transitions use that sequence to reject a delayed pre-exit start notification. Multiple overlapped diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 2c590471..814ce1dd 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -712,6 +712,15 @@ ViiperEvtUsbDeviceSetFunctionSuspendAndWake( UNREFERENCED_PARAMETER(Device); UNREFERENCED_PARAMETER(Interface); UNREFERENCED_PARAMETER(FunctionPower); + + // VIIPER's production controller set is low/full/high-speed, so UdeCx + // never invokes this SuperSpeed-only callback for a supported child. A + // virtual child has no physical function to power down; acknowledge the + // host's bookkeeping transition exactly as usbip-win2's UdeCx reference + // does, without mutating endpoint/media state behind UdeCx's queue + // lifecycle. If VIIPER adds a SuperSpeed controller with real remote-wake + // behavior, that device must add an explicit per-interface state contract + // rather than repurposing endpoint purge/start implicitly. return STATUS_SUCCESS; } @@ -1172,9 +1181,16 @@ ViiperEvtEndpointStart( ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); + + // UdeCx defines START as the boundary at which both the endpoint queue and + // any client-owned forwarded paths may resume. Open the kernel admission + // gate before publishing that boundary to user mode. Publishing first lets + // the newly started input publisher race back through SUBMIT_INPUT_REPORT + // while Purging is still true, consuming and discarding the first fresh + // sequence after resume. InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); InterlockedExchange(&endpointContext->Purging, FALSE); + (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); } VOID From 9f5553360d6e22c1f50c528496890b45506c0b31 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:06:13 -0500 Subject: [PATCH 075/240] Serialize native device reset admission --- docs/architecture/native-udecx.md | 3 +++ native/udecx/driver/Device.c | 38 +++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 7587f5a2..4d7e0d82 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -208,6 +208,9 @@ interface fields are only hints for alternates that contain no endpoints. every user-mode publisher before controller state is cleared. Admission and the active publishers reopen only after the generation-bound reset request has been acknowledged, so no HID snapshot can cross the reset boundary. + Post-enumeration reset, device initialization, and configuration replacement + share this one-child-at-a-time gate; concurrent reset transactions are + rejected instead of interleaving two controller resets. - The controller's default KMDF queue only routes requests: interrupt-IN submissions run on an independent parallel queue, while mutation, broker, and lifecycle IOCTLs retain their serialized control queue. Large media diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 814ce1dd..abe308c9 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -724,6 +724,32 @@ ViiperEvtUsbDeviceSetFunctionSuspendAndWake( return STATUS_SUCCESS; } +static +NTSTATUS +ViiperBeginAcknowledgedDeviceReset( + _In_ UDECXUSBDEVICE Device, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + NTSTATUS status; + + // Post-enumeration reset and device-configuration replacement are both + // asynchronous UdeCx reset boundaries. Close every client-owned admission + // path synchronously and permit only one reset transaction for a child. + // User mode stops and joins the publishers before acknowledging the + // operation; completion then reopens this exact kernel gate. + if (InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { + return STATUS_DEVICE_BUSY; + } + status = ViiperQueueAcknowledgedDeviceLifecycleEvent( + Device, Request, ViiperUdeOperationDeviceReset); + if (!NT_SUCCESS(status)) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + return status; +} + VOID ViiperEvtUsbDeviceReset( _In_ WDFDEVICE Controller, @@ -732,7 +758,6 @@ ViiperEvtUsbDeviceReset( _In_ BOOLEAN AllDevicesReset ) { - VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); NTSTATUS status; UNREFERENCED_PARAMETER(Controller); @@ -744,14 +769,8 @@ ViiperEvtUsbDeviceReset( return; } - // Direct interrupt-IN bypasses the ordinary endpoint broker. Close that - // admission path at the exact asynchronous UdeCx reset boundary instead - // of waiting for user mode to observe the lifecycle operation. - InterlockedExchange(&deviceContext->Resetting, TRUE); - status = ViiperQueueAcknowledgedDeviceLifecycleEvent( - Device, Request, ViiperUdeOperationDeviceReset); + status = ViiperBeginAcknowledgedDeviceReset(Device, Request); if (!NT_SUCCESS(status)) { - InterlockedExchange(&deviceContext->Resetting, FALSE); WdfRequestComplete(Request, status); } } @@ -1205,8 +1224,7 @@ ViiperEvtEndpointsConfigure( switch (ConfigureParams->ConfigureType) { case UdecxEndpointsConfigureTypeDeviceInitialize: case UdecxEndpointsConfigureTypeDeviceConfigurationChange: - status = ViiperQueueAcknowledgedDeviceLifecycleEvent( - Device, Request, ViiperUdeOperationDeviceReset); + status = ViiperBeginAcknowledgedDeviceReset(Device, Request); break; case UdecxEndpointsConfigureTypeInterfaceSettingChange: status = ViiperQueueAcknowledgedInterfaceLifecycleEvent( From a9c1cb636d4267e5d1a6dfc6f59696767c409d6b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:07:59 -0500 Subject: [PATCH 076/240] Soak concurrent DualSense native media lanes --- internal/server/usb/native_production_test.go | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go index f06e07d9..4f9fe9c8 100644 --- a/internal/server/usb/native_production_test.go +++ b/internal/server/usb/native_production_test.go @@ -3,7 +3,10 @@ package usb_test import ( "bytes" "context" + "fmt" "log/slog" + "sync" + "sync/atomic" "testing" "github.com/Alia5/VIIPER/device/dualsense" @@ -180,6 +183,163 @@ func TestNativeProcessorPreservesPlayStationIsochronousMedia(t *testing.T) { }) } +func TestNativeProcessorRunsDualSenseHIDSpeakerAndMicrophoneConcurrently(t *testing.T) { + const iterations = 12 + dev, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualsense.EndpointHapticsAudioOut) + startNativeEndpoint(t, processor, dev, dualsense.EndpointMicrophoneIn) + + var outputReports atomic.Uint64 + var speakerFrames atomic.Uint64 + var callbackFailure atomic.Value + dev.SetOutputCallback(func(dualsense.OutputState) { + outputReports.Add(1) + }) + dev.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, pcm []byte) { + if len(pcm) != 480*4 { + callbackFailure.CompareAndSwap(nil, fmt.Errorf( + "atomic speaker callback length=%d want=%d", len(pcm), 480*4)) + return + } + speakerFrames.Add(1) + }) + + microphoneFrame := make([]byte, dualsense.USBMicrophoneClientFrameSize) + for index := range microphoneFrame { + microphoneFrame[index] = byte(index*17 + 3) + } + // The production microphone contract deliberately primes six 10 ms source + // frames before serving its first 1 ms USB packet. + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + + errors := make(chan error, 3) + var workers sync.WaitGroup + workers.Add(3) + + go func() { + defer workers.Done() + for iteration := range iterations { + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1] = dualsense.ReportIDOutput, 0x03 + report[3], report[4] = byte(iteration+1), byte(0x80+iteration) + _, processErr := processor.Process(context.Background(), dev, udecx.Operation{ + Token: uint64(1000 + iteration), DeviceID: 1, Generation: 1, + Kind: udecx.OperationTransfer, EndpointAddress: dualsense.EndpointOut, + TransferLength: uint32(len(report)), Payload: report, + }) + if processErr != nil { + errors <- fmt.Errorf("HID output iteration %d: %w", iteration, processErr) + return + } + } + }() + + go func() { + defer workers.Done() + packetCount := 10 + packetLength := uint32(dualsense.USBHapticsAudioPacketSize) + payload := make([]byte, packetCount*int(packetLength)) + for index := range payload { + payload[index] = byte(index*29 + 5) + } + for iteration := range iterations { + op := productionIsoOperation( + uint64(2000+iteration), dualsense.EndpointHapticsAudioOut, + false, payload, packetCount, packetLength) + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("speaker iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.IsoPackets) != packetCount { + errors <- fmt.Errorf("speaker iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + go func() { + defer workers.Done() + packetCount := 10 + packetLength := uint32(dualsense.USBMicrophonePacketSize) + for iteration := range iterations { + dev.QueueMicrophonePCMFrame(microphoneFrame) + op := productionIsoOperation( + uint64(3000+iteration), dualsense.EndpointMicrophoneIn, + true, nil, packetCount, packetLength) + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("microphone iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength == 0 || len(completion.Payload) != packetCount*int(packetLength) || + len(completion.IsoPackets) != packetCount { + errors <- fmt.Errorf("microphone iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + workers.Wait() + close(errors) + for workerErr := range errors { + t.Error(workerErr) + } + if failure := callbackFailure.Load(); failure != nil { + t.Error(failure) + } + if got := outputReports.Load(); got != iterations { + t.Errorf("DualSense output callbacks=%d want=%d", got, iterations) + } + if got := speakerFrames.Load(); got != iterations { + t.Errorf("DualSense atomic speaker callbacks=%d want=%d", got, iterations) + } +} + +func productionIsoOperation(token uint64, endpoint uint8, input bool, payload []byte, + packetCount int, packetLength uint32) udecx.Operation { + packets := make([]udecx.IsoPacket, packetCount) + for index := range packets { + packets[index] = udecx.IsoPacket{ + Offset: uint32(index) * packetLength, Length: packetLength, + } + } + op := udecx.Operation{ + Token: token, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, + EndpointAddress: endpoint, TransferLength: uint32(packetCount) * packetLength, + IsoPackets: packets, Payload: payload, + } + if input { + op.Direction = 1 + } + return op +} + +func populateProductionEndpointMetadata(dev usbdevice.Device, op *udecx.Operation) { + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress != op.EndpointAddress { + continue + } + op.EndpointAttributes = endpoint.BMAttributes + op.EndpointInterval = endpoint.BInterval + op.EndpointMaxPacketSize = endpoint.WMaxPacketSize + return + } + } +} + func newProductionProcessor(t *testing.T) *serverusb.NativeProcessor { t.Helper() processor, err := serverusb.NewNativeProcessor( From 8dba58b1faf9bac43261a196e8ce1e8f3fe30cfa Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:12:19 -0500 Subject: [PATCH 077/240] Fault-close native direct input after lifecycle loss A broker notification overflow means user mode can no longer prove the reset and power history for the active device generations. Preserve the completion path so already-published URBs can drain, but reject new direct interrupt-IN reports with STATUS_DATA_ERROR. The host already treats the ordered BrokerFault operation as terminal and opens a fresh one-shot session. --- docs/architecture/native-udecx.md | 4 ++++ native/udecx/driver/Device.c | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 4d7e0d82..25adea13 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -218,6 +218,10 @@ interface fields are only hints for alternates that contain no endpoints. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. +- A lost ordered lifecycle notification faults both the broker and the direct + interrupt-IN producer lane. Already-published broker completions remain + drainable, but no new controller state is admitted into a generation whose + power/reset history is no longer trustworthy. - Endpoint start opens the kernel admission gate before publishing the ordered start notification. The first fresh input snapshot after resume therefore cannot consume its sequence against a still-purged kernel endpoint; the diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index abe308c9..8b0cc33a 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -992,6 +992,16 @@ ViiperSubmitInputReport( if (!NT_SUCCESS(status)) { return status; } + // A broker fault means an ordered lifecycle notification was lost. The + // completion path must remain available so already-published URBs can be + // drained, but accepting a new direct interrupt-IN state after that point + // could apply it to a generation whose reset/power boundary user mode did + // not observe. Fail the producer lane and let Host terminate this one-shot + // owner session when it dequeues ViiperUdeOperationBrokerFault. + if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + return STATUS_DATA_ERROR; + } ownerFile = WdfRequestGetFileObject(Request); status = WdfRequestRetrieveInputBuffer( Request, sizeof(*input), (PVOID *)&input, &inputLength); From 562826b7c7a1f69bbeb27081ce464acc2393961d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:12:19 -0500 Subject: [PATCH 078/240] Soak concurrent DualShock 4 native media lanes Exercise the production DualShock 4 engine with HID output, speaker ISO, and microphone ISO running concurrently. Repeat the complete three-lane scenario twelve times per test and validate callback counts, media sizes, and ISO completion structure; this gives DS4 the same transport-parity gate as DualSense. --- internal/server/usb/native_production_test.go | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go index 4f9fe9c8..55e1ad5a 100644 --- a/internal/server/usb/native_production_test.go +++ b/internal/server/usb/native_production_test.go @@ -307,6 +307,135 @@ func TestNativeProcessorRunsDualSenseHIDSpeakerAndMicrophoneConcurrently(t *test } } +func TestNativeProcessorRunsDualShock4HIDSpeakerAndMicrophoneConcurrently(t *testing.T) { + const iterations = 12 + dev, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + processor := newProductionProcessor(t) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointAudioOut) + startNativeEndpoint(t, processor, dev, dualshock4.EndpointMicrophoneIn) + + var outputReports atomic.Uint64 + var speakerTransfers atomic.Uint64 + var callbackFailure atomic.Value + dev.SetOutputCallback(func(dualshock4.OutputState) { + outputReports.Add(1) + }) + const speakerPackets = 10 + const speakerPacketLength = 128 + dev.SetSpeakerCallback(func(pcm []byte) { + if len(pcm) != speakerPackets*speakerPacketLength { + callbackFailure.CompareAndSwap(nil, fmt.Errorf( + "speaker callback length=%d want=%d", + len(pcm), speakerPackets*speakerPacketLength)) + return + } + speakerTransfers.Add(1) + }) + + microphoneFrame := make([]byte, dualshock4.USBMicrophoneClientFrameSize) + for index := range microphoneFrame { + microphoneFrame[index] = byte(index*11 + 7) + } + // Match the production capture contract's startup reserve before the first + // 1 ms USB microphone packet is requested. + for range 6 { + dev.QueueMicrophonePCMFrame(microphoneFrame) + } + + errors := make(chan error, 3) + var workers sync.WaitGroup + workers.Add(3) + + go func() { + defer workers.Done() + for iteration := range iterations { + report := []byte{ + dualshock4.ReportIDOutput, 0, 0, 0, + byte(iteration + 1), byte(0x80 + iteration), + 1, 2, 3, 0, 0, + } + _, processErr := processor.Process(context.Background(), dev, udecx.Operation{ + Token: uint64(4000 + iteration), DeviceID: 2, Generation: 1, + Kind: udecx.OperationTransfer, EndpointAddress: dualshock4.EndpointOut, + TransferLength: uint32(len(report)), Payload: report, + }) + if processErr != nil { + errors <- fmt.Errorf("HID output iteration %d: %w", iteration, processErr) + return + } + } + }() + + go func() { + defer workers.Done() + payload := make([]byte, speakerPackets*speakerPacketLength) + for index := range payload { + payload[index] = byte(index*31 + 9) + } + for iteration := range iterations { + op := productionIsoOperation( + uint64(5000+iteration), dualshock4.EndpointAudioOut, + false, payload, speakerPackets, speakerPacketLength) + op.DeviceID = 2 + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("speaker iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.IsoPackets) != speakerPackets { + errors <- fmt.Errorf("speaker iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + go func() { + defer workers.Done() + const microphonePackets = 10 + for iteration := range iterations { + dev.QueueMicrophonePCMFrame(microphoneFrame) + op := productionIsoOperation( + uint64(6000+iteration), dualshock4.EndpointMicrophoneIn, + true, nil, microphonePackets, dualshock4.USBMicrophonePacketSize) + op.DeviceID = 2 + populateProductionEndpointMetadata(dev, &op) + completion, processErr := processor.Process(context.Background(), dev, op) + if processErr != nil { + errors <- fmt.Errorf("microphone iteration %d: %w", iteration, processErr) + return + } + if completion.TransferLength == 0 || + len(completion.Payload) != microphonePackets*dualshock4.USBMicrophonePacketSize || + len(completion.IsoPackets) != microphonePackets { + errors <- fmt.Errorf("microphone iteration %d malformed completion: %+v", + iteration, completion) + return + } + } + }() + + workers.Wait() + close(errors) + for workerErr := range errors { + t.Error(workerErr) + } + if failure := callbackFailure.Load(); failure != nil { + t.Error(failure) + } + if got := outputReports.Load(); got != iterations { + t.Errorf("DualShock 4 output callbacks=%d want=%d", got, iterations) + } + if got := speakerTransfers.Load(); got != iterations { + t.Errorf("DualShock 4 speaker callbacks=%d want=%d", got, iterations) + } +} + func productionIsoOperation(token uint64, endpoint uint8, input bool, payload []byte, packetCount int, packetLength uint32) udecx.Operation { packets := make([]udecx.IsoPacket, packetCount) From 6fef455aa40637f25cf491314f8963d6398e893d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:19:54 -0500 Subject: [PATCH 079/240] Fail native sessions on direct input loss Exercise the ViGEm-style direct interrupt-IN publisher as a first-class one-shot session boundary. If SubmitInputReport loses the broker handle, the host now has a regression proving it tears down the entire native session instead of silently leaving a virtual pad alive without fresh input. --- internal/transport/udecx/host_test.go | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 2c67091e..494bfa58 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -24,10 +24,14 @@ type fakeHostDriver struct { type fastInputDriver struct { *fakeHostDriver - reports chan InputReport + reports chan InputReport + submitErr error } func (d *fastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { + if d.submitErr != nil { + return d.submitErr + } report.Payload = append([]byte(nil), report.Payload...) select { case d.reports <- report: @@ -1273,6 +1277,47 @@ func TestHostCompletionFailureFailsSession(t *testing.T) { } } +func TestHostDirectInputFailureFailsSession(t *testing.T) { + driver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), + reports: make(chan InputReport, 1), + submitErr: errors.New("direct input handle lost"), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 1), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 16, device) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1, 2, 3, 4} + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "direct input handle lost") { + t.Fatalf("Serve error=%v, want direct-input session failure", err) + } + case <-time.After(time.Second): + t.Fatal("direct input submission failure did not fail the host session") + } +} + func TestHostBrokerFaultFailsSessionWithoutDispatchingAnOperation(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{ From 0fdb56b1fa6745b6359e3d883aa388ffc3e3bd79 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:23:47 -0500 Subject: [PATCH 080/240] Register the native UDE preview explicitly Add an explicit install transport selector and persist native-ude in Windows autorun rather than relying on ambient state. Native registration skips USB-IP, negotiates and closes the exclusive broker before mutating autorun, and refuses to proceed when the signed driver is absent or incompatible. Keep usbip as the default and reject native registration on Linux. --- internal/cmd/install.go | 11 +++++- internal/cmd/install_linux.go | 16 +++++--- internal/cmd/install_windows.go | 57 ++++++++++++++++++++++------ internal/cmd/install_windows_test.go | 23 +++++++++++ native/udecx/README.md | 13 +++++++ 5 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 internal/cmd/install_windows_test.go diff --git a/internal/cmd/install.go b/internal/cmd/install.go index f115efb5..f9aa67eb 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -11,7 +11,9 @@ import ( ) // Install sets up VIIPER to run automatically. -type Install struct{} +type Install struct { + Transport string `help:"Virtual USB transport to register: usbip or native-ude." default:"usbip"` +} // Uninstall removes VIIPER startup configuration. type Uninstall struct { @@ -28,7 +30,12 @@ func (c *Install) Run(logger *slog.Logger) error { return errors.New("cannot install from 'go run'") } - return install(logger) + transport := strings.ToLower(strings.TrimSpace(c.Transport)) + if transport != "usbip" && transport != "native-ude" { + return fmt.Errorf("unsupported VIIPER transport %q (expected usbip or native-ude)", c.Transport) + } + + return install(logger, transport) } func (c *Uninstall) Run(logger *slog.Logger) error { diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index c37a5c96..e90242a8 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -17,13 +17,16 @@ const ( servicePath = "/etc/systemd/system/viiper.service" ) -func install(logger *slog.Logger) error { +func install(logger *slog.Logger, transport string) error { + if transport != "usbip" { + return fmt.Errorf("transport %q is unavailable on Linux", transport) + } exePath, err := currentExecutable() if err != nil { return err } - unit := systemdUnitContent(exePath) + unit := systemdUnitContent(exePath, transport) if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { return err } @@ -40,7 +43,8 @@ func install(logger *slog.Logger) error { } } - logger.Info("VIIPER systemd service installed", "path", servicePath, "exe", exePath) + logger.Info("VIIPER systemd service installed", "path", servicePath, "exe", exePath, + "transport", transport) return nil } @@ -70,7 +74,7 @@ func uninstall(logger *slog.Logger) error { return nil } -func systemdUnitContent(exePath string) string { +func systemdUnitContent(exePath, transport string) string { workingDir := filepath.Dir(exePath) return fmt.Sprintf(`[Unit] Description=VIIPER server @@ -79,13 +83,13 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%q server +ExecStart=%q server --transport %s WorkingDirectory=%s Restart=on-failure [Install] WantedBy=multi-user.target -`, exePath, workingDir) +`, exePath, transport, workingDir) } func runSystemctl(args ...string) error { diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 86568546..a19d1067 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -5,6 +5,7 @@ package cmd import ( "bufio" "bytes" + "context" "errors" "fmt" "log/slog" @@ -13,8 +14,10 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/Alia5/VIIPER/internal/configpaths" + "github.com/Alia5/VIIPER/internal/transport/udecx" "golang.org/x/sys/windows/registry" ) @@ -24,12 +27,14 @@ const ( runScheduledTask = "RunVIIPER" ) -func install(logger *slog.Logger) error { +func install(logger *slog.Logger, transport string) error { if os.Getenv("VIIPER_DEVELOPER_STANDALONE") != "1" { return errors.New("standalone VIIPER startup registration is developer-only on Windows; use the signed DS4Windows installer or its built-in VIIPER repair so one verified owner manages VIIPER and USB-IP") } - if err := requireUSBIPRuntime(); err != nil { - return err + if transport == "usbip" { + if err := requireUSBIPRuntime(); err != nil { + return err + } } scheduledExe, err := currentScheduledTaskExe() if err != nil { @@ -63,7 +68,18 @@ func install(logger *slog.Logger) error { return fmt.Errorf("failed to create log directory %s: %w", cfgDir, err) } - value := fmt.Sprintf("\"%s\" server --log.file \"%s\"", exePath, logFile) + if previousExe != "" { + if err := killProcessesByExe(previousExe, logger); err != nil { + return fmt.Errorf("failed to stop previous autorun instance: %w", err) + } + } + if transport == "native-ude" { + if err := requireNativeUDEBroker(); err != nil { + return err + } + } + + value := windowsAutorunCommand(exePath, transport, logFile) key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) if err != nil { return err @@ -74,17 +90,34 @@ func install(logger *slog.Logger) error { return err } - if previousExe != "" { - if err := killProcessesByExe(previousExe, logger); err != nil { - return fmt.Errorf("failed to stop previous autorun instance: %w", err) - } - } - - if err := exec.Command(exePath, "server", "--log.file", logFile).Start(); err != nil { + if err := exec.Command(exePath, serverArguments(transport, logFile)...).Start(); err != nil { return fmt.Errorf("failed to start server: %w", err) } - logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, "logFile", logFile) + logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, + "transport", transport, "logFile", logFile) + return nil +} + +func serverArguments(transport, logFile string) []string { + return []string{"server", "--transport", transport, "--log.file", logFile} +} + +func windowsAutorunCommand(exePath, transport, logFile string) string { + return fmt.Sprintf("\"%s\" server --transport %s --log.file \"%s\"", + exePath, transport, logFile) +} + +func requireNativeUDEBroker() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := udecx.Open(ctx) + if err != nil { + return fmt.Errorf("native UDE driver preflight failed without changing autorun: %w", err) + } + if err := client.Close(); err != nil { + return fmt.Errorf("native UDE driver preflight close failed without changing autorun: %w", err) + } return nil } diff --git a/internal/cmd/install_windows_test.go b/internal/cmd/install_windows_test.go new file mode 100644 index 00000000..1dc0f14a --- /dev/null +++ b/internal/cmd/install_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package cmd + +import ( + "reflect" + "testing" +) + +func TestNativeInstallPersistsExplicitTransport(t *testing.T) { + exe := `C:\Program Files\VIIPER\viiper.exe` + logFile := `C:\Users\test user\AppData\Local\VIIPER\viiper.log` + + wantArgs := []string{"server", "--transport", "native-ude", "--log.file", logFile} + if got := serverArguments("native-ude", logFile); !reflect.DeepEqual(got, wantArgs) { + t.Fatalf("server arguments=%q want=%q", got, wantArgs) + } + + wantCommand := `"C:\Program Files\VIIPER\viiper.exe" server --transport native-ude --log.file "C:\Users\test user\AppData\Local\VIIPER\viiper.log"` + if got := windowsAutorunCommand(exe, "native-ude", logFile); got != wantCommand { + t.Fatalf("autorun command=%q want=%q", got, wantCommand) + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index d758ec88..3acbea00 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -22,3 +22,16 @@ Directory contract: The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in `docs/architecture/native-udecx-signing.md`. + +After a Microsoft-signed native driver package has been installed and verified, +the developer-only standalone registration can persist the preview transport: + +```powershell +$env:VIIPER_DEVELOPER_STANDALONE = '1' +.\viiper.exe install --transport native-ude +``` + +This skips the USB/IP runtime prerequisite and records +`server --transport native-ude` in the startup command. It does not install or +trust an unsigned kernel driver. The default remains `usbip` until the signed +live-driver gates in the architecture document pass. From d0f0436b5d4998dc4091710c26fa43a88df8226a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:29:06 -0500 Subject: [PATCH 081/240] Align native UDE with the Windows 10 driver floor Target KMDF 1.27, which is the framework version present on the advertised Windows 10 1809 minimum, instead of producing a 1.33 driver that could only load on Windows 11 21H2/Server 2022 and newer. Add a CI contract test that couples the INF OS floor to the reviewed KMDF target so future framework upgrades cannot silently strand supported systems. --- .github/workflows/native-ude.yml | 3 ++ docs/architecture/native-udecx.md | 5 +++ native/udecx/driver/ViiperUde.vcxproj | 4 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 37 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 5a785361..b9c8d976 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -69,6 +69,9 @@ jobs: - uses: NuGet/setup-nuget@v2 - name: Restore WDK packages run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive + - name: Validate Windows and KMDF target contract + shell: pwsh + run: ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 - name: Expose WDK tools shell: pwsh run: | diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 25adea13..714762ad 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -318,6 +318,10 @@ request queues while accounting for UdeCx's endpoint-specific purge contract. ViGEmBus-style virtual input under the same workload. - Installation is signed, reversible, version-gated, and never replaces a live kernel driver across an unsafe reboot boundary. +- The INF's Windows 10 1809 floor and the linked KMDF contract remain aligned: + the driver targets KMDF 1.27, the framework version Microsoft ships in + Windows 10 1809. CI rejects a newer KMDF target unless the INF floor is also + intentionally raised. The exact attestation/HLK boundary, CAB construction, and Microsoft-signature validation contract is documented in @@ -327,5 +331,6 @@ validation contract is documented in - Microsoft, *Write a UDE client driver* - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` +- Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index c218bed6..40f1b712 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -26,7 +26,7 @@ 1 1 1 - 33 + 27 Spectre @@ -40,7 +40,7 @@ 1 1 1 - 33 + 27 Spectre diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 new file mode 100644 index 00000000..ce916a5e --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding()] +param( + [string]$ProjectPath, + [string]$InfPath +) + +$ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($ProjectPath)) { + $ProjectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +} +if ([string]::IsNullOrWhiteSpace($InfPath)) { + $InfPath = Join-Path $PSScriptRoot '..\package\ViiperUde.inf' +} +$projectPathResolved = (Resolve-Path -LiteralPath $ProjectPath).Path +$infPathResolved = (Resolve-Path -LiteralPath $InfPath).Path + +[xml]$project = Get-Content -LiteralPath $projectPathResolved -Raw +$namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$minorNodes = @($project.SelectNodes('//msb:KMDF_VERSION_MINOR', $namespace)) +if ($minorNodes.Count -ne 2) { + throw "Expected Debug and Release KMDF_VERSION_MINOR nodes; found $($minorNodes.Count)." +} +$minorVersions = @($minorNodes | ForEach-Object { $_.InnerText.Trim() } | Sort-Object -Unique) +if ($minorVersions.Count -ne 1 -or $minorVersions[0] -ne '27') { + throw "Windows 10 1809 requires the committed KMDF 1.27 contract; project targets: $($minorVersions -join ', ')." +} + +$inf = Get-Content -LiteralPath $infPathResolved -Raw +if ($inf -notmatch '(?mi)^\[Standard\.NTamd64\.10\.0\.\.\.17763\]\s*$') { + throw 'The INF no longer declares the reviewed Windows 10 1809 (build 17763) target floor.' +} +if ($inf -notmatch '(?mi)^DriverVer=\d{2}/\d{2}/\d{4},\d+\.\d+\.\d+\.\d+\s*$') { + throw 'The INF is missing a valid DriverVer entry.' +} + +Write-Host 'VIIPER UDE target contract is aligned: Windows 10 1809, KMDF 1.27.' From c5774bf0c092d50acaed2dd767dbeeed00a0bf8c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:40:14 -0500 Subject: [PATCH 082/240] Gate native input across endpoint recovery Honor UdeCx's asynchronous endpoint-reset contract without treating reset as purge. Close broker and direct-input admission at the callback, drain the operation that may have won the boundary, publish a generation-bound reset request, and reopen only after the Go device has acknowledged recovery. Stop and join the endpoint publisher around reset while preserving its sequence. Linearize device reset, D0, removal, endpoint purge, and endpoint reset with the broker admission lock. Add a regression that proves no direct input crosses an unacknowledged reset and that publication resumes afterward. --- docs/architecture/native-udecx.md | 8 ++ internal/transport/udecx/host.go | 19 +++- internal/transport/udecx/host_test.go | 84 ++++++++++++++++- native/udecx/driver/Broker.c | 48 ++++++++++ native/udecx/driver/Device.c | 128 +++++++++++++++++++++++--- native/udecx/driver/ViiperUde.h | 4 + 6 files changed, 278 insertions(+), 13 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 714762ad..3c470124 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -204,6 +204,14 @@ interface fields are only hints for alternates that contain no endpoints. completion only after the Go controller engine has applied the reset or alternate-setting transition. Start, purge, and power notifications remain unacknowledged and cannot add a media round trip. +- Endpoint reset owns a gate separate from endpoint purge. The UdeCx reset + callback closes both broker and direct-input admission under the broker lock, + cancels forwarded work, and defers its acknowledged lifecycle event until the + last already-admitted endpoint operation drains. User mode stops and joins + that endpoint's direct-input publisher before applying recovery, acknowledges + the reset, then resumes the same sequence. Reset never calls purge-complete or + waits for a later start callback, matching UdeCx's distinct reset and purge + contracts. - Device reset closes direct input admission in the kernel callback and pauses every user-mode publisher before controller state is cleared. Admission and the active publishers reopen only after the generation-bound reset request diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 5b8e7cc6..8da0c2be 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -60,6 +60,7 @@ type registeredDevice struct { fastInput map[uint8]struct{} publishers map[uint8]*inputPublisher activeInput map[uint8]bool + resettingInput map[uint8]bool inputSequences map[uint8]uint64 inD0 bool resetting bool @@ -185,7 +186,8 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D entry := ®isteredDevice{ identity: identity, device: dev, ctx: deviceCtx, cancel: cancel, fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), - activeInput: make(map[uint8]bool), inputSequences: make(map[uint8]uint64), inD0: true, + activeInput: make(map[uint8]bool), resettingInput: make(map[uint8]bool), + inputSequences: make(map[uint8]uint64), inD0: true, } h.devices[deviceID] = entry h.generations[deviceID] = generation @@ -286,6 +288,7 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { } h.mu.Lock() if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || entry.resetting || + entry.resettingInput[endpoint] || h.devices[entry.identity.DeviceID] != entry { h.mu.Unlock() return @@ -582,6 +585,12 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { case OperationEndpointPurge: h.mu.Lock() entry.activeInput[current.EndpointAddress] = false + delete(entry.resettingInput, current.EndpointAddress) + h.mu.Unlock() + h.stopInputPublisher(entry, current.EndpointAddress) + case OperationEndpointReset: + h.mu.Lock() + entry.resettingInput[current.EndpointAddress] = true h.mu.Unlock() h.stopInputPublisher(entry, current.EndpointAddress) case OperationDeviceD0Exit: @@ -629,6 +638,14 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { entry.activeInput[current.EndpointAddress] = true h.mu.Unlock() h.startInputPublisher(entry, current.EndpointAddress) + case OperationEndpointReset: + h.mu.Lock() + delete(entry.resettingInput, current.EndpointAddress) + restart := entry.activeInput[current.EndpointAddress] + h.mu.Unlock() + if restart { + h.startInputPublisher(entry, current.EndpointAddress) + } case OperationDeviceD0Entry: h.mu.Lock() if current.DeviceSequence > entry.powerSequence { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 494bfa58..3bbcf405 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -158,13 +158,18 @@ func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} type resetGateProcessor struct { started chan struct{} release chan struct{} + kind OperationKind } func (*resetGateProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { return Completion{}, nil } func (p *resetGateProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { - if op.Kind != OperationDeviceReset { + kind := p.kind + if kind == 0 { + kind = OperationDeviceReset + } + if op.Kind != kind { return nil } close(p.started) @@ -613,6 +618,83 @@ func TestHostPausesDirectInputAcrossAcknowledgedDeviceReset(t *testing.T) { } } +func TestHostPausesDirectInputAcrossAcknowledgedEndpointReset(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &resetGateProcessor{ + started: make(chan struct{}), release: make(chan struct{}), kind: OperationEndpointReset, + } + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 49, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 0x0000000180000002, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, DeviceSequence: 2, + Kind: OperationEndpointReset, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not reach processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an unacknowledged endpoint reset: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.release) + select { + case completion := <-driver.completions: + if completion.Token != 0x0000000180000002 || completion.Status != 0 { + t.Fatalf("endpoint reset acknowledgement=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("endpoint reset was not acknowledged") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("reset-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint reset acknowledgement") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index c5efe6bd..bcbef7a4 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -272,6 +272,40 @@ ViiperSetDeviceResettingByIdentity( WdfWaitLockRelease(ControllerContext->DeviceLock); } +static +VOID +ViiperSetEndpointResettingByIdentity( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ UCHAR EndpointAddress, + _In_ LONG Value + ) +{ + ULONG index; + + WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE device = ControllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + UDECXUSBENDPOINT endpoint; + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->DeviceId != DeviceId || + deviceContext->Generation != Generation) { + continue; + } + endpoint = deviceContext->Endpoints[EndpointAddress]; + if (endpoint != WDF_NO_HANDLE) { + InterlockedExchange(&ViiperGetEndpointContext(endpoint)->Resetting, Value); + } + break; + } + WdfWaitLockRelease(ControllerContext->DeviceLock); +} + static VOID ViiperClearSlotLocked( @@ -781,6 +815,7 @@ ViiperAllocatePendingSlot( WdfSpinLockAcquire(ControllerContext->BrokerLock); if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { status = STATUS_DEVICE_NOT_READY; @@ -1688,6 +1723,7 @@ ViiperCompleteManagementOperation( ULONG slot = (encodedSlot & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; WDFREQUEST request = WDF_NO_HANDLE; ULONG kind = 0; + UCHAR endpointAddress = 0; if ((encodedSlot & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 || slot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || @@ -1705,6 +1741,7 @@ ViiperCompleteManagementOperation( ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation) { request = ControllerContext->ManagementSlots[slot].Request; kind = ControllerContext->ManagementSlots[slot].Kind; + endpointAddress = ControllerContext->ManagementSlots[slot].EndpointAddress; ControllerContext->ManagementSlots[slot].State = ViiperUdePendingCompleting; WdfObjectReference(request); } @@ -1721,6 +1758,17 @@ ViiperCompleteManagementOperation( // sees the post-reset state, while no direct report can cross early. ViiperSetDeviceResettingByIdentity( ControllerContext, Completion->DeviceId, Completion->Generation, FALSE); + } else if (kind == ViiperUdeOperationEndpointReset) { + // Endpoint reset is a distinct UdeCx boundary, not a purge/start + // cycle. Reopen only this endpoint immediately before completing the + // asynchronous reset request. The host has already stopped and joined + // its direct-input publisher before sending this acknowledgement. + ViiperSetEndpointResettingByIdentity( + ControllerContext, + Completion->DeviceId, + Completion->Generation, + endpointAddress, + FALSE); } WdfRequestComplete(request, (NTSTATUS)Completion->Status); WdfSpinLockAcquire(ControllerContext->BrokerLock); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 8b0cc33a..63b85eaf 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -534,7 +534,13 @@ ViiperBeginRemoveDevice( if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { continue; } + // DeviceLock owns the table slot; BrokerLock is the admission + // linearization point shared with forwarded URBs and direct input. + // Set Purging through both before revoking the table entry so no + // request that already referenced this generation can start late. + WdfSpinLockAcquire(ControllerContext->BrokerLock); InterlockedExchange(&deviceContext->Purging, TRUE); + WdfSpinLockRelease(ControllerContext->BrokerLock); ControllerContext->Devices[index] = WDF_NO_HANDLE; ControllerContext->RemovingSlots[index] = TRUE; *Device = current; @@ -675,10 +681,13 @@ ViiperEvtUsbDeviceD0Entry( _In_ UDECXUSBDEVICE Device ) { - UNREFERENCED_PARAMETER(Controller); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); // This callback is the exact UdeCx power boundary. Open direct input // admission before publishing the ordered advisory event to user mode. + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, TRUE); + WdfSpinLockRelease(controllerContext->BrokerLock); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); return STATUS_SUCCESS; } @@ -690,12 +699,15 @@ ViiperEvtUsbDeviceD0Exit( _In_ UDECX_USB_DEVICE_WAKE_SETTING WakeSetting ) { - UNREFERENCED_PARAMETER(Controller); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); UNREFERENCED_PARAMETER(WakeSetting); // Close direct input admission synchronously. Waiting for the user-mode // notification would leave a scheduler window in which a fresh report // could complete a Windows poll after the child had left D0. + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); return STATUS_SUCCESS; } @@ -732,6 +744,8 @@ ViiperBeginAcknowledgedDeviceReset( ) { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); NTSTATUS status; // Post-enumeration reset and device-configuration replacement are both @@ -739,7 +753,15 @@ ViiperBeginAcknowledgedDeviceReset( // path synchronously and permit only one reset transaction for a child. // User mode stops and joins the publishers before acknowledging the // operation; completion then reopens this exact kernel gate. - if (InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { + status = STATUS_DEVICE_BUSY; + } else { + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { return STATUS_DEVICE_BUSY; } status = ViiperQueueAcknowledgedDeviceLifecycleEvent( @@ -893,6 +915,14 @@ ViiperEvtEndpointAdd( if (!NT_SUCCESS(status)) { return status; } + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->ResetWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } if (descriptor.bEndpointAddress == 0) { ViiperGetDeviceContext(Device)->DefaultEndpoint = endpoint; dispatchType = WdfIoQueueDispatchSequential; @@ -1080,15 +1110,23 @@ ViiperSubmitInputReport( // another. Serialize only this endpoint, preserving report order even if // a faulty or hostile owner submits concurrent updates for the same pad. WdfWaitLockAcquire(endpointContext->InputLock, NULL); - ViiperEndpointOperationStarted(endpoint); - if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { - ViiperEndpointOperationCompleted(endpoint); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); - // Endpoint purge and restart preserve the device generation. Do not - // turn the one report racing purge into a fatal user-mode session. + // Endpoint purge/start and endpoint reset preserve the device + // generation. A publisher can have one already-built latest-state + // report crossing either callback; acknowledge and discard it rather + // than faulting the otherwise valid owner session. return STATUS_SUCCESS; } + ViiperEndpointOperationStarted(endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); if (input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( &endpointContext->LastInputSequence, 0, 0)) { ViiperEndpointOperationCompleted(endpoint); @@ -1156,14 +1194,67 @@ ViiperEvtEndpointReset( _In_ WDFREQUEST Request ) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); NTSTATUS status; - InterlockedExchange64(&ViiperGetEndpointContext(Endpoint)->NextIsoStartFrame, 0); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, TRUE, FALSE) != FALSE) { + status = STATUS_DEVICE_BUSY; + } else { + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + return; + } + + InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + endpointContext->ResetRequest = Request; + // A forwarded broker operation or direct input copy may have won + // admission immediately before Resetting was raised. Defer publication of + // the reset request until those owners have actually completed; otherwise + // user mode could clear controller state while the old transfer is still + // writing into the endpoint. + WdfWorkItemEnqueue(endpointContext->ResetWorkItem); +} + +VOID +ViiperEvtEndpointResetWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + WDFREQUEST request; + NTSTATUS status; + + PAGED_CODE(); + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + request = endpointContext->ResetRequest; + endpointContext->ResetRequest = WDF_NO_HANDLE; + if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { + InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfRequestComplete(request, STATUS_DEVICE_NOT_READY); + return; + } status = ViiperQueueAcknowledgedEndpointLifecycleEvent( - Endpoint, Request, ViiperUdeOperationEndpointReset); + endpoint, request, ViiperUdeOperationEndpointReset); if (!NT_SUCCESS(status)) { - WdfRequestComplete(Request, status); + InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfRequestComplete(request, status); } } @@ -1194,7 +1285,17 @@ ViiperEvtEndpointPurge( ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + // Serialize the admission gate with both pending-slot allocation and the + // direct input fast path. This makes OperationsDrained a reliable purge + // barrier instead of allowing a transfer to start after the work item has + // already observed the event as signaled. + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Purging, TRUE); + WdfSpinLockRelease(controllerContext->BrokerLock); InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); @@ -1210,6 +1311,9 @@ ViiperEvtEndpointStart( ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); // UdeCx defines START as the boundary at which both the endpoint queue and // any client-owned forwarded paths may resume. Open the kernel admission @@ -1218,7 +1322,9 @@ ViiperEvtEndpointStart( // while Purging is still true, consuming and discarding the first fresh // sequence after resume. InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Purging, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index f4eabfd9..b5f8265c 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -176,9 +176,12 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { WDFQUEUE Queue; WDFWAITLOCK InputLock; WDFWORKITEM PurgeWorkItem; + WDFWORKITEM ResetWorkItem; + WDFREQUEST ResetRequest; KEVENT OperationsDrained; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; + volatile LONG Resetting; volatile LONG ActiveOperations; volatile LONG64 LastInputSequence; volatile LONG64 NextIsoStartFrame; @@ -212,6 +215,7 @@ EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; +EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; From 07c73d1bc0ef00c389afe03a65116b9e2d88e3f8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:44:40 -0500 Subject: [PATCH 083/240] Serialize native controller lifecycle mutations Keep endpoint reset, endpoint start/purge, device reset, and alternate-setting changes from entering the production controller engine concurrently. Ordinary HID and media transfers remain concurrent on their endpoint lanes. Add a deterministic blocked-reset regression proving that a new endpoint start cannot change the audio alternate setting until recovery has completed. --- docs/architecture/native-udecx.md | 4 ++ internal/server/usb/native.go | 27 +++++----- internal/server/usb/native_test.go | 82 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 3c470124..0c2cce23 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -190,6 +190,10 @@ interface fields are only hints for alternates that contain no endpoints. object teardown completes. User mode can retry only failures returned before ownership reached UdeCx. - Each device has a short-held state lock and independent endpoint queues. +- User mode serializes controller-engine lifecycle mutations as one complete + transaction. Endpoint reset cannot overlap an endpoint start/purge, device + reset, or alternate-setting change, while ordinary HID and media transfers + remain concurrent on their independent endpoint lanes. - Each endpoint owns a drain event covering both broker-forwarded URBs and the direct interrupt-IN fast path. UdeCx itself owns and purges the framework endpoint queue; VIIPER never starts or purges that queue. The purge callback diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index fd1e546e..e0547d87 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -74,6 +74,9 @@ func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx } func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op udecx.Operation) error { + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} key := nativeLaneKey{ deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, @@ -82,20 +85,20 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op switch op.Kind { case udecx.OperationEndpointStart: p.clearLane(key) - p.activateEndpoint(dev, op) + p.activateEndpointLocked(dev, op) case udecx.OperationEndpointPurge: p.clearLane(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } - p.deactivateEndpoint(dev, op) + p.deactivateEndpointLocked(dev, op) case udecx.OperationEndpointReset: p.clearLane(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } case udecx.OperationDeviceReset: - p.Reset(dev, identity) + p.resetDeviceLocked(dev, identity) case udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: // A link-power transition is not a USB reset. Preserve the selected // audio interfaces and controller state, but discard stale service-clock @@ -107,7 +110,7 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op // a hint only. Interfaces with endpoint-bearing alternate settings are // driven by the exact endpoint descriptors carried by start/purge and // transfer operations instead. - p.applyInterfaceHint(dev, op) + p.applyInterfaceHintLocked(dev, op) default: return fmt.Errorf("unsupported native UDE lifecycle operation %d", op.Kind) } @@ -187,6 +190,12 @@ func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, } func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operation) { + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + p.activateEndpointLocked(dev, op) +} + +func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx.Operation) { signature := signatureFromOperation(op) interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( dev.GetDescriptor(), signature) @@ -194,8 +203,6 @@ func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operat return } identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() active := p.active[identity] if active == nil { active = make(map[nativeEndpointSignature]struct{}) @@ -208,7 +215,7 @@ func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operat } } -func (p *NativeProcessor) deactivateEndpoint(dev usbdevice.Device, op udecx.Operation) { +func (p *NativeProcessor) deactivateEndpointLocked(dev usbdevice.Device, op udecx.Operation) { signature := signatureFromOperation(op) interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( dev.GetDescriptor(), signature) @@ -216,8 +223,6 @@ func (p *NativeProcessor) deactivateEndpoint(dev usbdevice.Device, op udecx.Oper return } identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() active := p.active[identity] delete(active, signature) if len(active) == 0 { @@ -230,14 +235,12 @@ func (p *NativeProcessor) deactivateEndpoint(dev usbdevice.Device, op udecx.Oper } } -func (p *NativeProcessor) applyInterfaceHint(dev usbdevice.Device, op udecx.Operation) { +func (p *NativeProcessor) applyInterfaceHintLocked(dev usbdevice.Device, op udecx.Operation) { desc := dev.GetDescriptor() if !descriptorHasInterfaceAlt(desc, op.InterfaceNumber, op.InterfaceSetting) || descriptorInterfaceUsesEndpointLifecycle(desc, op.InterfaceNumber) { return } - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() if p.server.getInterfaceAlt(dev, op.InterfaceNumber) == op.InterfaceSetting { return } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index edff8f30..afd75dfa 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -267,6 +267,88 @@ func (d *concurrentNativeTestDevice) SetInterfaceAltSetting(iface, alt uint8) { d.mu.Unlock() } +type lifecycleGateDevice struct { + desc *usbdevice.Descriptor + resetStarted chan struct{} + resetRelease chan struct{} + altChanged chan [2]uint8 +} + +func (*lifecycleGateDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + return nil +} +func (d *lifecycleGateDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*lifecycleGateDevice) GetDeviceSpecificArgs() map[string]any { return nil } +func (d *lifecycleGateDevice) ResetEndpoint(uint8) { + close(d.resetStarted) + <-d.resetRelease +} +func (d *lifecycleGateDevice) SetInterfaceAltSetting(iface, alt uint8) { + d.altChanged <- [2]uint8{iface, alt} +} + +func TestNativeProcessorSerializesEndpointResetWithEndpointStart(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + dev := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + processor := nativeProcessorForTest(t) + base := udecx.Operation{ + DeviceID: 92, Generation: 1, EndpointAddress: 0x02, + EndpointAttributes: 0x05, EndpointInterval: 1, EndpointMaxPacketSize: 4, + } + + resetDone := make(chan error, 1) + go func() { + op := base + op.Kind = udecx.OperationEndpointReset + resetDone <- processor.Lifecycle(context.Background(), dev, op) + }() + select { + case <-dev.resetStarted: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not reach the controller engine") + } + + startDone := make(chan error, 1) + go func() { + op := base + op.Kind = udecx.OperationEndpointStart + startDone <- processor.Lifecycle(context.Background(), dev, op) + }() + select { + case event := <-dev.altChanged: + close(dev.resetRelease) + t.Fatalf("endpoint start changed alternate setting during reset: %v", event) + case <-time.After(25 * time.Millisecond): + } + + close(dev.resetRelease) + if err := <-resetDone; err != nil { + t.Fatal(err) + } + if err := <-startDone; err != nil { + t.Fatal(err) + } + select { + case event := <-dev.altChanged: + if event != [2]uint8{2, 1} { + t.Fatalf("alternate setting event=%v want=[2 1]", event) + } + case <-time.After(time.Second): + t.Fatal("endpoint start did not resume after reset") + } +} + func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, From 2f91342f41f640c4357efab22a4ccf826ad63d46 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:47:09 -0500 Subject: [PATCH 084/240] Fix native input admission context scope --- native/udecx/driver/Device.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 63b85eaf..0c75f351 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1010,6 +1010,7 @@ ViiperSubmitInputReport( size_t payloadLength; WDFFILEOBJECT ownerFile; UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; WDFREQUEST urbRequest = WDF_NO_HANDLE; PURB urb; @@ -1063,7 +1064,6 @@ ViiperSubmitInputReport( WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = controllerContext->Devices[index]; - VIIPER_UDE_DEVICE_CONTEXT *deviceContext; if (device == WDF_NO_HANDLE) { continue; } From 3587494dcc270bea7a7a7771207bc423c3a1b849 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 00:52:09 -0500 Subject: [PATCH 085/240] Scope native lifecycle synchronization per controller --- docs/architecture/native-udecx.md | 4 +- internal/server/usb/native.go | 106 ++++++++++++++++++----------- internal/server/usb/native_test.go | 67 ++++++++++++++++-- 3 files changed, 132 insertions(+), 45 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 0c2cce23..7b358e8c 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -193,7 +193,9 @@ interface fields are only hints for alternates that contain no endpoints. - User mode serializes controller-engine lifecycle mutations as one complete transaction. Endpoint reset cannot overlap an endpoint start/purge, device reset, or alternate-setting change, while ordinary HID and media transfers - remain concurrent on their independent endpoint lanes. + remain concurrent on their independent endpoint lanes. The serialization + object belongs to one `(device ID, generation)` session; a blocked reset on + one controller cannot stall lifecycle or media activation on another. - Each endpoint owns a drain event covering both broker-forwarded URBs and the direct interrupt-IN fast path. UdeCx itself owns and purges the framework endpoint queue; VIIPER never starts or purges that queue. The purge callback diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index e0547d87..1128f01b 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -30,16 +30,20 @@ type nativeEndpointSignature struct { maxPacket uint16 } +type nativeSessionState struct { + mu sync.Mutex + active map[nativeEndpointSignature]struct{} +} + // NativeProcessor adapts the native UdeCx broker to the same control and // transfer engine used by USB/IP. Transport-specific clocks live here; device // state, feedback, HID, audio, and descriptor behavior remain in usb.Device. type NativeProcessor struct { - server *Server - mu sync.Mutex - lifecycleMu sync.Mutex - next map[nativeLaneKey]time.Time - lastIn map[nativeLaneKey][]byte - active map[nativeSessionKey]map[nativeEndpointSignature]struct{} + server *Server + mu sync.Mutex + next map[nativeLaneKey]time.Time + lastIn map[nativeLaneKey][]byte + sessions map[nativeSessionKey]*nativeSessionState } func NewNativeProcessor(server *Server) (*NativeProcessor, error) { @@ -47,20 +51,50 @@ func NewNativeProcessor(server *Server) (*NativeProcessor, error) { return nil, errors.New("native UDE processor requires a USB server engine") } return &NativeProcessor{ - server: server, - next: make(map[nativeLaneKey]time.Time), - lastIn: make(map[nativeLaneKey][]byte), - active: make(map[nativeSessionKey]map[nativeEndpointSignature]struct{}), + server: server, + next: make(map[nativeLaneKey]time.Time), + lastIn: make(map[nativeLaneKey][]byte), + sessions: make(map[nativeSessionKey]*nativeSessionState), }, nil } func (p *NativeProcessor) Reset(dev usbdevice.Device, identity udecx.DeviceIdentity) { - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() - p.resetDeviceLocked(dev, identity) + key := nativeSessionKey{deviceID: identity.DeviceID, generation: identity.Generation} + session := p.lockSession(key) + p.resetDeviceLocked(dev, identity, session) + p.mu.Lock() + if p.sessions[key] == session { + delete(p.sessions, key) + } + p.mu.Unlock() + session.mu.Unlock() +} + +func (p *NativeProcessor) lockSession(key nativeSessionKey) *nativeSessionState { + for { + p.mu.Lock() + session := p.sessions[key] + if session == nil { + session = &nativeSessionState{active: make(map[nativeEndpointSignature]struct{})} + p.sessions[key] = session + } + p.mu.Unlock() + + session.mu.Lock() + p.mu.Lock() + current := p.sessions[key] + p.mu.Unlock() + if current == session { + return session + } + // Reset retired this state while this goroutine waited. Retry against + // the current generation-owned state rather than mutating an orphan. + session.mu.Unlock() + } } -func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity) { +func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity, + session *nativeSessionState) { p.server.resetInterfaceAlts(dev) p.mu.Lock() for key := range p.next { @@ -70,14 +104,14 @@ func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx } } p.mu.Unlock() - delete(p.active, nativeSessionKey{deviceID: identity.DeviceID, generation: identity.Generation}) + clear(session.active) } func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op udecx.Operation) error { - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() - identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} + sessionKey := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + session := p.lockSession(sessionKey) + defer session.mu.Unlock() key := nativeLaneKey{ deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, } @@ -85,20 +119,20 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op switch op.Kind { case udecx.OperationEndpointStart: p.clearLane(key) - p.activateEndpointLocked(dev, op) + p.activateEndpointLocked(dev, op, session) case udecx.OperationEndpointPurge: p.clearLane(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } - p.deactivateEndpointLocked(dev, op) + p.deactivateEndpointLocked(dev, op, session) case udecx.OperationEndpointReset: p.clearLane(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } case udecx.OperationDeviceReset: - p.resetDeviceLocked(dev, identity) + p.resetDeviceLocked(dev, identity, session) case udecx.OperationDeviceD0Entry, udecx.OperationDeviceD0Exit: // A link-power transition is not a USB reset. Preserve the selected // audio interfaces and controller state, but discard stale service-clock @@ -190,46 +224,38 @@ func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, } func (p *NativeProcessor) activateEndpoint(dev usbdevice.Device, op udecx.Operation) { - p.lifecycleMu.Lock() - defer p.lifecycleMu.Unlock() - p.activateEndpointLocked(dev, op) + key := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} + session := p.lockSession(key) + defer session.mu.Unlock() + p.activateEndpointLocked(dev, op, session) } -func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx.Operation) { +func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx.Operation, + session *nativeSessionState) { signature := signatureFromOperation(op) interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( dev.GetDescriptor(), signature) if !ok { return } - identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} - active := p.active[identity] - if active == nil { - active = make(map[nativeEndpointSignature]struct{}) - p.active[identity] = active - } - active[signature] = struct{}{} + session.active[signature] = struct{}{} if p.server.getInterfaceAlt(dev, interfaceNumber) != alternateSetting { p.server.setInterfaceAlt(dev, interfaceNumber, alternateSetting) p.server.notifyInterfaceAlt(dev, interfaceNumber, alternateSetting) } } -func (p *NativeProcessor) deactivateEndpointLocked(dev usbdevice.Device, op udecx.Operation) { +func (p *NativeProcessor) deactivateEndpointLocked(dev usbdevice.Device, op udecx.Operation, + session *nativeSessionState) { signature := signatureFromOperation(op) interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( dev.GetDescriptor(), signature) if !ok { return } - identity := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} - active := p.active[identity] - delete(active, signature) - if len(active) == 0 { - delete(p.active, identity) - } + delete(session.active, signature) if p.server.getInterfaceAlt(dev, interfaceNumber) == alternateSetting && - !descriptorInterfaceAltIsActive(dev.GetDescriptor(), interfaceNumber, alternateSetting, active) { + !descriptorInterfaceAltIsActive(dev.GetDescriptor(), interfaceNumber, alternateSetting, session.active) { p.server.setInterfaceAlt(dev, interfaceNumber, 0) p.server.notifyInterfaceAlt(dev, interfaceNumber, 0) } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index afd75dfa..067261e9 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -349,6 +349,67 @@ func TestNativeProcessorSerializesEndpointResetWithEndpointStart(t *testing.T) { } } +func TestNativeProcessorDoesNotGloballySerializeIndependentDevices(t *testing.T) { + desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 4, BInterval: 1, + }}}, + }} + blocked := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + independent := &lifecycleGateDevice{ + desc: desc, resetStarted: make(chan struct{}), resetRelease: make(chan struct{}), + altChanged: make(chan [2]uint8, 1), + } + processor := nativeProcessorForTest(t) + resetDone := make(chan error, 1) + go func() { + resetDone <- processor.Lifecycle(context.Background(), blocked, udecx.Operation{ + DeviceID: 101, Generation: 3, Kind: udecx.OperationEndpointReset, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + }) + }() + select { + case <-blocked.resetStarted: + case <-time.After(time.Second): + t.Fatal("first controller did not enter its blocked endpoint reset") + } + + startDone := make(chan error, 1) + go func() { + startDone <- processor.Lifecycle(context.Background(), independent, udecx.Operation{ + DeviceID: 102, Generation: 8, Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 4, + }) + }() + select { + case event := <-independent.altChanged: + if event != [2]uint8{2, 1} { + close(blocked.resetRelease) + t.Fatalf("independent alternate setting event=%v want=[2 1]", event) + } + case <-time.After(100 * time.Millisecond): + close(blocked.resetRelease) + t.Fatal("one controller's reset blocked an independent controller") + } + if err := <-startDone; err != nil { + close(blocked.resetRelease) + t.Fatal(err) + } + close(blocked.resetRelease) + if err := <-resetDone; err != nil { + t.Fatal(err) + } +} + func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { desc := &usbdevice.Descriptor{Interfaces: []usbdevice.InterfaceConfig{ {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, @@ -414,9 +475,7 @@ func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { if len(processor.next) != 0 || len(processor.lastIn) != 0 { t.Fatalf("reset retained clocks=%d cached-input=%d", len(processor.next), len(processor.lastIn)) } - processor.lifecycleMu.Lock() - defer processor.lifecycleMu.Unlock() - if len(processor.active) != 0 { - t.Fatalf("reset retained %d active native sessions", len(processor.active)) + if len(processor.sessions) != 0 { + t.Fatalf("reset retained %d native sessions", len(processor.sessions)) } } From 23d3979d5e0babd652da6ced1838e4c5db4743be Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:03:29 -0500 Subject: [PATCH 086/240] Add signed native UDE live validation gate --- docs/architecture/native-udecx-signing.md | 11 + .../server/usb/native_live_windows_test.go | 270 ++++++++++++++++++ native/udecx/README.md | 19 ++ .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 98 +++++++ 4 files changed, 398 insertions(+) create mode 100644 internal/server/usb/native_live_windows_test.go create mode 100644 native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 5cfbdbb0..bf30ef90 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -63,6 +63,17 @@ passes Driver Verifier, HLK or the scoped attestation test matrix, repeated install/update/rollback, process crash, sleep/resume, and multi-controller media soak on a disposable test machine. +The first repeatable signed-driver gate is +`native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1`. It validates the +Microsoft-returned package, requires the installed service image to have the +same SHA-256 hash, requires exactly one Microsoft-signed root devnode, and then +runs the opt-in Windows integration test against Xbox 360, DualShock 4, +DualSense, DualSense Edge, and Switch 2 Pro production descriptors. Every +generation must enumerate, complete direct interrupt-input reports, tear down +to zero active devices and pending operations, and leave all protocol/fault +counters unchanged. The script does not install a driver or enable Driver +Verifier; those remain explicit disposable-machine operations. + ## Primary Microsoft references - [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go new file mode 100644 index 00000000..a88ca8b7 --- /dev/null +++ b/internal/server/usb/native_live_windows_test.go @@ -0,0 +1,270 @@ +//go:build windows + +package usb_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "testing" + "time" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/device/xbox360" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +const ( + liveNativeTestEnvironment = "VIIPER_UDE_LIVE" + liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" +) + +type liveNativeController struct { + name string + new func() (usbdevice.Device, func(uint64), error) +} + +func liveNativeControllers() []liveNativeController { + return []liveNativeController{ + {name: "Xbox360", new: func() (usbdevice.Device, func(uint64), error) { + dev, err := xbox360.New(nil) + return dev, func(sequence uint64) { + state := xbox360.NewInputState() + state.LX = int16(sequence % 1024) + dev.UpdateInputState(*state) + }, err + }}, + {name: "DualShock4", new: func() (usbdevice.Device, func(uint64), error) { + dev, err := dualshock4.New(nil) + return dev, func(sequence uint64) { + state := dualshock4.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSense", new: func() (usbdevice.Device, func(uint64), error) { + dev, err := dualsense.New(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSenseEdge", new: func() (usbdevice.Device, func(uint64), error) { + dev, err := dualsense.NewEdge(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.RX = int8(sequence % 32) + dev.UpdateInputState(state) + }, err + }}, + {name: "Switch2Pro", new: func() (usbdevice.Device, func(uint64), error) { + dev, err := ns2pro.New(nil) + return dev, func(sequence uint64) { + state := ns2pro.NewInputState() + state.LX += uint16(sequence % 32) + dev.UpdateInputState(*state) + }, err + }}, + } +} + +func liveNativeIterationCount(t *testing.T) int { + t.Helper() + raw := os.Getenv(liveNativeTestIterations) + if raw == "" { + return 1 + } + iterations, err := strconv.Atoi(raw) + if err != nil || iterations < 1 || iterations > 100 { + t.Fatalf("%s must be an integer from 1 through 100, got %q", + liveNativeTestIterations, raw) + } + return iterations +} + +func waitForNativeStats(ctx context.Context, client *udecx.Client, description string, + accept func(udecx.Stats) bool) (udecx.Stats, error) { + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + var last udecx.Stats + for { + stats, err := client.QueryStats(ctx) + if err != nil { + return last, fmt.Errorf("query stats while waiting for %s: %w", description, err) + } + last = stats + if accept(stats) { + return stats, nil + } + select { + case <-ctx.Done(): + return last, fmt.Errorf("wait for %s: %w (last stats: %+v)", + description, ctx.Err(), last) + case <-ticker.C: + } + } +} + +func assertCleanNativeStatsDelta(t *testing.T, before, after udecx.Stats) { + t.Helper() + if after.InvalidMessages != before.InvalidMessages || + after.QueueExhaustions != before.QueueExhaustions || + after.NotificationEventOverflows != before.NotificationEventOverflows || + after.LateCompletions != before.LateCompletions || + after.CleanupRetries != before.CleanupRetries { + t.Fatalf("native driver recorded a protocol/lifecycle fault: before=%+v after=%+v", + before, after) + } +} + +// TestNativeUDELiveProductionControllers is deliberately inert in normal CI. +// It opens an already-installed native controller and never installs, updates, +// enables, or removes a kernel driver. Release validation must first verify the +// package's Microsoft kernel-policy signature, then opt in on a disposable test +// machine with VIIPER_UDE_LIVE=1. +func TestNativeUDELiveProductionControllers(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + liveNativeTestEnvironment) + } + + iterations := liveNativeIterationCount(t) + testCtx, cancelTest := context.WithTimeout(context.Background(), + time.Duration(iterations)*5*time.Minute) + defer cancelTest() + + client, err := udecx.Open(testCtx) + if err != nil { + t.Fatalf("open native UDE controller: %v", err) + } + defer func() { + if closeErr := client.Close(); closeErr != nil { + t.Errorf("close native UDE controller: %v", closeErr) + } + }() + + baseline, err := client.QueryStats(testCtx) + if err != nil { + t.Fatalf("query native UDE baseline: %v", err) + } + if baseline.ActiveDevices != 0 || baseline.PendingOperations != 0 { + t.Fatalf("refusing a dirty native UDE session: %+v", baseline) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(testCtx) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + defer func() { + cancelServe() + host.Close() + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Errorf("native UDE host shutdown: %v", serveErr) + } + case <-time.After(5 * time.Second): + t.Error("native UDE host did not stop within 5 seconds") + } + }() + + const deviceIDBase uint64 = 0x5649495000000000 + for iteration := 1; iteration <= iterations; iteration++ { + for controllerIndex, controller := range liveNativeControllers() { + controller := controller + t.Run(fmt.Sprintf("%s/generation-%d", controller.name, iteration), func(t *testing.T) { + deviceID := deviceIDBase + uint64(controllerIndex+1) + dev, publishInput, createErr := controller.new() + if createErr != nil { + t.Fatalf("construct %s: %v", controller.name, createErr) + } + before, queryErr := client.QueryStats(testCtx) + if queryErr != nil { + t.Fatal(queryErr) + } + identity, registerErr := host.Register(testCtx, deviceID, dev) + if registerErr != nil { + t.Fatalf("register %s: %v", controller.name, registerErr) + } + if identity.Generation != uint32(iteration) { + t.Fatalf("%s generation=%d want %d", controller.name, + identity.Generation, iteration) + } + registered := true + defer func() { + if registered { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanupCancel() + if unregisterErr := host.Unregister(cleanupCtx, identity); unregisterErr != nil { + t.Errorf("cleanup %s: %v", controller.name, unregisterErr) + } + } + }() + + enumerateCtx, cancelEnumerate := context.WithTimeout(testCtx, 20*time.Second) + _, waitErr := waitForNativeStats(enumerateCtx, client, + controller.name+" enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 + }) + cancelEnumerate() + if waitErr != nil { + t.Fatal(waitErr) + } + + inputDeadline := time.Now().Add(750 * time.Millisecond) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + inputStats, waitErr := waitForNativeStats(inputCtx, client, + controller.name+" direct interrupt input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted > before.InputReportsCompleted + }) + cancelInput() + if waitErr != nil { + t.Fatal(waitErr) + } + if inputStats.InputReportsCompleted > inputStats.InputReportsSubmitted { + t.Fatalf("completed input reports exceed submissions: %+v", inputStats) + } + + unregisterCtx, cancelUnregister := context.WithTimeout(testCtx, 20*time.Second) + if unregisterErr := host.Unregister(unregisterCtx, identity); unregisterErr != nil { + cancelUnregister() + t.Fatalf("unregister %s: %v", controller.name, unregisterErr) + } + cancelUnregister() + registered = false + + teardownCtx, cancelTeardown := context.WithTimeout(testCtx, 20*time.Second) + after, waitErr := waitForNativeStats(teardownCtx, client, + controller.name+" teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelTeardown() + if waitErr != nil { + t.Fatal(waitErr) + } + assertCleanNativeStatsDelta(t, before, after) + }) + } + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index 3acbea00..31ec071a 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -16,6 +16,11 @@ Directory contract: unsigned CI artifact is a production driver. - `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned driver and catalog against kernel signing policy. +- `tools/Invoke-ViiperUdeLiveValidation.ps1` hash-binds that verified package + to the installed service image and root devnode, then exercises every + production controller through the real UdeCx host, direct interrupt-input + path, generation teardown, and driver fault counters. It never installs or + changes a driver. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. @@ -35,3 +40,17 @@ This skips the USB/IP runtime prerequisite and records `server --transport native-ude` in the startup command. It does not install or trust an unsigned kernel driver. The default remains `usbip` until the signed live-driver gates in the architecture document pass. + +On a disposable elevated test machine, validate an already-installed +Microsoft-signed package with: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -Iterations 10 +``` + +The command refuses an unsigned package, a package/service hash mismatch, a +non-Microsoft root devnode, a dirty driver session, or any increase in invalid +messages, queue exhaustion, notification overflow, late completion, or cleanup +retry counters. Normal CI never opts into this live test. diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 new file mode 100644 index 00000000..4fcec669 --- /dev/null +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [ValidateRange(1, 100)] + [int]$Iterations = 1, + + [string]$RepositoryRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path +} + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' +} +$repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +$signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' +& $signatureGate -PackageDirectory $SignedPackageDirectory + +$packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path +$packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') +if ($packageDrivers.Count -ne 1) { + throw "Expected exactly one signed package driver; found $($packageDrivers.Count)." +} + +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageHash = (Get-FileHash -LiteralPath $packageDrivers[0].FullName -Algorithm SHA256).Hash +$installedHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash +if ($packageHash -ne $installedHash) { + throw "The loaded VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." +} + +$devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { + [string]$_.DeviceID -like 'ROOT\VIIPER\UDE*' +}) +if ($devnodes.Count -ne 1) { + throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." +} +if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { + throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' +} + +$go = Get-Command go.exe -ErrorAction Stop +$oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') +$oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') +try { + $env:VIIPER_UDE_LIVE = '1' + $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations + $timeoutMinutes = ($Iterations * 5) + 2 + Push-Location $repository + try { + & $go.Source test -count=1 -timeout "${timeoutMinutes}m" ` + -run '^TestNativeUDELiveProductionControllers$' ./internal/server/usb + if ($LASTEXITCODE -ne 0) { + throw "Native UDE live validation failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } +} +finally { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') +} + +Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)." From 70e90889fa6520965a54f6e98aac210345c1a3ad Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:06:48 -0500 Subject: [PATCH 087/240] Remove cross-controller host lifecycle blocking --- docs/architecture/native-udecx.md | 3 + internal/transport/udecx/host.go | 44 +++++++- internal/transport/udecx/host_test.go | 145 ++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 7b358e8c..4f5ad54b 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -307,6 +307,9 @@ interface fields are only hints for alternates that contain no endpoints. This follows the useful ViGEmBus pattern of per-target ownership and manual request queues while accounting for UdeCx's endpoint-specific purge contract. +Host-side create/remove gates are keyed by stable device ID: generations of +one controller cannot cross, while a slow PnP transition for one pad cannot +stall an independent pad's registration or removal. ## Delivery checkpoints diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 8da0c2be..76830dcd 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -97,6 +97,11 @@ type operationState struct { done bool } +type deviceLifecycleGate struct { + mu sync.Mutex + references int +} + // Host owns one exclusive driver session and routes operations concurrently // across endpoints while preserving strict FIFO within each endpoint. type Host struct { @@ -106,6 +111,7 @@ type Host struct { workers int lifecycleMu sync.Mutex + lifecycles map[uint64]*deviceLifecycleGate mu sync.RWMutex devices map[uint64]*registeredDevice generations map[uint64]uint32 @@ -133,12 +139,39 @@ func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, e devices: make(map[uint64]*registeredDevice), generations: make(map[uint64]uint32), lanes: make(map[laneKey]*operationLane), + lifecycles: make(map[uint64]*deviceLifecycleGate), operations: make(map[uint64]*operationState), } host.input, _ = driver.(InputReportDriver) return host, nil } +// lockDeviceLifecycle serializes create/remove for one stable device ID while +// allowing independent controllers to enumerate or tear down concurrently. +// References include both the holder and waiters, so a gate cannot be deleted +// and replaced while an older waiter still targets it. +func (h *Host) lockDeviceLifecycle(deviceID uint64) func() { + h.lifecycleMu.Lock() + gate := h.lifecycles[deviceID] + if gate == nil { + gate = &deviceLifecycleGate{} + h.lifecycles[deviceID] = gate + } + gate.references++ + h.lifecycleMu.Unlock() + + gate.mu.Lock() + return func() { + gate.mu.Unlock() + h.lifecycleMu.Lock() + gate.references-- + if gate.references == 0 && h.lifecycles[deviceID] == gate { + delete(h.lifecycles, deviceID) + } + h.lifecycleMu.Unlock() + } +} + func fastInputEndpoints(dev usb.Device) map[uint8]struct{} { result := make(map[uint8]struct{}) if dev == nil || dev.GetDescriptor() == nil { @@ -158,11 +191,11 @@ func fastInputEndpoints(dev usb.Device) map[uint8]struct{} { // is installed before the driver plugs in the child because Windows can submit // its first descriptor request before CreateDevice returns. func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceIdentity, error) { - h.lifecycleMu.Lock() - defer h.lifecycleMu.Unlock() if deviceID == 0 || dev == nil { return DeviceIdentity{}, ErrInvalidRange } + unlockLifecycle := h.lockDeviceLifecycle(deviceID) + defer unlockLifecycle() h.mu.Lock() // One driver file owner is one native UDE host session. Once Serve has @@ -210,8 +243,11 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D } func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { - h.lifecycleMu.Lock() - defer h.lifecycleMu.Unlock() + if identity.DeviceID == 0 || identity.Generation == 0 { + return ErrInvalidRange + } + unlockLifecycle := h.lockDeviceLifecycle(identity.DeviceID) + defer unlockLifecycle() h.mu.RLock() entry := h.devices[identity.DeviceID] diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 3bbcf405..027f1ac8 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -28,6 +28,27 @@ type fastInputDriver struct { submitErr error } +type independentlyBlockingCreateDriver struct { + *fakeHostDriver + blockedDevice uint64 + started chan struct{} + release chan struct{} +} + +func (d *independentlyBlockingCreateDriver) CreateDevice( + ctx context.Context, device CreateDevice, +) error { + if device.DeviceID == d.blockedDevice { + close(d.started) + select { + case <-d.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return d.fakeHostDriver.CreateDevice(ctx, device) +} + func (d *fastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { if d.submitErr != nil { return d.submitErr @@ -196,6 +217,130 @@ func hostTestDevice() usb.Device { }} } +func TestHostDoesNotSerializeIndependentControllerRegistration(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 81, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + + type registerResult struct { + identity DeviceIdentity + err error + } + blockedDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 81, hostTestDevice()) + blockedDone <- registerResult{identity: identity, err: registerErr} + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("first controller registration did not reach the driver") + } + + independentDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 82, hostTestDevice()) + independentDone <- registerResult{identity: identity, err: registerErr} + }() + var independent registerResult + select { + case independent = <-independentDone: + if independent.err != nil { + t.Fatalf("independent registration failed: %v", independent.err) + } + case <-time.After(time.Second): + t.Fatal("independent controller registration was blocked by another controller") + } + + close(driver.release) + var blocked registerResult + select { + case blocked = <-blockedDone: + if blocked.err != nil { + t.Fatalf("blocked registration failed after release: %v", blocked.err) + } + case <-time.After(time.Second): + t.Fatal("first controller registration did not finish after release") + } + + if err = host.Unregister(context.Background(), independent.identity); err != nil { + t.Fatal(err) + } + if err = host.Unregister(context.Background(), blocked.identity); err != nil { + t.Fatal(err) + } + host.lifecycleMu.Lock() + remainingGates := len(host.lifecycles) + host.lifecycleMu.Unlock() + if remainingGates != 0 { + t.Fatalf("lifecycle gates=%d want 0", remainingGates) + } +} + +func TestHostSerializesSameControllerRegistration(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 83, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + + type registerResult struct { + identity DeviceIdentity + err error + } + firstDone := make(chan registerResult, 1) + go func() { + identity, registerErr := host.Register(context.Background(), 83, hostTestDevice()) + firstDone <- registerResult{identity: identity, err: registerErr} + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("first same-controller registration did not reach the driver") + } + + secondDone := make(chan error, 1) + go func() { + _, registerErr := host.Register(context.Background(), 83, hostTestDevice()) + secondDone <- registerErr + }() + select { + case registerErr := <-secondDone: + t.Fatalf("same-controller registration crossed the in-flight create: %v", registerErr) + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + var first registerResult + select { + case first = <-firstDone: + if first.err != nil { + t.Fatal(first.err) + } + case <-time.After(time.Second): + t.Fatal("first same-controller registration did not finish") + } + select { + case registerErr := <-secondDone: + if registerErr == nil || !strings.Contains(registerErr.Error(), "already registered") { + t.Fatalf("second same-controller registration error=%v", registerErr) + } + case <-time.After(time.Second): + t.Fatal("second same-controller registration did not revalidate after serialization") + } + if err = host.Unregister(context.Background(), first.identity); err != nil { + t.Fatal(err) + } +} + func TestHostRepeatedCreateRemoveLeavesOnlyGenerationHistory(t *testing.T) { driver := newFakeHostDriver() host, err := NewHost(driver, &noopProcessor{}, 4) From c4719dbfde4ff69d608370c07448944cee5dcf90 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:07:52 -0500 Subject: [PATCH 088/240] Exercise concurrent native production controllers --- .../server/usb/native_live_windows_test.go | 134 ++++++++++++++++++ native/udecx/README.md | 5 +- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index a88ca8b7..8f94302b 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "strconv" + "sync" "testing" "time" @@ -267,4 +268,137 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { }) } } + + t.Run("ConcurrentProductionSet", func(t *testing.T) { + type activeController struct { + name string + identity udecx.DeviceIdentity + publishInput func(uint64) + err error + } + controllers := liveNativeControllers() + before, queryErr := client.QueryStats(testCtx) + if queryErr != nil { + t.Fatal(queryErr) + } + + registered := make(chan activeController, len(controllers)) + var registerWG sync.WaitGroup + for controllerIndex, controller := range controllers { + controllerIndex, controller := controllerIndex, controller + registerWG.Add(1) + go func() { + defer registerWG.Done() + dev, publishInput, createErr := controller.new() + if createErr != nil { + registered <- activeController{name: controller.name, err: createErr} + return + } + identity, registerErr := host.Register( + testCtx, deviceIDBase+uint64(controllerIndex+1), dev) + registered <- activeController{ + name: controller.name, identity: identity, + publishInput: publishInput, err: registerErr, + } + }() + } + registerWG.Wait() + close(registered) + active := make([]activeController, 0, len(controllers)) + var registerErrors []error + for result := range registered { + if result.err != nil { + registerErrors = append(registerErrors, + fmt.Errorf("register %s: %w", result.name, result.err)) + continue + } + if result.identity.Generation != uint32(iterations+1) { + registerErrors = append(registerErrors, fmt.Errorf( + "%s concurrent generation=%d want %d", result.name, + result.identity.Generation, iterations+1)) + } + active = append(active, result) + } + defer func() { + for _, controller := range active { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second) + _ = host.Unregister(cleanupCtx, controller.identity) + cleanupCancel() + } + }() + if len(registerErrors) != 0 { + t.Fatalf("concurrent registration errors: %v", registerErrors) + } + + enumerateCtx, cancelEnumerate := context.WithTimeout(testCtx, 30*time.Second) + _, waitErr := waitForNativeStats(enumerateCtx, client, + "concurrent production enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == uint32(len(controllers)) + }) + cancelEnumerate() + if waitErr != nil { + t.Fatal(waitErr) + } + + var publishWG sync.WaitGroup + for _, controller := range active { + controller := controller + publishWG.Add(1) + go func() { + defer publishWG.Done() + deadline := time.Now().Add(2 * time.Second) + for sequence := uint64(1); time.Now().Before(deadline); sequence++ { + controller.publishInput(sequence) + time.Sleep(time.Millisecond) + } + }() + } + publishWG.Wait() + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + _, waitErr = waitForNativeStats(inputCtx, client, + "concurrent direct interrupt input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted >= + before.InputReportsCompleted+uint64(len(controllers)) + }) + cancelInput() + if waitErr != nil { + t.Fatal(waitErr) + } + + unregistered := make(chan error, len(active)) + var unregisterWG sync.WaitGroup + for _, controller := range active { + controller := controller + unregisterWG.Add(1) + go func() { + defer unregisterWG.Done() + unregisterCtx, cancelUnregister := context.WithTimeout(testCtx, 30*time.Second) + defer cancelUnregister() + unregistered <- host.Unregister(unregisterCtx, controller.identity) + }() + } + unregisterWG.Wait() + close(unregistered) + var unregisterErrors []error + for unregisterErr := range unregistered { + if unregisterErr != nil { + unregisterErrors = append(unregisterErrors, unregisterErr) + } + } + if len(unregisterErrors) != 0 { + t.Fatalf("concurrent unregister errors: %v", unregisterErrors) + } + active = nil + + teardownCtx, cancelTeardown := context.WithTimeout(testCtx, 30*time.Second) + after, waitErr := waitForNativeStats(teardownCtx, client, + "concurrent production teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelTeardown() + if waitErr != nil { + t.Fatal(waitErr) + } + assertCleanNativeStatsDelta(t, before, after) + }) } diff --git a/native/udecx/README.md b/native/udecx/README.md index 31ec071a..621a2300 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -53,4 +53,7 @@ Microsoft-signed package with: The command refuses an unsigned package, a package/service hash mismatch, a non-Microsoft root devnode, a dirty driver session, or any increase in invalid messages, queue exhaustion, notification overflow, late completion, or cleanup -retry counters. Normal CI never opts into this live test. +retry counters. After validating each controller and repeated generation +rollover independently, it enumerates the complete production controller set, +publishes input, and removes every child concurrently. Normal CI never opts +into this live test. From efdd5e2f0ae5615830920f93e6ab962092ad9460 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:09:26 -0500 Subject: [PATCH 089/240] Validate native owner crash recovery --- docs/architecture/native-udecx-signing.md | 5 +- .../server/usb/native_live_windows_test.go | 138 ++++++++++++++++++ native/udecx/README.md | 4 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 2 +- 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index bf30ef90..7afb5b91 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -72,7 +72,10 @@ DualSense, DualSense Edge, and Switch 2 Pro production descriptors. Every generation must enumerate, complete direct interrupt-input reports, tear down to zero active devices and pending operations, and leave all protocol/fault counters unchanged. The script does not install a driver or enable Driver -Verifier; those remain explicit disposable-machine operations. +Verifier. It also kills a subprocess that owns an enumerated DualSense and +requires kernel file cleanup to remove the child, drain pending operations, +release exclusive ownership, and accept a fresh session. Driver Verifier +remains an explicit disposable-machine operation. ## Primary Microsoft references diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index 8f94302b..eec0d42c 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -4,10 +4,12 @@ package usb_test import ( "context" + "errors" "fmt" "io" "log/slog" "os" + "os/exec" "strconv" "sync" "testing" @@ -25,6 +27,8 @@ import ( const ( liveNativeTestEnvironment = "VIIPER_UDE_LIVE" liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" + liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" + liveNativeCrashExitCode = 86 ) type liveNativeController struct { @@ -402,3 +406,137 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { assertCleanNativeStatsDelta(t, before, after) }) } + +// TestNativeUDELiveOwnerCrashRecovery proves the kernel owner's file cleanup +// contract with an actual process death. The child intentionally bypasses all +// Go defers; the parent must be able to reacquire the exclusive broker only +// after the driver has removed its children and drained forwarded URBs. +func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + liveNativeTestEnvironment) + } + if os.Getenv(liveNativeCrashChild) == "1" { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + client, err := udecx.Open(ctx) + if err != nil { + t.Fatalf("crash child open native UDE controller: %v", err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(ctx) }() + + dev, publishInput, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + if _, err = host.Register(ctx, 0x5649495043524153, dev); err != nil { + t.Fatalf("crash child register DualSense: %v", err) + } + enumerateCtx, cancelEnumerate := context.WithTimeout(ctx, 20*time.Second) + _, err = waitForNativeStats(enumerateCtx, client, + "crash child enumeration", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 + }) + cancelEnumerate() + if err != nil { + t.Fatal(err) + } + inputDeadline := time.Now().Add(time.Second) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(ctx, 20*time.Second) + _, err = waitForNativeStats(inputCtx, client, + "crash child direct input", func(stats udecx.Stats) bool { + return stats.InputReportsCompleted != 0 + }) + cancelInput() + if err != nil { + t.Fatal(err) + } + os.Exit(liveNativeCrashExitCode) + } + + command := exec.Command(os.Args[0], + "-test.run=^TestNativeUDELiveOwnerCrashRecovery$", "-test.timeout=90s") + command.Env = append(os.Environ(), liveNativeCrashChild+"=1") + output, err := command.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != liveNativeCrashExitCode { + t.Fatalf("crash child exit=%v want %d; output:\n%s", + err, liveNativeCrashExitCode, output) + } + + recoveryCtx, cancelRecovery := context.WithTimeout(context.Background(), 45*time.Second) + defer cancelRecovery() + var recovered *udecx.Client + for recovered == nil { + candidate, openErr := udecx.Open(recoveryCtx) + if openErr == nil { + stats, queryErr := candidate.QueryStats(recoveryCtx) + if queryErr == nil && stats.ActiveDevices == 0 && stats.PendingOperations == 0 { + recovered = candidate + break + } + _ = candidate.Close() + } + select { + case <-recoveryCtx.Done(): + t.Fatalf("native UDE owner/child cleanup did not recover after broker death: %v", + recoveryCtx.Err()) + case <-time.After(50 * time.Millisecond): + } + } + defer func() { + if closeErr := recovered.Close(); closeErr != nil { + t.Errorf("close recovered native UDE controller: %v", closeErr) + } + }() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(recovered, processor, 0) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(recoveryCtx) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + dev, _, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(recoveryCtx, 0x5649495043524153, dev) + if err != nil { + t.Fatalf("register controller after broker-death recovery: %v", err) + } + if err = host.Unregister(recoveryCtx, identity); err != nil { + t.Fatalf("unregister controller after broker-death recovery: %v", err) + } + cancelServe() + host.Close() + select { + case err = <-serveDone: + if err != nil { + t.Fatalf("recovered native UDE host shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("recovered native UDE host did not stop within 5 seconds") + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index 621a2300..0a19d1c8 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -55,5 +55,7 @@ non-Microsoft root devnode, a dirty driver session, or any increase in invalid messages, queue exhaustion, notification overflow, late completion, or cleanup retry counters. After validating each controller and repeated generation rollover independently, it enumerates the complete production controller set, -publishes input, and removes every child concurrently. Normal CI never opts +publishes input, and removes every child concurrently. A subprocess then exits +without running cleanup; the driver must remove its child, drain pending URBs, +release exclusive ownership, and accept a fresh session. Normal CI never opts into this live test. diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 4fcec669..2417d048 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -81,7 +81,7 @@ try { Push-Location $repository try { & $go.Source test -count=1 -timeout "${timeoutMinutes}m" ` - -run '^TestNativeUDELiveProductionControllers$' ./internal/server/usb + -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery)$' ./internal/server/usb if ($LASTEXITCODE -ne 0) { throw "Native UDE live validation failed with exit code $LASTEXITCODE." } From 312156ac9a799109c5938779ec3780d8eb7b3ece Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:12:18 -0500 Subject: [PATCH 090/240] Rollback native registration across host shutdown --- docs/architecture/native-udecx.md | 4 ++ internal/transport/udecx/host.go | 44 +++++++++++++++++++--- internal/transport/udecx/host_test.go | 53 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 4f5ad54b..df4ca290 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -310,6 +310,10 @@ request queues while accounting for UdeCx's endpoint-specific purge contract. Host-side create/remove gates are keyed by stable device ID: generations of one controller cannot cross, while a slow PnP transition for one pad cannot stall an independent pad's registration or removal. +- PnP creation is revalidated after the overlapped kernel transaction. If a + one-shot host session stopped while creation was in flight, that exact child + generation is transactionally destroyed and cannot be published into the + terminal host. ## Delivery checkpoints diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 76830dcd..7755455e 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -12,11 +12,12 @@ import ( ) const ( - defaultDequeueWorkers = 8 - laneQueueDepth = 128 - completionTimeout = 2 * time.Second - completedTokenHistory = MaxPendingOperations * 2 - statusUnsuccessful = int32(-1073741823) // STATUS_UNSUCCESSFUL + defaultDequeueWorkers = 8 + laneQueueDepth = 128 + completionTimeout = 2 * time.Second + terminalCleanupTimeout = 30 * time.Second + completedTokenHistory = MaxPendingOperations * 2 + statusUnsuccessful = int32(-1073741823) // STATUS_UNSUCCESSFUL ) // Driver is the narrow host-side contract implemented by the overlapped @@ -239,6 +240,39 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D cancel() return DeviceIdentity{}, err } + + // CreateDevice is an overlapped PnP transaction and can outlive a fatal or + // cancelled one-shot Serve session. Revalidate after the kernel commits the + // child. Reporting success here would publish a controller into a host that + // can never service its USB requests. Roll the exact generation back while + // the per-device lifecycle gate is still held. + h.mu.RLock() + terminal := h.started && (!h.running || h.runCtx == nil || h.runCtx.Err() != nil) + h.mu.RUnlock() + if terminal { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + cleanupErr := h.driver.DestroyDevice(cleanupCtx, identity) + cleanupCancel() + h.mu.Lock() + if h.devices[deviceID] == entry { + entry.stopping = true + entry.publisherStopping = true + if cleanupErr == nil { + delete(h.devices, deviceID) + } + } + h.mu.Unlock() + cancel() + if cleanupErr == nil { + h.processor.Reset(dev, identity) + return DeviceIdentity{}, errors.New( + "native UDE host session stopped while controller registration was in flight") + } + return DeviceIdentity{}, errors.Join( + errors.New("native UDE host session stopped while controller registration was in flight"), + fmt.Errorf("rollback native UDE device %d generation %d: %w", + identity.DeviceID, identity.Generation, cleanupErr)) + } return identity, nil } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 027f1ac8..f959d664 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -341,6 +341,59 @@ func TestHostSerializesSameControllerRegistration(t *testing.T) { } } +func TestHostRollsBackRegistrationThatOutlivesServe(t *testing.T) { + driver := &independentlyBlockingCreateDriver{ + fakeHostDriver: newFakeHostDriver(), blockedDevice: 84, + started: make(chan struct{}), release: make(chan struct{}), + } + host, err := NewHost(driver, &noopProcessor{}, 2) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + registerDone := make(chan error, 1) + go func() { + _, registerErr := host.Register(context.Background(), 84, hostTestDevice()) + registerDone <- registerErr + }() + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("registration did not reach blocking PnP create") + } + cancelServe() + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Fatalf("Serve shutdown: %v", serveErr) + } + case <-time.After(time.Second): + t.Fatal("Serve did not stop around in-flight registration") + } + close(driver.release) + select { + case registerErr := <-registerDone: + if registerErr == nil || !strings.Contains(registerErr.Error(), "registration was in flight") { + t.Fatalf("registration error=%v, want terminal-session rollback", registerErr) + } + case <-time.After(time.Second): + t.Fatal("registration did not finish after PnP create was released") + } + + host.mu.RLock() + _, leaked := host.devices[84] + host.mu.RUnlock() + driver.mu.Lock() + created, destroyed := len(driver.created), len(driver.destroyed) + driver.mu.Unlock() + if leaked || created != 1 || destroyed != 1 { + t.Fatalf("terminal registration leaked=%t created=%d destroyed=%d", leaked, created, destroyed) + } +} + func TestHostRepeatedCreateRemoveLeavesOnlyGenerationHistory(t *testing.T) { driver := newFakeHostDriver() host, err := NewHost(driver, &noopProcessor{}, 4) From 771dac2eeb5d3af6ba4780209e6ba15efc21e251 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:16:55 -0500 Subject: [PATCH 091/240] Gate native UDE under one-boot Driver Verifier Hash-bind the installed driver to the Microsoft-signed package before staging standard WDF-aware verification, refuse to replace foreign verifier settings, and constrain verification to one boot on an explicitly acknowledged disposable machine. Require live validation to prove Verifier is active and syntax-check every native PowerShell tool in CI. --- .github/workflows/native-ude.yml | 14 +++ docs/architecture/native-udecx-signing.md | 10 +- native/udecx/README.md | 21 ++++ .../Enable-ViiperUdeVerifierForNextBoot.ps1 | 100 ++++++++++++++++++ .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 17 ++- 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index b9c8d976..479480a1 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -72,6 +72,20 @@ jobs: - name: Validate Windows and KMDF target contract shell: pwsh run: ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 + - name: Parse native PowerShell tooling + shell: pwsh + run: | + $failed = $false + Get-ChildItem native/udecx/tools -File -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = $true + Write-Error "$($_.Name): $($errors -join [Environment]::NewLine)" + } + } + if ($failed) { throw "Native PowerShell parser gate failed" } - name: Expose WDK tools shell: pwsh run: | diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 7afb5b91..2f1d6825 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -75,10 +75,18 @@ counters unchanged. The script does not install a driver or enable Driver Verifier. It also kills a subprocess that owns an enumerated DualSense and requires kernel file cleanup to remove the child, drain pending operations, release exclusive ownership, and accept a fresh session. Driver Verifier -remains an explicit disposable-machine operation. +remains an explicit disposable-machine operation. The companion +`Enable-ViiperUdeVerifierForNextBoot.ps1` script first repeats the signed +package/service hash binding, refuses to replace verifier settings for another +driver, and stages Microsoft's standard checks for `ViiperUde.sys` in +`oneboot` mode. After the restart, live validation with +`-RequireDriverVerifier` refuses to run unless `verifier /query` proves the +driver is actually being verified. Neither script restarts the computer. ## Primary Microsoft references - [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) - [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) - [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) +- [Driver Verifier](https://learn.microsoft.com/windows-hardware/drivers/devtest/driver-verifier) +- [Driver Verifier command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/verifier-command-line) diff --git a/native/udecx/README.md b/native/udecx/README.md index 0a19d1c8..2e149c59 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -21,6 +21,11 @@ Directory contract: production controller through the real UdeCx host, direct interrupt-input path, generation teardown, and driver fault counters. It never installs or changes a driver. +- `tools/Enable-ViiperUdeVerifierForNextBoot.ps1` stages Microsoft standard + Driver Verifier checks for `ViiperUde.sys` for exactly one boot. It refuses + daily-use machines unless the disposable-machine acknowledgement is given, + refuses to replace another driver's verifier configuration, and never + restarts the machine. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. @@ -59,3 +64,19 @@ publishes input, and removes every child concurrently. A subprocess then exits without running cleanup; the driver must remove its child, drain pending URBs, release exclusive ownership, and accept a fresh session. Normal CI never opts into this live test. + +The Driver Verifier pass is a separate, explicit disposable-machine gate: + +```powershell +.\native\udecx\tools\Enable-ViiperUdeVerifierForNextBoot.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -DisposableTestMachine +# Restart once, then: +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -Iterations 10 ` + -RequireDriverVerifier +``` + +Microsoft warns that Driver Verifier can intentionally bugcheck a machine; +this workflow is never run by ordinary CI, an installer, or DS4Windows. diff --git a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 new file mode 100644 index 00000000..23aab12c --- /dev/null +++ b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 @@ -0,0 +1,100 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [switch]$DisposableTestMachine +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path +} + +if (-not $DisposableTestMachine) { + throw 'Driver Verifier can deliberately crash Windows. Run this only on a disposable test machine and pass -DisposableTestMachine.' +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Driver Verifier configuration requires an elevated PowerShell session.' +} + +$signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' +& $signatureGate -PackageDirectory $SignedPackageDirectory + +$packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path +$packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') +if ($packageDrivers.Count -ne 1) { + throw "Expected exactly one signed package driver; found $($packageDrivers.Count)." +} + +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageHash = (Get-FileHash -LiteralPath $packageDrivers[0].FullName -Algorithm SHA256).Hash +$installedHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash +if ($packageHash -ne $installedHash) { + throw "The installed VIIPER UDE driver does not match the verified Microsoft-signed package. Installed='$installedDriver'." +} + +$existingOutput = (& verifier.exe /querysettings 2>&1 | Out-String) +if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not inspect existing Driver Verifier settings (exit $LASTEXITCODE).`n$existingOutput" +} +$configuredDrivers = @([regex]::Matches($existingOutput, '(?im)\b[^\s\\/:*?"<>|]+\.sys\b') | + ForEach-Object { $_.Value } | + Sort-Object -Unique) +$foreignDrivers = @($configuredDrivers | Where-Object { $_ -ine 'ViiperUde.sys' }) +if ($foreignDrivers.Count -gt 0) { + throw "Refusing to replace an existing Driver Verifier configuration for: $($foreignDrivers -join ', '). Reset or preserve it manually first." +} + +$configured = $false +try { + $standardOutput = (& verifier.exe /standard /driver ViiperUde.sys 2>&1 | Out-String) + if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not configure standard Driver Verifier checks (exit $LASTEXITCODE).`n$standardOutput" + } + $configured = $true + + $bootOutput = (& verifier.exe /bootmode oneboot 2>&1 | Out-String) + if ($LASTEXITCODE -notin @(0, 2)) { + throw "Could not constrain Driver Verifier to one boot (exit $LASTEXITCODE).`n$bootOutput" + } + + $queryOutput = (& verifier.exe /querysettings 2>&1 | Out-String) + if (($LASTEXITCODE -notin @(0, 2)) -or $queryOutput -notmatch '(?im)\bViiperUde\.sys\b') { + throw "Driver Verifier did not report ViiperUde.sys in its next-boot configuration.`n$queryOutput" + } +} +catch { + if ($configured -and $foreignDrivers.Count -eq 0) { + & verifier.exe /reset 2>&1 | Out-Null + } + throw +} + +Write-Host 'Driver Verifier standard checks are staged for ViiperUde.sys for the next boot only.' +Write-Host 'Restart this disposable test machine, then run Invoke-ViiperUdeLiveValidation.ps1 with -RequireDriverVerifier.' +Write-Host 'Recovery if needed: start Windows Safe Mode, run "verifier.exe /reset" as administrator, and restart.' diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 2417d048..24275471 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -6,7 +6,9 @@ param( [ValidateRange(1, 100)] [int]$Iterations = 1, - [string]$RepositoryRoot + [string]$RepositoryRoot, + + [switch]$RequireDriverVerifier ) Set-StrictMode -Version Latest @@ -71,6 +73,16 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' } +if ($RequireDriverVerifier) { + $verifierOutput = (& verifier.exe /query 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "Driver Verifier query failed with exit code $LASTEXITCODE.`n$verifierOutput" + } + if ($verifierOutput -notmatch '(?im)\bViiperUde\.sys\b') { + throw 'Driver Verifier is not currently active for ViiperUde.sys. Configure one-boot verification, restart, and retry.' + } +} + $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') @@ -95,4 +107,5 @@ finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') } -Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)." +$verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } +Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix." From 8dc5b752d1bab3554a852ed5d3177b84c1458c29 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:23:03 -0500 Subject: [PATCH 092/240] Add native UDE full-duplex CoreAudio gate Build a dependency-free WASAPI probe that snapshots active endpoints before virtual PlayStation enumeration, drives the newly-created render and capture pair concurrently, and requires real ISO plus bidirectional byte counters. Keep the gate opt-in and package/hash-bound for Microsoft-signed disposable-machine validation. --- .github/workflows/native-ude.yml | 9 +- docs/architecture/native-udecx-signing.md | 7 + .../server/usb/native_live_windows_test.go | 46 ++ native/udecx/README.md | 13 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 23 +- native/udecx/tools/ViiperUdeMediaProbe.cpp | 418 ++++++++++++++++++ 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 native/udecx/tools/ViiperUdeMediaProbe.cpp diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 479480a1..aac9c2b2 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -95,7 +95,7 @@ jobs: $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Build x64 driver run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" - - name: Build transactional root-devnode helper + - name: Build transactional root-devnode and live-media helpers shell: pwsh run: | $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath @@ -110,6 +110,13 @@ jobs: if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } & $output status if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl status smoke test failed" } + $mediaSource = (Resolve-Path "native\udecx\tools\ViiperUdeMediaProbe.cpp").Path + $mediaOutput = Join-Path $outputDir "ViiperUdeMediaProbe.exe" + $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib" + cmd.exe /d /s /c $mediaCommand + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaOutput)) { throw "ViiperUdeMediaProbe build failed" } + & $mediaOutput + if ($LASTEXITCODE -ne 2) { throw "ViiperUdeMediaProbe usage smoke test failed" } - name: Validate Hardware Dev Center CAB structure shell: pwsh run: | diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 2f1d6825..be5e369f 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -82,6 +82,13 @@ driver, and stages Microsoft's standard checks for `ViiperUde.sys` in `oneboot` mode. After the restart, live validation with `-RequireDriverVerifier` refuses to run unless `verifier /query` proves the driver is actually being verified. Neither script restarts the computer. +The optional `ViiperUdeMediaProbe.exe` gate snapshots active CoreAudio endpoints +before enumeration, requires exactly one newly-active render/capture pair for +DualShock 4 and DualSense, drives both simultaneously through event-mode WASAPI, +and requires the driver's ISO packet, OUT-byte, and IN-byte counters all to +advance. This distinguishes a visible-but-nonfunctional audio endpoint from a +working full-duplex bus and avoids confusing an already-connected physical pad +with the newly-created virtual one. ## Primary Microsoft references diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index eec0d42c..c1c4cca8 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -28,6 +28,7 @@ const ( liveNativeTestEnvironment = "VIIPER_UDE_LIVE" liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" + liveNativeMediaProbe = "VIIPER_UDE_LIVE_MEDIA_PROBE" liveNativeCrashExitCode = 86 ) @@ -130,6 +131,16 @@ func assertCleanNativeStatsDelta(t *testing.T, before, after udecx.Stats) { } } +func runLiveMediaProbe(t *testing.T, ctx context.Context, probe string, arguments ...string) string { + t.Helper() + command := exec.CommandContext(ctx, probe, arguments...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run native CoreAudio probe %v: %v\n%s", arguments, err, output) + } + return string(output) +} + // TestNativeUDELiveProductionControllers is deliberately inert in normal CI. // It opens an already-installed native controller and never installs, updates, // enables, or removes a kernel driver. Release validation must first verify the @@ -191,6 +202,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { }() const deviceIDBase uint64 = 0x5649495000000000 + mediaProbe := os.Getenv(liveNativeMediaProbe) for iteration := 1; iteration <= iterations; iteration++ { for controllerIndex, controller := range liveNativeControllers() { controller := controller @@ -200,6 +212,21 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { if createErr != nil { t.Fatalf("construct %s: %v", controller.name, createErr) } + mediaSnapshot := "" + mediaController := iteration == 1 && mediaProbe != "" && + (controller.name == "DualShock4" || controller.name == "DualSense") + if mediaController { + snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-media-*.snapshot") + if snapshotErr != nil { + t.Fatalf("create media endpoint snapshot: %v", snapshotErr) + } + mediaSnapshot = snapshot.Name() + if closeErr := snapshot.Close(); closeErr != nil { + t.Fatalf("close media endpoint snapshot: %v", closeErr) + } + defer os.Remove(mediaSnapshot) + runLiveMediaProbe(t, testCtx, mediaProbe, "snapshot", mediaSnapshot) + } before, queryErr := client.QueryStats(testCtx) if queryErr != nil { t.Fatal(queryErr) @@ -233,6 +260,25 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { t.Fatal(waitErr) } + if mediaController { + mediaBefore, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media baseline: %v", controller.name, mediaErr) + } + probeOutput := runLiveMediaProbe( + t, testCtx, mediaProbe, "exercise", mediaSnapshot, "3") + mediaAfter, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media result: %v", controller.name, mediaErr) + } + if mediaAfter.IsoPackets <= mediaBefore.IsoPackets || + mediaAfter.BytesToDevice <= mediaBefore.BytesToDevice || + mediaAfter.BytesFromDevice <= mediaBefore.BytesFromDevice { + t.Fatalf("%s CoreAudio did not exercise full-duplex ISO media: before=%+v after=%+v probe=%s", + controller.name, mediaBefore, mediaAfter, probeOutput) + } + } + inputDeadline := time.Now().Add(750 * time.Millisecond) for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { publishInput(sequence) diff --git a/native/udecx/README.md b/native/udecx/README.md index 2e149c59..d7e8d4a9 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -26,6 +26,11 @@ Directory contract: daily-use machines unless the disposable-machine acknowledgement is given, refuses to replace another driver's verifier configuration, and never restarts the machine. +- `tools/ViiperUdeMediaProbe.cpp` is a dependency-free CoreAudio live probe. + It snapshots active endpoints before a virtual PlayStation controller is + created, opens exactly the new render/capture pair concurrently through + WASAPI, and lets the signed-driver test require real ISO traffic and bytes in + both directions rather than treating endpoint enumeration as media success. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. @@ -52,7 +57,8 @@ Microsoft-signed package with: ```powershell .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` - -Iterations 10 + -Iterations 10 ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ``` The command refuses an unsigned package, a package/service hash mismatch, a @@ -64,6 +70,11 @@ publishes input, and removes every child concurrently. A subprocess then exits without running cleanup; the driver must remove its child, drain pending URBs, release exclusive ownership, and accept a fresh session. Normal CI never opts into this live test. +When `-MediaProbePath` is supplied, the first DualShock 4 and DualSense +generation must also create one new render/capture endpoint pair; three seconds +of simultaneous WASAPI render/capture must increase native ISO, host-to-device, +and device-to-host byte counters. The baseline snapshot prevents a connected +physical controller from being mistaken for the virtual device. The Driver Verifier pass is a separate, explicit disposable-machine gate: diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 24275471..a03b0a32 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -8,7 +8,9 @@ param( [string]$RepositoryRoot, - [switch]$RequireDriverVerifier + [switch]$RequireDriverVerifier, + + [string]$MediaProbePath ) Set-StrictMode -Version Latest @@ -83,12 +85,27 @@ if ($RequireDriverVerifier) { } } +$resolvedMediaProbe = $null +if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { + $resolvedMediaProbe = (Resolve-Path -LiteralPath $MediaProbePath -ErrorAction Stop).Path + if ([IO.Path]::GetExtension($resolvedMediaProbe) -ine '.exe') { + throw "The native CoreAudio probe must be an executable: '$resolvedMediaProbe'." + } +} + $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') +$oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', 'Process') try { $env:VIIPER_UDE_LIVE = '1' $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations + if ($null -ne $resolvedMediaProbe) { + $env:VIIPER_UDE_LIVE_MEDIA_PROBE = $resolvedMediaProbe + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $null, 'Process') + } $timeoutMinutes = ($Iterations * 5) + 2 Push-Location $repository try { @@ -105,7 +122,9 @@ try { finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') } $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } -Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix." +$mediaSuffix = if ($null -ne $resolvedMediaProbe) { ' with full-duplex CoreAudio media' } else { '' } +Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix." diff --git a/native/udecx/tools/ViiperUdeMediaProbe.cpp b/native/udecx/tools/ViiperUdeMediaProbe.cpp new file mode 100644 index 00000000..d7538039 --- /dev/null +++ b/native/udecx/tools/ViiperUdeMediaProbe.cpp @@ -0,0 +1,418 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +template +class ComPtr final { +public: + ComPtr() = default; + ~ComPtr() { reset(); } + ComPtr(const ComPtr&) = delete; + ComPtr& operator=(const ComPtr&) = delete; + ComPtr(ComPtr&& other) noexcept : value_(other.value_) { other.value_ = nullptr; } + ComPtr& operator=(ComPtr&& other) noexcept { + if (this != &other) { + reset(); + value_ = other.value_; + other.value_ = nullptr; + } + return *this; + } + T* get() const { return value_; } + T** put() { + reset(); + return &value_; + } + T* operator->() const { return value_; } + explicit operator bool() const { return value_ != nullptr; } + void reset() { + if (value_ != nullptr) { + value_->Release(); + value_ = nullptr; + } + } +private: + T* value_ = nullptr; +}; + +class Handle final { +public: + explicit Handle(HANDLE value = nullptr) : value_(value) {} + ~Handle() { if (value_ != nullptr) CloseHandle(value_); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + HANDLE get() const { return value_; } +private: + HANDLE value_; +}; + +class ComApartment final { +public: + ComApartment() { + const HRESULT result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(result)) { + throw std::runtime_error("CoInitializeEx failed: 0x" + hex(result)); + } + initialized_ = true; + } + ~ComApartment() { if (initialized_) CoUninitialize(); } + static std::string hex(HRESULT value) { + char buffer[16]{}; + sprintf_s(buffer, "%08lX", static_cast(value)); + return buffer; + } +private: + bool initialized_ = false; +}; + +[[noreturn]] void ThrowHRESULT(const char* operation, HRESULT result) { + throw std::runtime_error(std::string(operation) + " failed: 0x" + ComApartment::hex(result)); +} + +void CheckHRESULT(const char* operation, HRESULT result) { + if (FAILED(result)) ThrowHRESULT(operation, result); +} + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) throw std::runtime_error("WideCharToMultiByte failed"); + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size, nullptr, nullptr) != size) { + throw std::runtime_error("WideCharToMultiByte returned a short conversion"); + } + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) throw std::runtime_error("MultiByteToWideChar failed"); + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size) != size) { + throw std::runtime_error("MultiByteToWideChar returned a short conversion"); + } + return result; +} + +struct EndpointSet { + std::set render; + std::set capture; +}; + +std::set Enumerate(EDataFlow flow) { + ComApartment apartment; + ComPtr enumerator; + CheckHRESULT("CoCreateInstance(MMDeviceEnumerator)", CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(enumerator.put()))); + ComPtr collection; + CheckHRESULT("EnumAudioEndpoints", enumerator->EnumAudioEndpoints( + flow, DEVICE_STATE_ACTIVE, collection.put())); + UINT count = 0; + CheckHRESULT("IMMDeviceCollection::GetCount", collection->GetCount(&count)); + std::set result; + for (UINT index = 0; index < count; ++index) { + ComPtr device; + CheckHRESULT("IMMDeviceCollection::Item", collection->Item(index, device.put())); + LPWSTR id = nullptr; + CheckHRESULT("IMMDevice::GetId", device->GetId(&id)); + result.emplace(id); + CoTaskMemFree(id); + } + return result; +} + +EndpointSet EnumerateEndpoints() { + return EndpointSet{Enumerate(eRender), Enumerate(eCapture)}; +} + +void WriteSnapshot(const std::filesystem::path& path, const EndpointSet& endpoints) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) throw std::runtime_error("could not create endpoint snapshot"); + for (const auto& id : endpoints.render) output << "R\t" << WideToUtf8(id) << "\n"; + for (const auto& id : endpoints.capture) output << "C\t" << WideToUtf8(id) << "\n"; + output.flush(); + if (!output) throw std::runtime_error("could not write endpoint snapshot"); +} + +EndpointSet ReadSnapshot(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) throw std::runtime_error("could not open endpoint snapshot"); + EndpointSet result; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.size() < 3 || line[1] != '\t') { + throw std::runtime_error("invalid endpoint snapshot record"); + } + auto& destination = line[0] == 'R' ? result.render : result.capture; + if (line[0] != 'R' && line[0] != 'C') { + throw std::runtime_error("invalid endpoint snapshot flow"); + } + destination.insert(Utf8ToWide(line.substr(2))); + } + return result; +} + +std::vector Difference(const std::set& current, + const std::set& baseline) { + std::vector result; + std::set_difference(current.begin(), current.end(), baseline.begin(), baseline.end(), + std::back_inserter(result)); + return result; +} + +ComPtr OpenEndpoint(const std::wstring& endpointId) { + ComPtr enumerator; + CheckHRESULT("CoCreateInstance(MMDeviceEnumerator)", CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(enumerator.put()))); + ComPtr result; + CheckHRESULT("IMMDeviceEnumerator::GetDevice", enumerator->GetDevice( + endpointId.c_str(), result.put())); + return result; +} + +bool IsFloatFormat(const WAVEFORMATEX* format) { + if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE || + format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) return false; + const auto* extensible = reinterpret_cast(format); + return IsEqualGUID(extensible->SubFormat, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) != FALSE; +} + +bool IsPCMFormat(const WAVEFORMATEX* format) { + if (format->wFormatTag == WAVE_FORMAT_PCM) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE || + format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) return false; + const auto* extensible = reinterpret_cast(format); + return IsEqualGUID(extensible->SubFormat, KSDATAFORMAT_SUBTYPE_PCM) != FALSE; +} + +void FillTone(BYTE* data, UINT32 frames, const WAVEFORMATEX* format, double& phase) { + if (format->nChannels == 0 || format->nSamplesPerSec == 0 || format->nBlockAlign == 0) { + throw std::runtime_error("audio endpoint returned an invalid mix format"); + } + const UINT32 sampleBytes = format->nBlockAlign / format->nChannels; + if (sampleBytes == 0 || sampleBytes * format->nChannels != format->nBlockAlign) { + throw std::runtime_error("audio endpoint returned an unsupported block alignment"); + } + const bool floating = IsFloatFormat(format); + const bool pcm = IsPCMFormat(format); + if (!floating && !pcm) throw std::runtime_error("audio endpoint mix format is neither PCM nor float"); + if ((floating && sampleBytes != 4) || (!floating && sampleBytes != 1 && sampleBytes != 2 && + sampleBytes != 3 && sampleBytes != 4)) { + throw std::runtime_error("audio endpoint mix format has an unsupported sample width"); + } + + constexpr double frequency = 523.251130601; + constexpr double amplitude = 0.08; + const double increment = 2.0 * kPi * frequency / format->nSamplesPerSec; + for (UINT32 frame = 0; frame < frames; ++frame) { + const double sample = std::sin(phase) * amplitude; + phase += increment; + if (phase >= 2.0 * kPi) phase -= 2.0 * kPi; + for (WORD channel = 0; channel < format->nChannels; ++channel) { + BYTE* destination = data + static_cast(frame) * format->nBlockAlign + + static_cast(channel) * sampleBytes; + if (floating) { + *reinterpret_cast(destination) = static_cast(sample); + } else if (sampleBytes == 1) { + destination[0] = static_cast(std::clamp(128.0 + sample * 127.0, 0.0, 255.0)); + } else { + const auto scaled = static_cast(std::llround(sample * + (sampleBytes == 2 ? 32767.0 : sampleBytes == 3 ? 8388607.0 : 2147483647.0))); + for (UINT32 byte = 0; byte < sampleBytes; ++byte) { + destination[byte] = static_cast((scaled >> (byte * 8)) & 0xff); + } + } + } + } +} + +uint64_t ExerciseRender(const std::wstring& endpointId, std::chrono::seconds duration) { + ComApartment apartment; + auto device = OpenEndpoint(endpointId); + ComPtr client; + CheckHRESULT("IMMDevice::Activate(IAudioClient)", device->Activate( + __uuidof(IAudioClient), CLSCTX_INPROC_SERVER, nullptr, + reinterpret_cast(client.put()))); + WAVEFORMATEX* rawFormat = nullptr; + CheckHRESULT("IAudioClient::GetMixFormat", client->GetMixFormat(&rawFormat)); + std::unique_ptr format(rawFormat, CoTaskMemFree); + CheckHRESULT("IAudioClient::Initialize(render)", client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_NOPERSIST, + 0, 0, format.get(), nullptr)); + Handle event(CreateEventW(nullptr, FALSE, FALSE, nullptr)); + if (event.get() == nullptr) throw std::runtime_error("CreateEvent(render) failed"); + CheckHRESULT("IAudioClient::SetEventHandle(render)", client->SetEventHandle(event.get())); + ComPtr render; + CheckHRESULT("IAudioClient::GetService(IAudioRenderClient)", client->GetService( + __uuidof(IAudioRenderClient), reinterpret_cast(render.put()))); + UINT32 bufferFrames = 0; + CheckHRESULT("IAudioClient::GetBufferSize(render)", client->GetBufferSize(&bufferFrames)); + BYTE* data = nullptr; + double phase = 0.0; + CheckHRESULT("IAudioRenderClient::GetBuffer(prime)", render->GetBuffer(bufferFrames, &data)); + FillTone(data, bufferFrames, format.get(), phase); + CheckHRESULT("IAudioRenderClient::ReleaseBuffer(prime)", render->ReleaseBuffer(bufferFrames, 0)); + CheckHRESULT("IAudioClient::Start(render)", client->Start()); + + uint64_t framesWritten = bufferFrames; + const auto deadline = std::chrono::steady_clock::now() + duration; + while (std::chrono::steady_clock::now() < deadline) { + const DWORD wait = WaitForSingleObject(event.get(), 2000); + if (wait != WAIT_OBJECT_0) throw std::runtime_error("render event timed out"); + UINT32 padding = 0; + CheckHRESULT("IAudioClient::GetCurrentPadding", client->GetCurrentPadding(&padding)); + if (padding > bufferFrames) throw std::runtime_error("render padding exceeds buffer size"); + const UINT32 available = bufferFrames - padding; + if (available == 0) continue; + CheckHRESULT("IAudioRenderClient::GetBuffer", render->GetBuffer(available, &data)); + FillTone(data, available, format.get(), phase); + CheckHRESULT("IAudioRenderClient::ReleaseBuffer", render->ReleaseBuffer(available, 0)); + framesWritten += available; + } + CheckHRESULT("IAudioClient::Stop(render)", client->Stop()); + return framesWritten; +} + +uint64_t ExerciseCapture(const std::wstring& endpointId, std::chrono::seconds duration) { + ComApartment apartment; + auto device = OpenEndpoint(endpointId); + ComPtr client; + CheckHRESULT("IMMDevice::Activate(IAudioClient)", device->Activate( + __uuidof(IAudioClient), CLSCTX_INPROC_SERVER, nullptr, + reinterpret_cast(client.put()))); + WAVEFORMATEX* rawFormat = nullptr; + CheckHRESULT("IAudioClient::GetMixFormat(capture)", client->GetMixFormat(&rawFormat)); + std::unique_ptr format(rawFormat, CoTaskMemFree); + CheckHRESULT("IAudioClient::Initialize(capture)", client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_NOPERSIST, + 0, 0, format.get(), nullptr)); + Handle event(CreateEventW(nullptr, FALSE, FALSE, nullptr)); + if (event.get() == nullptr) throw std::runtime_error("CreateEvent(capture) failed"); + CheckHRESULT("IAudioClient::SetEventHandle(capture)", client->SetEventHandle(event.get())); + ComPtr capture; + CheckHRESULT("IAudioClient::GetService(IAudioCaptureClient)", client->GetService( + __uuidof(IAudioCaptureClient), reinterpret_cast(capture.put()))); + CheckHRESULT("IAudioClient::Start(capture)", client->Start()); + + uint64_t framesRead = 0; + const auto deadline = std::chrono::steady_clock::now() + duration; + while (std::chrono::steady_clock::now() < deadline) { + const DWORD wait = WaitForSingleObject(event.get(), 2000); + if (wait != WAIT_OBJECT_0) throw std::runtime_error("capture event timed out"); + for (;;) { + UINT32 packetFrames = 0; + CheckHRESULT("IAudioCaptureClient::GetNextPacketSize", capture->GetNextPacketSize(&packetFrames)); + if (packetFrames == 0) break; + BYTE* data = nullptr; + DWORD flags = 0; + CheckHRESULT("IAudioCaptureClient::GetBuffer", capture->GetBuffer( + &data, &packetFrames, &flags, nullptr, nullptr)); + framesRead += packetFrames; + CheckHRESULT("IAudioCaptureClient::ReleaseBuffer", capture->ReleaseBuffer(packetFrames)); + } + } + CheckHRESULT("IAudioClient::Stop(capture)", client->Stop()); + return framesRead; +} + +int Exercise(const std::filesystem::path& snapshotPath, int seconds) { + const EndpointSet baseline = ReadSnapshot(snapshotPath); + std::vector render; + std::vector capture; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + do { + const EndpointSet current = EnumerateEndpoints(); + render = Difference(current.render, baseline.render); + capture = Difference(current.capture, baseline.capture); + if (render.size() == 1 && capture.size() == 1) break; + if (render.size() > 1 || capture.size() > 1) { + throw std::runtime_error("more than one new active endpoint appeared; refusing an ambiguous media test"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } while (std::chrono::steady_clock::now() < deadline); + if (render.size() != 1 || capture.size() != 1) { + throw std::runtime_error("the virtual controller did not expose exactly one new render and capture endpoint"); + } + + std::exception_ptr renderError; + std::exception_ptr captureError; + uint64_t renderFrames = 0; + uint64_t captureFrames = 0; + const auto duration = std::chrono::seconds(seconds); + std::thread renderThread([&] { + try { renderFrames = ExerciseRender(render[0], duration); } + catch (...) { renderError = std::current_exception(); } + }); + std::thread captureThread([&] { + try { captureFrames = ExerciseCapture(capture[0], duration); } + catch (...) { captureError = std::current_exception(); } + }); + renderThread.join(); + captureThread.join(); + if (renderError) std::rethrow_exception(renderError); + if (captureError) std::rethrow_exception(captureError); + if (renderFrames == 0 || captureFrames == 0) { + throw std::runtime_error("CoreAudio endpoint completed no frames"); + } + std::cout << "renderFrames=" << renderFrames << " captureFrames=" << captureFrames << "\n"; + return 0; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + try { + if (argc == 3 && _wcsicmp(argv[1], L"snapshot") == 0) { + WriteSnapshot(argv[2], EnumerateEndpoints()); + return 0; + } + if (argc == 4 && _wcsicmp(argv[1], L"exercise") == 0) { + const int seconds = _wtoi(argv[3]); + if (seconds < 1 || seconds > 30) throw std::runtime_error("duration must be 1 through 30 seconds"); + return Exercise(argv[2], seconds); + } + std::wcerr << L"Usage:\n" + << L" ViiperUdeMediaProbe.exe snapshot \n" + << L" ViiperUdeMediaProbe.exe exercise \n"; + return 2; + } catch (const std::exception& error) { + std::cerr << "VIIPER UDE media probe failed: " << error.what() << "\n"; + return 1; + } +} From 02017d1d2def3098d43690ce48c2f92f40353f18 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:25:59 -0500 Subject: [PATCH 093/240] Validate active native root restart recovery Restart only the exact package-verified VIIPER root instance while a production DualSense and direct-input publisher are active. Require the old broker session to terminate, the restarted kernel controller to return clean, and a new exclusive owner to re-enumerate, service input, and drain without lifecycle faults. --- docs/architecture/native-udecx-signing.md | 11 ++ .../server/usb/native_live_windows_test.go | 154 ++++++++++++++++++ native/udecx/README.md | 10 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 30 +++- 4 files changed, 200 insertions(+), 5 deletions(-) diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index be5e369f..d25bd8e2 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -89,6 +89,16 @@ and requires the driver's ISO packet, OUT-byte, and IN-byte counters all to advance. This distinguishes a visible-but-nonfunctional audio endpoint from a working full-duplex bus and avoids confusing an already-connected physical pad with the newly-created virtual one. +The opt-in root-restart gate uses Microsoft's `pnputil /restart-device` only +against the one package-verified VIIPER instance ID and only on an explicitly +acknowledged disposable machine. It keeps a DualSense child and direct-input +publisher active across removal, requires the old owner session to terminate, +then hash-preserving PnP restart must expose a clean controller that accepts a +new exclusive owner, re-enumerates the child, services input, and drains to +zero again. Windows 10 1809 remains a supported runtime target, but this +particular automated gate requires Windows 10 2004 because that is when +Microsoft added `pnputil /restart-device`; 1809 power/PnP coverage belongs in +the HLK/DevFund matrix. ## Primary Microsoft references @@ -97,3 +107,4 @@ with the newly-created virtual one. - [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) - [Driver Verifier](https://learn.microsoft.com/windows-hardware/drivers/devtest/driver-verifier) - [Driver Verifier command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/verifier-command-line) +- [PnPUtil command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/pnputil-command-syntax) diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index c1c4cca8..d7e6369d 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -29,6 +29,7 @@ const ( liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" liveNativeMediaProbe = "VIIPER_UDE_LIVE_MEDIA_PROBE" + liveNativeRestartInstance = "VIIPER_UDE_LIVE_RESTART_INSTANCE_ID" liveNativeCrashExitCode = 86 ) @@ -586,3 +587,156 @@ func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { t.Fatal("recovered native UDE host did not stop within 5 seconds") } } + +// TestNativeUDELiveRootRestartRecovery is enabled only by the signed-package +// PowerShell gate on a disposable Windows test machine. It restarts the exact +// installed root devnode while a real child and direct-input publisher are +// active, then requires the invalidated owner to terminate and a fresh broker +// session to enumerate and service input without stale kernel state. +func TestNativeUDELiveRootRestartRecovery(t *testing.T) { + if os.Getenv(liveNativeTestEnvironment) != "1" { + t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + liveNativeTestEnvironment) + } + instanceID := os.Getenv(liveNativeRestartInstance) + if instanceID == "" { + t.Skipf("set %s only through the signed disposable-machine validation gate", + liveNativeRestartInstance) + } + + testCtx, cancelTest := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancelTest() + client, err := udecx.Open(testCtx) + if err != nil { + t.Fatalf("open native UDE controller before root restart: %v", err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err := serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + host, err := udecx.NewHost(client, processor, 0) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(testCtx) }() + dev, publishInput, err := liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + if _, err = host.Register(testCtx, 0x56494950504e5052, dev); err != nil { + t.Fatalf("register DualSense before root restart: %v", err) + } + inputDeadline := time.Now().Add(time.Second) + for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + inputCtx, cancelInput := context.WithTimeout(testCtx, 20*time.Second) + _, err = waitForNativeStats(inputCtx, client, + "pre-restart direct input", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 && stats.InputReportsCompleted != 0 + }) + cancelInput() + if err != nil { + t.Fatal(err) + } + + restart := exec.CommandContext(testCtx, "pnputil.exe", "/restart-device", instanceID) + restartOutput, restartErr := restart.CombinedOutput() + if restartErr != nil { + host.Close() + _ = client.Close() + t.Fatalf("restart exact native UDE root devnode %q: %v\n%s", + instanceID, restartErr, restartOutput) + } + select { + case <-serveDone: + case <-time.After(30 * time.Second): + host.Close() + _ = client.Close() + t.Fatal("native host did not observe root-devnode restart within 30 seconds") + } + host.Close() + if closeErr := client.Close(); closeErr != nil { + t.Fatalf("close invalidated pre-restart controller handle: %v", closeErr) + } + + var recovered *udecx.Client + recoveryDeadline := time.Now().Add(45 * time.Second) + for recovered == nil && time.Now().Before(recoveryDeadline) { + candidate, openErr := udecx.Open(testCtx) + if openErr == nil { + stats, queryErr := candidate.QueryStats(testCtx) + if queryErr == nil && stats.ActiveDevices == 0 && stats.PendingOperations == 0 { + recovered = candidate + break + } + _ = candidate.Close() + } + time.Sleep(50 * time.Millisecond) + } + if recovered == nil { + t.Fatal("native UDE root devnode did not return as a clean exclusive broker after restart") + } + defer recovered.Close() + + server = serverusb.New(serverusb.ServerConfig{ConnectionTimeout: 5 * time.Second}, logger, nil) + processor, err = serverusb.NewNativeProcessor(server) + if err != nil { + t.Fatal(err) + } + recoveredHost, err := udecx.NewHost(recovered, processor, 0) + if err != nil { + t.Fatal(err) + } + recoveredCtx, cancelRecovered := context.WithCancel(testCtx) + recoveredDone := make(chan error, 1) + go func() { recoveredDone <- recoveredHost.Serve(recoveredCtx) }() + dev, publishInput, err = liveNativeControllers()[2].new() + if err != nil { + t.Fatal(err) + } + identity, err := recoveredHost.Register(testCtx, 0x56494950504e5052, dev) + if err != nil { + t.Fatalf("register DualSense after root restart: %v", err) + } + for sequence := uint64(1); sequence <= 1000; sequence++ { + publishInput(sequence) + time.Sleep(time.Millisecond) + } + recoveredInputCtx, cancelRecoveredInput := context.WithTimeout(testCtx, 20*time.Second) + _, err = waitForNativeStats(recoveredInputCtx, recovered, + "post-restart direct input", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 1 && stats.InputReportsCompleted != 0 + }) + cancelRecoveredInput() + if err != nil { + t.Fatal(err) + } + if err = recoveredHost.Unregister(testCtx, identity); err != nil { + t.Fatalf("unregister DualSense after root restart: %v", err) + } + cleanCtx, cancelClean := context.WithTimeout(testCtx, 20*time.Second) + after, err := waitForNativeStats(cleanCtx, recovered, + "post-restart teardown", func(stats udecx.Stats) bool { + return stats.ActiveDevices == 0 && stats.PendingOperations == 0 + }) + cancelClean() + if err != nil { + t.Fatal(err) + } + assertCleanNativeStatsDelta(t, udecx.Stats{}, after) + cancelRecovered() + recoveredHost.Close() + select { + case err = <-recoveredDone: + if err != nil { + t.Fatalf("post-restart host shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("post-restart native host did not stop within 5 seconds") + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index d7e8d4a9..b60a5ab2 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -75,6 +75,12 @@ generation must also create one new render/capture endpoint pair; three seconds of simultaneous WASAPI render/capture must increase native ISO, host-to-device, and device-to-host byte counters. The baseline snapshot prevents a connected physical controller from being mistaken for the virtual device. +On Windows 10 2004 or newer, `-RestartRootDevice -DisposableTestMachine` +restarts the exact signed root devnode with a live DualSense child and input +publisher. The invalidated owner must terminate, the restarted controller must +return with zero children and pending requests, and a fresh exclusive session +must re-enumerate, service input, and tear down cleanly. No wildcard or +hardware-ID-wide PnP operation is used. The Driver Verifier pass is a separate, explicit disposable-machine gate: @@ -86,7 +92,9 @@ The Driver Verifier pass is a separate, explicit disposable-machine gate: .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` -Iterations 10 ` - -RequireDriverVerifier + -RequireDriverVerifier ` + -RestartRootDevice ` + -DisposableTestMachine ``` Microsoft warns that Driver Verifier can intentionally bugcheck a machine; diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index a03b0a32..715b2a5c 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -10,7 +10,11 @@ param( [switch]$RequireDriverVerifier, - [string]$MediaProbePath + [string]$MediaProbePath, + + [switch]$RestartRootDevice, + + [switch]$DisposableTestMachine ) Set-StrictMode -Version Latest @@ -75,6 +79,15 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' } +if ($RestartRootDevice) { + if (-not $DisposableTestMachine) { + throw 'Root-device restart validation is destructive to the active native session. Pass -DisposableTestMachine on a dedicated test system.' + } + if ([Environment]::OSVersion.Version.Build -lt 19041) { + throw 'PnPUtil /restart-device requires Windows 10 version 2004 (build 19041) or newer.' + } +} + if ($RequireDriverVerifier) { $verifierOutput = (& verifier.exe /query 2>&1 | Out-String) if ($LASTEXITCODE -ne 0) { @@ -97,6 +110,7 @@ $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') $oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', 'Process') +$oldRestartInstance = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', 'Process') try { $env:VIIPER_UDE_LIVE = '1' $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations @@ -106,11 +120,17 @@ try { else { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $null, 'Process') } - $timeoutMinutes = ($Iterations * 5) + 2 + if ($RestartRootDevice) { + $env:VIIPER_UDE_LIVE_RESTART_INSTANCE_ID = [string]$devnodes[0].DeviceID + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $null, 'Process') + } + $timeoutMinutes = ($Iterations * 5) + $(if ($RestartRootDevice) { 5 } else { 2 }) Push-Location $repository try { & $go.Source test -count=1 -timeout "${timeoutMinutes}m" ` - -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery)$' ./internal/server/usb + -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ./internal/server/usb if ($LASTEXITCODE -ne 0) { throw "Native UDE live validation failed with exit code $LASTEXITCODE." } @@ -123,8 +143,10 @@ finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $oldRestartInstance, 'Process') } $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } $mediaSuffix = if ($null -ne $resolvedMediaProbe) { ' with full-duplex CoreAudio media' } else { '' } -Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix." +$restartSuffix = if ($RestartRootDevice) { ' with active root-device restart recovery' } else { '' } +Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$restartSuffix." From c984a98b6636b5d7ad3e6a38e14b570dfb6a402b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:27:09 -0500 Subject: [PATCH 094/240] Exercise CoreAudio helper with a real CI snapshot --- .github/workflows/native-ude.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index aac9c2b2..160d5737 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -115,8 +115,10 @@ jobs: $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib" cmd.exe /d /s /c $mediaCommand if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaOutput)) { throw "ViiperUdeMediaProbe build failed" } - & $mediaOutput - if ($LASTEXITCODE -ne 2) { throw "ViiperUdeMediaProbe usage smoke test failed" } + $mediaSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-media-smoke.snapshot" + & $mediaOutput snapshot $mediaSnapshot + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaSnapshot)) { throw "ViiperUdeMediaProbe endpoint snapshot smoke test failed" } + Remove-Item -LiteralPath $mediaSnapshot -Force - name: Validate Hardware Dev Center CAB structure shell: pwsh run: | From 0eceb8ff5fa1545d9d8a5e5cce1ce4284a90f4f0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:33:44 -0500 Subject: [PATCH 095/240] Cache native UDE input until Windows polls Replace the lossy direct-input rendezvous with a ViGEm-style pending-read contract. Each interrupt-IN endpoint now owns a preallocated latest-state cache, and KMDF queue-ready notification services host polls that arrive after a physical update instead of waiting for another feeder report. Snapshot queue depth before completion to prevent synchronous poll replenishment from becoming an unbounded kernel loop. Preserve per-endpoint ordering and invalidate cached input at endpoint purge/reset and device reset/D0 exit so stale button state cannot cross lifecycle boundaries. Document the demand-driven transport and keep control/media traffic on the existing ordered broker. --- docs/architecture/native-udecx.md | 17 ++- native/udecx/README.md | 8 ++ native/udecx/driver/Device.c | 199 +++++++++++++++++++++++------- native/udecx/driver/ViiperUde.h | 4 + 4 files changed, 182 insertions(+), 46 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index df4ca290..a417b6ec 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -74,8 +74,12 @@ The transport is intentionally split by USB semantics: - interrupt-IN input reports use the ViGEm-style manual-queue fast path. The Windows poll stays parked in the endpoint queue; one versioned - `SUBMIT_INPUT_REPORT` call copies the already encoded report into that URB - and completes it without an allocation or broker round trip; + `SUBMIT_INPUT_REPORT` call atomically replaces the endpoint's preallocated + latest-state cache and completes a waiting URB without an allocation or + broker round trip. If the state arrives first, KMDF's manual-queue ready + notification completes the later poll from that cache. Input timing is + therefore host-poll-driven rather than dependent on a second physical + report arriving after the poll; - control, interrupt-OUT, isochronous speaker/microphone/haptics, feedback, and every lifecycle transition use the cancel-safe ordered inverted-call broker. VIIPER posts multiple `DEQUEUE_OPERATION` requests, processes each immutable @@ -256,8 +260,13 @@ interface fields are only hints for alternates that contain no endpoints. `UdecxUrbRetrieveBuffer` is used only within its separately reported mapped span. Chained or short mappings fall through to a bounded MDL-chain walk; the driver never treats the URB length as permission to overrun one mapping. -- Interrupt-IN queues are manual and completed from fresh input snapshots; - output and media endpoints retain independent ordered queues. +- Interrupt-IN queues are manual and completed from a generation-owned, + sequence-checked latest-state cache. The queue-ready callback snapshots the + number of already-waiting polls before it completes any of them, preventing + a synchronously replenished Windows poll from turning into a kernel drain + loop. Endpoint purge/reset and device reset/D0 exit invalidate the cache + after closing admission, so no held button can cross a lifecycle boundary. + Output and media endpoints retain independent ordered queues. - A direct input report that was already submitted when D0 exit, device reset, unplug, or endpoint purge begins is acknowledged and discarded at that exact lifecycle boundary. The kernel closes admission in the UdeCx callback itself diff --git a/native/udecx/README.md b/native/udecx/README.md index b60a5ab2..d47668a6 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -34,6 +34,14 @@ Directory contract: - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. +The interrupt-IN path follows ViGEmBus's useful pending-read principle without +copying its target-specific implementation. Each endpoint owns a preallocated, +sequence-checked latest-state cache. A report arriving before a Windows poll is +retained and completed by KMDF's manual-queue ready notification; reset, purge, +D0 exit, and device reset invalidate it behind the same admission barriers used +by the direct producer. This removes the old lost-rendezvous window and never +requires an extra feeder update to wake an already-posted host poll. + The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in `docs/architecture/native-udecx-signing.md`. diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 0c75f351..6a41ca4d 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -675,6 +675,36 @@ ViiperEvtVirtualDeviceCleanup( } } +static +VOID +ViiperInvalidateDeviceInputReports( + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + ULONG index; + + // Device power/reset admission is already closed before this helper is + // called, so no new report can become valid. Reference each endpoint while + // outside DeviceLock because asynchronous UdeCx cleanup owns its lifetime. + for (index = 0; index < RTL_NUMBER_OF(deviceContext->Endpoints); ++index) { + UDECXUSBENDPOINT endpoint; + WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + endpoint = deviceContext->Endpoints[index]; + if (endpoint != WDF_NO_HANDLE) { + WdfObjectReference(endpoint); + } + WdfWaitLockRelease(controllerContext->DeviceLock); + if (endpoint != WDF_NO_HANDLE) { + InterlockedExchange( + &ViiperGetEndpointContext(endpoint)->InputReportValid, FALSE); + WdfObjectDereference(endpoint); + } + } +} + NTSTATUS ViiperEvtUsbDeviceD0Entry( _In_ WDFDEVICE Controller, @@ -708,6 +738,7 @@ ViiperEvtUsbDeviceD0Exit( WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, FALSE); WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperInvalidateDeviceInputReports(Device); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); return STATUS_SUCCESS; } @@ -764,6 +795,7 @@ ViiperBeginAcknowledgedDeviceReset( if (!NT_SUCCESS(status)) { return STATUS_DEVICE_BUSY; } + ViiperInvalidateDeviceInputReports(Device); status = ViiperQueueAcknowledgedDeviceLifecycleEvent( Device, Request, ViiperUdeOperationDeviceReset); if (!NT_SUCCESS(status)) { @@ -943,6 +975,18 @@ ViiperEvtEndpointAdd( if (!NT_SUCCESS(status)) { return status; } + if (endpointContext->FastInput) { + // A direct report can arrive just before Windows posts its interrupt + // poll. Preserve that latest state and service the poll when the + // manual endpoint queue changes from empty to non-empty. This mirrors + // ViGEmBus's pending-read/cache contract without routing HID input + // through the ordered control/media broker. + status = WdfIoQueueReadyNotify( + endpointContext->Queue, ViiperEvtFastInputQueueReady, endpoint); + if (!NT_SUCCESS(status)) { + return status; + } + } { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); @@ -996,6 +1040,106 @@ ViiperCompleteRetrievedInputUrb( } } +static +NTSTATUS +ViiperCompleteCachedInputUrb( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + PURB urb = ViiperGetUrb(Request); + ULONG transferLength; + NTSTATUS status; + + if (urb == NULL || + (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && + urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || + (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { + ViiperCompleteRetrievedInputUrb(Request, STATUS_INVALID_DEVICE_REQUEST); + return STATUS_INVALID_DEVICE_REQUEST; + } + transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + if (endpointContext->InputReportLength > transferLength) { + ViiperCompleteRetrievedInputUrb(Request, STATUS_BUFFER_TOO_SMALL); + return STATUS_BUFFER_TOO_SMALL; + } + status = ViiperCopyTransferBuffer( + Request, + urb, + endpointContext->InputReport, + endpointContext->InputReportLength, + TRUE); + if (!NT_SUCCESS(status)) { + ViiperCompleteRetrievedInputUrb(Request, status); + return status; + } + + urb->UrbBulkOrInterruptTransfer.TransferBufferLength = endpointContext->InputReportLength; + UdecxUrbSetBytesCompleted(Request, endpointContext->InputReportLength); + InterlockedAdd64(&controllerContext->BytesFromDevice, endpointContext->InputReportLength); + InterlockedIncrement64(&controllerContext->InputReportsCompleted); + ViiperCompleteRetrievedInputUrb(Request, STATUS_SUCCESS); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtFastInputQueueReady( + _In_ WDFQUEUE Queue, + _In_ WDFCONTEXT Context + ) +{ + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + WDFREQUEST request = WDF_NO_HANDLE; + ULONG requestCount = 0; + ULONG index; + BOOLEAN admitted = FALSE; + + PAGED_CODE(); + // Snapshot only the polls which caused this ready transition. Completing + // an URB can synchronously cause the USB stack to post its successor; an + // unbounded drain loop would therefore become a kernel busy loop. + (VOID)WdfIoQueueGetState(Queue, &requestCount, NULL); + if (requestCount == 0) { + return; + } + + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0) { + ViiperEndpointOperationStarted(endpoint); + admitted = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!admitted) { + WdfWaitLockRelease(endpointContext->InputLock); + return; + } + + for (index = 0; index < requestCount; ++index) { + if (!NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { + break; + } + (VOID)ViiperCompleteCachedInputUrb(endpoint, request); + request = WDF_NO_HANDLE; + } + ViiperEndpointOperationCompleted(endpoint); + WdfWaitLockRelease(endpointContext->InputLock); +} + NTSTATUS ViiperSubmitInputReport( _In_ WDFQUEUE Queue, @@ -1013,8 +1157,6 @@ ViiperSubmitInputReport( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; WDFREQUEST urbRequest = WDF_NO_HANDLE; - PURB urb; - ULONG transferLength; ULONG index; NTSTATUS status; BOOLEAN lifecycleDrop = FALSE; @@ -1134,58 +1276,29 @@ ViiperSubmitInputReport( WdfObjectDereference(endpoint); return STATUS_INVALID_DEVICE_STATE; } - // Claim the sequence when the report is accepted, including when no host - // poll is parked. That makes latest-state coalescing replay-safe. + // Claim and cache every accepted sequence, including when no Windows poll + // is parked. The queue-ready callback will satisfy the next poll from this + // exact latest state instead of waiting for or fabricating another feeder + // update. InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); + RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength); + endpointContext->InputReportLength = input->PayloadLength; + InterlockedExchange(&endpointContext->InputReportValid, TRUE); InterlockedIncrement64(&controllerContext->InputReportsSubmitted); status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); if (!NT_SUCCESS(status)) { ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); - // A producer update is allowed to arrive before Windows posts its - // next interrupt poll. This is normal latest-state coalescing, not a - // session fault; the following report services the following poll. + // The cached report now owns this state. Queue-ready delivery services + // the next Windows poll even if the physical feeder becomes idle. return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; } - urb = ViiperGetUrb(urbRequest); - if (urb == NULL || - (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && - urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || - (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { - ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_INVALID_DEVICE_REQUEST); - ViiperEndpointOperationCompleted(endpoint); - WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); - return STATUS_INVALID_DEVICE_REQUEST; - } - transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; - if (input->PayloadLength > transferLength) { - ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_BUFFER_TOO_SMALL); - ViiperEndpointOperationCompleted(endpoint); - WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); - return STATUS_BUFFER_TOO_SMALL; - } - status = ViiperCopyTransferBuffer( - urbRequest, urb, payload, input->PayloadLength, TRUE); - if (!NT_SUCCESS(status)) { - ViiperCompleteRetrievedInputUrb(urbRequest, status); - ViiperEndpointOperationCompleted(endpoint); - WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); - return status; - } - - urb->UrbBulkOrInterruptTransfer.TransferBufferLength = input->PayloadLength; - UdecxUrbSetBytesCompleted(urbRequest, input->PayloadLength); - InterlockedAdd64(&controllerContext->BytesFromDevice, input->PayloadLength); - InterlockedIncrement64(&controllerContext->InputReportsCompleted); - ViiperCompleteRetrievedInputUrb(urbRequest, STATUS_SUCCESS); + status = ViiperCompleteCachedInputUrb(endpoint, urbRequest); ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); - return STATUS_SUCCESS; + return status; } VOID @@ -1216,6 +1329,7 @@ ViiperEvtEndpointReset( } InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + InterlockedExchange(&endpointContext->InputReportValid, FALSE); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); endpointContext->ResetRequest = Request; // A forwarded broker operation or direct input copy may have won @@ -1297,6 +1411,7 @@ ViiperEvtEndpointPurge( InterlockedExchange(&endpointContext->Purging, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); + InterlockedExchange(&endpointContext->InputReportValid, FALSE); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); // UdeCx owns the state of the endpoint queue. We only drain requests that diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index b5f8265c..672ae7a5 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -185,6 +185,9 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { volatile LONG ActiveOperations; volatile LONG64 LastInputSequence; volatile LONG64 NextIsoStartFrame; + volatile LONG InputReportValid; + ULONG InputReportLength; + UCHAR InputReport[VIIPER_UDE_MAX_INPUT_REPORT_BYTES]; ULONGLONG NextAdmissionSequence; BOOLEAN FastInput; } VIIPER_UDE_ENDPOINT_CONTEXT; @@ -214,6 +217,7 @@ EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; +EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; From 7379dd4c6839c4f8cf75e1958d3b7423f07ad026 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:36:24 -0500 Subject: [PATCH 096/240] Align live input assertions with cached polling Document and validate the native interrupt-IN counter contract after adding the latest-state cache. InputReportsSubmitted counts producer publications while InputReportsCompleted counts host polls, so repeated polls of a stable cached state are valid and no longer rejected by the signed live gate. --- docs/architecture/native-udecx.md | 8 ++++++++ internal/server/usb/native_live_windows_test.go | 5 +++-- native/udecx/README.md | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index a417b6ec..f858b000 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -86,6 +86,14 @@ The transport is intentionally split by USB semantics: operation through the existing `usb.Device` interface, then submits `COMPLETE_OPERATION`. +The input counters intentionally measure opposite sides of that cache: +`InputReportsSubmitted` counts accepted latest-state publications, while +`InputReportsCompleted` counts Windows interrupt-IN polls completed from the +cache. A host may poll the same stable controller state more than once, so the +completed count can legitimately exceed the submitted count. Live validation +requires both forward publication and a completed Windows poll; it does not +invent a one-to-one relationship that USB interrupt polling does not have. + Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before device removal. Removal rejected before UdeCx takes the child restores the diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index d7e6369d..bf24008d 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -294,8 +294,9 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { if waitErr != nil { t.Fatal(waitErr) } - if inputStats.InputReportsCompleted > inputStats.InputReportsSubmitted { - t.Fatalf("completed input reports exceed submissions: %+v", inputStats) + if inputStats.InputReportsSubmitted <= before.InputReportsSubmitted { + t.Fatalf("%s did not publish a direct input state: before=%+v after=%+v", + controller.name, before, inputStats) } unregisterCtx, cancelUnregister := context.WithTimeout(testCtx, 20*time.Second) diff --git a/native/udecx/README.md b/native/udecx/README.md index d47668a6..8af5ede1 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -41,6 +41,9 @@ retained and completed by KMDF's manual-queue ready notification; reset, purge, D0 exit, and device reset invalidate it behind the same admission barriers used by the direct producer. This removes the old lost-rendezvous window and never requires an extra feeder update to wake an already-posted host poll. +`InputReportsSubmitted` counts accepted state publications and +`InputReportsCompleted` counts host polls served from that cache, so a stable +state may produce more completions than submissions by design. The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in From ff1e33fd5b5ceddfdb662e909399363cd29d7e21 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:42:55 -0500 Subject: [PATCH 097/240] Gate native UDE input latency through HIDClass Add a dependency-free signed-live HID probe that snapshots existing collections, opens exactly the newly enumerated Sony gamepad, and correlates 256 alternating state markers using cross-process QPC timestamps. Gate DualShock 4, DualSense, and Edge publisher-to-HID latency at 4 ms p95, 8 ms p99, and 20 ms max. Build and smoke-test the probe in WDK CI and expose it through the signed package validation script. --- .github/workflows/native-ude.yml | 9 + docs/architecture/native-udecx.md | 12 + .../server/usb/native_live_windows_test.go | 240 ++++++++++-- native/udecx/README.md | 16 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 21 +- native/udecx/tools/ViiperUdeInputProbe.cpp | 345 ++++++++++++++++++ 6 files changed, 605 insertions(+), 38 deletions(-) create mode 100644 native/udecx/tools/ViiperUdeInputProbe.cpp diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 160d5737..f490e133 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -119,6 +119,15 @@ jobs: & $mediaOutput snapshot $mediaSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaSnapshot)) { throw "ViiperUdeMediaProbe endpoint snapshot smoke test failed" } Remove-Item -LiteralPath $mediaSnapshot -Force + $inputSource = (Resolve-Path "native\udecx\tools\ViiperUdeInputProbe.cpp").Path + $inputOutput = Join-Path $outputDir "ViiperUdeInputProbe.exe" + $inputCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$inputSource`" /Fe:`"$inputOutput`" /link Setupapi.lib Hid.lib" + cmd.exe /d /s /c $inputCommand + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputOutput)) { throw "ViiperUdeInputProbe build failed" } + $inputSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-input-smoke.snapshot" + & $inputOutput snapshot $inputSnapshot + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } + Remove-Item -LiteralPath $inputSnapshot -Force - name: Validate Hardware Dev Center CAB structure shell: pwsh run: | diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f858b000..34381b8b 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -354,6 +354,12 @@ stall an independent pad's registration or removal. - DualSense and DualShock 4 media survive concurrent state and feedback traffic. - Native latency and CPU are measured against the current USB/IP path and ViGEmBus-style virtual input under the same workload. +- Signed live input validation discovers the exact newly created HID gamepad, + continuously reads reports through HIDClass, and correlates 256 unique + publication markers with cross-process QPC timestamps. DualShock 4, + DualSense, and DualSense Edge must remain at or below 4 ms p95, 8 ms p99, + and 20 ms maximum publisher-to-HID latency. These gates include user-mode + scheduling and prevent a nominal polling-rate claim from hiding tail stalls. - Installation is signed, reversible, version-gated, and never replaces a live kernel driver across an unsafe reboot boundary. - The INF's Windows 10 1809 floor and the linked KMDF contract remain aligned: @@ -372,3 +378,9 @@ validation contract is documented in - Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance +- Microsoft, *Finding and Opening a HID Collection* + +- Microsoft, *Obtaining HID Reports* + +- Microsoft, *Acquiring high-resolution time stamps* + diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index bf24008d..65f1d13c 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -3,6 +3,7 @@ package usb_test import ( + "bufio" "context" "errors" "fmt" @@ -10,10 +11,13 @@ import ( "log/slog" "os" "os/exec" + "sort" "strconv" + "strings" "sync" "testing" "time" + "unsafe" "github.com/Alia5/VIIPER/device/dualsense" "github.com/Alia5/VIIPER/device/dualshock4" @@ -22,6 +26,7 @@ import ( serverusb "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/transport/udecx" usbdevice "github.com/Alia5/VIIPER/usb" + "golang.org/x/sys/windows" ) const ( @@ -29,56 +34,78 @@ const ( liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" liveNativeMediaProbe = "VIIPER_UDE_LIVE_MEDIA_PROBE" + liveNativeInputProbe = "VIIPER_UDE_LIVE_INPUT_PROBE" liveNativeRestartInstance = "VIIPER_UDE_LIVE_RESTART_INSTANCE_ID" liveNativeCrashExitCode = 86 ) type liveNativeController struct { - name string - new func() (usbdevice.Device, func(uint64), error) + name string + vendorID uint16 + productID uint16 + inputMarkerOffset uint16 + new func() (usbdevice.Device, func(uint64), func(byte), error) } func liveNativeControllers() []liveNativeController { return []liveNativeController{ - {name: "Xbox360", new: func() (usbdevice.Device, func(uint64), error) { + {name: "Xbox360", new: func() (usbdevice.Device, func(uint64), func(byte), error) { dev, err := xbox360.New(nil) return dev, func(sequence uint64) { state := xbox360.NewInputState() state.LX = int16(sequence % 1024) dev.UpdateInputState(*state) - }, err + }, nil, err }}, - {name: "DualShock4", new: func() (usbdevice.Device, func(uint64), error) { - dev, err := dualshock4.New(nil) - return dev, func(sequence uint64) { - state := dualshock4.NewInputState() - state.LX = int8(sequence % 32) - dev.UpdateInputState(state) - }, err - }}, - {name: "DualSense", new: func() (usbdevice.Device, func(uint64), error) { - dev, err := dualsense.New(nil) - return dev, func(sequence uint64) { - state := dualsense.NewInputState() - state.LX = int8(sequence % 32) - dev.UpdateInputState(state) - }, err - }}, - {name: "DualSenseEdge", new: func() (usbdevice.Device, func(uint64), error) { - dev, err := dualsense.NewEdge(nil) - return dev, func(sequence uint64) { - state := dualsense.NewInputState() - state.RX = int8(sequence % 32) - dev.UpdateInputState(state) - }, err - }}, - {name: "Switch2Pro", new: func() (usbdevice.Device, func(uint64), error) { + {name: "DualShock4", vendorID: dualshock4.DefaultVID, + productID: dualshock4.DefaultPID, inputMarkerOffset: 1, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualshock4.New(nil) + return dev, func(sequence uint64) { + state := dualshock4.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualshock4.NewInputState() + state.LX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSense", vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDS, inputMarkerOffset: 1, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualsense.New(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.LX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualsense.NewInputState() + state.LX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "DualSenseEdge", vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDSEdge, inputMarkerOffset: 3, + new: func() (usbdevice.Device, func(uint64), func(byte), error) { + dev, err := dualsense.NewEdge(nil) + return dev, func(sequence uint64) { + state := dualsense.NewInputState() + state.RX = int8(sequence % 32) + dev.UpdateInputState(state) + }, func(marker byte) { + state := dualsense.NewInputState() + state.RX = int8(int(marker) - 128) + dev.UpdateInputState(state) + }, err + }}, + {name: "Switch2Pro", new: func() (usbdevice.Device, func(uint64), func(byte), error) { dev, err := ns2pro.New(nil) return dev, func(sequence uint64) { state := ns2pro.NewInputState() state.LX += uint16(sequence % 32) dev.UpdateInputState(*state) - }, err + }, nil, err }}, } } @@ -142,6 +169,128 @@ func runLiveMediaProbe(t *testing.T, ctx context.Context, probe string, argument return string(output) } +var queryPerformanceCounter = windows.NewLazySystemDLL("kernel32.dll"). + NewProc("QueryPerformanceCounter") + +func performanceCounter(t *testing.T) int64 { + t.Helper() + var counter int64 + result, _, callErr := queryPerformanceCounter.Call( + uintptr(unsafe.Pointer(&counter))) + if result == 0 { + t.Fatalf("QueryPerformanceCounter: %v", callErr) + } + return counter +} + +func percentile(sorted []float64, percentile float64) float64 { + if len(sorted) == 0 { + return 0 + } + index := int(float64(len(sorted)-1) * percentile) + return sorted[index] +} + +func runLiveInputLatencyProbe( + t *testing.T, + ctx context.Context, + probe string, + snapshot string, + controller liveNativeController, + publishMarker func(byte), +) { + t.Helper() + const samples = 256 + probeCtx, cancelProbe := context.WithTimeout(ctx, 45*time.Second) + defer cancelProbe() + command := exec.CommandContext(probeCtx, probe, + "measure", snapshot, + fmt.Sprintf("0x%04X", controller.vendorID), + fmt.Sprintf("0x%04X", controller.productID), + strconv.Itoa(int(controller.inputMarkerOffset)), + strconv.Itoa(samples), "qpc-v1") + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatalf("open %s input-probe stdout: %v", controller.name, err) + } + var stderr strings.Builder + command.Stderr = &stderr + if err = command.Start(); err != nil { + t.Fatalf("start %s input probe: %v", controller.name, err) + } + waited := false + defer func() { + if !waited { + _ = command.Wait() + } + }() + + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + _ = command.Wait() + waited = true + t.Fatalf("%s input probe never became ready: scan=%v stderr=%s", + controller.name, scanner.Err(), stderr.String()) + } + ready := strings.Fields(scanner.Text()) + if len(ready) < 3 || ready[0] != "READY" { + t.Fatalf("%s input probe returned an invalid ready record: %q", + controller.name, scanner.Text()) + } + frequency, err := strconv.ParseInt(ready[1], 10, 64) + if err != nil || frequency <= 0 { + t.Fatalf("%s input probe returned invalid QPC frequency %q", + controller.name, ready[1]) + } + + latencies := make([]float64, 0, samples) + for index := 0; index < samples; index++ { + marker := byte(0xFD + (index & 1)) + published := performanceCounter(t) + publishMarker(marker) + if !scanner.Scan() { + _ = command.Wait() + waited = true + t.Fatalf("%s input probe ended after %d/%d samples: scan=%v stderr=%s", + controller.name, index, samples, scanner.Err(), stderr.String()) + } + match := strings.Fields(scanner.Text()) + if len(match) != 3 || match[0] != "MATCH" { + t.Fatalf("%s input probe returned an invalid match record: %q", + controller.name, scanner.Text()) + } + observedMarker, markerErr := strconv.ParseUint(match[1], 10, 8) + observed, observedErr := strconv.ParseInt(match[2], 10, 64) + if markerErr != nil || observedErr != nil || byte(observedMarker) != marker || + observed < published { + t.Fatalf("%s input probe returned an invalid marker/timestamp: %q published=%d", + controller.name, scanner.Text(), published) + } + latencies = append(latencies, + float64(observed-published)*1000/float64(frequency)) + } + if err = command.Wait(); err != nil { + waited = true + t.Fatalf("%s input probe failed: %v stderr=%s", + controller.name, err, stderr.String()) + } + waited = true + sort.Float64s(latencies) + p50 := percentile(latencies, 0.50) + p95 := percentile(latencies, 0.95) + p99 := percentile(latencies, 0.99) + maximum := latencies[len(latencies)-1] + t.Logf("%s native publish-to-HID latency: samples=%d p50=%.3fms p95=%.3fms p99=%.3fms max=%.3fms", + controller.name, samples, p50, p95, p99, maximum) + // These limits include the Go publisher, native IOCTL, UdeCx/HIDClass, and + // the independent observer process. They deliberately gate long-tail loss + // without pretending the host's nominal poll interval is end-to-end latency. + if p95 > 4 || p99 > 8 || maximum > 20 { + t.Fatalf("%s native input latency exceeded the release gate: p95=%.3fms p99=%.3fms max=%.3fms", + controller.name, p95, p99, maximum) + } +} + // TestNativeUDELiveProductionControllers is deliberately inert in normal CI. // It opens an already-installed native controller and never installs, updates, // enables, or removes a kernel driver. Release validation must first verify the @@ -204,12 +353,13 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { const deviceIDBase uint64 = 0x5649495000000000 mediaProbe := os.Getenv(liveNativeMediaProbe) + inputProbe := os.Getenv(liveNativeInputProbe) for iteration := 1; iteration <= iterations; iteration++ { for controllerIndex, controller := range liveNativeControllers() { controller := controller t.Run(fmt.Sprintf("%s/generation-%d", controller.name, iteration), func(t *testing.T) { deviceID := deviceIDBase + uint64(controllerIndex+1) - dev, publishInput, createErr := controller.new() + dev, publishInput, publishMarker, createErr := controller.new() if createErr != nil { t.Fatalf("construct %s: %v", controller.name, createErr) } @@ -228,6 +378,20 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { defer os.Remove(mediaSnapshot) runLiveMediaProbe(t, testCtx, mediaProbe, "snapshot", mediaSnapshot) } + inputSnapshot := "" + inputController := iteration == 1 && inputProbe != "" && publishMarker != nil + if inputController { + snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-input-*.snapshot") + if snapshotErr != nil { + t.Fatalf("create input endpoint snapshot: %v", snapshotErr) + } + inputSnapshot = snapshot.Name() + if closeErr := snapshot.Close(); closeErr != nil { + t.Fatalf("close input endpoint snapshot: %v", closeErr) + } + defer os.Remove(inputSnapshot) + runLiveMediaProbe(t, testCtx, inputProbe, "snapshot", inputSnapshot) + } before, queryErr := client.QueryStats(testCtx) if queryErr != nil { t.Fatal(queryErr) @@ -279,6 +443,10 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { controller.name, mediaBefore, mediaAfter, probeOutput) } } + if inputController { + runLiveInputLatencyProbe( + t, testCtx, inputProbe, inputSnapshot, controller, publishMarker) + } inputDeadline := time.Now().Add(750 * time.Millisecond) for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { @@ -341,7 +509,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { registerWG.Add(1) go func() { defer registerWG.Done() - dev, publishInput, createErr := controller.new() + dev, publishInput, _, createErr := controller.new() if createErr != nil { registered <- activeController{name: controller.name, err: createErr} return @@ -484,7 +652,7 @@ func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { serveDone := make(chan error, 1) go func() { serveDone <- host.Serve(ctx) }() - dev, publishInput, err := liveNativeControllers()[2].new() + dev, publishInput, _, err := liveNativeControllers()[2].new() if err != nil { t.Fatal(err) } @@ -566,7 +734,7 @@ func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { serveCtx, cancelServe := context.WithCancel(recoveryCtx) serveDone := make(chan error, 1) go func() { serveDone <- host.Serve(serveCtx) }() - dev, _, err := liveNativeControllers()[2].new() + dev, _, _, err := liveNativeControllers()[2].new() if err != nil { t.Fatal(err) } @@ -623,7 +791,7 @@ func TestNativeUDELiveRootRestartRecovery(t *testing.T) { } serveDone := make(chan error, 1) go func() { serveDone <- host.Serve(testCtx) }() - dev, publishInput, err := liveNativeControllers()[2].new() + dev, publishInput, _, err := liveNativeControllers()[2].new() if err != nil { t.Fatal(err) } @@ -696,7 +864,7 @@ func TestNativeUDELiveRootRestartRecovery(t *testing.T) { recoveredCtx, cancelRecovered := context.WithCancel(testCtx) recoveredDone := make(chan error, 1) go func() { recoveredDone <- recoveredHost.Serve(recoveredCtx) }() - dev, publishInput, err = liveNativeControllers()[2].new() + dev, publishInput, _, err = liveNativeControllers()[2].new() if err != nil { t.Fatal(err) } diff --git a/native/udecx/README.md b/native/udecx/README.md index 8af5ede1..cad4cf1a 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -31,6 +31,12 @@ Directory contract: created, opens exactly the new render/capture pair concurrently through WASAPI, and lets the signed-driver test require real ISO traffic and bytes in both directions rather than treating endpoint enumeration as media success. +- `tools/ViiperUdeInputProbe.cpp` follows Microsoft's HIDClass discovery and + continuous `ReadFile` contract. It snapshots existing HID collections, + opens only the newly enumerated matching gamepad, and timestamps unique + state markers with the system-wide performance counter. The signed live + gate measures the complete publisher-to-Windows-HID path instead of an + internal queue approximation. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. @@ -69,7 +75,8 @@ Microsoft-signed package with: .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` -Iterations 10 ` - -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ``` The command refuses an unsigned package, a package/service hash mismatch, a @@ -86,6 +93,13 @@ generation must also create one new render/capture endpoint pair; three seconds of simultaneous WASAPI render/capture must increase native ISO, host-to-device, and device-to-host byte counters. The baseline snapshot prevents a connected physical controller from being mistaken for the virtual device. +When `-InputProbePath` is supplied, the first DualShock 4, DualSense, and +DualSense Edge generations each publish 256 alternating stick markers. QPC is +sampled immediately before publication and when a continuous HID `ReadFile` +observes the matching report. The release gate requires p95 <= 4 ms, +p99 <= 8 ms, and maximum <= 20 ms, including user-mode scheduling, the native +IOCTL, UdeCx, and HIDClass. These are measured long-tail limits, not claims +derived from the nominal USB polling interval. On Windows 10 2004 or newer, `-RestartRootDevice -DisposableTestMachine` restarts the exact signed root devnode with a live DualSense child and input publisher. The invalidated owner must terminate, the restarted controller must diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 715b2a5c..c8f84c02 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -12,6 +12,8 @@ param( [string]$MediaProbePath, + [string]$InputProbePath, + [switch]$RestartRootDevice, [switch]$DisposableTestMachine @@ -106,10 +108,19 @@ if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { } } +$resolvedInputProbe = $null +if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { + $resolvedInputProbe = (Resolve-Path -LiteralPath $InputProbePath -ErrorAction Stop).Path + if ([IO.Path]::GetExtension($resolvedInputProbe) -ine '.exe') { + throw "The native HID input probe must be an executable: '$resolvedInputProbe'." + } +} + $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') $oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', 'Process') +$oldInputProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', 'Process') $oldRestartInstance = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', 'Process') try { $env:VIIPER_UDE_LIVE = '1' @@ -120,6 +131,12 @@ try { else { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $null, 'Process') } + if ($null -ne $resolvedInputProbe) { + $env:VIIPER_UDE_LIVE_INPUT_PROBE = $resolvedInputProbe + } + else { + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $null, 'Process') + } if ($RestartRootDevice) { $env:VIIPER_UDE_LIVE_RESTART_INSTANCE_ID = [string]$devnodes[0].DeviceID } @@ -143,10 +160,12 @@ finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $oldInputProbe, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $oldRestartInstance, 'Process') } $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } $mediaSuffix = if ($null -ne $resolvedMediaProbe) { ' with full-duplex CoreAudio media' } else { '' } +$inputSuffix = if ($null -ne $resolvedInputProbe) { ' with end-to-end HID latency' } else { '' } $restartSuffix = if ($RestartRootDevice) { ' with active root-device restart recovery' } else { '' } -Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$restartSuffix." +Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix." diff --git a/native/udecx/tools/ViiperUdeInputProbe.cpp b/native/udecx/tools/ViiperUdeInputProbe.cpp new file mode 100644 index 00000000..ed0432d5 --- /dev/null +++ b/native/udecx/tools/ViiperUdeInputProbe.cpp @@ -0,0 +1,345 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class DeviceInfoSet final { +public: + explicit DeviceInfoSet(HDEVINFO value = INVALID_HANDLE_VALUE) : value_(value) {} + ~DeviceInfoSet() { + if (value_ != INVALID_HANDLE_VALUE) SetupDiDestroyDeviceInfoList(value_); + } + DeviceInfoSet(const DeviceInfoSet&) = delete; + DeviceInfoSet& operator=(const DeviceInfoSet&) = delete; + HDEVINFO get() const { return value_; } +private: + HDEVINFO value_; +}; + +class Handle final { +public: + explicit Handle(HANDLE value = INVALID_HANDLE_VALUE) : value_(value) {} + ~Handle() { + if (value_ != INVALID_HANDLE_VALUE && value_ != nullptr) CloseHandle(value_); + } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + Handle(Handle&& other) noexcept : value_(other.value_) { + other.value_ = INVALID_HANDLE_VALUE; + } + Handle& operator=(Handle&& other) noexcept { + if (this != &other) { + if (value_ != INVALID_HANDLE_VALUE && value_ != nullptr) CloseHandle(value_); + value_ = other.value_; + other.value_ = INVALID_HANDLE_VALUE; + } + return *this; + } + HANDLE get() const { return value_; } + bool valid() const { return value_ != INVALID_HANDLE_VALUE && value_ != nullptr; } +private: + HANDLE value_; +}; + +class PreparsedData final { +public: + explicit PreparsedData(PHIDP_PREPARSED_DATA value = nullptr) : value_(value) {} + ~PreparsedData() { if (value_ != nullptr) HidD_FreePreparsedData(value_); } + PreparsedData(const PreparsedData&) = delete; + PreparsedData& operator=(const PreparsedData&) = delete; + PHIDP_PREPARSED_DATA get() const { return value_; } +private: + PHIDP_PREPARSED_DATA value_; +}; + +std::string Win32Error(const char* operation) { + return std::string(operation) + " failed with Win32 error " + + std::to_string(GetLastError()); +} + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) throw std::runtime_error(Win32Error("WideCharToMultiByte")); + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size, nullptr, nullptr) != size) { + throw std::runtime_error("WideCharToMultiByte returned a short conversion"); + } + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) throw std::runtime_error(Win32Error("MultiByteToWideChar")); + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size) != size) { + throw std::runtime_error("MultiByteToWideChar returned a short conversion"); + } + return result; +} + +std::set EnumerateHidPaths() { + GUID hidGuid{}; + HidD_GetHidGuid(&hidGuid); + DeviceInfoSet devices(SetupDiGetClassDevsW( + &hidGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); + if (devices.get() == INVALID_HANDLE_VALUE) { + throw std::runtime_error(Win32Error("SetupDiGetClassDevs(HID)")); + } + + std::set paths; + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interfaceData{}; + interfaceData.cbSize = sizeof(interfaceData); + if (!SetupDiEnumDeviceInterfaces( + devices.get(), nullptr, &hidGuid, index, &interfaceData)) { + if (GetLastError() == ERROR_NO_MORE_ITEMS) break; + throw std::runtime_error(Win32Error("SetupDiEnumDeviceInterfaces(HID)")); + } + + DWORD required = 0; + SetupDiGetDeviceInterfaceDetailW( + devices.get(), &interfaceData, nullptr, 0, &required, nullptr); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || + required < sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W)) { + throw std::runtime_error(Win32Error("SetupDiGetDeviceInterfaceDetail(size)")); + } + std::vector storage(required); + auto* detail = reinterpret_cast(storage.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + if (!SetupDiGetDeviceInterfaceDetailW( + devices.get(), &interfaceData, detail, required, nullptr, nullptr)) { + throw std::runtime_error(Win32Error("SetupDiGetDeviceInterfaceDetail(HID)")); + } + paths.emplace(detail->DevicePath); + } + return paths; +} + +void WriteSnapshot(const std::filesystem::path& path) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) throw std::runtime_error("could not create HID snapshot"); + for (const auto& devicePath : EnumerateHidPaths()) { + output << WideToUtf8(devicePath) << "\n"; + } + output.flush(); + if (!output) throw std::runtime_error("could not write HID snapshot"); +} + +std::set ReadSnapshot(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) throw std::runtime_error("could not open HID snapshot"); + std::set result; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty()) result.emplace(Utf8ToWide(line)); + } + if (!input.eof()) throw std::runtime_error("could not read HID snapshot"); + return result; +} + +struct OpenHid final { + Handle file; + USHORT inputReportLength = 0; + std::wstring path; +}; + +std::unique_ptr TryOpenGamepad( + const std::wstring& path, + USHORT vendorId, + USHORT productId) { + Handle file(CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr)); + if (!file.valid()) return nullptr; + + HIDD_ATTRIBUTES attributes{}; + attributes.Size = sizeof(attributes); + if (!HidD_GetAttributes(file.get(), &attributes) || + attributes.VendorID != vendorId || attributes.ProductID != productId) { + return nullptr; + } + + PHIDP_PREPARSED_DATA rawData = nullptr; + if (!HidD_GetPreparsedData(file.get(), &rawData)) return nullptr; + PreparsedData data(rawData); + HIDP_CAPS caps{}; + if (HidP_GetCaps(data.get(), &caps) != HIDP_STATUS_SUCCESS || + caps.UsagePage != 0x01 || caps.Usage != 0x05 || caps.InputReportByteLength == 0) { + return nullptr; + } + + auto result = std::make_unique(); + result->file = std::move(file); + result->inputReportLength = caps.InputReportByteLength; + result->path = path; + return result; +} + +std::unique_ptr WaitForNewGamepad( + const std::set& baseline, + USHORT vendorId, + USHORT productId, + std::chrono::seconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + std::unique_ptr match; + for (const auto& path : EnumerateHidPaths()) { + if (baseline.contains(path)) continue; + auto candidate = TryOpenGamepad(path, vendorId, productId); + if (!candidate) continue; + if (match) { + throw std::runtime_error( + "more than one new matching gamepad collection appeared; refusing an ambiguous latency measurement"); + } + match = std::move(candidate); + } + if (match) return match; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("the virtual HID gamepad collection did not appear before timeout"); +} + +std::uint32_t ParseUnsigned(const wchar_t* value, const wchar_t* name, std::uint32_t maximum) { + wchar_t* end = nullptr; + const unsigned long parsed = wcstoul(value, &end, 0); + if (value == end || *end != L'\0' || parsed > maximum) { + throw std::runtime_error("invalid " + WideToUtf8(name)); + } + return static_cast(parsed); +} + +int Measure( + const std::filesystem::path& snapshotPath, + USHORT vendorId, + USHORT productId, + std::size_t markerOffset, + std::size_t sampleCount) { + const auto baseline = ReadSnapshot(snapshotPath); + auto device = WaitForNewGamepad( + baseline, vendorId, productId, std::chrono::seconds(30)); + if (markerOffset >= device->inputReportLength) { + throw std::runtime_error("marker offset exceeds the HID input report length"); + } + + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event.valid()) throw std::runtime_error(Win32Error("CreateEvent")); + std::vector report(device->inputReportLength); + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + + LARGE_INTEGER frequency{}; + if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + throw std::runtime_error(Win32Error("QueryPerformanceFrequency")); + } + // Reports already buffered during PnP enumeration predate the producer's + // timestamp. Flush only this probe handle, then continuously use ReadFile + // as prescribed by HIDClass rather than polling HidD_GetInputReport. + if (!HidD_FlushQueue(device->file.get())) { + throw std::runtime_error(Win32Error("HidD_FlushQueue")); + } + std::cout << "READY " << frequency.QuadPart << " " + << device->inputReportLength << " " << WideToUtf8(device->path) << "\n"; + std::cout.flush(); + + std::size_t matches = 0; + int previousMarker = -1; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (matches < sampleCount && std::chrono::steady_clock::now() < deadline) { + ResetEvent(event.get()); + std::fill(report.begin(), report.end(), 0); + DWORD transferred = 0; + BOOL completed = ReadFile(device->file.get(), report.data(), + static_cast(report.size()), &transferred, &overlapped); + if (!completed) { + const DWORD error = GetLastError(); + if (error != ERROR_IO_PENDING) { + throw std::runtime_error(Win32Error("ReadFile(HID)")); + } + DWORD wait = WAIT_TIMEOUT; + while (wait == WAIT_TIMEOUT && std::chrono::steady_clock::now() < deadline) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + const DWORD waitMilliseconds = remaining.count() <= 0 + ? 0 + : static_cast(std::min(remaining.count(), 1000)); + wait = WaitForSingleObject(event.get(), waitMilliseconds); + } + if (wait == WAIT_TIMEOUT) { + CancelIoEx(device->file.get(), &overlapped); + break; + } + if (wait != WAIT_OBJECT_0 || + !GetOverlappedResult(device->file.get(), &overlapped, &transferred, FALSE)) { + throw std::runtime_error(Win32Error("GetOverlappedResult(HID)")); + } + } + if (transferred <= markerOffset) continue; + const int marker = report[markerOffset]; + if ((marker != 0xFD && marker != 0xFE) || marker == previousMarker) continue; + previousMarker = marker; + LARGE_INTEGER observed{}; + QueryPerformanceCounter(&observed); + std::cout << "MATCH " << marker << " " << observed.QuadPart << "\n"; + std::cout.flush(); + ++matches; + } + if (matches != sampleCount) { + CancelIoEx(device->file.get(), &overlapped); + throw std::runtime_error("timed out before observing every unique input marker"); + } + return 0; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + try { + if (argc == 3 && _wcsicmp(argv[1], L"snapshot") == 0) { + WriteSnapshot(argv[2]); + return 0; + } + if (argc == 8 && _wcsicmp(argv[1], L"measure") == 0) { + const auto vendorId = static_cast(ParseUnsigned(argv[3], L"vendor ID", 0xFFFF)); + const auto productId = static_cast(ParseUnsigned(argv[4], L"product ID", 0xFFFF)); + const auto offset = static_cast(ParseUnsigned(argv[5], L"marker offset", 4095)); + const auto samples = static_cast(ParseUnsigned(argv[6], L"sample count", 10000)); + // argv[7] is a versioned invocation token. Requiring it catches a + // stale helper copied from a different native ABI package. + if (wcscmp(argv[7], L"qpc-v1") != 0) { + throw std::runtime_error("unsupported latency probe contract"); + } + if (samples == 0) throw std::runtime_error("sample count must be nonzero"); + return Measure(argv[2], vendorId, productId, offset, samples); + } + std::wcerr << L"Usage:\n" + << L" ViiperUdeInputProbe.exe snapshot \n" + << L" ViiperUdeInputProbe.exe measure qpc-v1\n"; + return 2; + } catch (const std::exception& error) { + std::cerr << "VIIPER UDE input probe failed: " << error.what() << "\n"; + return 1; + } +} From fd83ecd4e69922f1c993047c4a6c7971b699e7ec Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:48:17 -0500 Subject: [PATCH 098/240] Fix strict HID latency probe build Use an explicitly typed uint8_t fill value so current MSVC /W4 /WX does not reject the HID input observer for implicit narrowing. This keeps the latency gate warning-clean rather than suppressing diagnostics. --- native/udecx/tools/ViiperUdeInputProbe.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/ViiperUdeInputProbe.cpp b/native/udecx/tools/ViiperUdeInputProbe.cpp index ed0432d5..e2c72b8d 100644 --- a/native/udecx/tools/ViiperUdeInputProbe.cpp +++ b/native/udecx/tools/ViiperUdeInputProbe.cpp @@ -269,7 +269,7 @@ int Measure( const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); while (matches < sampleCount && std::chrono::steady_clock::now() < deadline) { ResetEvent(event.get()); - std::fill(report.begin(), report.end(), 0); + std::fill(report.begin(), report.end(), std::uint8_t{0}); DWORD transferred = 0; BOOL completed = ReadFile(device->file.get(), report.data(), static_cast(report.size()), &transferred, &overlapped); From 1ea520bd992b0ca3681c369e6990e269c50e07ad Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 01:55:15 -0500 Subject: [PATCH 099/240] Isolate native input sequencing per endpoint Remove the controller-wide Host mutex from every direct input publication by retaining each endpoint generation's sequence in an atomic counter that survives only its purge/start publisher replacement. This prevents lifecycle, media, and unrelated controller work from adding tail latency to the 1 kHz input lane. Add a regression that holds the global routing mutex across a fresh state update and requires that report to reach the direct driver lane. Preserve existing D0 and failed-removal sequence continuity coverage. Document why UDE URB completion deliberately retains Microsoft's required separate DISPATCH-level DPC boundary, resolving the apparent conflict between the API reference table and the full UDE client-driver compatibility guidance. --- docs/architecture/native-udecx.md | 13 +++++ internal/transport/udecx/host.go | 29 +++++++---- internal/transport/udecx/host_test.go | 71 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 34381b8b..b15e1823 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -94,6 +94,18 @@ completed count can legitimately exceed the submitted count. Live validation requires both forward publication and a completed Windows poll; it does not invent a one-to-one relationship that USB interrupt polling does not have. +UDE URB completion deliberately keeps a separate DPC boundary. The individual +`UdecxUrbComplete` API reference currently lists `PASSIVE_LEVEL`, but +Microsoft's complete UDE client-driver guide is more specific: existing USB +drivers require completion at `DISPATCH_LEVEL`, a synchronously processed URB +must not be completed on its submitting thread, and cancellation must complete +on a separate DPC. usbip-win2 0.9.7.8 independently follows that same contract. +VIIPER therefore copies data and transfers ownership at PASSIVE level, then +uses one preallocated controller DPC to finish bounded pending slots. Direct +input also crosses that DPC-compatible completion boundary without allocating +per report. This is not optional scheduler padding; removing it would violate +the documented UDE compatibility contract. + Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before device removal. Removal rejected before UdeCx takes the child restores the @@ -374,6 +386,7 @@ validation contract is documented in ## Primary documentation - Microsoft, *Write a UDE client driver* + - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` - Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 7755455e..3c39f808 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" "github.com/Alia5/VIIPER/usb" @@ -62,7 +63,7 @@ type registeredDevice struct { publishers map[uint8]*inputPublisher activeInput map[uint8]bool resettingInput map[uint8]bool - inputSequences map[uint8]uint64 + inputSequences map[uint8]*atomic.Uint64 inD0 bool resetting bool powerSequence uint64 @@ -70,6 +71,7 @@ type registeredDevice struct { type inputPublisher struct { endpoint uint8 + sequence *atomic.Uint64 cancel context.CancelFunc done chan struct{} } @@ -221,7 +223,7 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D identity: identity, device: dev, ctx: deviceCtx, cancel: cancel, fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), activeInput: make(map[uint8]bool), resettingInput: make(map[uint8]bool), - inputSequences: make(map[uint8]uint64), inD0: true, + inputSequences: make(map[uint8]*atomic.Uint64), inD0: true, } h.devices[deviceID] = entry h.generations[deviceID] = generation @@ -367,8 +369,15 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { h.mu.Unlock() return } + sequence := entry.inputSequences[endpoint] + if sequence == nil { + sequence = &atomic.Uint64{} + entry.inputSequences[endpoint] = sequence + } ctx, cancel := context.WithCancel(entry.ctx) - publisher := &inputPublisher{endpoint: endpoint, cancel: cancel, done: make(chan struct{})} + publisher := &inputPublisher{ + endpoint: endpoint, sequence: sequence, cancel: cancel, done: make(chan struct{}), + } entry.publishers[endpoint] = publisher h.mu.Unlock() @@ -417,7 +426,6 @@ func (h *Host) activeInputEndpoints(entry *registeredDevice) []uint8 { func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { defer close(publisher.done) - var sequence uint64 for { payload := entry.device.HandleTransfer( ctx, uint32(publisher.endpoint&0x0f), usbip.DirIn, nil) @@ -430,13 +438,16 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p entry.identity.DeviceID, publisher.endpoint)) return } - h.mu.Lock() - sequence = entry.inputSequences[publisher.endpoint] + 1 + // The sequence is owned by this endpoint generation and survives only a + // purge/start publisher replacement. Keeping it in an atomic endpoint + // counter removes the controller-wide host mutex from the 1 kHz input + // path, so unrelated lifecycle/media work and other pads cannot add input + // tail latency. There is at most one publisher per endpoint, but atomic + // ownership also makes that invariant safe under restart transitions. + sequence := publisher.sequence.Add(1) if sequence == 0 { - sequence = 1 + sequence = publisher.sequence.Add(1) } - entry.inputSequences[publisher.endpoint] = sequence - h.mu.Unlock() if err := h.input.SubmitInputReport(ctx, InputReport{ DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, EndpointAddress: publisher.endpoint, Sequence: sequence, Payload: payload, diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index f959d664..9c1428b7 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -516,6 +516,77 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { } } +func TestHostInputPublisherDoesNotWaitForGlobalRoutingLock(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 441, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + deadline := time.Now().Add(time.Second) + for { + host.mu.RLock() + entry := host.devices[identity.DeviceID] + publisherReady := entry != nil && entry.publishers[0x81] != nil + host.mu.RUnlock() + if publisherReady { + break + } + if time.Now().After(deadline) { + t.Fatal("direct input publisher did not start") + } + time.Sleep(time.Millisecond) + } + + // Hold the host-wide routing lock exactly while a fresh state is published. + // A per-endpoint input sequence must still reach the direct driver lane; + // otherwise unrelated lifecycle/media work can stall every controller. + host.mu.Lock() + device.reports <- []byte{9, 8, 7, 6} + select { + case report := <-driver.reports: + host.mu.Unlock() + if report.Sequence != 1 || string(report.Payload) != string([]byte{9, 8, 7, 6}) { + t.Fatalf("unexpected lock-independent input report: %+v", report) + } + case <-time.After(250 * time.Millisecond): + host.mu.Unlock() + t.Fatal("direct input waited for the global host routing lock") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostRestoresInputPublisherAfterFailedTransactionalRemoval(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ From c8e2b7081f53f1805e65b1dc60fca9fd2883072e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:03:03 -0500 Subject: [PATCH 100/240] Make native input benchmarking transport-honest Allow the existing SDL end-to-end input benchmark to exercise the signed native UDE transport under the same API, controller, and observer workload used for USB/IP. Surface server exits, input timeouts, and stream failures instead of silently reporting invalid samples. Open the transport that each plain/encrypted benchmark actually labels, replace the accidental 10000-second release timeout, and add a non-CGO SDL type-check stub plus CI coverage so benchmark-only changes cannot rot outside normal package discovery. --- .github/workflows/native-ude.yml | 8 ++++ _testing/e2e/bench_test.go | 74 ++++++++++++++++++++++---------- _testing/e2e/sdl/sdl_nocgo.go | 40 +++++++++++++++++ docs/testing/e2e_latency.md | 11 ++++- 4 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 _testing/e2e/sdl/sdl_nocgo.go diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index f490e133..79ebb15e 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -10,6 +10,8 @@ on: - "internal/cmd/**" - "device/**" - "usb/**" + - "_testing/e2e/**" + - "docs/testing/e2e_latency.md" - ".github/workflows/native-ude.yml" pull_request: paths: @@ -19,6 +21,8 @@ on: - "internal/cmd/**" - "device/**" - "usb/**" + - "_testing/e2e/**" + - "docs/testing/e2e_latency.md" - ".github/workflows/native-ude.yml" workflow_dispatch: @@ -40,6 +44,10 @@ jobs: run: go test ./... - name: Vet complete VIIPER tree run: go vet ./... + - name: Type-check cross-transport end-to-end benchmark + env: + CGO_ENABLED: "0" + run: go test -run=^$ ./_testing/e2e - name: Fuzz native protocol decoders run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=20s ./internal/transport/udecx diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 83a2b66c..3d1842e2 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -2,9 +2,11 @@ package e2e_bench_test import ( "context" + "fmt" "log/slog" "os" "os/signal" + "strings" "syscall" "testing" "time" @@ -30,7 +32,26 @@ const ( TimeWhat_WaitRelease ) +const e2eTransportEnvironment = "VIIPER_E2E_TRANSPORT" + +func selectedE2ETransport() (string, error) { + transport := strings.ToLower(strings.TrimSpace(os.Getenv(e2eTransportEnvironment))) + if transport == "" { + return "usbip", nil + } + if transport != "usbip" && transport != "native-ude" { + return "", fmt.Errorf("%s must be usbip or native-ude, got %q", + e2eTransportEnvironment, transport) + } + return transport, nil +} + func Benchmark_Xbox360_Delay(b *testing.B) { + transport, err := selectedE2ETransport() + if err != nil { + b.Fatal(err) + } + b.Logf("VIIPER end-to-end transport: %s", transport) type bench struct { name string @@ -177,45 +198,42 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }, }, ConnectionTimeout: 5 * time.Second, + Transport: transport, } logger := slog.Default() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + serverDone := make(chan error, 1) go func() { - if err := s.StartServer(ctx, logger, nil); err != nil { - panic(err) - } + serverDone <- s.StartServer(ctx, logger, nil) }() - var c *viiperclient.Client - - c = viiperclient.New("localhost:3245") + client := viiperclient.New("localhost:3245") var busResp *viipertypes.BusCreateResponse - var err error + var createErr error for range 10 { - busResp, err = c.BusCreate(1) - if err == nil { + select { + case serverErr := <-serverDone: + b.Fatalf("VIIPER %s server stopped during startup: %v", transport, serverErr) + default: + } + busResp, createErr = client.BusCreate(1) + if createErr == nil { break } time.Sleep(time.Second * 1) } if busResp == nil { - b.Fatalf("BusCreate failed: %v", err) + b.Fatalf("BusCreate over %s failed: %v", transport, createErr) } busID := busResp.BusID - defer c.BusRemove(busID) + defer client.BusRemove(busID) //nolint:errcheck - devInfo, err := c.DeviceAdd(busID, "xbox360", nil) + devInfo, err := client.DeviceAdd(busID, "xbox360", nil) if err != nil { b.Fatalf("DeviceAdd failed: %v", err) } - devStream, err := c.OpenStream(ctx, busID, devInfo.DevID) - if err != nil { - b.Fatalf("OpenStream failed: %v", err) - } - defer devStream.Close() //nolint:errcheck - var gamepad *sdl.Gamepad for range 10 { sdl.UpdateGamepads() @@ -258,8 +276,13 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }() for _, bench := range benches { + benchClient := viiperclient.New("localhost:3245") if bench.useEncryption { - c = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") + benchClient = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") + } + devStream, openErr := benchClient.OpenStream(ctx, busID, devInfo.DevID) + if openErr != nil { + b.Fatalf("OpenStream for %s failed: %v", bench.name, openErr) } b.Run(bench.name, func(b *testing.B) { for b.Loop() { @@ -275,7 +298,9 @@ func Benchmark_Xbox360_Delay(b *testing.B) { timeout := time.After(1 * time.Second) bench.timeOn(TimeWhat_WaitInput, b) - waitForInput(ctx, timeout, padChann, true) + if err = waitForInput(ctx, timeout, padChann, true); err != nil { + b.Fatalf("wait for pressed input over %s: %v", transport, err) + } b.StopTimer() bench.timeOn(TimeWhat_ClientWriteRelease, b) @@ -284,13 +309,18 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("WriteBinary failed: %v", err) } - timeout = time.After(10000 * time.Second) + timeout = time.After(1 * time.Second) bench.timeOn(TimeWhat_WaitRelease, b) - waitForInput(ctx, timeout, padChann, false) + if err = waitForInput(ctx, timeout, padChann, false); err != nil { + b.Fatalf("wait for released input over %s: %v", transport, err) + } b.StartTimer() } }) + if closeErr := devStream.Close(); closeErr != nil { + b.Fatalf("Close stream for %s: %v", bench.name, closeErr) + } } } diff --git a/_testing/e2e/sdl/sdl_nocgo.go b/_testing/e2e/sdl/sdl_nocgo.go new file mode 100644 index 00000000..0e57a0fb --- /dev/null +++ b/_testing/e2e/sdl/sdl_nocgo.go @@ -0,0 +1,40 @@ +//go:build !cgo + +package sdl + +import "errors" + +// This stub keeps the end-to-end benchmark type-checked in ordinary +// CGO-disabled builds. The real benchmark still requires the vendored SDL3 +// development files and CGO; it fails explicitly instead of disappearing from +// compilation and allowing benchmark-only regressions to go unnoticed. + +type InitFlags uint32 + +const InitFlagGamepad InitFlags = 0x00002000 + +type GamepadID uint32 + +type GamepadButton int32 + +const GamepadButtonSouth GamepadButton = 0 + +type Gamepad struct{} + +func Init(InitFlags) error { + return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") +} + +func Quit() {} + +func UpdateGamepads() {} + +func GetGamepads() ([]GamepadID, error) { return nil, nil } + +func OpenGamepad(GamepadID) (*Gamepad, error) { + return nil, errors.New("SDL3 end-to-end benchmarks require CGO") +} + +func (*Gamepad) Close() {} + +func (*Gamepad) GetButton(GamepadButton) bool { return false } diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 5fc090af..e14472bf 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -1,6 +1,15 @@ # E2E Latency Benchmarks -The script `viiper/_testing/e2e/scripts/lat_bench.go` runs (or parses) end‑to‑end input latency benchmarks and produces enriched output (table, markdown, or JSON). +The script `viiper/_testing/e2e/scripts/lat_bench.go` runs (or parses) end‑to‑end input latency benchmarks and produces enriched output (table, markdown, or JSON). + +The benchmark defaults to the supported USB/IP transport. On a disposable +Windows test system with the exact Microsoft-signed native UDE package already +installed, set `VIIPER_E2E_TRANSPORT=native-ude` to run the identical API, +controller, SDL, press, and release workload through the native bus. The +benchmark never installs or trusts a driver. Invalid transport names, a server +that exits during startup, input timeouts, and stream failures fail the run; +they are not reported as latency samples. Plain and encrypted cases open their +own matching API stream, so their labels describe the path actually measured. It groups repeated cycles when `-count > 1` and uses the single press E2E measurement (`E2E-InputDelay`) as the 100% baseline. From 638ba39a541f52d1d3235f2dd2da52c140e08b51 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:10:55 -0500 Subject: [PATCH 101/240] Defer cached UDE input off the submitter thread Honor Microsoft's UdeCx completion contract by making the synchronous manual-queue ready callback enqueue a preallocated endpoint work item instead of completing an interrupt-IN URB inline. Represent a producer-before-poll rendezvous with one cached-delivery token. Consume one later Windows poll, leave its successor parked for the next physical publication, and avoid an unbounded cache replay loop or per-poll allocation. Close the lifecycle race in which an already-admitted producer could republish cached input after reset, purge, or D0 invalidation. Revalidate at operation completion and clear again behind reset/purge drain barriers. --- docs/architecture/native-udecx.md | 37 ++++++---- native/udecx/README.md | 18 +++-- native/udecx/driver/Device.c | 110 ++++++++++++++++++++++++------ native/udecx/driver/ViiperUde.h | 3 + 4 files changed, 127 insertions(+), 41 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index b15e1823..cf1b20a5 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -77,9 +77,11 @@ The transport is intentionally split by USB semantics: `SUBMIT_INPUT_REPORT` call atomically replaces the endpoint's preallocated latest-state cache and completes a waiting URB without an allocation or broker round trip. If the state arrives first, KMDF's manual-queue ready - notification completes the later poll from that cache. Input timing is - therefore host-poll-driven rather than dependent on a second physical - report arriving after the poll; + notification schedules one preallocated passive work item, which completes + one later poll from that cache. The ready callback itself never completes an + URB because KMDF can invoke it synchronously on UdeCx's submitter thread. + Input timing is therefore host-poll-driven rather than dependent on a second + physical report arriving after the poll; - control, interrupt-OUT, isochronous speaker/microphone/haptics, feedback, and every lifecycle transition use the cancel-safe ordered inverted-call broker. VIIPER posts multiple `DEQUEUE_OPERATION` requests, processes each immutable @@ -89,10 +91,11 @@ The transport is intentionally split by USB semantics: The input counters intentionally measure opposite sides of that cache: `InputReportsSubmitted` counts accepted latest-state publications, while `InputReportsCompleted` counts Windows interrupt-IN polls completed from the -cache. A host may poll the same stable controller state more than once, so the -completed count can legitimately exceed the submitted count. Live validation -requires both forward publication and a completed Windows poll; it does not -invent a one-to-one relationship that USB interrupt polling does not have. +cache. Several publications can coalesce before a Windows poll, so completion +can trail submission. A one-shot cached-delivery token ensures a publication +cannot replay itself into multiple successor polls. Live validation requires +both forward publication and a completed Windows poll without inventing a +strict one-to-one relationship. UDE URB completion deliberately keeps a separate DPC boundary. The individual `UdecxUrbComplete` API reference currently lists `PASSIVE_LEVEL`, but @@ -101,10 +104,13 @@ drivers require completion at `DISPATCH_LEVEL`, a synchronously processed URB must not be completed on its submitting thread, and cancellation must complete on a separate DPC. usbip-win2 0.9.7.8 independently follows that same contract. VIIPER therefore copies data and transfers ownership at PASSIVE level, then -uses one preallocated controller DPC to finish bounded pending slots. Direct -input also crosses that DPC-compatible completion boundary without allocating -per report. This is not optional scheduler padding; removing it would violate -the documented UDE compatibility contract. +uses one preallocated controller DPC to finish bounded broker slots. Direct +producer input arrives on a separate user I/O path and completes at +`DISPATCH_LEVEL` without allocating per report. A late cached poll first crosses +a preallocated endpoint work-item boundary before the same DISPATCH-level +completion, because `WdfIoQueueReadyNotify` is permitted to run inline on the +UdeCx submitter thread. This is not optional scheduler padding; removing either +boundary would violate the documented UDE compatibility contract. Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before @@ -281,10 +287,11 @@ interface fields are only hints for alternates that contain no endpoints. span. Chained or short mappings fall through to a bounded MDL-chain walk; the driver never treats the URB length as permission to overrun one mapping. - Interrupt-IN queues are manual and completed from a generation-owned, - sequence-checked latest-state cache. The queue-ready callback snapshots the - number of already-waiting polls before it completes any of them, preventing - a synchronously replenished Windows poll from turning into a kernel drain - loop. Endpoint purge/reset and device reset/D0 exit invalidate the cache + sequence-checked latest-state cache. The queue-ready callback only enqueues a + preallocated work item; that separate execution boundary consumes one cached + delivery token and one poll. A synchronously replenished Windows poll is left + parked for the next producer instead of becoming a kernel replay loop. + Endpoint purge/reset and device reset/D0 exit invalidate the cache and token after closing admission, so no held button can cross a lifecycle boundary. Output and media endpoints retain independent ordered queues. - A direct input report that was already submitted when D0 exit, device reset, diff --git a/native/udecx/README.md b/native/udecx/README.md index cad4cf1a..5b1a251e 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -43,13 +43,19 @@ Directory contract: The interrupt-IN path follows ViGEmBus's useful pending-read principle without copying its target-specific implementation. Each endpoint owns a preallocated, sequence-checked latest-state cache. A report arriving before a Windows poll is -retained and completed by KMDF's manual-queue ready notification; reset, purge, -D0 exit, and device reset invalidate it behind the same admission barriers used -by the direct producer. This removes the old lost-rendezvous window and never -requires an extra feeder update to wake an already-posted host poll. +retained and completed after KMDF's manual-queue ready notification crosses a +preallocated passive work-item boundary; the notification itself can run +synchronously on UdeCx's submitter thread and therefore never completes the URB. +One token permits exactly one later cached completion, so the successor poll is +left parked for the next producer instead of replaying the cache in a busy loop. +Reset, purge, D0 exit, and device reset invalidate both the cache and token +behind the same admission barriers used by the direct producer. This removes +the old lost-rendezvous window and never requires an extra feeder update to wake +the first already-posted host poll. `InputReportsSubmitted` counts accepted state publications and -`InputReportsCompleted` counts host polls served from that cache, so a stable -state may produce more completions than submissions by design. +`InputReportsCompleted` counts host polls served from them. Multiple publications +can coalesce into one latest state before Windows polls, but one publication can +never manufacture multiple completions. The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 6a41ca4d..4d108789 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -675,6 +675,40 @@ ViiperEvtVirtualDeviceCleanup( } } +static +VOID +ViiperInvalidateEndpointInputReport( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + + InterlockedExchange(&endpointContext->InputReportValid, FALSE); + InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); +} + +static +VOID +ViiperInvalidateInputIfLifecycleClosed( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + ViiperInvalidateEndpointInputReport(Endpoint); + } + WdfSpinLockRelease(controllerContext->BrokerLock); +} + static VOID ViiperInvalidateDeviceInputReports( @@ -698,8 +732,7 @@ ViiperInvalidateDeviceInputReports( } WdfWaitLockRelease(controllerContext->DeviceLock); if (endpoint != WDF_NO_HANDLE) { - InterlockedExchange( - &ViiperGetEndpointContext(endpoint)->InputReportValid, FALSE); + ViiperInvalidateEndpointInputReport(endpoint); WdfObjectDereference(endpoint); } } @@ -968,6 +1001,14 @@ ViiperEvtEndpointAdd( if (!NT_SUCCESS(status)) { return status; } + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtFastInputWorkItem); + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->InputReadyWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } } else { dispatchType = WdfIoQueueDispatchParallel; } @@ -1094,23 +1135,32 @@ ViiperEvtFastInputQueueReady( { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + + UNREFERENCED_PARAMETER(Queue); + PAGED_CODE(); + // WdfIoQueueReadyNotify is allowed to invoke this callback synchronously + // on UdeCx's URB submitter thread, including before registration returns. + // A cached poll must therefore cross a real execution boundary before it + // is retrieved and completed. KMDF 1.7+ safely coalesces repeated enqueue + // calls for one reusable work item while it is already queued. + WdfWorkItemEnqueue(endpointContext->InputReadyWorkItem); +} + +VOID +ViiperEvtFastInputWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBENDPOINT endpoint = + (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request = WDF_NO_HANDLE; - ULONG requestCount = 0; - ULONG index; BOOLEAN admitted = FALSE; PAGED_CODE(); - // Snapshot only the polls which caused this ready transition. Completing - // an URB can synchronously cause the USB stack to post its successor; an - // unbounded drain loop would therefore become a kernel busy loop. - (VOID)WdfIoQueueGetState(Queue, &requestCount, NULL); - if (requestCount == 0) { - return; - } - WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && @@ -1119,7 +1169,8 @@ ViiperEvtFastInputQueueReady( InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && - InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0) { + InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->CachedDeliveryPending, 0, 0) != 0) { ViiperEndpointOperationStarted(endpoint); admitted = TRUE; } @@ -1129,13 +1180,16 @@ ViiperEvtFastInputQueueReady( return; } - for (index = 0; index < requestCount; ++index) { - if (!NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { - break; - } + // One cached-delivery token represents one accepted publication which + // arrived before its Windows poll. Consume exactly one parked request. + // Completing it can cause HIDClass to post a successor; leaving that poll + // parked prevents a cache replay loop and lets the next producer update + // complete it on the allocation-free direct path. + if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &request))) { + InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); (VOID)ViiperCompleteCachedInputUrb(endpoint, request); - request = WDF_NO_HANDLE; } + ViiperInvalidateInputIfLifecycleClosed(endpoint); ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); } @@ -1287,6 +1341,10 @@ ViiperSubmitInputReport( InterlockedIncrement64(&controllerContext->InputReportsSubmitted); status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); if (!NT_SUCCESS(status)) { + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + status == STATUS_NO_MORE_ENTRIES ? TRUE : FALSE); + ViiperInvalidateInputIfLifecycleClosed(endpoint); ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); @@ -1294,7 +1352,13 @@ ViiperSubmitInputReport( // the next Windows poll even if the physical feeder becomes idle. return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; } + InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); status = ViiperCompleteCachedInputUrb(endpoint, urbRequest); + // Lifecycle admission can close after this operation was admitted. The + // pre-boundary poll may finish, but its cached state must never survive the + // reset/purge/D0 boundary. Revalidate under the same admission lock so + // either this path or the lifecycle callback performs the final clear. + ViiperInvalidateInputIfLifecycleClosed(endpoint); ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); @@ -1329,7 +1393,7 @@ ViiperEvtEndpointReset( } InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); - InterlockedExchange(&endpointContext->InputReportValid, FALSE); + ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); endpointContext->ResetRequest = Request; // A forwarded broker operation or direct input copy may have won @@ -1357,6 +1421,9 @@ ViiperEvtEndpointResetWorkItem( KernelMode, FALSE, NULL); + // An input publisher admitted immediately before Resetting was raised is + // allowed to finish, then this barrier performs the final invalidation. + ViiperInvalidateEndpointInputReport(endpoint); request = endpointContext->ResetRequest; endpointContext->ResetRequest = WDF_NO_HANDLE; if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { @@ -1390,6 +1457,9 @@ ViiperEvtEndpointPurgeWorkItem( KernelMode, FALSE, NULL); + // The admission barrier is closed and all pre-boundary publishers have + // drained, so no cached state can be republished after this clear. + ViiperInvalidateEndpointInputReport(endpoint); UdecxUsbEndpointPurgeComplete(endpoint); } @@ -1411,7 +1481,7 @@ ViiperEvtEndpointPurge( InterlockedExchange(&endpointContext->Purging, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); - InterlockedExchange(&endpointContext->InputReportValid, FALSE); + ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); // UdeCx owns the state of the endpoint queue. We only drain requests that diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 672ae7a5..d35ccfdc 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -175,6 +175,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; WDFWAITLOCK InputLock; + WDFWORKITEM InputReadyWorkItem; WDFWORKITEM PurgeWorkItem; WDFWORKITEM ResetWorkItem; WDFREQUEST ResetRequest; @@ -186,6 +187,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { volatile LONG64 LastInputSequence; volatile LONG64 NextIsoStartFrame; volatile LONG InputReportValid; + volatile LONG CachedDeliveryPending; ULONG InputReportLength; UCHAR InputReport[VIIPER_UDE_MAX_INPUT_REPORT_BYTES]; ULONGLONG NextAdmissionSequence; @@ -218,6 +220,7 @@ EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; +EVT_WDF_WORKITEM ViiperEvtFastInputWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; From be6a94ab664f24507a9795380dd060f27dc31e49 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:21:29 -0500 Subject: [PATCH 102/240] Make transport latency observation event driven Replace the SDL controller-state busy spin with exact gamepad button transition waits. This removes a synthetic saturated CPU core and its scheduler contention from native UDE versus USB/IP measurements while preserving the same press/release contract and one-second failure bound. --- _testing/e2e/bench_test.go | 51 ++++++++----------------------- _testing/e2e/sdl/gamepad.go | 56 +++++++++++++++++++++++++++++++++++ _testing/e2e/sdl/sdl_nocgo.go | 7 ++++- docs/testing/e2e_latency.md | 14 ++++++--- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 3d1842e2..c036e243 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -172,7 +172,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { b.SetParallelism(1) defer sdl.Quit() - if err := sdl.Init(sdl.InitFlagGamepad); err != nil { + if err := sdl.Init(sdl.InitFlagGamepad | sdl.InitFlagEvents); err != nil { b.Fatalf("SDL init failed: %v", err) } @@ -256,25 +256,6 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if gamepad == nil { b.Fatalf("No new gamepad found for testing (expected VIIPER virtual device)") } - padChann := make(chan bool) - prevPadPressed := false - go func() { - defer close(padChann) - for { - select { - case <-ctx.Done(): - return - default: - } - sdl.UpdateGamepads() - pressed := gamepad.GetButton(sdl.GamepadButtonSouth) - if pressed != prevPadPressed { - padChann <- pressed - prevPadPressed = pressed - } - } - }() - for _, bench := range benches { benchClient := viiperclient.New("localhost:3245") if bench.useEncryption { @@ -295,10 +276,8 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("WriteBinary failed: %v", err) } - timeout := time.After(1 * time.Second) - bench.timeOn(TimeWhat_WaitInput, b) - if err = waitForInput(ctx, timeout, padChann, true); err != nil { + if err = waitForInput(ctx, gamepad, true); err != nil { b.Fatalf("wait for pressed input over %s: %v", transport, err) } @@ -309,9 +288,8 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("WriteBinary failed: %v", err) } - timeout = time.After(1 * time.Second) bench.timeOn(TimeWhat_WaitRelease, b) - if err = waitForInput(ctx, timeout, padChann, false); err != nil { + if err = waitForInput(ctx, gamepad, false); err != nil { b.Fatalf("wait for released input over %s: %v", transport, err) } @@ -324,20 +302,15 @@ func Benchmark_Xbox360_Delay(b *testing.B) { } } -func waitForInput(ctx context.Context, timeout <-chan time.Time, padChann <-chan bool, wantPressed bool) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - return context.DeadlineExceeded - case pressed, ok := <-padChann: - if !ok { - return context.Canceled - } - if pressed == wantPressed { - return nil - } +func waitForInput(ctx context.Context, gamepad *sdl.Gamepad, wantPressed bool) error { + if err := ctx.Err(); err != nil { + return err + } + if !gamepad.WaitButtonEvent(sdl.GamepadButtonSouth, wantPressed, 1000) { + if err := ctx.Err(); err != nil { + return err } + return context.DeadlineExceeded } + return nil } diff --git a/_testing/e2e/sdl/gamepad.go b/_testing/e2e/sdl/gamepad.go index 893412da..39436a43 100644 --- a/_testing/e2e/sdl/gamepad.go +++ b/_testing/e2e/sdl/gamepad.go @@ -5,9 +5,44 @@ package sdl #include +#include +#include #include #include +static inline int wait_gamepad_button_event( + SDL_JoystickID which, + SDL_GamepadButton button, + bool down, + Sint32 timeout_ms) +{ + Uint64 deadline = timeout_ms < 0 ? 0 : SDL_GetTicks() + (Uint64)timeout_ms; + + for (;;) { + Sint32 remaining = timeout_ms; + if (timeout_ms >= 0) { + Uint64 now = SDL_GetTicks(); + if (now >= deadline) { + return 0; + } + Uint64 delta = deadline - now; + remaining = delta > 0x7fffffffULL ? 0x7fffffff : (Sint32)delta; + } + + SDL_Event event; + if (!SDL_WaitEventTimeout(&event, remaining)) { + return 0; + } + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button && + event.gbutton.down == down) { + return 1; + } + } +} + static inline int gamepad_binding_input_button(const SDL_GamepadBinding *b) { return b->input.button; @@ -341,6 +376,27 @@ func (g *Gamepad) GetButton(button GamepadButton) bool { return bool(C.SDL_GetGamepadButton(g.cGamepad, C.SDL_GamepadButton(button))) } +// WaitButtonEvent waits for an exact transition from this gamepad without +// polling SDL in a busy loop. SDL's event wait pumps the gamepad event queue +// and returns the transition generated by Windows, so benchmark CPU and +// scheduler-tail measurements are not contaminated by a synthetic observer +// consuming an entire core. +// +// SDL requires event waits to run on the thread which initialized the event +// subsystem. The e2e benchmark calls this method directly from its locked main +// test thread. +func (g *Gamepad) WaitButtonEvent(button GamepadButton, down bool, timeoutMS int32) bool { + if g == nil || g.cGamepad == nil { + return false + } + return C.wait_gamepad_button_event( + C.SDL_GetGamepadID(g.cGamepad), + C.SDL_GamepadButton(button), + C.bool(down), + C.Sint32(timeoutMS), + ) != 0 +} + // GetButtonLabel gets the label of a button on a gamepad. func (g *Gamepad) GetButtonLabel(button GamepadButton) GamepadButtonLabel { if g == nil || g.cGamepad == nil { diff --git a/_testing/e2e/sdl/sdl_nocgo.go b/_testing/e2e/sdl/sdl_nocgo.go index 0e57a0fb..69470fa2 100644 --- a/_testing/e2e/sdl/sdl_nocgo.go +++ b/_testing/e2e/sdl/sdl_nocgo.go @@ -11,7 +11,10 @@ import "errors" type InitFlags uint32 -const InitFlagGamepad InitFlags = 0x00002000 +const ( + InitFlagGamepad InitFlags = 0x00002000 + InitFlagEvents InitFlags = 0x00004000 +) type GamepadID uint32 @@ -38,3 +41,5 @@ func OpenGamepad(GamepadID) (*Gamepad, error) { func (*Gamepad) Close() {} func (*Gamepad) GetButton(GamepadButton) bool { return false } + +func (*Gamepad) WaitButtonEvent(GamepadButton, bool, int32) bool { return false } diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index e14472bf..4cf722da 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -28,9 +28,14 @@ It groups repeated cycles when `-count > 1` and uses the single press E2E measur ## Scope / Methodology -- All benchmarks included here are executed against a VIIPER server on the same host (localhost). - They therefore measure in-process client emission plus local USBIP stack + emulated device processing only. - Remote/network USBIP attachment will add network RTT and jitter which is intentionally excluded from these baseline figures. +- All benchmarks included here are executed against a VIIPER server on the same host (localhost). + They therefore measure in-process client emission plus the selected local + transport and emulated-device processing. Remote/network USB/IP attachment + adds network RTT and jitter and is intentionally excluded from these + baseline figures. +- The Windows observer waits on SDL gamepad transition events. It does not + busy-poll controller state, so the harness does not consume a synthetic CPU + core or add that contention to transport tail latency. - Benchmarks use a single emulated Xbox360 controller device. Other devices might produce slightly different results depending on USB report size and VIIPER-InputState size. - Benchmarks use a single button press, which is enough as clients/VIIPER always produce a full report of the devices state. @@ -75,5 +80,6 @@ Use a larger `-count` if you want to increase the number of runs. - Memory statistics from Go benchmarks are intentionally omitted. - `% of Full` falls back to the largest ns/op if the baseline row is missing. - All benchmarking must run with parallelism 1 in underlying benches. -- Benchmarks use a tight polling loop using SDL3 to detect input state changes on the emulated device. +- Benchmarks use SDL3 gamepad transition events to detect input changes on the + emulated device without a measurement-side polling loop. - Benchmarks must be run without an already running VIIPER server instance. From 54e5e9bafda54cc0ebc1eaa4e84afefb8cb74421 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:22:51 -0500 Subject: [PATCH 103/240] Capture native UDE scheduler evidence with WPR Wrap the Microsoft-signed live controller, HID, and media validation in a uniquely named GeneralProfile.Light memory trace. Preserve the ETL on workload failure, never stop foreign WPR instances, and document the CPU precise, ready-thread, WDF DPC, interrupt, and ISR evidence required before scheduler changes. --- docs/architecture/native-udecx.md | 9 ++ native/udecx/README.md | 22 +++ .../Invoke-ViiperUdePerformanceValidation.ps1 | 132 ++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index cf1b20a5..bbc5cc31 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -373,6 +373,11 @@ stall an independent pad's registration or removal. - DualSense and DualShock 4 media survive concurrent state and feedback traffic. - Native latency and CPU are measured against the current USB/IP path and ViGEmBus-style virtual input under the same workload. +- Product changes to scheduling, thread priority, DPC behavior, or queue depth + require a named, bounded-memory WPR capture of the signed live gate. CPU + sampled/precise, ready-thread, context-switch, WDF DPC, interrupt, and ISR + evidence must identify the actual critical path; a polling benchmark or Task + Manager percentage alone is not a valid basis for such a change. - Signed live input validation discovers the exact newly created HID gamepad, continuously reads reports through HIDClass, and correlates 256 unique publication markers with cross-process QPC timestamps. DualShock 4, @@ -404,3 +409,7 @@ validation contract is documented in - Microsoft, *Acquiring high-resolution time stamps* +- Microsoft, *WPR Command-Line Options* + +- Microsoft, *CPU Analysis* + diff --git a/native/udecx/README.md b/native/udecx/README.md index 5b1a251e..d3f95569 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -21,6 +21,12 @@ Directory contract: production controller through the real UdeCx host, direct interrupt-input path, generation teardown, and driver fault counters. It never installs or changes a driver. +- `tools/Invoke-ViiperUdePerformanceValidation.ps1` wraps that exact signed + live gate in a uniquely named, bounded-memory Windows Performance Recorder + session. It preserves an ETL on success or workload failure without stopping + another recorder instance, enabling CPU sampled/precise, ready-thread, + context-switch, WDF DPC, interrupt, and ISR analysis before performance code + is changed. - `tools/Enable-ViiperUdeVerifierForNextBoot.ps1` stages Microsoft standard Driver Verifier checks for `ViiperUde.sys` for exactly one boot. It refuses daily-use machines unless the disposable-machine acknowledgement is given, @@ -130,3 +136,19 @@ The Driver Verifier pass is a separate, explicit disposable-machine gate: Microsoft warns that Driver Verifier can intentionally bugcheck a machine; this workflow is never run by ordinary CI, an installer, or DS4Windows. + +For evidence-based CPU and scheduler analysis, run the same signed workload +inside WPR's bounded `GeneralProfile.Light` memory profile: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdePerformanceValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -OutputPath C:\ViiperUde\Traces\native-ude.etl ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe +``` + +Open the ETL in Windows Performance Analyzer and inspect CPU Usage (Sampled), +CPU Usage (Precise), and DPC/ISR by module and stack. The script never uses +WPR file mode, which Microsoft documents as unbounded, and never mutates an +unnamed or foreign recording session. diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 new file mode 100644 index 00000000..a70d02a1 --- /dev/null +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -0,0 +1,132 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [ValidateRange(1, 1000)] + [int]$Iterations = 10, + + [string]$MediaProbePath, + + [string]$InputProbePath, + + [switch]$RequireDriverVerifier, + + [switch]$RestartRootDevice, + + [switch]$DisposableTestMachine +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdministrator)) { + throw 'Native UDE performance validation requires an elevated PowerShell session.' +} + +$wprPath = Join-Path $env:SystemRoot 'System32\wpr.exe' +if (-not (Test-Path -LiteralPath $wprPath -PathType Leaf)) { + throw "Windows Performance Recorder was not found at '$wprPath'." +} + +$validationPath = Join-Path $PSScriptRoot 'Invoke-ViiperUdeLiveValidation.ps1' +if (-not (Test-Path -LiteralPath $validationPath -PathType Leaf)) { + throw "The signed live-validation script was not found at '$validationPath'." +} + +$resolvedOutput = [IO.Path]::GetFullPath($OutputPath) +if (Test-Path -LiteralPath $resolvedOutput) { + throw "Refusing to overwrite the existing trace '$resolvedOutput'." +} +$outputDirectory = Split-Path -Parent $resolvedOutput +if ([string]::IsNullOrWhiteSpace($outputDirectory)) { + throw 'The trace output path must include a parent directory.' +} +[void][IO.Directory]::CreateDirectory($outputDirectory) + +# A unique instance name is the ownership boundary. Every WPR mutation below +# carries it as the final argument, as required by WPR, so this gate can never +# stop or cancel an unrelated recording on the test machine. +$instanceName = 'ViiperUdePerf_{0}_{1}' -f $PID, [Guid]::NewGuid().ToString('N') +$profile = 'GeneralProfile.Light' +$started = $false +$validationFailure = $null + +$validationArguments = @{ + SignedPackageDirectory = $SignedPackageDirectory + Iterations = $Iterations +} +if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { + $validationArguments.MediaProbePath = $MediaProbePath +} +if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { + $validationArguments.InputProbePath = $InputProbePath +} +if ($RequireDriverVerifier) { + $validationArguments.RequireDriverVerifier = $true +} +if ($RestartRootDevice) { + $validationArguments.RestartRootDevice = $true +} +if ($DisposableTestMachine) { + $validationArguments.DisposableTestMachine = $true +} + +try { + $startOutput = & $wprPath -start $profile -instancename $instanceName 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "WPR failed to start '$profile' (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" + } + $started = $true + + try { + & $validationPath @validationArguments + } + catch { + $validationFailure = $_ + } +} +finally { + if ($started) { + # Stop, rather than cancel, after a workload failure. The trace is most + # valuable when a latency or lifecycle gate failed. GeneralProfile is + # intentionally left in its bounded default memory mode; file mode is + # never enabled by this script. + $stopOutput = & $wprPath -stop $resolvedOutput -instancename $instanceName 2>&1 + $stopExitCode = $LASTEXITCODE + if ($stopExitCode -ne 0) { + if ($null -ne $validationFailure) { + throw [AggregateException]::new( + 'Native UDE validation and WPR trace finalization both failed.', + @( + $validationFailure.Exception, + [InvalidOperationException]::new( + "WPR stop failed with exit $stopExitCode. $($stopOutput -join ' ')") + )) + } + throw "WPR failed to save '$resolvedOutput' (exit $stopExitCode).`n$($stopOutput -join [Environment]::NewLine)" + } + } +} + +if (-not (Test-Path -LiteralPath $resolvedOutput -PathType Leaf) -or + (Get-Item -LiteralPath $resolvedOutput).Length -eq 0) { + throw "WPR reported success but did not create a non-empty trace at '$resolvedOutput'." +} + +if ($null -ne $validationFailure) { + throw [InvalidOperationException]::new( + "Native UDE live validation failed; the diagnostic trace was preserved at '$resolvedOutput'.", + $validationFailure.Exception) +} + +Write-Host "Native UDE performance validation passed. Trace: '$resolvedOutput'." From 9517179979f5b215b9e90fa9304db329ade1c0e3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:29:43 -0500 Subject: [PATCH 104/240] Eliminate native controller input heap churn Add an optional caller-buffer interrupt-IN contract and let each native endpoint publisher retain exactly one descriptor-sized report buffer. DualSense, DualShock 4, and Xbox 360 now encode directly into that buffer after input state is snapshotted, while USB/IP and unconverted devices retain HandleTransfer ownership unchanged. Serialize only DualSense report encoding so HID GET_REPORT cannot race the realtime publisher's sequence and corruption counters. Prove report encoders allocate zero heap objects, verify buffer reuse and sequence continuity through the native host, and extend CI race coverage to the production controller encoders. --- .github/workflows/native-ude.yml | 10 ++- device/dualsense/device.go | 48 +++++++++++++- device/dualsense/native_input_test.go | 27 ++++++++ device/dualshock4/device.go | 38 ++++++++++- device/dualshock4/native_input_test.go | 27 ++++++++ device/xbox360/device.go | 19 ++++++ device/xbox360/inputstate.go | 13 +++- device/xbox360/native_input_test.go | 22 +++++++ docs/architecture/native-udecx.md | 4 ++ internal/transport/udecx/host.go | 63 ++++++++++++++---- internal/transport/udecx/host_test.go | 88 ++++++++++++++++++++++++++ native/udecx/README.md | 6 ++ usb/device.go | 14 ++++ 13 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 device/dualsense/native_input_test.go create mode 100644 device/dualshock4/native_input_test.go create mode 100644 device/xbox360/native_input_test.go diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 79ebb15e..a77be6be 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -59,8 +59,14 @@ jobs: with: go-version: "1.26.5" cache: true - - name: Race-test native host and USB processor - run: go test -race -count=5 ./internal/transport/udecx ./internal/server/usb + - name: Race-test native host, USB processor, and realtime controller encoders + run: >- + go test -race -count=5 + ./internal/transport/udecx + ./internal/server/usb + ./device/dualsense + ./device/dualshock4 + ./device/xbox360 driver: runs-on: windows-2025-vs2026 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d1ffc259..b1f79547 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "math" "net" @@ -96,7 +97,8 @@ type DualSense struct { hapticsPCMStartedAt time.Time timestampBase time.Time - mtx sync.Mutex + inputReportMu sync.Mutex + mtx sync.Mutex } func New(o *device.CreateOptions) (*DualSense, error) { @@ -435,6 +437,31 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the native UDE +// fast path. The caller owns dst and reuses it only after SubmitInputReport has +// completed, so encoding here removes the per-sample report allocation without +// changing USB/IP behavior. +func (d *DualSense) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointIn&0x0f { + return 0, fmt.Errorf("DualSense interrupt-IN endpoint %d is unsupported", ep) + } + var is InputState + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + d.mtx.Lock() + is = d.inputState + d.mtx.Unlock() + case is = <-d.inputCh: + } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReportInto(&is, &ms, dst) +} + func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { if len(frame) != USBMicrophoneClientFrameSize { return @@ -1096,6 +1123,23 @@ func (d *DualSense) featureReportCommandResponse() []byte { func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = d.buildUSBInputReportInto(s, m, b) + return b +} + +func (d *DualSense) buildUSBInputReportInto(s *InputState, m *MetaState, dst []byte) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) + + // HID GET_REPORT and the native interrupt publisher can encode + // concurrently. Sequence and corruption telemetry are one ordered report + // stream, so serialize only encoding rather than the controller state or + // media paths. + d.inputReportMu.Lock() + defer d.inputReportMu.Unlock() b[0] = ReportIDInput b[1] = uint8(int16(s.LX) + 128) @@ -1173,7 +1217,7 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery) } - return b + return InputReportSize, nil } func inputStateControlsInvalid(s *InputState) bool { diff --git a/device/dualsense/native_input_test.go b/device/dualsense/native_input_test.go new file mode 100644 index 00000000..88185188 --- /dev/null +++ b/device/dualsense/native_input_test.go @@ -0,0 +1,27 @@ +package dualsense + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + meta := &MetaState{BatteryStatus: BatteryFullyCharged} + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.buildUSBInputReportInto(state, meta, buffer) + if encodeErr != nil || written != InputReportSize { + panic("DualSense native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.buildUSBInputReportInto(state, meta, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index abe79a54..2287a4f1 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "sync" "sync/atomic" @@ -337,6 +338,31 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. It +// writes the controller's next HID sample into caller-owned storage; USB/IP +// continues to use HandleTransfer and its independently owned report slice. +func (d *DualShock4) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointIn&0x0f { + return 0, fmt.Errorf("DualShock 4 interrupt-IN endpoint %d is unsupported", ep) + } + var is InputState + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + d.mtx.Lock() + is = *d.inputState + d.mtx.Unlock() + case next := <-d.inputCh: + is = *next + } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReportInto(&is, &ms, dst) +} + func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { if len(frame) != USBMicrophoneClientFrameSize { return @@ -734,6 +760,16 @@ func (d *DualShock4) buildCalibrationReport(id byte) []byte { func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = d.buildUSBInputReportInto(s, m, b) + return b +} + +func (d *DualShock4) buildUSBInputReportInto(s *InputState, m *MetaState, dst []byte) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDInput @@ -809,7 +845,7 @@ func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[39] = touch2Counter encodeTouchCoords(b[40:43], s.Touch2X, s.Touch2Y) - return b + return InputReportSize, nil } func (d *DualShock4) nextReportTimestamp() uint32 { diff --git a/device/dualshock4/native_input_test.go b/device/dualshock4/native_input_test.go new file mode 100644 index 00000000..26108ca2 --- /dev/null +++ b/device/dualshock4/native_input_test.go @@ -0,0 +1,27 @@ +package dualshock4 + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + meta := &MetaState{BatteryStatus: DefaultBatteryStatus} + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.buildUSBInputReportInto(state, meta, buffer) + if encodeErr != nil || written != InputReportSize { + panic("DualShock 4 native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.buildUSBInputReportInto(state, meta, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/xbox360/device.go b/device/xbox360/device.go index a12acc43..42f10345 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -126,6 +126,25 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the native UDE +// input lane without changing the USB/IP report ownership contract. +func (x *Xbox360) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep != 1 { + return 0, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) + } + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + case <-x.inputSignal: + } + x.inputMu.RLock() + st := x.inputState + x.inputMu.RUnlock() + return st.BuildReportInto(dst) +} + func (x *Xbox360) emitRumble(rumble XRumbleState) { x.rumbleDispatchMu.Lock() defer x.rumbleDispatchMu.Unlock() diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 1a233c54..bb34f236 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -56,6 +56,17 @@ type GuitarHeroDrumsInputState struct { // 14-19: Reserved / zero func (x *InputState) BuildReport() []byte { b := make([]byte, 20) + _, _ = x.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the wired input report into caller-owned storage. +func (x *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 20 { + return 0, io.ErrShortBuffer + } + b := dst[:20] + clear(b) b[0] = 0x00 b[1] = 0x14 binary.LittleEndian.PutUint16(b[2:4], uint16(x.Buttons&0xffff)) @@ -66,7 +77,7 @@ func (x *InputState) BuildReport() []byte { binary.LittleEndian.PutUint16(b[10:12], uint16(x.RX)) binary.LittleEndian.PutUint16(b[12:14], uint16(x.RY)) copy(b[14:20], x.Reserved[:]) - return b + return 20, nil } // MarshalBinary encodes InputState to 20 bytes. diff --git a/device/xbox360/native_input_test.go b/device/xbox360/native_input_test.go new file mode 100644 index 00000000..b8688660 --- /dev/null +++ b/device/xbox360/native_input_test.go @@ -0,0 +1,22 @@ +package xbox360 + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 20) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 20 { + panic("Xbox 360 native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:19]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bbc5cc31..cae437b4 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -80,6 +80,10 @@ The transport is intentionally split by USB semantics: notification schedules one preallocated passive work item, which completes one later poll from that cache. The ready callback itself never completes an URB because KMDF can invoke it synchronously on UdeCx's submitter thread. + The Go publisher allocates one buffer from the endpoint's descriptor at + publisher startup and supported controller engines encode directly into it. + The serial overlapped IOCTL copies the report before that buffer is reused, + eliminating per-sample Go heap work without shared-memory lifetime hazards. Input timing is therefore host-poll-driven rather than dependent on a second physical report arriving after the poll; - control, interrupt-OUT, isochronous speaker/microphone/haptics, feedback, and diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 3c39f808..317534d7 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -59,7 +59,7 @@ type registeredDevice struct { cancel context.CancelFunc stopping bool publisherStopping bool - fastInput map[uint8]struct{} + fastInput map[uint8]int publishers map[uint8]*inputPublisher activeInput map[uint8]bool resettingInput map[uint8]bool @@ -70,10 +70,11 @@ type registeredDevice struct { } type inputPublisher struct { - endpoint uint8 - sequence *atomic.Uint64 - cancel context.CancelFunc - done chan struct{} + endpoint uint8 + reportSize int + sequence *atomic.Uint64 + cancel context.CancelFunc + done chan struct{} } type laneKey struct { @@ -175,15 +176,24 @@ func (h *Host) lockDeviceLifecycle(deviceID uint64) func() { } } -func fastInputEndpoints(dev usb.Device) map[uint8]struct{} { - result := make(map[uint8]struct{}) +func fastInputEndpoints(dev usb.Device) map[uint8]int { + result := make(map[uint8]int) if dev == nil || dev.GetDescriptor() == nil { return result } for _, iface := range dev.GetDescriptor().Interfaces { for _, endpoint := range iface.Endpoints { if endpoint.BEndpointAddress&0x80 != 0 && endpoint.BMAttributes&0x03 == 0x03 { - result[endpoint.BEndpointAddress] = struct{}{} + // USB 2.0 wMaxPacketSize uses bits 0..10 for bytes and bits + // 11..12 for additional high-bandwidth transactions. Allocate + // the complete service opportunity while enforcing the native + // ABI's hard report bound. + packetBytes := int(endpoint.WMaxPacketSize & 0x07ff) + transactions := 1 + int((endpoint.WMaxPacketSize>>11)&0x03) + reportSize := packetBytes * transactions + if reportSize > 0 && reportSize <= MaxInputReportBytes { + result[endpoint.BEndpointAddress] = reportSize + } } } } @@ -365,7 +375,8 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { h.mu.Unlock() return } - if _, fast := entry.fastInput[endpoint]; !fast || entry.publishers[endpoint] != nil { + reportSize, fast := entry.fastInput[endpoint] + if !fast || entry.publishers[endpoint] != nil { h.mu.Unlock() return } @@ -376,7 +387,8 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { } ctx, cancel := context.WithCancel(entry.ctx) publisher := &inputPublisher{ - endpoint: endpoint, sequence: sequence, cancel: cancel, done: make(chan struct{}), + endpoint: endpoint, reportSize: reportSize, sequence: sequence, + cancel: cancel, done: make(chan struct{}), } entry.publishers[endpoint] = publisher h.mu.Unlock() @@ -426,9 +438,36 @@ func (h *Host) activeInputEndpoints(entry *registeredDevice) []uint8 { func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { defer close(publisher.done) + reader, direct := entry.device.(usb.InterruptInputDevice) + var reportBuffer []byte + if direct { + reportBuffer = make([]byte, publisher.reportSize) + } for { - payload := entry.device.HandleTransfer( - ctx, uint32(publisher.endpoint&0x0f), usbip.DirIn, nil) + var payload []byte + if direct { + written, err := reader.ReadInterruptInput( + ctx, uint32(publisher.endpoint&0x0f), reportBuffer) + if err != nil { + if ctx.Err() != nil { + return + } + h.reportFatal(fmt.Errorf( + "encode native UDE input report for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + if written <= 0 || written > len(reportBuffer) { + h.reportFatal(fmt.Errorf( + "device %d encoded invalid interrupt-IN length %d for endpoint 0x%02x (capacity %d)", + entry.identity.DeviceID, written, publisher.endpoint, len(reportBuffer))) + return + } + payload = reportBuffer[:written] + } else { + payload = entry.device.HandleTransfer( + ctx, uint32(publisher.endpoint&0x0f), usbip.DirIn, nil) + } if ctx.Err() != nil { return } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 9c1428b7..bd4093fa 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -436,11 +436,42 @@ type inputPublisherTestDevice struct { reports chan []byte } +type directInputPublisherTestDevice struct { + *inputPublisherTestDevice + buffers chan *byte +} + func newInputPublisherTestDevice() *inputPublisherTestDevice { base := hostTestDevice().GetDescriptor() return &inputPublisherTestDevice{descriptor: *base, reports: make(chan []byte, 4)} } +func newDirectInputPublisherTestDevice() *directInputPublisherTestDevice { + return &directInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + buffers: make(chan *byte, 4), + } +} + +func (d *directInputPublisherTestDevice) ReadInterruptInput( + ctx context.Context, _ uint32, dst []byte, +) (int, error) { + if len(dst) == 0 { + return 0, errors.New("empty native input buffer") + } + select { + case report := <-d.reports: + if len(report) > len(dst) { + return 0, errors.New("native input buffer is too short") + } + d.buffers <- &dst[0] + copy(dst, report) + return len(report), nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + func (d *inputPublisherTestDevice) HandleTransfer( ctx context.Context, _ uint32, _ uint32, _ []byte, ) []byte { @@ -516,6 +547,63 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { } } +func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newDirectInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 45, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1, 2, 3, 4} + first := <-driver.reports + firstBuffer := <-device.buffers + device.reports <- []byte{5, 6} + second := <-driver.reports + secondBuffer := <-device.buffers + if firstBuffer != secondBuffer { + t.Fatal("direct input publisher allocated a replacement endpoint buffer") + } + if string(first.Payload) != string([]byte{1, 2, 3, 4}) || + string(second.Payload) != string([]byte{5, 6}) { + t.Fatalf("direct input payloads first=%v second=%v", first.Payload, second.Payload) + } + if first.Sequence != 1 || second.Sequence != 2 { + t.Fatalf("direct input sequences first=%d second=%d", first.Sequence, second.Sequence) + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostInputPublisherDoesNotWaitForGlobalRoutingLock(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ diff --git a/native/udecx/README.md b/native/udecx/README.md index d3f95569..0ff6c82d 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -58,6 +58,12 @@ Reset, purge, D0 exit, and device reset invalidate both the cache and token behind the same admission barriers used by the direct producer. This removes the old lost-rendezvous window and never requires an extra feeder update to wake the first already-posted host poll. +The Go publisher likewise owns one descriptor-sized buffer per active endpoint. +DualSense, DualShock 4, and Xbox 360 encode directly into that buffer, which is +reused only after the overlapped IOCTL has completed and the kernel has copied +the report. Allocation gates enforce zero heap allocations in those report +encoders; USB/IP and unconverted device engines retain their existing ownership +contract through the optional interface. `InputReportsSubmitted` counts accepted state publications and `InputReportsCompleted` counts host polls served from them. Multiple publications can coalesce into one latest state before Windows polls, but one publication can diff --git a/usb/device.go b/usb/device.go index 7863acf3..70972733 100644 --- a/usb/device.go +++ b/usb/device.go @@ -14,6 +14,20 @@ type Device interface { GetDeviceSpecificArgs() map[string]any } +// InterruptInputDevice is an optional allocation-free interrupt-IN contract. +// Native transports may keep one endpoint-sized buffer and ask the device to +// encode directly into it instead of allocating a new report for every input +// sample. Implementations must block until input is available or ctx is +// cancelled, must not retain dst, and must be safe when different endpoints +// are read concurrently. A successful call returns the number of bytes written +// to dst; zero-length successful reports are invalid. +// +// HandleTransfer remains the compatibility contract for USB/IP and for devices +// which do not implement this interface. +type InterruptInputDevice interface { + ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) +} + // ControlDevice is an optional interface for devices that need to handle // control transfers on endpoint 0 (EP0). // From b3386a8d43d3ba8852319d4f81b237bc6737ed67 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:31:35 -0500 Subject: [PATCH 105/240] Remove input heap churn from every native HID engine Extend the caller-buffer interrupt-IN path to Switch 2 Pro, keyboard, and mouse while retaining the ordered bulk path and original USB/IP report ownership. Each encoder clears and bounds its descriptor-sized destination before writing, including Switch protocol counters and relative-mouse neutral follow-up state. Add zero-allocation and short-buffer gates for every remaining production HID engine and include all six device packages in repeated CI race testing. --- .github/workflows/native-ude.yml | 3 ++ device/keyboard/device.go | 14 +++++++++ device/keyboard/inputstate.go | 13 ++++++++- device/keyboard/native_input_test.go | 22 ++++++++++++++ device/mouse/device.go | 21 ++++++++++++++ device/mouse/inputstate.go | 13 ++++++++- device/mouse/native_input_test.go | 22 ++++++++++++++ device/ns2pro/device.go | 43 +++++++++++++++++++++++++--- device/ns2pro/inputstate.go | 28 ++++++++++++++++-- device/ns2pro/native_input_test.go | 25 ++++++++++++++++ native/udecx/README.md | 8 +++--- 11 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 device/keyboard/native_input_test.go create mode 100644 device/mouse/native_input_test.go create mode 100644 device/ns2pro/native_input_test.go diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index a77be6be..b7f93b7e 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -67,6 +67,9 @@ jobs: ./device/dualsense ./device/dualshock4 ./device/xbox360 + ./device/ns2pro + ./device/keyboard + ./device/mouse driver: runs-on: windows-2025-vs2026 diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 291d546b..b6210180 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -3,6 +3,7 @@ package keyboard import ( "context" + "fmt" "sync" "github.com/Alia5/VIIPER/device" @@ -117,6 +118,19 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. +func (k *Keyboard) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep != 1 { + return 0, fmt.Errorf("keyboard interrupt-IN endpoint %d is unsupported", ep) + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case st := <-k.inputCh: + return st.BuildReportInto(dst) + } +} + func ledStateFromMask(mask uint8) LEDState { return LEDState{ NumLock: mask&LEDNumLock != 0, diff --git a/device/keyboard/inputstate.go b/device/keyboard/inputstate.go index aa6d118e..b942ed5d 100644 --- a/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -49,10 +49,21 @@ func (ls *LEDState) UnmarshalBinary(data []byte) error { // Bytes 2-33: Key bitmap (256 bits, 32 bytes) func (kb *InputState) BuildReport() []byte { b := make([]byte, 34) + _, _ = kb.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the HID report into caller-owned storage. +func (kb *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 34 { + return 0, io.ErrShortBuffer + } + b := dst[:34] + clear(b) b[0] = kb.Modifiers b[1] = 0x00 // Reserved copy(b[2:34], kb.KeyBitmap[:]) - return b + return 34, nil } // MarshalBinary encodes InputState to variable-length wire format. diff --git a/device/keyboard/native_input_test.go b/device/keyboard/native_input_test.go new file mode 100644 index 00000000..69b09b71 --- /dev/null +++ b/device/keyboard/native_input_test.go @@ -0,0 +1,22 @@ +package keyboard + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 34) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 34 { + panic("keyboard native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:33]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/mouse/device.go b/device/mouse/device.go index 268f2aba..703813d0 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -3,6 +3,7 @@ package mouse import ( "context" + "fmt" "sync" "github.com/Alia5/VIIPER/device" @@ -72,6 +73,26 @@ func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out [ return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for native UDE. +func (m *Mouse) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep != 1 { + return 0, fmt.Errorf("mouse interrupt-IN endpoint %d is unsupported", ep) + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case st := <-m.inputCh: + if st.DX != 0 || st.DY != 0 || st.Wheel != 0 || st.Pan != 0 { + zeroed := InputState{Buttons: st.Buttons} + select { + case m.inputCh <- zeroed: + default: + } + } + return st.BuildReportInto(dst) + } +} + // HID Report Descriptor for a 5-button mouse with vertical and horizontal wheels. // Boot protocol compatible. var reportDescriptor = hid.ReportDescriptor{ diff --git a/device/mouse/inputstate.go b/device/mouse/inputstate.go index 85c0c7ee..fa091831 100644 --- a/device/mouse/inputstate.go +++ b/device/mouse/inputstate.go @@ -31,6 +31,17 @@ func NewInputState() *InputState { return &InputState{} } // Bytes 7-8: Pan (int16 little-endian) func (m *InputState) BuildReport() []byte { b := make([]byte, 9) + _, _ = m.BuildReportInto(b) + return b +} + +// BuildReportInto encodes the HID report into caller-owned storage. +func (m *InputState) BuildReportInto(dst []byte) (int, error) { + if len(dst) < 9 { + return 0, io.ErrShortBuffer + } + b := dst[:9] + clear(b) b[0] = m.Buttons & 0x1F // 5 buttons, mask upper bits b[1] = byte(m.DX) b[2] = byte(m.DX >> 8) @@ -40,7 +51,7 @@ func (m *InputState) BuildReport() []byte { b[6] = byte(m.Wheel >> 8) b[7] = byte(m.Pan) b[8] = byte(m.Pan >> 8) - return b + return 9, nil } // MarshalBinary encodes InputState to 9 bytes. diff --git a/device/mouse/native_input_test.go b/device/mouse/native_input_test.go new file mode 100644 index 00000000..2d68e414 --- /dev/null +++ b/device/mouse/native_input_test.go @@ -0,0 +1,22 @@ +package mouse + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + state := NewInputState() + buffer := make([]byte, 9) + if allocations := testing.AllocsPerRun(1000, func() { + written, err := state.BuildReportInto(buffer) + if err != nil || written != 9 { + panic("mouse native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err := state.BuildReportInto(buffer[:8]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 3ad1b5bc..4f9a7416 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -164,6 +164,27 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out return nil } +// ReadInterruptInput implements usb.InterruptInputDevice for the HID input +// endpoint. The bulk response endpoint remains on the ordered transfer broker. +func (d *NS2Pro) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep != EndpointHIDIn&0x0f { + return 0, fmt.Errorf("Switch 2 Pro interrupt-IN endpoint %d is unsupported", ep) + } + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) && d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + return 0, ctx.Err() + case <-d.inputCh: + if d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + } + } +} + func (d *NS2Pro) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex uint16, wLength uint16, data []byte) ([]byte, bool) { reportType := uint8(wValue >> 8) reportID := uint8(wValue) @@ -231,7 +252,20 @@ func (d *NS2Pro) nextInputReport() []byte { return d.inputReportForID(reportID) } +func (d *NS2Pro) nextInputReportInto(dst []byte) (int, error) { + d.protoMu.Lock() + reportID := d.activeReportID + d.protoMu.Unlock() + return d.inputReportForIDInto(reportID, dst) +} + func (d *NS2Pro) inputReportForID(reportID uint8) []byte { + report := make([]byte, InputReportSize) + _, _ = d.inputReportForIDInto(reportID, report) + return report +} + +func (d *NS2Pro) inputReportForIDInto(reportID uint8, dst []byte) (int, error) { d.stateMu.Lock() st := *d.inputState meta := *d.metaState @@ -242,7 +276,8 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { reportID = d.activeReportID } features := d.featureFlags - var report []byte + var written int + var err error switch reportID { case ReportIDCommon: d.reportCounter32++ @@ -251,13 +286,13 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { motionTS = uint32(time.Since(d.motionStart).Microseconds()) d.lastMotionTS = motionTS } - report = st.buildCommonReport(d.reportCounter32, motionTS, features, meta) + written, err = st.buildCommonReportInto(dst, d.reportCounter32, motionTS, features, meta) default: d.reportCounter8++ - report = st.buildProReport(d.reportCounter8, features, meta) + written, err = st.buildProReportInto(dst, d.reportCounter8, features, meta) } d.protoMu.Unlock() - return report + return written, err } func (d *NS2Pro) serialNumber() string { diff --git a/device/ns2pro/inputstate.go b/device/ns2pro/inputstate.go index 62436134..b4e02386 100644 --- a/device/ns2pro/inputstate.go +++ b/device/ns2pro/inputstate.go @@ -111,6 +111,18 @@ func (o *OutputState) UnmarshalBinary(data []byte) error { func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features uint8, meta MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = s.buildCommonReportInto(b, counter, motionTimestamp, features, meta) + return b +} + +func (s InputState) buildCommonReportInto( + dst []byte, counter, motionTimestamp uint32, features uint8, meta MetaState, +) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDCommon binary.LittleEndian.PutUint32(b[1:5], counter) @@ -133,11 +145,23 @@ func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features binary.LittleEndian.PutUint16(b[0x3B:0x3D], uint16(s.GyroZ)) } - return b + return InputReportSize, nil } func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState) []byte { b := make([]byte, InputReportSize) + _, _ = s.buildProReportInto(b, counter, features, meta) + return b +} + +func (s InputState) buildProReportInto( + dst []byte, counter uint8, features uint8, meta MetaState, +) (int, error) { + if len(dst) < InputReportSize { + return 0, io.ErrShortBuffer + } + b := dst[:InputReportSize] + clear(b) b[0] = ReportIDPro b[1] = counter b[2] = powerInfo(meta) @@ -155,7 +179,7 @@ func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState b[13] = 0x00 b[14] = 0x00 b[15] = 0x00 - return b + return InputReportSize, nil } func (s InputState) commonButtonBytes() [4]byte { diff --git a/device/ns2pro/native_input_test.go b/device/ns2pro/native_input_test.go new file mode 100644 index 00000000..d1b6ffaa --- /dev/null +++ b/device/ns2pro/native_input_test.go @@ -0,0 +1,25 @@ +package ns2pro + +import ( + "io" + "testing" +) + +func TestNativeInputEncodingUsesCallerBufferWithoutAllocating(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + buffer := make([]byte, InputReportSize) + if allocations := testing.AllocsPerRun(1000, func() { + written, encodeErr := dev.inputReportForIDInto(ReportIDPro, buffer) + if encodeErr != nil || written != InputReportSize { + panic("Switch 2 Pro native input encoding failed") + } + }); allocations != 0 { + t.Fatalf("native input allocations=%v want 0", allocations) + } + if _, err = dev.inputReportForIDInto(ReportIDPro, buffer[:InputReportSize-1]); err != io.ErrShortBuffer { + t.Fatalf("short-buffer error=%v want %v", err, io.ErrShortBuffer) + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index 0ff6c82d..3b0e1437 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -59,10 +59,10 @@ behind the same admission barriers used by the direct producer. This removes the old lost-rendezvous window and never requires an extra feeder update to wake the first already-posted host poll. The Go publisher likewise owns one descriptor-sized buffer per active endpoint. -DualSense, DualShock 4, and Xbox 360 encode directly into that buffer, which is -reused only after the overlapped IOCTL has completed and the kernel has copied -the report. Allocation gates enforce zero heap allocations in those report -encoders; USB/IP and unconverted device engines retain their existing ownership +Every production HID engine encodes directly into that buffer, which is reused +only after the overlapped IOCTL has completed and the kernel has copied the +report. Allocation gates enforce zero heap allocations in those report +encoders; USB/IP and third-party device engines retain their existing ownership contract through the optional interface. `InputReportsSubmitted` counts accepted state publications and `InputReportsCompleted` counts host polls served from them. Multiple publications From 71b3fa361fe17f070fbfc287e2126a7eacd0e3d6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:33:17 -0500 Subject: [PATCH 106/240] Keep allocation assertions out of race instrumentation Go's race runtime intentionally adds allocator and synchronization bookkeeping, so zero-allocation assertions are not meaningful under -race. Keep functional DualSense media coverage in both modes, guard its allocation-only assertion with the standard race build tag, and exclude the dedicated allocation-only input files while leaving the full production device suites in repeated race CI. --- device/dualsense/native_audio_v5_test.go | 10 ++++++---- device/dualsense/native_input_test.go | 2 ++ device/dualsense/race_disabled_test.go | 5 +++++ device/dualsense/race_enabled_test.go | 5 +++++ device/dualshock4/native_input_test.go | 2 ++ device/keyboard/native_input_test.go | 2 ++ device/mouse/native_input_test.go | 2 ++ device/ns2pro/native_input_test.go | 2 ++ device/xbox360/native_input_test.go | 2 ++ 9 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 device/dualsense/race_disabled_test.go create mode 100644 device/dualsense/race_enabled_test.go diff --git a/device/dualsense/native_audio_v5_test.go b/device/dualsense/native_audio_v5_test.go index 6d9f7ef2..cd513fbc 100644 --- a/device/dualsense/native_audio_v5_test.go +++ b/device/dualsense/native_audio_v5_test.go @@ -26,10 +26,12 @@ func TestAppendDualSenseV5SpeakerPreservesRawFrontPair(t *testing.T) { } destination := make([]byte, 0, dualSenseV5SpeakerPayloadSize) - if allocations := testing.AllocsPerRun(1000, func() { - destination = appendDualSenseV5Speaker(destination[:0], source) - }); allocations != 0 { - t.Fatalf("V5 front-channel assembler allocated %.2f objects per generation", allocations) + if !raceDetectorEnabled { + if allocations := testing.AllocsPerRun(1000, func() { + destination = appendDualSenseV5Speaker(destination[:0], source) + }); allocations != 0 { + t.Fatalf("V5 front-channel assembler allocated %.2f objects per generation", allocations) + } } destination = appendDualSenseV5Speaker(destination[:0], source) if len(destination) != dualSenseV5SpeakerPayloadSize { diff --git a/device/dualsense/native_input_test.go b/device/dualsense/native_input_test.go index 88185188..b470b02c 100644 --- a/device/dualsense/native_input_test.go +++ b/device/dualsense/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package dualsense import ( diff --git a/device/dualsense/race_disabled_test.go b/device/dualsense/race_disabled_test.go new file mode 100644 index 00000000..a6561ad5 --- /dev/null +++ b/device/dualsense/race_disabled_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package dualsense + +const raceDetectorEnabled = false diff --git a/device/dualsense/race_enabled_test.go b/device/dualsense/race_enabled_test.go new file mode 100644 index 00000000..44ef928e --- /dev/null +++ b/device/dualsense/race_enabled_test.go @@ -0,0 +1,5 @@ +//go:build race + +package dualsense + +const raceDetectorEnabled = true diff --git a/device/dualshock4/native_input_test.go b/device/dualshock4/native_input_test.go index 26108ca2..594964fe 100644 --- a/device/dualshock4/native_input_test.go +++ b/device/dualshock4/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package dualshock4 import ( diff --git a/device/keyboard/native_input_test.go b/device/keyboard/native_input_test.go index 69b09b71..ae124d79 100644 --- a/device/keyboard/native_input_test.go +++ b/device/keyboard/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package keyboard import ( diff --git a/device/mouse/native_input_test.go b/device/mouse/native_input_test.go index 2d68e414..68f86d01 100644 --- a/device/mouse/native_input_test.go +++ b/device/mouse/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package mouse import ( diff --git a/device/ns2pro/native_input_test.go b/device/ns2pro/native_input_test.go index d1b6ffaa..6af25106 100644 --- a/device/ns2pro/native_input_test.go +++ b/device/ns2pro/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package ns2pro import ( diff --git a/device/xbox360/native_input_test.go b/device/xbox360/native_input_test.go index b8688660..5fb147a2 100644 --- a/device/xbox360/native_input_test.go +++ b/device/xbox360/native_input_test.go @@ -1,3 +1,5 @@ +//go:build !race + package xbox360 import ( From 58f14f1a73e916f4120bcfa756619e1e7d49cc87 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:44:04 -0500 Subject: [PATCH 107/240] Eliminate native microphone packet jitter Encode DualSense and DualShock 4 isochronous-IN microphone packets directly into the UDE URB packet regions at the transport-owned USB service point. This removes the per-packet payload allocation and the redundant per-packet timer from the native path while leaving USB/IP ownership unchanged. Teach the adaptive microphone buffer to honor nominal-only host packet reservations without consuming and truncating a pending long clock-correction packet. Add direct-region, sparse-layout, short-buffer, PCM-integrity, zero-allocation, and correction-debt regression coverage, plus architecture documentation. --- device/dualsense/device.go | 29 +++++++++ device/dualsense/device_output_test.go | 30 ++++++++++ .../dualsense/native_microphone_alloc_test.go | 32 ++++++++++ device/dualshock4/audio_test.go | 23 +++++++ device/dualshock4/device.go | 27 +++++++++ .../native_microphone_alloc_test.go | 32 ++++++++++ device/internal/microphonebuffer/buffer.go | 22 +++++-- .../internal/microphonebuffer/buffer_test.go | 34 +++++++++++ docs/architecture/native-udecx.md | 9 ++- internal/server/usb/native.go | 32 ++++++++-- internal/server/usb/native_test.go | 60 +++++++++++++++++++ native/udecx/README.md | 5 ++ usb/device.go | 10 ++++ 13 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 device/dualsense/native_microphone_alloc_test.go create mode 100644 device/dualshock4/native_microphone_alloc_test.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index b1f79547..d6f50126 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -526,6 +526,35 @@ func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { } } +// ReadIsochronousInput implements usb.IsochronousInputDevice. Native UDE owns +// the packet service deadline and destination, so this path neither allocates a +// packet nor creates a timer per USB packet. +func (d *DualSense) ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointMicrophoneIn&0x0f { + return 0, fmt.Errorf("DualSense isochronous-IN endpoint %d is unsupported", ep) + } + if len(dst) < USBMicrophonePacketSize { + return 0, io.ErrShortBuffer + } + if err := ctx.Err(); err != nil { + return 0, err + } + packet := dst[:min(len(dst), USBMicrophoneMaxPacketSize)] + clear(packet) + d.mtx.Lock() + defer d.mtx.Unlock() + if d.microphoneInterfaceActive { + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + d.microphoneAudioFeature.applyPCMInPlace( + packet[:actualLength], USBMicrophoneChannels, + ) + return actualLength, nil + } + } + d.microphoneBuffer.RecordZeroPacket() + return USBMicrophonePacketSize, nil +} + func (d *DualSense) drainMicrophoneSignal() { for { select { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 4fbcc0e3..0e10e45e 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/binary" "encoding/hex" + "io" "testing" "github.com/Alia5/VIIPER/usbip" @@ -108,6 +109,35 @@ func TestMicrophoneInUsesUSBIPEndpointNumber(t *testing.T) { } } +func TestNativeMicrophoneInWritesCallerBuffer(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for index := range frame { + frame[index] = byte(index*17 + 5) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := make([]byte, USBMicrophonePacketSize) + actual, err := dev.ReadIsochronousInput( + context.Background(), uint32(EndpointMicrophoneIn), packet) + if err != nil || actual != len(packet) { + t.Fatalf("native microphone read len=%d err=%v", actual, err) + } + if !bytes.Equal(packet, frame[:len(packet)]) { + t.Fatal("native microphone read changed caller-buffer PCM") + } + if _, err = dev.ReadIsochronousInput(context.Background(), + uint32(EndpointMicrophoneIn), packet[:len(packet)-1]); err != io.ErrShortBuffer { + t.Fatalf("short native microphone buffer error=%v", err) + } +} + func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { dev, err := New(nil) if err != nil { diff --git a/device/dualsense/native_microphone_alloc_test.go b/device/dualsense/native_microphone_alloc_test.go new file mode 100644 index 00000000..c31bc6c0 --- /dev/null +++ b/device/dualsense/native_microphone_alloc_test.go @@ -0,0 +1,32 @@ +//go:build !race + +package dualsense + +import ( + "context" + "testing" +) + +func TestNativeMicrophonePacketEncodingDoesNotAllocate(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneMaximumClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + packet := make([]byte, USBMicrophoneMaxPacketSize) + ctx := context.Background() + allocations := testing.AllocsPerRun(100, func() { + if _, readErr := dev.ReadIsochronousInput( + ctx, uint32(EndpointMicrophoneIn), packet, + ); readErr != nil { + panic(readErr) + } + }) + if allocations != 0 { + t.Fatalf("native microphone packet encoding allocated %.2f objects", allocations) + } +} diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index 1996d573..a1bbcd77 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -118,6 +118,29 @@ func TestAudioInterfacesTrackAlternateSettings(t *testing.T) { assert.Equal(t, make([]byte, USBMicrophonePacketSize), microphone) } +func TestNativeMicrophoneInWritesCallerBuffer(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for index := range frame { + frame[index] = byte(index*13 + 3) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := make([]byte, USBMicrophonePacketSize) + actual, err := dev.ReadIsochronousInput( + context.Background(), uint32(EndpointMicrophoneIn), packet) + require.NoError(t, err) + require.Equal(t, len(packet), actual) + assert.Equal(t, frame[:len(packet)], packet) + _, err = dev.ReadIsochronousInput(context.Background(), + uint32(EndpointMicrophoneIn), packet[:len(packet)-1]) + assert.ErrorIs(t, err, io.ErrShortBuffer) +} + func TestSpeakerTransferIsForwardedWithoutLoopbackCapture(t *testing.T) { dev, err := New(nil) require.NoError(t, err) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 2287a4f1..a131e6c3 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -425,6 +425,33 @@ func (d *DualShock4) handleMicrophoneIn(ctx context.Context) []byte { } } +// ReadIsochronousInput implements usb.IsochronousInputDevice without changing +// the USB/IP packet timeout and ownership contract. Native UDE calls at the +// packet service point, so an empty capture queue becomes a legal zero packet +// rather than a second timer in the real-time path. +func (d *DualShock4) ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + if ep&0x0f != EndpointMicrophoneIn&0x0f { + return 0, fmt.Errorf("DualShock 4 isochronous-IN endpoint %d is unsupported", ep) + } + if len(dst) < USBMicrophonePacketSize { + return 0, io.ErrShortBuffer + } + if err := ctx.Err(); err != nil { + return 0, err + } + packet := dst[:min(len(dst), USBMicrophoneMaxPacketSize)] + clear(packet) + d.mtx.Lock() + defer d.mtx.Unlock() + if d.microphoneInterfaceActive { + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + return actualLength, nil + } + } + d.microphoneBuffer.RecordZeroPacket() + return USBMicrophonePacketSize, nil +} + func (d *DualShock4) drainMicrophoneSignal() { for { select { diff --git a/device/dualshock4/native_microphone_alloc_test.go b/device/dualshock4/native_microphone_alloc_test.go new file mode 100644 index 00000000..f2e0ea36 --- /dev/null +++ b/device/dualshock4/native_microphone_alloc_test.go @@ -0,0 +1,32 @@ +//go:build !race + +package dualshock4 + +import ( + "context" + "testing" +) + +func TestNativeMicrophonePacketEncodingDoesNotAllocate(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneMaximumClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + packet := make([]byte, USBMicrophoneMaxPacketSize) + ctx := context.Background() + allocations := testing.AllocsPerRun(100, func() { + if _, readErr := dev.ReadIsochronousInput( + ctx, uint32(EndpointMicrophoneIn), packet, + ); readErr != nil { + panic(readErr) + } + }) + if allocations != 0 { + t.Fatalf("native microphone packet encoding allocated %.2f objects", allocations) + } +} diff --git a/device/internal/microphonebuffer/buffer.go b/device/internal/microphonebuffer/buffer.go index 1c42f9ef..08dad4a5 100644 --- a/device/internal/microphonebuffer/buffer.go +++ b/device/internal/microphonebuffer/buffer.go @@ -156,9 +156,13 @@ func (b *Buffer) QueueFrame(frame []byte) bool { // byte length. Packets contain exactly one fewer, the nominal number, or one // additional interleaved PCM sample-frame. USB Audio accepts these variable // isochronous packet lengths to reconcile the source and host clocks without -// resampling or dropping waveform samples. dst is never modified on failure. +// resampling or dropping waveform samples. A host is also allowed to reserve +// only the nominal packet capacity in an individual URB. In that case a long +// correction remains owed instead of consuming and truncating a PCM frame. +// dst is never modified on failure. func (b *Buffer) ReadPacket(dst []byte) (int, bool) { - if len(dst) < b.packetSize+b.pcmFrameSize { + shortSize := b.packetSize - b.pcmFrameSize + if len(dst) < shortSize { return 0, false } if !b.primed { @@ -166,15 +170,25 @@ func (b *Buffer) ReadPacket(dst []byte) (int, bool) { } actualSize := b.nextPacketSize() + if actualSize > len(dst) { + // The long correction cannot fit in this URB's packet region. Present + // the largest legal size it can hold and leave the positive servo debt + // untouched so a later max-packet reservation can service it. This is + // materially different from reading a long packet and truncating it. + actualSize = min(b.packetSize, len(dst)) + actualSize -= actualSize % b.pcmFrameSize + if actualSize < shortSize { + return 0, false + } + } if b.size < actualSize { // USB Audio accepts the nominal packet and one fewer PCM sample-frame. // Use the largest legal packet still available instead of turning a // single clock-phase deficit into a capture gap. Packet accounting is // committed only afterward so servo telemetry describes what reached the // host and any unserved long-packet correction remains owed. - shortSize := b.packetSize - b.pcmFrameSize if b.size >= b.packetSize { - actualSize = b.packetSize + actualSize = min(b.packetSize, len(dst)) } else if b.size >= shortSize { actualSize = shortSize } else { diff --git a/device/internal/microphonebuffer/buffer_test.go b/device/internal/microphonebuffer/buffer_test.go index 1d9262c4..37b928cf 100644 --- a/device/internal/microphonebuffer/buffer_test.go +++ b/device/internal/microphonebuffer/buffer_test.go @@ -132,6 +132,40 @@ func TestBufferFallsBackFromLongToNominalAndKeepsServoDebt(t *testing.T) { } } +func TestBufferHonorsNominalHostPacketCapacityWithoutDroppingPCM(t *testing.T) { + buffer := New(8, 2, 16, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 16)) + } + buffer.servoAccumulator = servoPulseScale + + nominal := make([]byte, 8) + actual, ok := buffer.ReadPacket(nominal) + if !ok || actual != len(nominal) { + t.Fatalf("nominal-capacity URB read len=%d ok=%t", actual, ok) + } + if !bytes.Equal(nominal, bytes.Repeat([]byte{1}, len(nominal))) { + t.Fatalf("nominal-capacity URB changed PCM: % x", nominal) + } + if state := buffer.State(); state.QueuedBytes != 40 || state.LongPackets != 0 { + t.Fatalf("nominal-capacity URB consumed a hidden long sample: %+v", state) + } + if buffer.servoAccumulator < servoPulseScale { + t.Fatalf("nominal-capacity URB discarded correction debt: %d", + buffer.servoAccumulator) + } + + maximum := make([]byte, 10) + actual, ok = buffer.ReadPacket(maximum) + if !ok || actual != len(maximum) { + t.Fatalf("later max-capacity URB did not service correction: len=%d ok=%t", + actual, ok) + } + if state := buffer.State(); state.QueuedBytes != 30 || state.LongPackets != 1 { + t.Fatalf("max-capacity URB did not account for one long packet: %+v", state) + } +} + func TestBufferTrueUnderrunRetainsAlignedTail(t *testing.T) { buffer := New(8, 2, 16, 3, 4) residual := []byte{0xA1, 0xA2, 0xA3, 0xA4} diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index cae437b4..81579da2 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -90,7 +90,14 @@ The transport is intentionally split by USB semantics: every lifecycle transition use the cancel-safe ordered inverted-call broker. VIIPER posts multiple `DEQUEUE_OPERATION` requests, processes each immutable operation through the existing `usb.Device` interface, then submits - `COMPLETE_OPERATION`. + `COMPLETE_OPERATION`. Native microphone engines encode directly into the + host URB's packet regions at each reserved USB service point. They neither + allocate a packet nor create a per-packet timer; an unavailable source frame + becomes the legal nominal zero packet immediately. The adaptive PCM buffer + also observes the actual capacity reserved for each URB. If Windows reserves + only nominal capacity, a pending long clock-correction packet remains owed + instead of being consumed and silently truncated. The USB/IP microphone path + retains its existing allocation and timeout ownership contract. The input counters intentionally measure opposite sides of that cache: `InputReportsSubmitted` counts accepted latest-state publications, while diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 1128f01b..2512baa3 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -414,6 +414,7 @@ func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device packets := make([]udecx.IsoPacket, len(op.IsoPackets)) actualTotal := uint32(0) serviceTime := serviceStart + reader, direct := dev.(usbdevice.IsochronousInputDevice) for i, packet := range op.IsoPackets { if packet.Offset > op.TransferLength || packet.Length > op.TransferLength-packet.Offset { return udecx.Completion{}, fmt.Errorf("native ISO packet %d is outside transfer buffer", i) @@ -422,17 +423,38 @@ func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device return udecx.Completion{}, ctx.Err() } serviceTime = serviceTime.Add(interval) - attemptCtx, cancel := context.WithTimeout(ctx, interval) - packetData := p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) - cancel() + var packetData []byte + if direct { + packetRegion := payload[packet.Offset : packet.Offset+packet.Length] + written, readErr := reader.ReadIsochronousInput(ctx, ep, packetRegion) + if readErr != nil { + return udecx.Completion{}, readErr + } + if written < 0 || uint32(written) > packet.Length { + return udecx.Completion{}, fmt.Errorf( + "native ISO packet %d encoded %d bytes into a %d-byte region", + i, written, packet.Length) + } + packetData = packetRegion[:written] + } else { + attemptCtx, cancel := context.WithTimeout(ctx, interval) + packetData = p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + cancel() + } if ctx.Err() != nil { return udecx.Completion{}, ctx.Err() } if len(packetData) == 0 { - packetData = make([]byte, packet.Length) + if direct { + packetData = payload[packet.Offset : packet.Offset+packet.Length] + } else { + packetData = make([]byte, packet.Length) + } } actual := min(packet.Length, uint32(len(packetData))) - copy(payload[packet.Offset:packet.Offset+actual], packetData[:actual]) + if !direct { + copy(payload[packet.Offset:packet.Offset+actual], packetData[:actual]) + } packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: actual} actualTotal += actual } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 067261e9..8913bb9f 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -214,6 +214,66 @@ type isoOutRecordingDevice struct { payload []byte } +type directIsoInTestDevice struct { + desc *usbdevice.Descriptor + calls int + fallbackCalls int +} + +func (d *directIsoInTestDevice) HandleTransfer( + context.Context, uint32, uint32, []byte, +) []byte { + d.fallbackCalls++ + return nil +} + +func (d *directIsoInTestDevice) ReadIsochronousInput( + _ context.Context, _ uint32, dst []byte, +) (int, error) { + d.calls++ + actual := len(dst) - d.calls + for index := 0; index < actual; index++ { + dst[index] = byte(0x20*d.calls + index) + } + return actual, nil +} + +func (d *directIsoInTestDevice) GetDescriptor() *usbdevice.Descriptor { return d.desc } +func (*directIsoInTestDevice) GetDeviceSpecificArgs() map[string]any { return nil } + +func TestNativeProcessorWritesIsoInDirectlyIntoURBPacketRegions(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, WMaxPacketSize: 8, BInterval: 1, + }}}}, + } + dev := &directIsoInTestDevice{desc: desc} + op := udecx.Operation{ + Token: 8, DeviceID: 4, Generation: 2, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, TransferLength: 24, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 16, Length: 8}}, + } + completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) + if err != nil { + t.Fatal(err) + } + if dev.calls != 2 || dev.fallbackCalls != 0 { + t.Fatalf("direct calls=%d fallback calls=%d want 2/0", dev.calls, dev.fallbackCalls) + } + if completion.TransferLength != 13 || len(completion.Payload) != 24 || + completion.IsoPackets[0].Length != 7 || completion.IsoPackets[1].Length != 6 { + t.Fatalf("unexpected direct ISO completion: %+v", completion) + } + wantFirst := []byte{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26} + wantSecond := []byte{0x40, 0x41, 0x42, 0x43, 0x44, 0x45} + if !bytes.Equal(completion.Payload[:7], wantFirst) || + !bytes.Equal(completion.Payload[16:22], wantSecond) || + !bytes.Equal(completion.Payload[8:16], make([]byte, 8)) { + t.Fatalf("direct ISO packet regions were not preserved: % x", completion.Payload) + } +} + func (d *isoOutRecordingDevice) HandleTransfer(_ context.Context, _ uint32, _ uint32, out []byte) []byte { d.payload = append(d.payload[:0], out...) return nil diff --git a/native/udecx/README.md b/native/udecx/README.md index 3b0e1437..44e0af23 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -64,6 +64,11 @@ only after the overlapped IOCTL has completed and the kernel has copied the report. Allocation gates enforce zero heap allocations in those report encoders; USB/IP and third-party device engines retain their existing ownership contract through the optional interface. +DualSense and DualShock 4 microphone engines use the same optional +caller-buffer rule for native isochronous IN. The broker invokes them only at +the endpoint's reserved service time, and they write directly into the current +URB packet region without a second timer or packet allocation. Nominal-only URB +capacity never causes an adaptive long packet to be consumed and truncated. `InputReportsSubmitted` counts accepted state publications and `InputReportsCompleted` counts host polls served from them. Multiple publications can coalesce into one latest state before Windows polls, but one publication can diff --git a/usb/device.go b/usb/device.go index 70972733..767c1e68 100644 --- a/usb/device.go +++ b/usb/device.go @@ -28,6 +28,16 @@ type InterruptInputDevice interface { ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) } +// IsochronousInputDevice is the corresponding optional caller-buffer contract +// for isochronous IN packets. The transport supplies exactly the packet region +// owned by the current URB. The native scheduler invokes this at the packet's +// service time, so implementations must not wait for source data: they return +// a legal zero packet when capture has not arrived. Implementations may return +// a shorter legal packet, must not retain dst, and must honor cancellation. +type IsochronousInputDevice interface { + ReadIsochronousInput(ctx context.Context, ep uint32, dst []byte) (int, error) +} + // ControlDevice is an optional interface for devices that need to handle // control transfers on endpoint 0 (EP0). // From 0231b8ea04d46ab793318b3c2f9d07eae2142ff5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:52:34 -0500 Subject: [PATCH 108/240] Correct native driver production signing gate Microsoft's current 2026 Hardware Dev Center documentation restricts attestation signing to controlled testing scenarios. Require an explicit testing-only acknowledgement, mark generated manifests release-ineligible, and document HLK/WHCP dashboard signing as VIIPER's production route. --- .github/workflows/native-ude.yml | 5 +-- docs/architecture/native-udecx-signing.md | 35 +++++++++++-------- native/udecx/README.md | 5 +-- .../tools/New-ViiperUdeAttestationPackage.ps1 | 14 ++++++-- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index b7f93b7e..acfcad18 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -145,7 +145,7 @@ jobs: & $inputOutput snapshot $inputSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } Remove-Item -LiteralPath $inputSnapshot -Force - - name: Validate Hardware Dev Center CAB structure + - name: Validate testing-only Hardware Dev Center CAB structure shell: pwsh run: | ./native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 ` @@ -153,7 +153,8 @@ jobs: -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` -CatalogPath native/udecx/x64/Release/ViiperUde/viiperude.cat ` - -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab + -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab ` + -AcknowledgeTestingOnly - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@v4 with: diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index d25bd8e2..4ab48946 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -6,21 +6,25 @@ package must be signed by Microsoft through Hardware Dev Center. ## Supported release paths -### Desktop preview: attestation signing +### Controlled testing: attestation signing -Attestation signing is the shortest supported path for a Windows 10/11 Desktop -preview. It is not Windows Certified, cannot be distributed to retail users by -Windows Update, and does not support Windows Server 2016 or later. +Microsoft now documents attestation signing as **testing-only**. An +attestation-signed package is not Windows Certified and is not a supported +retail release path. It can be used only in Microsoft's documented controlled +testing scenarios (for example, CoDev or Test Registry Key / Surface SSRK), and +it does not support Windows Server 2016 or later. It must never be shipped as +the public VIIPER driver. 1. Build the exact x64 Release driver, INF, PDB, and catalog. 2. Run `native/udecx/tools/New-ViiperUdeAttestationPackage.ps1` with explicit - paths to those four artifacts. The script validates the INF contract, + paths to those four artifacts and `-AcknowledgeTestingOnly`. The script + validates the INF contract, creates the required non-root `ViiperUde` folder in the CAB, re-extracts the CAB, verifies every SHA-256 hash, and writes a sidecar hash manifest. 3. Sign the CAB with a SHA-256 code-signing certificate registered to the organization's Hardware Dev Center account. Establishing that account and submitting attestation packages requires a currently valid EV certificate. -4. Submit the signed CAB in Partner Center with test-signing options disabled. +4. Submit the signed CAB through the applicable Partner Center testing flow. 5. Download Microsoft's returned package and run `native/udecx/tools/Test-ViiperUdeSignedPackage.ps1`. It requires valid Microsoft kernel-policy signatures on both the SYS and catalog and reruns @@ -29,15 +33,18 @@ Windows Update, and does not support Windows Server 2016 or later. The structural CAB produced by CI is not installable production media. It has not been EV-signed, submitted to Microsoft, or returned with Microsoft's -signature. CI names it accordingly and never promotes it as a release driver. +signature. Even a Microsoft attestation-signed result remains a controlled-test +artifact under the current Microsoft contract. CI names it accordingly and +never promotes it as a release driver. ### Production certification: HLK/WHCP -HLK/WHCP is the production target. It covers Windows Server and is the route -required for retail Windows Update publication. Run the controller and child -devices through the applicable Device Fundamentals, USB, HID, audio, power, -reliability, and security playlists, submit the resulting HLKX package, and -validate the dashboard-signed result with the same local validation script. +HLK/WHCP is the only VIIPER production target. Microsoft recommends HLK-tested, +dashboard-signed drivers for release; WHCP is required for retail Windows +Update publication. Run the controller and child devices through the +applicable Device Fundamentals, USB, HID, audio, power, reliability, and +security playlists, submit the resulting HLKX package, and validate the +dashboard-signed result with the same local validation script. ## Package invariants @@ -58,8 +65,8 @@ validate the dashboard-signed result with the same local validation script. The branch currently proves compilation, static analysis, ABI/lifecycle tests, fuzzing, race tests, deterministic package structure, and payload hashing. A -native driver is not production-ready until the Microsoft-signed package also -passes Driver Verifier, HLK or the scoped attestation test matrix, repeated +native driver is not production-ready until the HLK/WHCP dashboard-signed +package also passes Driver Verifier, the complete HLK matrix, repeated install/update/rollback, process crash, sleep/resume, and multi-controller media soak on a disposable test machine. diff --git a/native/udecx/README.md b/native/udecx/README.md index 44e0af23..a2432304 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -12,8 +12,9 @@ Directory contract: - `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root controller without creating duplicates or leaving a failed devnode behind. - `tools/New-ViiperUdeAttestationPackage.ps1` creates and hash-verifies the - exact Hardware Dev Center CAB structure; it does not pretend that an - unsigned CI artifact is a production driver. + exact controlled-test Hardware Dev Center CAB structure and requires an + explicit testing-only acknowledgement. Microsoft currently restricts + attestation to testing scenarios; production release requires HLK/WHCP. - `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned driver and catalog against kernel signing policy. - `tools/Invoke-ViiperUdeLiveValidation.ps1` hash-binds that verified package diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index 0e174005..cc36a667 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -15,12 +15,18 @@ param( [Parameter(Mandatory = $true)] [string]$OutputPath, + [switch]$AcknowledgeTestingOnly, + [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +if (-not $AcknowledgeTestingOnly) { + throw 'Microsoft documents attestation signing as testing-only. Pass -AcknowledgeTestingOnly to create a controlled-test submission CAB; use HLK/WHCP for a VIIPER retail release.' +} + function Resolve-RequiredFile { param( [Parameter(Mandatory = $true)] @@ -156,7 +162,9 @@ try { $manifest = [ordered]@{ schema = 1 - purpose = 'Microsoft Hardware Dev Center attestation submission' + purpose = 'Microsoft Hardware Dev Center controlled-test attestation submission; not a retail release package' + releaseEligible = $false + requiredProductionRoute = 'HLK/WHCP dashboard signing' cabinet = [System.IO.Path]::GetFileName($outputFullPath) cabinetSha256 = (Get-FileHash -LiteralPath $outputFullPath -Algorithm SHA256).Hash packageFolder = $packageFolder @@ -177,9 +185,9 @@ try { ($manifest | ConvertTo-Json -Depth 5), [System.Text.UTF8Encoding]::new($false)) - Write-Host "Created exact VIIPER attestation package: $outputFullPath" + Write-Host "Created exact VIIPER controlled-test attestation package: $outputFullPath" Write-Host "Hash manifest: $manifestPath" - Write-Host 'The CAB is not production-loadable yet. EV-sign it, submit it to Microsoft Hardware Dev Center, and validate the Microsoft-signed result.' + Write-Warning 'This CAB and any attestation-signed result are testing-only under Microsoft current policy. Do not ship them to retail users. A release requires HLK/WHCP dashboard signing.' } finally { if (Test-Path -LiteralPath $workRoot) { From f1057afc4ab23b01b9074f76f0836b14672eaa54 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 02:52:34 -0500 Subject: [PATCH 109/240] Complete native input writes inline when possible Use Microsoft's FILE_SKIP_COMPLETION_PORT_ON_SUCCESS contract on the overlapped owner handle. Kernel input IOCTLs that finish synchronously now return directly to their endpoint publisher instead of taking an unnecessary IOCP pump and Go channel round trip; pending and cancelled operations preserve the existing completion path. --- docs/architecture/native-udecx.md | 7 ++++ internal/transport/udecx/client_windows.go | 34 +++++++++++++++---- .../transport/udecx/client_windows_test.go | 6 ++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 81579da2..87d815c3 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -384,6 +384,11 @@ stall an independent pad's registration or removal. - DualSense and DualShock 4 media survive concurrent state and feedback traffic. - Native latency and CPU are measured against the current USB/IP path and ViGEmBus-style virtual input under the same workload. +- The overlapped owner handle uses Microsoft's + `FILE_SKIP_COMPLETION_PORT_ON_SUCCESS` contract. A direct input IOCTL which + the kernel completes inline returns on its publisher goroutine without an + otherwise redundant IOCP-pump/channel scheduling hop; operations that return + `ERROR_IO_PENDING` retain the existing cancellation-safe completion path. - Product changes to scheduling, thread priority, DPC behavior, or queue depth require a named, bounded-memory WPR capture of the signed live gate. CPU sampled/precise, ready-thread, context-switch, WDF DPC, interrupt, and ISR @@ -424,3 +429,5 @@ validation contract is documented in - Microsoft, *CPU Analysis* +- Microsoft, `SetFileCompletionNotificationModes` + diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 3d88db12..a6ddd2a9 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -35,6 +35,7 @@ const ( ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered ioctlSubmitInputReport = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 6) << 2) | methodInDirect completionPortCloseKey uintptr = ^uintptr(0) + fileSkipCompletionPortOnSuccess byte = 0x1 requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports ) @@ -48,6 +49,8 @@ var ( cfgmgr32 = windows.NewLazySystemDLL("cfgmgr32.dll") procCMGetDeviceInterfaceListSize = cfgmgr32.NewProc("CM_Get_Device_Interface_List_SizeW") procCMGetDeviceInterfaceList = cfgmgr32.NewProc("CM_Get_Device_Interface_ListW") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procSetFileCompletionModes = kernel32.NewProc("SetFileCompletionNotificationModes") ) type Client struct { @@ -58,9 +61,14 @@ type Client struct { pumpDone chan struct{} pumpErr error requestPool sync.Pool - driverNonce uint64 - capabilities Capabilities - limits NegotiateResponse + // Windows suppresses IOCP packets only for operations that return success + // inline. Pending operations still use the shared completion pump. This + // removes a scheduler/channel round trip from direct input without changing + // cancellation or lifecycle I/O. + skipCompletionPortOnSuccess bool + driverNonce uint64 + capabilities Capabilities + limits NegotiateResponse } type ioCompletion struct { @@ -110,9 +118,10 @@ func Open(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("associate native UDE controller with I/O completion port: %w", err) } client := &Client{ - handle: handle, - completionPort: completionPort, - pumpDone: make(chan struct{}), + handle: handle, + completionPort: completionPort, + pumpDone: make(chan struct{}), + skipCompletionPortOnSuccess: enableSkipCompletionPortOnSuccess(handle), } client.requestPool.New = func() any { return &ioRequest{done: make(chan ioCompletion, 1)} @@ -125,6 +134,12 @@ func Open(ctx context.Context) (*Client, error) { return client, nil } +func enableSkipCompletionPortOnSuccess(handle windows.Handle) bool { + result, _, _ := procSetFileCompletionModes.Call( + uintptr(handle), uintptr(fileSkipCompletionPortOnSuccess)) + return result != 0 +} + func (c *Client) Close() error { c.mu.Lock() if c.handle == 0 || c.handle == windows.InvalidHandle { @@ -385,6 +400,13 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( inputPointer, uint32(len(input)), outputPointer, uint32(len(output)), &immediate, &request.overlapped) + if err == nil && c.skipCompletionPortOnSuccess { + // FILE_SKIP_COMPLETION_PORT_ON_SUCCESS guarantees that no completion + // packet exists for this exact immediate-success operation. Returning + // inline mirrors ViGEmBus's report submission path and avoids waking the + // completion pump merely to hand the same result back to this goroutine. + return immediate, nil + } if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { return 0, err } diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 6e41edb2..2fbaacd0 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -141,3 +141,9 @@ func TestCompletionPortRoutesExactOverlappedRequest(t *testing.T) { t.Fatal(err) } } + +func TestEnableSkipCompletionPortOnSuccessRejectsInvalidHandle(t *testing.T) { + if enableSkipCompletionPortOnSuccess(windows.InvalidHandle) { + t.Fatal("SetFileCompletionNotificationModes accepted an invalid handle") + } +} From 24177d5c5663183d07faf478f4464180ee0dfb41 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:00:48 -0500 Subject: [PATCH 110/240] Fix native completion ABI and remove media buffer churn Make the packed completion header explicitly 72 bytes on both sides of the broker by adding the second reserved word that the Go wire contract already emitted. Add C89-compatible size guards so MSVC C builds cannot silently skip ABI validation, and reject either nonzero reserved word in the driver. Encode completions into sync.Pool-backed bounded slabs instead of allocating a fresh buffer for every control or isochronous URB. Explicitly clear all completion and ISO reserved fields before reuse, with allocation and stale-byte regression tests. --- docs/architecture/native-udecx.md | 7 +++ internal/transport/udecx/client_windows.go | 31 +++++++++++- .../transport/udecx/client_windows_test.go | 15 ++++++ internal/transport/udecx/protocol.go | 48 +++++++++++++++---- internal/transport/udecx/protocol_test.go | 38 +++++++++++++++ native/udecx/driver/Broker.c | 2 +- native/udecx/include/ViiperUdeProtocol.h | 21 +++++++- 7 files changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 87d815c3..3f442f27 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -67,6 +67,9 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. state never share mutable buffers. 9. No raw user pointer crosses the ABI. 10. The ABI is size- and version-negotiated before any mutating operation. +11. Every packed wire structure has a compiler-independent size guard. The + 72-byte completion header carries two explicit reserved words; its size + never depends on compiler tail padding. ## Kernel/user transport @@ -389,6 +392,10 @@ stall an independent pad's registration or removal. the kernel completes inline returns on its publisher goroutine without an otherwise redundant IOCP-pump/channel scheduling hop; operations that return `ERROR_IO_PENDING` retain the existing cancellation-safe completion path. +- Native completion encoding writes into bounded buffers recycled by the + client. Continuous control and isochronous traffic no longer allocates a new + wire buffer for every URB; an allocation gate protects the caller-buffer + encoder while the existing public marshal API remains available for tooling. - Product changes to scheduling, thread priority, DPC behavior, or queue depth require a named, bounded-memory WPR capture of the signed live gate. CPU sampled/precise, ready-thread, context-switch, WDF DPC, interrupt, and ISR diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index a6ddd2a9..16b6a9ab 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -61,6 +61,7 @@ type Client struct { pumpDone chan struct{} pumpErr error requestPool sync.Pool + completionPool sync.Pool // Windows suppresses IOCP packets only for operations that return success // inline. Pending operations still use the shared completion pump. This // removes a scheduler/channel round trip from direct input without changing @@ -126,6 +127,11 @@ func Open(ctx context.Context) (*Client, error) { client.requestPool.New = func() any { return &ioRequest{done: make(chan ioCompletion, 1)} } + client.completionPool.New = func() any { + // Control/state completions stay inside this initial slab. Larger media + // buffers grow once and are then recycled by capacity. + return make([]byte, 0, 4096) + } go client.runCompletionPort(completionPort) if err = client.negotiate(ctx); err != nil { _ = client.Close() @@ -325,16 +331,39 @@ func (c *Client) Complete(ctx context.Context, completion Completion) error { completion.TransferLength > limits.MaxTransferBytes { return ErrLimitExceeded } - request, err := completion.MarshalBinary() + _, _, total, err := completion.wireLayout() if err != nil { return err } + request := c.acquireCompletionBuffer(total) + defer c.releaseCompletionBuffer(request) + if err = completion.marshalBinaryInto(request); err != nil { + return err + } // METHOD_IN_DIRECT keeps the fixed metadata in the system buffer and maps // the variable packet/payload tail read-only into the driver. _, err = c.ioctl(ctx, ioctlCompleteOperation, request[:CompletionSize], request[CompletionSize:]) return err } +func (c *Client) acquireCompletionBuffer(size int) []byte { + var buffer []byte + if pooled := c.completionPool.Get(); pooled != nil { + buffer = pooled.([]byte) + } + if cap(buffer) < size { + return make([]byte, size) + } + return buffer[:size] +} + +func (c *Client) releaseCompletionBuffer(buffer []byte) { + // A negotiated completion cannot exceed the protocol's bounded maximum. + // Retaining the slab avoids high-frequency ISO completion churn while + // keeping worst-case pool entries bounded by the ABI. + c.completionPool.Put(buffer[:0]) +} + func (c *Client) SubmitInputReport(ctx context.Context, report InputReport) error { var metadata [InputReportSize]byte if err := report.marshalMetadata(metadata[:]); err != nil { diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 2fbaacd0..e18901ab 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -83,6 +83,21 @@ func TestClientRejectsRequestsOutsideNegotiatedLimitsBeforeKernelIO(t *testing.T } } +func TestCompletionPoolReusesBoundedMediaBuffer(t *testing.T) { + client := &Client{} + first := client.acquireCompletionBuffer(2048) + if len(first) != 2048 || cap(first) < 2048 { + t.Fatalf("first buffer len=%d cap=%d", len(first), cap(first)) + } + first[0] = 0x5a + client.releaseCompletionBuffer(first) + second := client.acquireCompletionBuffer(1024) + if len(second) != 1024 || cap(second) < 2048 { + t.Fatalf("reused buffer len=%d cap=%d", len(second), cap(second)) + } + client.releaseCompletionBuffer(second) +} + func TestIOCTLCodesMatchPackedHeader(t *testing.T) { wants := map[string]struct{ got, want uint32 }{ "negotiate": {ioctlNegotiate, 0x22e400}, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 3a9e3594..abf1289e 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -466,14 +466,14 @@ func ParseStats(src []byte) (Stats, error) { }, nil } -func (m Completion) MarshalBinary() ([]byte, error) { +func (m Completion) wireLayout() (transferLength uint32, isoBytes int, total int, err error) { if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { - return nil, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) + return 0, 0, 0, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) } if len(m.Payload) > MaxTransferBytes || len(m.IsoPackets) > MaxIsoPackets { - return nil, ErrLimitExceeded + return 0, 0, 0, ErrLimitExceeded } - transferLength := m.TransferLength + transferLength = m.TransferLength // Non-isochronous IN completions historically infer the completed byte // count from their contiguous payload. Isochronous payloads are different: // the buffer preserves the host packet offsets, including sparse gaps, while @@ -484,15 +484,28 @@ func (m Completion) MarshalBinary() ([]byte, error) { transferLength = uint32(len(m.Payload)) } if transferLength > MaxTransferBytes { - return nil, ErrLimitExceeded + return 0, 0, 0, ErrLimitExceeded + } + isoBytes = len(m.IsoPackets) * IsoPacketSize + total = CompletionSize + isoBytes + len(m.Payload) + if _, err = NewHeader(total); err != nil { + return 0, 0, 0, err + } + return transferLength, isoBytes, total, nil +} + +func (m Completion) marshalBinaryInto(dst []byte) error { + transferLength, isoBytes, total, err := m.wireLayout() + if err != nil { + return err + } + if len(dst) != total { + return ErrInvalidSize } - isoBytes := len(m.IsoPackets) * IsoPacketSize - total := CompletionSize + isoBytes + len(m.Payload) h, err := NewHeader(total) if err != nil { - return nil, err + return err } - dst := make([]byte, total) putHeader(dst, h) binary.LittleEndian.PutUint64(dst[16:24], m.Token) binary.LittleEndian.PutUint64(dst[24:32], m.DeviceID) @@ -504,13 +517,30 @@ func (m Completion) MarshalBinary() ([]byte, error) { binary.LittleEndian.PutUint32(dst[52:56], uint32(CompletionSize+isoBytes)) binary.LittleEndian.PutUint32(dst[56:60], uint32(len(m.Payload))) binary.LittleEndian.PutUint32(dst[60:64], CompletionSize) + // CompletionSize includes the C ABI's two explicit reserved words. Fresh + // allocations make both zero implicitly; caller-owned and pooled buffers + // must make that wire invariant explicit. + clear(dst[64:CompletionSize]) for i, packet := range m.IsoPackets { off := CompletionSize + i*IsoPacketSize binary.LittleEndian.PutUint32(dst[off:off+4], packet.Offset) binary.LittleEndian.PutUint32(dst[off+4:off+8], packet.Length) binary.LittleEndian.PutUint32(dst[off+8:off+12], uint32(packet.Status)) + binary.LittleEndian.PutUint32(dst[off+12:off+16], 0) } copy(dst[CompletionSize+isoBytes:], m.Payload) + return nil +} + +func (m Completion) MarshalBinary() ([]byte, error) { + _, _, total, err := m.wireLayout() + if err != nil { + return nil, err + } + dst := make([]byte, total) + if err := m.marshalBinaryInto(dst); err != nil { + return nil, err + } return dst, nil } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 0542607f..4a3ea3c5 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -160,6 +160,44 @@ func TestCompletionMarshallingPreservesZeroLengthSparseISO(t *testing.T) { } } +func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { + completion := Completion{ + Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + IsoPackets: []IsoPacket{ + {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, + {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, + }, + Payload: make([]byte, 4*196), + } + _, _, total, err := completion.wireLayout() + if err != nil { + t.Fatal(err) + } + dst := make([]byte, total) + for index := range dst { + dst[index] = 0xff + } + allocations := testing.AllocsPerRun(1000, func() { + if err := completion.marshalBinaryInto(dst); err != nil { + panic(err) + } + }) + if allocations != 0 { + t.Fatalf("caller-buffer completion encoding allocated %.2f objects", allocations) + } + for index, value := range dst[64:CompletionSize] { + if value != 0 { + t.Fatalf("completion reserved byte %d retained %#x", 64+index, value) + } + } + for packet := range completion.IsoPackets { + offset := CompletionSize + packet*IsoPacketSize + 12 + if value := binary.LittleEndian.Uint32(dst[offset : offset+4]); value != 0 { + t.Fatalf("ISO packet %d reserved word retained %#x", packet, value) + } + } +} + func TestInputReportMarshalling(t *testing.T) { raw, err := (InputReport{ DeviceID: 5, Generation: 7, EndpointAddress: 0x81, diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index bcbef7a4..8260a4df 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1829,7 +1829,7 @@ ViiperCompleteOperation( completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || completion->PayloadLength > VIIPER_UDE_MAX_TRANSFER_BYTES || completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS || - completion->Reserved != 0) { + completion->Reserved[0] != 0 || completion->Reserved[1] != 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 338f87cb..1e15b1cf 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -182,7 +182,7 @@ typedef struct VIIPER_UDE_COMPLETION { VIIPER_UDE_UINT32 PayloadOffset; VIIPER_UDE_UINT32 PayloadLength; VIIPER_UDE_UINT32 IsoPacketsOffset; - VIIPER_UDE_UINT32 Reserved; + VIIPER_UDE_UINT32 Reserved[2]; } VIIPER_UDE_COMPLETION; typedef struct VIIPER_UDE_INPUT_REPORT { @@ -245,3 +245,22 @@ _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI d _Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); _Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); #endif + +/* + * MSVC compiles the KMDF driver as C without defining __STDC_VERSION__, so + * neither static_assert branch above is guaranteed to run in the production + * driver build. These C89-compatible guards deliberately fail every C/C++ + * compiler if the packed wire ABI drifts. Keep them in addition to the more + * readable assertions above. + */ +typedef char VIIPER_UDE_ABI_HEADER_SIZE[(sizeof(VIIPER_UDE_HEADER) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_NEGOTIATE_REQUEST_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_NEGOTIATE_RESPONSE_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_DESCRIPTOR_RECORD_SIZE[(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_CREATE_DEVICE_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_DEVICE_IDENTITY_SIZE[(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_ISO_PACKET_SIZE[(sizeof(VIIPER_UDE_ISO_PACKET) == 16) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 104) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 48) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 144) ? 1 : -1]; From bc5606eeff96e64116f49074af7139f612d5be37 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:03:02 -0500 Subject: [PATCH 111/240] Reject pre-fix native completion drivers Advance the native UDE ABI to 1.8 so a service built for the explicit 72-byte completion header cannot negotiate with an older driver that compiled the header as 68 bytes. --- docs/architecture/native-udecx.md | 2 +- internal/transport/udecx/protocol.go | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 3f442f27..a5e11ddb 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -249,7 +249,7 @@ interface fields are only hints for alternates that contain no endpoints. completed. A pipe can therefore never restart or disappear across a live request. - Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx - management requests, not notifications. ABI 1.7 gives only those lifecycle + management requests, not notifications. ABI 1.8 gives only those lifecycle operations a generation-bound management token. Windows receives the request completion only after the Go controller engine has applied the reset or alternate-setting transition. Start, purge, and power notifications remain diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index abf1289e..f65648d8 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -13,7 +13,7 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 7 + ABIMinor uint16 = 8 HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 1e15b1cf..04ca8feb 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -33,7 +33,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(7) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(8) #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) From 54c3fa66eebdac25a96c4ff7a5147bc2cb940554 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:13:36 -0500 Subject: [PATCH 112/240] Report native driver ABI mismatches explicitly --- docs/architecture/native-udecx.md | 2 ++ internal/transport/udecx/client_windows.go | 11 ++++++++++- internal/transport/udecx/client_windows_test.go | 13 +++++++++++++ internal/transport/udecx/protocol.go | 1 + native/udecx/driver/Ioctl.c | 9 ++++++++- 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index a5e11ddb..6fe48272 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -67,6 +67,8 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. state never share mutable buffers. 9. No raw user pointer crosses the ABI. 10. The ABI is size- and version-negotiated before any mutating operation. + A revision mismatch has a distinct status that directs the service or + installer to the exact matching native-driver package. 11. Every packed wire structure has a compiler-independent size guard. The 72-byte completion header carries two explicit reserved words; its size never depends on compiler tail padding. diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 16b6a9ab..d8740576 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -248,7 +248,7 @@ func (c *Client) negotiate(ctx context.Context) error { response := make([]byte, NegotiateResponseSize) written, err := c.ioctl(ctx, ioctlNegotiate, request, response) if err != nil { - return fmt.Errorf("negotiate native UDE ABI: %w", err) + return normalizeNegotiationError(err) } if written != NegotiateResponseSize { return fmt.Errorf("negotiate native UDE ABI: response bytes=%d want=%d", written, NegotiateResponseSize) @@ -266,6 +266,15 @@ func (c *Client) negotiate(ctx context.Context) error { return nil } +func normalizeNegotiationError(err error) error { + if errors.Is(err, windows.ERROR_REVISION_MISMATCH) { + return fmt.Errorf( + "%w: service expects ABI %d.%d; install the exact native UDE driver packaged with this VIIPER build: %v", + ErrIncompatibleABI, ABIMajor, ABIMinor, err) + } + return fmt.Errorf("negotiate native UDE ABI: %w", err) +} + func validateNegotiation(negotiated NegotiateResponse, nonce uint64) error { if negotiated.ClientNonce != nonce || negotiated.DriverNonce == 0 { return errors.New("validate native UDE negotiation: session nonce mismatch") diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index e18901ab..566f81a4 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -5,12 +5,25 @@ package udecx import ( "context" "errors" + "strings" "testing" "time" "golang.org/x/sys/windows" ) +func TestNegotiationRevisionMismatchExplainsPackageRepair(t *testing.T) { + err := normalizeNegotiationError(windows.ERROR_REVISION_MISMATCH) + if !errors.Is(err, ErrIncompatibleABI) { + t.Fatalf("revision mismatch error = %v, want ErrIncompatibleABI", err) + } + for _, phrase := range []string{"ABI 1.8", "exact native UDE driver", "VIIPER build"} { + if !strings.Contains(err.Error(), phrase) { + t.Errorf("revision mismatch error %q does not contain %q", err, phrase) + } + } +} + func TestCompletionAfterCancelPreservesKernelOutcome(t *testing.T) { t.Parallel() diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index f65648d8..90faf6cf 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -40,6 +40,7 @@ var ( ErrBadMagic = errors.New("native UDE message has an invalid magic value") ErrIncompatibleMajor = errors.New("native UDE ABI major version is incompatible") ErrIncompatibleMinor = errors.New("native UDE ABI minor version is incompatible") + ErrIncompatibleABI = errors.New("native UDE service and driver ABIs are incompatible") ErrInvalidSize = errors.New("native UDE message size is invalid") ErrInvalidRange = errors.New("native UDE message contains an invalid range") ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 48bbcbc0..6d8397fb 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -50,13 +50,20 @@ ViiperHandleNegotiate( if (!NT_SUCCESS(status)) { return status; } - if (!ViiperValidateHeader(&input->Header, inputLength, sizeof(*input)) || + if (inputLength != sizeof(*input) || + input->Header.Magic != VIIPER_UDE_MAGIC || + input->Header.Flags != 0 || + input->Header.Size != sizeof(*input) || input->ClientNonce == 0 || input->Reserved != 0 || (input->RequestedCapabilities & ~(VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | VIIPER_UDE_CAP_INPUT_REPORTS)) != 0) { return STATUS_INVALID_PARAMETER; } + if (input->Header.Major != VIIPER_UDE_ABI_MAJOR || + input->Header.Minor != VIIPER_UDE_ABI_MINOR) { + return STATUS_REVISION_MISMATCH; + } fileObject = WdfRequestGetFileObject(Request); if (fileObject == WDF_NO_HANDLE) { From 0bf42d3c24510234c1a02092b6a665c09382a5fd Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:15:53 -0500 Subject: [PATCH 113/240] Repair native preview ABI mismatches deterministically --- docs/architecture/native-udecx.md | 4 +++- internal/transport/udecx/client_windows.go | 12 +++++++++- .../transport/udecx/client_windows_test.go | 23 ++++++++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 6fe48272..13177bb9 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -68,7 +68,9 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. 9. No raw user pointer crosses the ABI. 10. The ABI is size- and version-negotiated before any mutating operation. A revision mismatch has a distinct status that directs the service or - installer to the exact matching native-driver package. + installer to the exact matching native-driver package. The service also + recognizes the parameter/length errors returned by native previews from + before that distinct status existed, so an upgrade cannot strand ABI 1.7. 11. Every packed wire structure has a compiler-independent size guard. The 72-byte completion header carries two explicit reserved words; its size never depends on compiler tail padding. diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index d8740576..b90e87ab 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -267,7 +267,17 @@ func (c *Client) negotiate(ctx context.Context) error { } func normalizeNegotiationError(err error) error { - if errors.Is(err, windows.ERROR_REVISION_MISMATCH) { + // ABI 1.8 is the first driver that reports ERROR_REVISION_MISMATCH. Older + // native previews reject this service's otherwise internally generated, + // fixed negotiation request as ERROR_INVALID_PARAMETER. A future fixed + // request-size change can surface as either length error before the driver + // reaches its version check. None of these can be caused by user data, so + // they all mean that the service and installed package must be repaired as + // one version-locked unit. + if errors.Is(err, windows.ERROR_REVISION_MISMATCH) || + errors.Is(err, windows.ERROR_INVALID_PARAMETER) || + errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) || + errors.Is(err, windows.ERROR_BAD_LENGTH) { return fmt.Errorf( "%w: service expects ABI %d.%d; install the exact native UDE driver packaged with this VIIPER build: %v", ErrIncompatibleABI, ABIMajor, ABIMinor, err) diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 566f81a4..b32a1495 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -12,14 +12,21 @@ import ( "golang.org/x/sys/windows" ) -func TestNegotiationRevisionMismatchExplainsPackageRepair(t *testing.T) { - err := normalizeNegotiationError(windows.ERROR_REVISION_MISMATCH) - if !errors.Is(err, ErrIncompatibleABI) { - t.Fatalf("revision mismatch error = %v, want ErrIncompatibleABI", err) - } - for _, phrase := range []string{"ABI 1.8", "exact native UDE driver", "VIIPER build"} { - if !strings.Contains(err.Error(), phrase) { - t.Errorf("revision mismatch error %q does not contain %q", err, phrase) +func TestNegotiationABIMismatchExplainsPackageRepair(t *testing.T) { + for _, transportErr := range []error{ + windows.ERROR_REVISION_MISMATCH, + windows.ERROR_INVALID_PARAMETER, // Native preview before ABI 1.8. + windows.ERROR_INSUFFICIENT_BUFFER, + windows.ERROR_BAD_LENGTH, + } { + err := normalizeNegotiationError(transportErr) + if !errors.Is(err, ErrIncompatibleABI) { + t.Errorf("negotiation error for %v = %v, want ErrIncompatibleABI", transportErr, err) + } + for _, phrase := range []string{"ABI 1.8", "exact native UDE driver", "VIIPER build"} { + if !strings.Contains(err.Error(), phrase) { + t.Errorf("negotiation error %q does not contain %q", err, phrase) + } } } } From c6ce82b01bcbb3e91543cac986061e26f1955158 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:16:54 -0500 Subject: [PATCH 114/240] Pin every native wire field offset --- docs/architecture/native-udecx.md | 3 +- native/udecx/include/ViiperUdeProtocol.h | 120 +++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 13177bb9..1b285290 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -73,7 +73,8 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. before that distinct status existed, so an upgrade cannot strand ABI 1.7. 11. Every packed wire structure has a compiler-independent size guard. The 72-byte completion header carries two explicit reserved words; its size - never depends on compiler tail padding. + never depends on compiler tail padding. Every field offset is guarded too, + so a same-size reorder cannot silently desynchronize the C and Go layouts. ## Kernel/user transport diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 04ca8feb..73de1e0f 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #if defined(_KERNEL_MODE) #include #include @@ -264,3 +266,121 @@ typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 104) typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 48) ? 1 : -1]; typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 144) ? 1 : -1]; + +/* + * A same-size field reorder is just as destructive as a size change but would + * pass the guards above. Pin every cross-language field offset independently + * so the WDK build proves the C layout consumed by the driver is exactly the + * byte layout encoded by Go. + */ +#define VIIPER_UDE_ASSERT_OFFSET(Type, Field, Expected) \ + typedef char VIIPER_UDE_ABI_OFFSET_##Type##_##Field[(offsetof(Type, Field) == (Expected)) ? 1 : -1] + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Magic, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Major, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Minor, 6); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Size, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_HEADER, Flags, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, ClientNonce, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, RequestedCapabilities, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_REQUEST, Reserved, 28); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, ClientNonce, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, DriverNonce, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, Capabilities, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxDevices, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxDescriptorBytes, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxTransferBytes, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxIsoPackets, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxPendingOperations, 52); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Kind, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Index, 2); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, LanguageId, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Reserved, 6); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Offset, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Length, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Speed, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorCount, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorRecordsOffset, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorDataOffset, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorDataLength, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, MaxPendingOperations, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Reserved, 52); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Reserved, 28); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Offset, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Length, 4); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Status, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_ISO_PACKET, Reserved, 12); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Token, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Generation, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Kind, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointAddress, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, Direction, 41); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, InterfaceNumber, 42); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, InterfaceSetting, 43); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, UrbFunction, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, TransferFlags, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, StartFrame, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, IsoPacketCount, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, TransferLength, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, PayloadOffset, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, PayloadLength, 68); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, IsoPacketsOffset, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, SetupPacket, 76); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointAttributes, 84); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointInterval, 85); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointMaxPacketSize, 86); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointSequence, 88); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, DeviceSequence, 96); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Token, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Generation, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Status, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, UsbdStatus, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, TransferLength, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketCount, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadOffset, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadLength, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketsOffset, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Reserved, 64); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, EndpointAddress, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Reserved1, 29); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadOffset, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadLength, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Sequence, 40); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsDequeued, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsCompleted, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsCancelled, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsPurged, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, LateCompletions, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InvalidMessages, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, QueueExhaustions, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, IsoPackets, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, BytesToDevice, 80); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, BytesFromDevice, 88); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, NotificationEvents, 96); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, NotificationEventOverflows, 104); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, ActiveDevices, 112); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, PendingOperations, 116); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, WaitingDequeues, 120); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, CleanupRetries, 124); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsSubmitted, 128); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsCompleted, 136); + +#undef VIIPER_UDE_ASSERT_OFFSET From da1d54eaa57bb29ffdc2b733c94014c631a05fe1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:18:30 -0500 Subject: [PATCH 115/240] Cancel superseded native driver builds --- .github/workflows/native-ude.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index acfcad18..acf07feb 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -31,6 +31,13 @@ permissions: contents: read security-events: write +# A driver artifact is meaningful only for the exact current branch head. +# Cancel superseded WDK/CodeQL work instead of letting several incompatible +# ABI revisions finish and present equally downloadable test packages. +concurrency: + group: native-ude-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: protocol: runs-on: windows-2025 From 1bdfb36166720aadd7d6e77ce0d9f49eab046d2d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:19:18 -0500 Subject: [PATCH 116/240] Bind native driver packages to source revisions --- .github/workflows/native-ude.yml | 1 + docs/architecture/native-udecx-signing.md | 3 ++- native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index acf07feb..28dc7222 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -161,6 +161,7 @@ jobs: -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` -CatalogPath native/udecx/x64/Release/ViiperUde/viiperude.cat ` -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab ` + -SourceRevision $env:GITHUB_SHA ` -AcknowledgeTestingOnly - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@v4 diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 4ab48946..4646b76a 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -20,7 +20,8 @@ the public VIIPER driver. paths to those four artifacts and `-AcknowledgeTestingOnly`. The script validates the INF contract, creates the required non-root `ViiperUde` folder in the CAB, re-extracts the - CAB, verifies every SHA-256 hash, and writes a sidecar hash manifest. + CAB, verifies every SHA-256 hash, and writes a sidecar hash manifest bound + to the required source revision. 3. Sign the CAB with a SHA-256 code-signing certificate registered to the organization's Hardware Dev Center account. Establishing that account and submitting attestation packages requires a currently valid EV certificate. diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index cc36a667..c2fe2df1 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -15,6 +15,10 @@ param( [Parameter(Mandatory = $true)] [string]$OutputPath, + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$SourceRevision, + [switch]$AcknowledgeTestingOnly, [switch]$Force @@ -165,6 +169,7 @@ try { purpose = 'Microsoft Hardware Dev Center controlled-test attestation submission; not a retail release package' releaseEligible = $false requiredProductionRoute = 'HLK/WHCP dashboard signing' + sourceRevision = $SourceRevision.ToLowerInvariant() cabinet = [System.IO.Path]::GetFileName($outputFullPath) cabinetSha256 = (Get-FileHash -LiteralPath $outputFullPath -Algorithm SHA256).Hash packageFolder = $packageFolder From 6860b8502a4ad6d9073306b9eb1ca76a486ce0b9 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:40:18 -0500 Subject: [PATCH 117/240] Prove native HID feedback end to end Extend the Microsoft-signed live gate beyond enumeration and input latency. The HID probe now opens the newly enumerated PlayStation collection for overlapped WriteFile output and sends versioned DS4 or DualSense feedback markers. The Go live workload requires exact rumble, lightbar, player LED, flash, and adaptive-trigger fields to reach the device engine while driver operation, completion, and host-to-device byte counters advance. Also drain cancelled probe I/O before releasing OVERLAPPED storage. --- docs/architecture/native-udecx.md | 7 + .../server/usb/native_live_windows_test.go | 169 +++++++++++++++++- native/udecx/README.md | 19 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 6 +- native/udecx/tools/ViiperUdeInputProbe.cpp | 136 +++++++++++++- 5 files changed, 320 insertions(+), 17 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 1b285290..f18be9e6 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -412,6 +412,13 @@ stall an independent pad's registration or removal. DualSense, and DualSense Edge must remain at or below 4 ms p95, 8 ms p99, and 20 ms maximum publisher-to-HID latency. These gates include user-mode scheduling and prevent a nominal polling-rate claim from hiding tail stalls. +- That same signed live HID gate writes a full output report through the newly + enumerated HIDClass collection. The exact marker must survive UdeCx and the + native broker into DualShock 4 rumble/lightbar feedback and DualSense rumble, + player/lightbar LED, and both adaptive-trigger blocks; kernel operation, + completion, and host-to-device byte counters must advance. Unit-level + processor tests alone are not accepted as proof of Windows game-feedback + delivery. - Installation is signed, reversible, version-gated, and never replaces a live kernel driver across an unsafe reboot boundary. - The INF's Windows 10 1809 floor and the linked KMDF contract remain aligned: diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index 65f1d13c..56f0d081 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -26,6 +26,7 @@ import ( serverusb "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/transport/udecx" usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" "golang.org/x/sys/windows" ) @@ -44,9 +45,87 @@ type liveNativeController struct { vendorID uint16 productID uint16 inputMarkerOffset uint16 + feedbackProbeKind string + feedbackReportLen uint64 + armFeedbackProbe func(usbdevice.Device) (func(context.Context) error, error) new func() (usbdevice.Device, func(uint64), func(byte), error) } +func armDualShock4FeedbackProbe(dev usbdevice.Device) (func(context.Context) error, error) { + controller, ok := dev.(*dualshock4.DualShock4) + if !ok { + return nil, fmt.Errorf("feedback probe expected *dualshock4.DualShock4, got %T", dev) + } + want := dualshock4.OutputState{ + RumbleSmall: 0x23, RumbleLarge: 0xA7, + LedRed: 0x11, LedGreen: 0x52, LedBlue: 0xC3, + FlashOn: 0x04, FlashOff: 0x09, + } + done := make(chan struct{}) + var matched sync.Once + controller.SetOutputCallback(func(got dualshock4.OutputState) { + if got == want { + matched.Do(func() { close(done) }) + } + }) + return func(ctx context.Context) error { + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("DualShock 4 feedback marker did not reach the device engine: %w", ctx.Err()) + } + }, nil +} + +func armDualSenseFeedbackProbe(dev usbdevice.Device) (func(context.Context) error, error) { + controller, ok := dev.(*dualsense.DualSense) + if !ok { + return nil, fmt.Errorf("feedback probe expected *dualsense.DualSense, got %T", dev) + } + var want [dualsense.OutputReportSize]byte + want[0] = dualsense.ReportIDOutput + want[1] = 0x0F + want[2] = 0x14 + want[3] = 0x22 + want[4] = 0x88 + want[11] = 0x21 + want[12] = 0xFC + want[13] = 0x03 + want[20] = 0x44 + want[22] = 0x25 + want[23] = 0x40 + want[24] = 0x05 + want[31] = 0x55 + want[44] = 0x24 + want[45] = 0x11 + want[46] = 0x52 + want[47] = 0xC3 + + done := make(chan struct{}) + var matched sync.Once + controller.SetOutputCallback(func(got dualsense.OutputState) { + if got.RawOutputReport == want && + got.RumbleSmall == 0x22 && got.RumbleLarge == 0x88 && + got.LedRed == 0x11 && got.LedGreen == 0x52 && got.LedBlue == 0xC3 && + got.PlayerLeds == 0x24 && + got.TriggerR2Mode == 0x21 && got.TriggerR2StartResistance == 0xFC && + got.TriggerR2EffectForce == 0x03 && got.TriggerR2Frequency == 0x44 && + got.TriggerL2Mode == 0x25 && got.TriggerL2StartResistance == 0x40 && + got.TriggerL2EffectForce == 0x05 && got.TriggerL2Frequency == 0x55 { + matched.Do(func() { close(done) }) + } + }) + return func(ctx context.Context) error { + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("DualSense feedback marker did not reach the device engine: %w", ctx.Err()) + } + }, nil +} + func liveNativeControllers() []liveNativeController { return []liveNativeController{ {name: "Xbox360", new: func() (usbdevice.Device, func(uint64), func(byte), error) { @@ -59,6 +138,8 @@ func liveNativeControllers() []liveNativeController { }}, {name: "DualShock4", vendorID: dualshock4.DefaultVID, productID: dualshock4.DefaultPID, inputMarkerOffset: 1, + feedbackProbeKind: "dualshock4", feedbackReportLen: 32, + armFeedbackProbe: armDualShock4FeedbackProbe, new: func() (usbdevice.Device, func(uint64), func(byte), error) { dev, err := dualshock4.New(nil) return dev, func(sequence uint64) { @@ -73,6 +154,8 @@ func liveNativeControllers() []liveNativeController { }}, {name: "DualSense", vendorID: dualsense.DefaultVID, productID: dualsense.DefaultPIDDS, inputMarkerOffset: 1, + feedbackProbeKind: "dualsense", feedbackReportLen: dualsense.OutputReportSize, + armFeedbackProbe: armDualSenseFeedbackProbe, new: func() (usbdevice.Device, func(uint64), func(byte), error) { dev, err := dualsense.New(nil) return dev, func(sequence uint64) { @@ -87,6 +170,8 @@ func liveNativeControllers() []liveNativeController { }}, {name: "DualSenseEdge", vendorID: dualsense.DefaultVID, productID: dualsense.DefaultPIDDSEdge, inputMarkerOffset: 3, + feedbackProbeKind: "dualsense-edge", feedbackReportLen: dualsense.OutputReportSize, + armFeedbackProbe: armDualSenseFeedbackProbe, new: func() (usbdevice.Device, func(uint64), func(byte), error) { dev, err := dualsense.NewEdge(nil) return dev, func(sequence uint64) { @@ -110,6 +195,51 @@ func liveNativeControllers() []liveNativeController { } } +func TestNativeLiveFeedbackProbeContracts(t *testing.T) { + for _, controller := range liveNativeControllers()[1:4] { + controller := controller + t.Run(controller.name, func(t *testing.T) { + dev, _, _, err := controller.new() + if err != nil { + t.Fatal(err) + } + waitForFeedback, err := controller.armFeedbackProbe(dev) + if err != nil { + t.Fatal(err) + } + + var endpoint uint8 + var report []byte + switch controller.feedbackProbeKind { + case "dualshock4": + endpoint = dualshock4.EndpointOut + report = make([]byte, 32) + report[0] = dualshock4.ReportIDOutput + report[4], report[5] = 0x23, 0xA7 + report[6], report[7], report[8] = 0x11, 0x52, 0xC3 + report[9], report[10] = 0x04, 0x09 + case "dualsense", "dualsense-edge": + endpoint = dualsense.EndpointOut + report = make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[2] = dualsense.ReportIDOutput, 0x0F, 0x14 + report[3], report[4] = 0x22, 0x88 + report[11], report[12], report[13], report[20] = 0x21, 0xFC, 0x03, 0x44 + report[22], report[23], report[24], report[31] = 0x25, 0x40, 0x05, 0x55 + report[44], report[45], report[46], report[47] = 0x24, 0x11, 0x52, 0xC3 + default: + t.Fatalf("unsupported feedback probe kind %q", controller.feedbackProbeKind) + } + + dev.HandleTransfer(context.Background(), uint32(endpoint), usbip.DirOut, report) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = waitForFeedback(ctx); err != nil { + t.Fatal(err) + } + }) + } +} + func liveNativeIterationCount(t *testing.T) int { t.Helper() raw := os.Getenv(liveNativeTestIterations) @@ -363,6 +493,15 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { if createErr != nil { t.Fatalf("construct %s: %v", controller.name, createErr) } + feedbackController := iteration == 1 && inputProbe != "" && + controller.armFeedbackProbe != nil + var waitForFeedback func(context.Context) error + if feedbackController { + waitForFeedback, createErr = controller.armFeedbackProbe(dev) + if createErr != nil { + t.Fatalf("arm %s HID feedback probe: %v", controller.name, createErr) + } + } mediaSnapshot := "" mediaController := iteration == 1 && mediaProbe != "" && (controller.name == "DualShock4" || controller.name == "DualSense") @@ -380,7 +519,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { } inputSnapshot := "" inputController := iteration == 1 && inputProbe != "" && publishMarker != nil - if inputController { + if inputController || feedbackController { snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-input-*.snapshot") if snapshotErr != nil { t.Fatalf("create input endpoint snapshot: %v", snapshotErr) @@ -447,6 +586,34 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { runLiveInputLatencyProbe( t, testCtx, inputProbe, inputSnapshot, controller, publishMarker) } + if feedbackController { + feedbackBefore, feedbackErr := client.QueryStats(testCtx) + if feedbackErr != nil { + t.Fatalf("query %s feedback baseline: %v", controller.name, feedbackErr) + } + probeOutput := runLiveMediaProbe(t, testCtx, inputProbe, + "feedback", inputSnapshot, + fmt.Sprintf("0x%04X", controller.vendorID), + fmt.Sprintf("0x%04X", controller.productID), + controller.feedbackProbeKind, "hid-output-v1") + feedbackCtx, cancelFeedback := context.WithTimeout(testCtx, 10*time.Second) + if feedbackErr = waitForFeedback(feedbackCtx); feedbackErr != nil { + cancelFeedback() + t.Fatalf("%s HID output was not preserved end to end: %v; probe=%s", + controller.name, feedbackErr, probeOutput) + } + feedbackAfter, waitErr := waitForNativeStats(feedbackCtx, client, + controller.name+" HID output completion", func(stats udecx.Stats) bool { + return stats.OperationsDequeued > feedbackBefore.OperationsDequeued && + stats.OperationsCompleted > feedbackBefore.OperationsCompleted && + stats.BytesToDevice >= feedbackBefore.BytesToDevice+controller.feedbackReportLen + }) + cancelFeedback() + if waitErr != nil { + t.Fatalf("%s HID output did not complete through the native driver: %v; before=%+v after=%+v probe=%s", + controller.name, waitErr, feedbackBefore, feedbackAfter, probeOutput) + } + } inputDeadline := time.Now().Add(750 * time.Millisecond) for sequence := uint64(1); time.Now().Before(inputDeadline); sequence++ { diff --git a/native/udecx/README.md b/native/udecx/README.md index a2432304..e98eb293 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -39,11 +39,13 @@ Directory contract: WASAPI, and lets the signed-driver test require real ISO traffic and bytes in both directions rather than treating endpoint enumeration as media success. - `tools/ViiperUdeInputProbe.cpp` follows Microsoft's HIDClass discovery and - continuous `ReadFile` contract. It snapshots existing HID collections, - opens only the newly enumerated matching gamepad, and timestamps unique - state markers with the system-wide performance counter. The signed live - gate measures the complete publisher-to-Windows-HID path instead of an - internal queue approximation. + continuous `ReadFile`/`WriteFile` contracts. It snapshots existing HID + collections, opens only the newly enumerated matching gamepad, timestamps + unique state markers with the system-wide performance counter, and writes a + versioned feedback marker containing rumble, LEDs, and adaptive-trigger + state. The signed live gate therefore measures the complete + publisher-to-Windows-HID path and proves the reverse HIDClass-to-device + path instead of trusting internal queue approximations. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. @@ -123,7 +125,12 @@ sampled immediately before publication and when a continuous HID `ReadFile` observes the matching report. The release gate requires p95 <= 4 ms, p99 <= 8 ms, and maximum <= 20 ms, including user-mode scheduling, the native IOCTL, UdeCx, and HIDClass. These are measured long-tail limits, not claims -derived from the nominal USB polling interval. +derived from the nominal USB polling interval. The same newly enumerated HID +collection must then accept a full-length overlapped `WriteFile`; exact +DualShock 4 rumble/lightbar data or exact DualSense rumble, lightbar, player +LED, and left/right adaptive-trigger data must arrive at the corresponding +VIIPER device callback, and the driver's completion and host-to-device byte +counters must advance. On Windows 10 2004 or newer, `-RestartRootDevice -DisposableTestMachine` restarts the exact signed root devnode with a live DualSense child and input publisher. The invalidated owner must terminate, the restarted controller must diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index c8f84c02..9e5b30ca 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -112,7 +112,7 @@ $resolvedInputProbe = $null if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { $resolvedInputProbe = (Resolve-Path -LiteralPath $InputProbePath -ErrorAction Stop).Path if ([IO.Path]::GetExtension($resolvedInputProbe) -ine '.exe') { - throw "The native HID input probe must be an executable: '$resolvedInputProbe'." + throw "The native HID input/output probe must be an executable: '$resolvedInputProbe'." } } @@ -166,6 +166,6 @@ finally { $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } $mediaSuffix = if ($null -ne $resolvedMediaProbe) { ' with full-duplex CoreAudio media' } else { '' } -$inputSuffix = if ($null -ne $resolvedInputProbe) { ' with end-to-end HID latency' } else { '' } +$inputSuffix = if ($null -ne $resolvedInputProbe) { ' with end-to-end HID input latency and output feedback' } else { '' } $restartSuffix = if ($RestartRootDevice) { ' with active root-device restart recovery' } else { '' } -Write-Host "VIIPER UDE live lifecycle/input validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix." +Write-Host "VIIPER UDE live lifecycle/HID/media validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix." diff --git a/native/udecx/tools/ViiperUdeInputProbe.cpp b/native/udecx/tools/ViiperUdeInputProbe.cpp index e2c72b8d..2a655ebc 100644 --- a/native/udecx/tools/ViiperUdeInputProbe.cpp +++ b/native/udecx/tools/ViiperUdeInputProbe.cpp @@ -163,14 +163,16 @@ std::set ReadSnapshot(const std::filesystem::path& path) { struct OpenHid final { Handle file; USHORT inputReportLength = 0; + USHORT outputReportLength = 0; std::wstring path; }; std::unique_ptr TryOpenGamepad( const std::wstring& path, USHORT vendorId, - USHORT productId) { - Handle file(CreateFileW(path.c_str(), GENERIC_READ, + USHORT productId, + DWORD desiredAccess) { + Handle file(CreateFileW(path.c_str(), desiredAccess, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr)); if (!file.valid()) return nullptr; @@ -194,6 +196,7 @@ std::unique_ptr TryOpenGamepad( auto result = std::make_unique(); result->file = std::move(file); result->inputReportLength = caps.InputReportByteLength; + result->outputReportLength = caps.OutputReportByteLength; result->path = path; return result; } @@ -202,13 +205,14 @@ std::unique_ptr WaitForNewGamepad( const std::set& baseline, USHORT vendorId, USHORT productId, + DWORD desiredAccess, std::chrono::seconds timeout) { const auto deadline = std::chrono::steady_clock::now() + timeout; do { std::unique_ptr match; for (const auto& path : EnumerateHidPaths()) { if (baseline.contains(path)) continue; - auto candidate = TryOpenGamepad(path, vendorId, productId); + auto candidate = TryOpenGamepad(path, vendorId, productId, desiredAccess); if (!candidate) continue; if (match) { throw std::runtime_error( @@ -231,6 +235,25 @@ std::uint32_t ParseUnsigned(const wchar_t* value, const wchar_t* name, std::uint return static_cast(parsed); } +void CancelAndDrainOverlapped(HANDLE file, OVERLAPPED& overlapped) { + if (!CancelIoEx(file, &overlapped)) { + const DWORD error = GetLastError(); + if (error != ERROR_NOT_FOUND) { + throw std::runtime_error( + "CancelIoEx failed with Win32 error " + std::to_string(error)); + } + } + DWORD ignored = 0; + if (!GetOverlappedResult(file, &overlapped, &ignored, TRUE)) { + const DWORD error = GetLastError(); + if (error != ERROR_OPERATION_ABORTED && error != ERROR_NOT_FOUND) { + throw std::runtime_error( + "draining cancelled overlapped I/O failed with Win32 error " + + std::to_string(error)); + } + } +} + int Measure( const std::filesystem::path& snapshotPath, USHORT vendorId, @@ -239,7 +262,7 @@ int Measure( std::size_t sampleCount) { const auto baseline = ReadSnapshot(snapshotPath); auto device = WaitForNewGamepad( - baseline, vendorId, productId, std::chrono::seconds(30)); + baseline, vendorId, productId, GENERIC_READ, std::chrono::seconds(30)); if (markerOffset >= device->inputReportLength) { throw std::runtime_error("marker offset exceeds the HID input report length"); } @@ -288,7 +311,7 @@ int Measure( wait = WaitForSingleObject(event.get(), waitMilliseconds); } if (wait == WAIT_TIMEOUT) { - CancelIoEx(device->file.get(), &overlapped); + CancelAndDrainOverlapped(device->file.get(), overlapped); break; } if (wait != WAIT_OBJECT_0 || @@ -307,12 +330,102 @@ int Measure( ++matches; } if (matches != sampleCount) { - CancelIoEx(device->file.get(), &overlapped); throw std::runtime_error("timed out before observing every unique input marker"); } return 0; } +std::vector BuildFeedbackReport( + const std::wstring& controllerKind, + USHORT outputReportLength) { + if (_wcsicmp(controllerKind.c_str(), L"dualshock4") == 0) { + if (outputReportLength != 32) { + throw std::runtime_error( + "DualShock 4 HID output report length is not the expected 32 bytes"); + } + std::vector report(outputReportLength); + report[0] = 0x05; + report[4] = 0x23; + report[5] = 0xA7; + report[6] = 0x11; + report[7] = 0x52; + report[8] = 0xC3; + report[9] = 0x04; + report[10] = 0x09; + return report; + } + if (_wcsicmp(controllerKind.c_str(), L"dualsense") == 0 || + _wcsicmp(controllerKind.c_str(), L"dualsense-edge") == 0) { + if (outputReportLength != 48) { + throw std::runtime_error( + "DualSense HID output report length is not the expected 48 bytes"); + } + std::vector report(outputReportLength); + report[0] = 0x02; + report[1] = 0x0F; // compatible rumble and both adaptive triggers + report[2] = 0x14; // player LEDs and lightbar + report[3] = 0x22; + report[4] = 0x88; + report[11] = 0x21; + report[12] = 0xFC; + report[13] = 0x03; + report[20] = 0x44; + report[22] = 0x25; + report[23] = 0x40; + report[24] = 0x05; + report[31] = 0x55; + report[44] = 0x24; + report[45] = 0x11; + report[46] = 0x52; + report[47] = 0xC3; + return report; + } + throw std::runtime_error("unsupported feedback controller kind"); +} + +int SendFeedback( + const std::filesystem::path& snapshotPath, + USHORT vendorId, + USHORT productId, + const std::wstring& controllerKind) { + const auto baseline = ReadSnapshot(snapshotPath); + auto device = WaitForNewGamepad( + baseline, vendorId, productId, GENERIC_READ | GENERIC_WRITE, + std::chrono::seconds(30)); + if (device->outputReportLength == 0) { + throw std::runtime_error("the virtual HID gamepad has no output report"); + } + auto report = BuildFeedbackReport(controllerKind, device->outputReportLength); + + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event.valid()) throw std::runtime_error(Win32Error("CreateEvent")); + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + DWORD transferred = 0; + BOOL completed = WriteFile(device->file.get(), report.data(), + static_cast(report.size()), &transferred, &overlapped); + if (!completed) { + const DWORD error = GetLastError(); + if (error != ERROR_IO_PENDING) { + throw std::runtime_error(Win32Error("WriteFile(HID output)")); + } + const DWORD wait = WaitForSingleObject(event.get(), 10000); + if (wait == WAIT_TIMEOUT) { + CancelAndDrainOverlapped(device->file.get(), overlapped); + throw std::runtime_error("timed out writing the HID output report"); + } + if (wait != WAIT_OBJECT_0 || + !GetOverlappedResult(device->file.get(), &overlapped, &transferred, FALSE)) { + throw std::runtime_error(Win32Error("GetOverlappedResult(HID output)")); + } + } + if (transferred != static_cast(report.size())) { + throw std::runtime_error("the HID output report completed with a short write"); + } + std::cout << "WROTE " << transferred << " " << WideToUtf8(device->path) << "\n"; + return 0; +} + } // namespace int wmain(int argc, wchar_t** argv) { @@ -334,9 +447,18 @@ int wmain(int argc, wchar_t** argv) { if (samples == 0) throw std::runtime_error("sample count must be nonzero"); return Measure(argv[2], vendorId, productId, offset, samples); } + if (argc == 7 && _wcsicmp(argv[1], L"feedback") == 0) { + const auto vendorId = static_cast(ParseUnsigned(argv[3], L"vendor ID", 0xFFFF)); + const auto productId = static_cast(ParseUnsigned(argv[4], L"product ID", 0xFFFF)); + if (wcscmp(argv[6], L"hid-output-v1") != 0) { + throw std::runtime_error("unsupported HID output probe contract"); + } + return SendFeedback(argv[2], vendorId, productId, argv[5]); + } std::wcerr << L"Usage:\n" << L" ViiperUdeInputProbe.exe snapshot \n" - << L" ViiperUdeInputProbe.exe measure qpc-v1\n"; + << L" ViiperUdeInputProbe.exe measure qpc-v1\n" + << L" ViiperUdeInputProbe.exe feedback hid-output-v1\n"; return 2; } catch (const std::exception& error) { std::cerr << "VIIPER UDE input probe failed: " << error.what() << "\n"; From 982f40609f71991b4093505115b6ad9124c6ede3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:48:36 -0500 Subject: [PATCH 118/240] Fix native driver INF release contract --- .github/workflows/native-ude.yml | 7 ++ native/udecx/driver/ViiperUde.vcxproj | 9 ++- native/udecx/package/ViiperUde.inf | 9 ++- .../Test-ViiperUdeTargetCompatibility.ps1 | 71 +++++++++++++++++-- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 28dc7222..350a5858 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -119,6 +119,13 @@ jobs: $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Build x64 driver run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" + - name: Verify stamped KMDF and DriverVer contract + shell: pwsh + run: >- + ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 + -ProjectPath ./native/udecx/driver/ViiperUde.vcxproj + -InfPath ./native/udecx/x64/Release/ViiperUde.inf + -RequireStampedInf - name: Build transactional root-devnode and live-media helpers shell: pwsh run: | diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 40f1b712..a5d65c0f 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,6 +13,8 @@ ViiperUde 17.0 x64 + 08/10/2026 + 0.1.0.0 @@ -77,7 +79,12 @@ - + + true + $(ViiperUdeDriverDate) + true + $(ViiperUdeDriverVersion) + diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 4bfecfbc..0fbef45b 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/09/2026,0.1.0.0 +DriverVer=08/10/2026,0.1.0.0 PnpLockDown=1 [DestinationDirs] @@ -28,6 +28,9 @@ CopyFiles=@ViiperUde.sys [ViiperUde_Install.NT.Services] AddService=ViiperUde,0x00000002,ViiperUde_Service +[ViiperUde_Install.NT.Wdf] +KmdfService=ViiperUde,ViiperUde_Wdf + [ViiperUde_Service] DisplayName=%ServiceName% ServiceType=1 @@ -36,9 +39,11 @@ ErrorControl=1 ServiceBinary=%13%\ViiperUde.sys Dependencies=ucx01000,udecx +[ViiperUde_Wdf] +KmdfLibraryVersion=$KMDFVERSION$ + [Strings] ProviderName="VIIPER Project" DeviceName="VIIPER Native USB Emulation Controller" ServiceName="VIIPER Native UdeCx Bus" DiskName="VIIPER Native UdeCx Installation Media" - diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index ce916a5e..ef6be179 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [string]$ProjectPath, - [string]$InfPath + [string]$InfPath, + [switch]$RequireStampedInf ) $ErrorActionPreference = 'Stop' @@ -26,12 +27,74 @@ if ($minorVersions.Count -ne 1 -or $minorVersions[0] -ne '27') { throw "Windows 10 1809 requires the committed KMDF 1.27 contract; project targets: $($minorVersions -join ', ')." } +$majorNodes = @($project.SelectNodes('//msb:KMDF_VERSION_MAJOR', $namespace)) +if ($majorNodes.Count -ne 2) { + throw "Expected Debug and Release KMDF_VERSION_MAJOR nodes; found $($majorNodes.Count)." +} +$majorVersions = @($majorNodes | ForEach-Object { $_.InnerText.Trim() } | Sort-Object -Unique) +if ($majorVersions.Count -ne 1 -or $majorVersions[0] -ne '1') { + throw "The committed driver must target KMDF major version 1; project targets: $($majorVersions -join ', ')." +} + +function Get-SingleProjectValue([string]$elementName) { + $nodes = @($project.SelectNodes("//msb:$elementName", $namespace)) + if ($nodes.Count -ne 1 -or [string]::IsNullOrWhiteSpace($nodes[0].InnerText)) { + throw "Expected exactly one non-empty $elementName project value; found $($nodes.Count)." + } + return $nodes[0].InnerText.Trim() +} + +$driverDate = Get-SingleProjectValue 'ViiperUdeDriverDate' +$driverVersion = Get-SingleProjectValue 'ViiperUdeDriverVersion' +$parsedDriverDate = [DateTime]::MinValue +if (-not [DateTime]::TryParseExact($driverDate, 'MM/dd/yyyy', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, [ref]$parsedDriverDate)) { + throw "ViiperUdeDriverDate must use deterministic MM/dd/yyyy format; found '$driverDate'." +} +if ($driverVersion -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw "ViiperUdeDriverVersion must be a four-part numeric version; found '$driverVersion'." +} + +$infItems = @($project.SelectNodes('//msb:Inf', $namespace)) +if ($infItems.Count -ne 1) { + throw "Expected exactly one INF project item; found $($infItems.Count)." +} +$infItem = $infItems[0] +$stampContract = [ordered]@{ + 'SpecifyDriverVerDirectiveDate' = 'true' + 'DateStamp' = '$(ViiperUdeDriverDate)' + 'SpecifyDriverVerDirectiveVersion' = 'true' + 'TimeStamp' = '$(ViiperUdeDriverVersion)' +} +foreach ($entry in $stampContract.GetEnumerator()) { + $node = $infItem.SelectSingleNode("msb:$($entry.Key)", $namespace) + if ($null -eq $node -or $node.InnerText.Trim() -cne $entry.Value) { + throw "INF build metadata '$($entry.Key)' must be '$($entry.Value)' so StampInf cannot synthesize a date or version." + } +} + $inf = Get-Content -LiteralPath $infPathResolved -Raw if ($inf -notmatch '(?mi)^\[Standard\.NTamd64\.10\.0\.\.\.17763\]\s*$') { throw 'The INF no longer declares the reviewed Windows 10 1809 (build 17763) target floor.' } -if ($inf -notmatch '(?mi)^DriverVer=\d{2}/\d{2}/\d{4},\d+\.\d+\.\d+\.\d+\s*$') { - throw 'The INF is missing a valid DriverVer entry.' +$driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($driverDate) + '\s*,\s*' + + [regex]::Escape($driverVersion) + '\s*$' +if ($inf -notmatch $driverVerPattern) { + throw "The INF DriverVer must exactly match the deterministic project contract '$driverDate,$driverVersion'." +} +if ($inf -notmatch '(?mi)^\[ViiperUde_Install\.NT\.Wdf\]\s*$' -or + $inf -notmatch '(?mi)^KmdfService\s*=\s*ViiperUde\s*,\s*ViiperUde_Wdf\s*$' -or + $inf -notmatch '(?mi)^\[ViiperUde_Wdf\]\s*$') { + throw 'The INF must bind the ViiperUde service through ViiperUde_Install.NT.Wdf.' +} +$expectedKmdfLibraryVersion = if ($RequireStampedInf) { '1.27' } else { '$KMDFVERSION$' } +$kmdfLibraryPattern = '(?mi)^KmdfLibraryVersion\s*=\s*' + + [regex]::Escape($expectedKmdfLibraryVersion) + '\s*$' +if ($inf -notmatch $kmdfLibraryPattern) { + throw "The INF KmdfLibraryVersion must be '$expectedKmdfLibraryVersion'." } -Write-Host 'VIIPER UDE target contract is aligned: Windows 10 1809, KMDF 1.27.' +$stampState = if ($RequireStampedInf) { 'stamped output' } else { 'source template' } +Write-Host "VIIPER UDE target contract is aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." From 098408e8c0c5186386a6ffadec8683b233ba5aa4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:56:09 -0500 Subject: [PATCH 119/240] Bind signed native packages to their catalog Require canonical returned-package contents, explicit controlled-test versus production signature modes, source-revision manifests, exact INF/PDB provenance, SignTool catalog membership for INF and SYS, and mandatory WHQL/Universal InfVerif gates. Propagate that evidence contract through live, performance, and Driver Verifier validation so an unrelated or attestation-only package cannot satisfy a production release gate. --- docs/architecture/native-udecx-signing.md | 15 +- native/udecx/README.md | 17 +- .../Enable-ViiperUdeVerifierForNextBoot.ps1 | 16 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 16 +- .../Invoke-ViiperUdePerformanceValidation.ps1 | 13 ++ .../tools/New-ViiperUdeAttestationPackage.ps1 | 1 + .../tools/Test-ViiperUdeSignedPackage.ps1 | 151 ++++++++++++++++-- 7 files changed, 206 insertions(+), 23 deletions(-) diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 4646b76a..a9bcf5aa 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -28,8 +28,10 @@ the public VIIPER driver. 4. Submit the signed CAB through the applicable Partner Center testing flow. 5. Download Microsoft's returned package and run `native/udecx/tools/Test-ViiperUdeSignedPackage.ps1`. It requires valid - Microsoft kernel-policy signatures on both the SYS and catalog and reruns - INF verification when the WDK tool is available. + Microsoft kernel-policy signatures on both the SYS and catalog, proves the + INF and SYS are members of that exact catalog, requires the testing-only + attestation EKU, binds the unchanged INF/PDB to the source-revision sidecar, + and requires both WHQL-aligned and Universal INF verification. 6. Hash-lock only that validated Microsoft-signed package into the installer. The structural CAB produced by CI is not installable production media. It has @@ -45,7 +47,9 @@ dashboard-signed drivers for release; WHCP is required for retail Windows Update publication. Run the controller and child devices through the applicable Device Fundamentals, USB, HID, audio, power, reliability, and security playlists, submit the resulting HLKX package, and validate the -dashboard-signed result with the same local validation script. +dashboard-signed result with the same local validation script in `Production` +mode. That mode rejects the attestation EKU and requires a release-eligible +`HLK/WHCP` evidence manifest bound to the reviewed source revision. ## Package invariants @@ -55,6 +59,11 @@ dashboard-signed result with the same local validation script. - The INF targets only `ROOT\VIIPER\UDE`, copies only `ViiperUde.sys`, and names only `ViiperUde.cat`. - The build and submission hash manifests identify the exact reviewed bits. +- Returned packages contain only the canonical INF, SYS, PDB, and CAT in one + directory. The unchanged INF/PDB must match the submission manifest, and + SignTool must prove INF/SYS membership in the returned Microsoft catalog. +- Controlled-test and production signatures are separate validation modes; + an attestation EKU can never satisfy the production release gate. - Test certificates, test-signing state, or disabled Secure Boot are never a release prerequisite. - The installer refuses an unsigned, test-signed, mismatched, downgraded, or diff --git a/native/udecx/README.md b/native/udecx/README.md index e98eb293..1fc323df 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -16,7 +16,10 @@ Directory contract: explicit testing-only acknowledgement. Microsoft currently restricts attestation to testing scenarios; production release requires HLK/WHCP. - `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned - driver and catalog against kernel signing policy. + driver and catalog against kernel signing policy, proves that INF and SYS are + members of that exact catalog, distinguishes testing-only attestation from + production HLK/WHCP signatures, and binds the returned INF/PDB to the + reviewed source-revision manifest. - `tools/Invoke-ViiperUdeLiveValidation.ps1` hash-binds that verified package to the installed service image and root devnode, then exercises every production controller through the real UdeCx host, direct interrupt-input @@ -100,6 +103,9 @@ Microsoft-signed package with: ```powershell .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` -Iterations 10 ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe @@ -143,10 +149,16 @@ The Driver Verifier pass is a separate, explicit disposable-machine gate: ```powershell .\native\udecx\tools\Enable-ViiperUdeVerifierForNextBoot.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` -DisposableTestMachine # Restart once, then: .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` -Iterations 10 ` -RequireDriverVerifier ` -RestartRootDevice ` @@ -162,6 +174,9 @@ inside WPR's bounded `GeneralProfile.Light` memory profile: ```powershell .\native\udecx\tools\Invoke-ViiperUdePerformanceValidation.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode Production ` -OutputPath C:\ViiperUde\Traces\native-ude.etl ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe diff --git a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 index 23aab12c..3b8365c7 100644 --- a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 +++ b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 @@ -3,6 +3,16 @@ param( [Parameter(Mandatory = $true)] [string]$SignedPackageDirectory, + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + [switch]$DisposableTestMachine ) @@ -39,7 +49,11 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra } $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' -& $signatureGate -PackageDirectory $SignedPackageDirectory +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $SubmissionManifestPath ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode $SignatureValidationMode $packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path $packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 9e5b30ca..89185032 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -3,6 +3,16 @@ param( [Parameter(Mandatory = $true)] [string]$SignedPackageDirectory, + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + [ValidateRange(1, 100)] [int]$Iterations = 1, @@ -46,7 +56,11 @@ if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { } $repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' -& $signatureGate -PackageDirectory $SignedPackageDirectory +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $SubmissionManifestPath ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode $SignatureValidationMode $packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path $packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 index a70d02a1..54b67c6f 100644 --- a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -3,6 +3,16 @@ param( [Parameter(Mandatory = $true)] [string]$SignedPackageDirectory, + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('ControlledTest', 'Production')] + [string]$SignatureValidationMode = 'Production', + [Parameter(Mandatory = $true)] [string]$OutputPath, @@ -63,6 +73,9 @@ $validationFailure = $null $validationArguments = @{ SignedPackageDirectory = $SignedPackageDirectory + SubmissionManifestPath = $SubmissionManifestPath + ExpectedSourceRevision = $ExpectedSourceRevision + SignatureValidationMode = $SignatureValidationMode Iterations = $Iterations } if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index c2fe2df1..965e4bca 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -168,6 +168,7 @@ try { schema = 1 purpose = 'Microsoft Hardware Dev Center controlled-test attestation submission; not a retail release package' releaseEligible = $false + signingRoute = 'ControlledTestAttestation' requiredProductionRoute = 'HLK/WHCP dashboard signing' sourceRevision = $SourceRevision.ToLowerInvariant() cabinet = [System.IO.Path]::GetFileName($outputFullPath) diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index d9c6fca6..6c85b607 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -1,47 +1,164 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$PackageDirectory + [string]$PackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$ExpectedSourceRevision, + + [ValidateSet('ControlledTest', 'Production')] + [string]$ValidationMode = 'Production' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +function Get-CertificateEkuOids { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $oids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($extension in $Certificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { + continue + } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + foreach ($oid in $eku.EnhancedKeyUsages) { + [void]$oids.Add($oid.Value) + } + } + return ,$oids +} + +function Assert-MicrosoftHardwareSignature { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [ValidateSet('ControlledTest', 'Production')] + [string]$Mode + ) + + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." + } + if ($null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Subject -notmatch '(?i)(^|,\s*)O=Microsoft Corporation(,|$)') { + throw "'$Path' is not signed by Microsoft Corporation." + } + + $ekuOids = Get-CertificateEkuOids -Certificate $signature.SignerCertificate + $hardwareVerificationOid = '1.3.6.1.4.1.311.10.3.5' + $attestedVerificationOid = '1.3.6.1.4.1.311.10.3.5.1' + if (-not $ekuOids.Contains($hardwareVerificationOid)) { + throw "'$Path' lacks the Windows Hardware Driver Verification EKU." + } + if ($Mode -eq 'ControlledTest') { + if (-not $ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is not a Microsoft attestation-signed controlled-test artifact." + } + } + elseif ($ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is attestation signed and cannot pass the production HLK/WHCP release gate." + } +} + $root = Resolve-Path -LiteralPath $PackageDirectory -ErrorAction Stop if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { - throw "The signed package path must be a directory." + throw 'The signed package path must be a directory.' } -$expected = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$expectedNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$allFiles = @(Get-ChildItem -LiteralPath $root.Path -Recurse -File) +if ($allFiles.Count -ne $expectedNames.Count) { + throw "The signed package must contain exactly $($expectedNames.Count) files; found $($allFiles.Count)." +} $files = @{} -foreach ($name in $expected) { - $matches = @(Get-ChildItem -LiteralPath $root.Path -Recurse -File -Filter $name) +foreach ($name in $expectedNames) { + $matches = @($allFiles | Where-Object Name -CEQ $name) if ($matches.Count -ne 1) { - throw "The signed package must contain exactly one '$name'; found $($matches.Count)." + throw "The signed package must contain exactly one case-exact '$name'; found $($matches.Count)." } $files[$name] = $matches[0].FullName } +$packageParents = @($allFiles.DirectoryName | Sort-Object -Unique) +if ($packageParents.Count -ne 1) { + throw 'The signed package files must share one canonical package directory.' +} + +$manifestFile = Resolve-Path -LiteralPath $SubmissionManifestPath -ErrorAction Stop +$manifest = Get-Content -LiteralPath $manifestFile.Path -Raw | ConvertFrom-Json +if ($manifest.schema -ne 1 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant()) { + throw 'The submission manifest schema or source revision does not match the reviewed source.' +} +if ($ValidationMode -eq 'ControlledTest') { + if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'ControlledTestAttestation') { + throw 'Controlled-test validation requires a testing-only attestation submission manifest.' + } +} +elseif (-not [bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'HLK/WHCP') { + throw 'Production validation requires a release-eligible HLK/WHCP submission manifest.' +} + +$manifestFiles = @($manifest.files) +if ($manifestFiles.Count -ne $expectedNames.Count) { + throw "The submission manifest must describe exactly $($expectedNames.Count) files." +} +$manifestByName = @{} +foreach ($entry in $manifestFiles) { + $name = [string]$entry.name + if ($expectedNames -cnotcontains $name -or $manifestByName.ContainsKey($name)) { + throw "The submission manifest contains an unexpected or duplicate file '$name'." + } + $manifestByName[$name] = $entry +} +foreach ($name in @('ViiperUde.inf', 'ViiperUde.pdb')) { + if (-not $manifestByName.ContainsKey($name)) { + throw "The submission manifest does not describe '$name'." + } + $actual = Get-Item -LiteralPath $files[$name] + $actualHash = (Get-FileHash -LiteralPath $actual.FullName -Algorithm SHA256).Hash + if ($actual.Length -ne [long]$manifestByName[$name].length -or + $actualHash -cne ([string]$manifestByName[$name].sha256).ToUpperInvariant()) { + throw "The Microsoft-returned '$name' does not match the source-bound submission manifest." + } +} $signTool = Get-Command signtool.exe -ErrorAction Stop -foreach ($name in @('ViiperUde.sys', 'ViiperUde.cat')) { +foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { & $signTool.Source verify /kp /v $files[$name] if ($LASTEXITCODE -ne 0) { throw "Kernel-policy signature validation failed for '$name' with exit code $LASTEXITCODE." } - $signature = Get-AuthenticodeSignature -LiteralPath $files[$name] - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or - $null -eq $signature.SignerCertificate -or - $signature.SignerCertificate.Subject -notmatch '(?i)Microsoft') { - throw "'$name' does not have a valid Microsoft production signature." + Assert-MicrosoftHardwareSignature -Path $files[$name] -Mode $ValidationMode +} +foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { + & $signTool.Source verify /kp /v /c $files['ViiperUde.cat'] $files[$name] + if ($LASTEXITCODE -ne 0) { + throw "'$name' is not a verified member of the Microsoft-signed catalog (exit code $LASTEXITCODE)." } } -$infVerif = Get-Command infverif.exe -ErrorAction SilentlyContinue -if ($null -ne $infVerif) { - & $infVerif.Source /v $files['ViiperUde.inf'] +$infVerif = Get-Command infverif.exe -ErrorAction Stop +foreach ($mode in @('/h', '/u')) { + & $infVerif.Source $mode $files['ViiperUde.inf'] if ($LASTEXITCODE -ne 0) { - throw "InfVerif rejected the Microsoft-signed package with exit code $LASTEXITCODE." + throw "InfVerif $mode rejected the Microsoft-signed package with exit code $LASTEXITCODE." } } -Write-Host "Validated Microsoft-signed VIIPER native UDE package at '$($root.Path)'." +Write-Host "Validated source-bound Microsoft-signed VIIPER native UDE package in $ValidationMode mode at '$($root.Path)'." From c766ef2c54d652ae7c636137eacb6da691a5ec21 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:58:20 -0500 Subject: [PATCH 120/240] Harden native broker API defaults Bind the management API to IPv4 loopback by default, authenticate localhost by default, and force localhost authentication for native UDE topology and stream control. Refuse remote/auth-required listeners without a configured credential while preserving explicit authenticated remote binding and the legacy USB/IP localhost development opt-out. Stop printing generated credentials and document the new contract. Add deterministic policy, bind-classification, config-default, and secret-log tests. --- docs/api/overview.md | 18 ++-- docs/cli/configuration.md | 19 ++--- docs/cli/server.md | 29 +++---- docs/getting-started/quickstart.md | 8 +- docs/index.md | 6 +- internal/cmd/server.go | 27 ++++-- internal/cmd/server_security_test.go | 47 +++++++++++ internal/server/api/config.go | 6 +- internal/server/api/security_test.go | 122 +++++++++++++++++++++++++++ internal/server/api/server.go | 40 +++++++++ 10 files changed, 268 insertions(+), 54 deletions(-) create mode 100644 internal/cmd/server_security_test.go create mode 100644 internal/server/api/security_test.go diff --git a/docs/api/overview.md b/docs/api/overview.md index 586960ce..f020ebaf 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -77,8 +77,8 @@ If you ever worked with HTTP APIs before, you'll feel right at home. The exception to this are the device-control and feedback streams, which are raw binary streams specific to each device type. - **Transport**: TCP with optional encryption (ChaCha20-Poly1305) -- **Default listen address**: `:3242` (configurable via `--api.addr`) -- **Authentication**: Required for remote connections, optional for localhost (password-based with HMAC validation) +- **Default listen address**: `127.0.0.1:3242` (configurable via `--api.addr`) +- **Authentication**: Required by default for localhost and always required for remote connections (password-based with HMAC validation) - **Encryption**: Automatic for authenticated connections (ChaCha20-Poly1305 with unique session keys) - **Request format**: a single ASCII/UTF‑8 line terminated by `\0` - **Routing**: path followed by optional payload separated by whitespace @@ -94,11 +94,11 @@ The exception to this are the device-control and feedback streams, which are raw !!! warning "Connection timing and auto‑cleanup" After you add a device with `bus/{id}/add`, you must connect to its streaming endpoint within the configured `DeviceHandlerConnectTimeout` (default: 5s). If no stream connection is established in time, the device is automatically removed. Likewise, when a stream disconnects, a reconnection timer with the same timeout starts; if the client doesn’t reconnect before it expires, the device is removed. -!!! warning "Authentication Required for Remote Connections" - **VIIPER requires authentication for all non-localhost connections.** - - - **Localhost clients** (`127.0.0.1`, `::1`, `localhost`): Authentication is **optional** (but supported) by default - - **Remote clients**: Authentication is **required** and enforced +!!! warning "Authentication Required" + **VIIPER requires authentication for API topology and device-stream control by default.** + + - **Localhost clients** (`127.0.0.1`, `::1`, `localhost`): Authentication is **required by default** + - **Remote clients**: Authentication is **always required** and enforced On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. @@ -106,9 +106,9 @@ The exception to this are the device-control and feedback streams, which are raw Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - Remote clients must provide this password to establish a connection. + Clients must provide this password to establish an authenticated connection. The value is never printed to a VIIPER log or console. - See the [Configuration](../cli/configuration.md) documentation for details on password management and the `--api.require-localhost-auth` option. + See the [Configuration](../cli/configuration.md) documentation for credential management and the legacy USB/IP localhost development opt-out. ## Endpoints diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0dd0791a..af5fe5f7 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -21,10 +21,10 @@ All command-line flags have corresponding environment variables for easier deplo | Environment Variable | CLI Flag | Default | Description | |---------------------|----------|---------|-------------| | `VIIPER_USB_ADDR` | `--usb.addr` | `:3241` | USBIP server listen address | -| `VIIPER_API_ADDR` | `--api.addr` | `:3242` | API server listen address | +| `VIIPER_API_ADDR` | `--api.addr` | `127.0.0.1:3242` | API server listen address | | `VIIPER_API_DEVICE_HANDLER_TIMEOUT` | `--api.device-handler-timeout` | `5s` | Device handler auto-cleanup timeout | | `VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT` | `--api.auto-attach-local-client` | `true` | Auto-attach exported devices to local usbip client | -| `VIIPER_API_REQUIRE_LOCALHOST_AUTH` | `--api.require-localhost-auth` | `false` | Require authentication even for localhost connections | +| `VIIPER_API_REQUIRE_LOCALHOST_AUTH` | `--api.require-local-host-auth` | `true` | Require authentication for localhost connections | | `VIIPER_CONNECTION_TIMEOUT` | `--connection-timeout` | `30s` | Connection operation timeout | ### Proxy Configuration @@ -66,8 +66,7 @@ If --config is not provided, VIIPER will search for configuration in this order ## Authentication and Security -VIIPER requires authentication for remote (non-localhost) connections -to prevent unauthorized device creation. +VIIPER requires authentication by default for local and remote API clients to prevent unauthorized device creation and stream takeover. The password file is _intentionally_ separated from the main configuration @@ -77,15 +76,13 @@ The password file is _intentionally_ separated from the main configuration - **Windows:** `%APPDATA%\VIIPER\` - **Linux/macOS (user):** `~/.config/github.com/Alia5/viiper/` - **Linux (root/systemd):** `/etc/viiper/` -- **Auto-generation:** If the file doesn't exist, -VIIPER generates a random 16-character password on first start and displays it in the console +- **Auto-generation:** If the file doesn't exist, VIIPER generates a random 16-character password on first start. The value is stored only in the credential file and is not printed to logs or the console. - **Custom passwords:** You can edit `viiper.key.txt` and replace it with any password of any length - **Encryption:** All authenticated connections use fast ChaCha20-Poly1305 encryption with unique session keys -### Localhost Exemption - -By default, clients connecting from `localhost`, `127.0.0.1`, or `::1` do NOT require authentication (they can optionally provide it). -To require authentication even for localhost connections, use `--api.require-localhost-auth=true`. +### Localhost Authentication + +Clients connecting from `localhost`, `127.0.0.1`, or `::1` authenticate by default. For legacy USB/IP development only, `--api.require-local-host-auth=false` opts out locally. Native UDE transport always requires authentication. ### Remote Connections @@ -100,7 +97,7 @@ All remote clients MUST authenticate using the password from `viiper.key.txt`. ```json { "api": { - "addr": ":3242", + "addr": "127.0.0.1:3242", "device-handler-connect-timeout": "5s", "auto-attach-local-client": true }, diff --git a/docs/cli/server.md b/docs/cli/server.md index bf8f4a99..3625edbb 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -18,8 +18,8 @@ The server exposes two interfaces: 1. **USBIP Server** - Standard USBIP protocol for device attachment 2. **VIIPER API Server** - Management API for device/bus control -!!! warning "Authentication Required for Remote Connections" - VIIPER requires **authentication for all remote (non-localhost) connections** to prevent unauthorized device creation. +!!! warning "Authentication Required" + VIIPER requires authentication by default, including for localhost, to prevent an unrelated local process from creating devices or taking over a live controller stream. On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. @@ -27,11 +27,11 @@ The server exposes two interfaces: Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is optional by default - - **Remote clients**: Authentication is required and enforced - - All authenticated connections use **ChaCha20-Poly1305 encryption** - - See the `--api.require-localhost-auth` option below to require authentication for localhost connections. + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is required by default + - **Remote clients**: Authentication is always required and enforced + - All authenticated connections use **ChaCha20-Poly1305 encryption** + + The credential is never printed to the console or log. Clients read it from the protected credential file. Native UDE mode always requires localhost authentication. !!! info "Automatic Local Attachment" By default, VIIPER automatically attaches newly created devices to the local USBIP client (localhost only). @@ -51,7 +51,7 @@ USBIP server listen address. API server listen address. -**Default:** `:3242` +**Default:** `127.0.0.1:3242` **Environment Variable:** `VIIPER_API_ADDR` ### `--api.device-handler-timeout` @@ -76,20 +76,19 @@ Disable example: viiper server --api.auto-attach-local-client=false ``` -### `--api.require-localhost-auth` +### `--api.require-local-host-auth` Require authentication even for clients connecting from localhost (`127.0.0.1`, `::1`, `localhost`). -By default, localhost clients are exempt from authentication for convenience during local development. -Enable this option if you want to enforce authentication for all connections regardless of origin. +Authentication is enabled by default. Legacy USB/IP development can explicitly disable it for localhost only; native UDE mode ignores that opt-out and remains authenticated. -**Default:** `false` +**Default:** `true` **Environment Variable:** `VIIPER_API_REQUIRE_LOCALHOST_AUTH` -Enable example: +Local USB/IP development opt-out: ```bash -viiper server --api.require-localhost-auth=true +viiper server --api.require-local-host-auth=false ``` ### `--connection-timeout` @@ -103,7 +102,7 @@ Connection operation timeout for both USBIP and API servers. ### Basic Server -Start server with default settings (USBIP on :3241, API on :3242): +Start server with default settings (USBIP on :3241, API on 127.0.0.1:3242): ```bash viiper server diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index bfcc7e75..861324f9 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -20,19 +20,19 @@ This starts two services: - **USBIP Server** on port `3241` (standard USBIP protocol) - **VIIPER API Server** on port `3242` (management and device interactions) -!!! warning "Authentication for Remote Connections" +!!! warning "API Authentication" On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. Windows: `%APPDATA%\VIIPER\viiper.key.txt` Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` Linux (root/systemd): `/etc/viiper/viiper.key.txt` - - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is **optional** (but supported) - - **Remote clients**: Authentication is **required** - provide the password using your client library + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is **required by default** + - **Remote clients**: Authentication is **always required** - provide the password using your client library All authenticated connections use **ChaCha20-Poly1305 encryption** to protect against man-in-the-middle attacks. - You can change the password at any time by editing `viiper.key.txt`. + The password is never printed in logs or the console. Read it from `viiper.key.txt`; you can change it by editing that file while VIIPER is stopped. !!! tip "Auto-attach Feature" By default, VIIPER automatically attaches newly created devices to the local machine. You can disable this with `--api.auto-attach-local-client=false`. diff --git a/docs/index.md b/docs/index.md index 6b38f70b..b4a03668 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,11 +90,11 @@ VIIPER takes care of all USBIP protocol details, so you can focus on implementin On `localhost` VIIPER also automatically attached the USBIP client, so you don't have to worry about USBIP details at all. !!! info "Security: Authentication & Encryption" - VIIPER **requires authentication for remote connections** - to prevent unauthorized device creation. + VIIPER **requires authentication by default**, including on localhost, + to prevent unauthorized device creation and stream takeover. All authenticated connections use fast **ChaCha20-Poly1305 encryption** to protect against man-in-the-middle attacks. - Localhost connections are exempt from authentication by default for convenience. + Native UDE mode never permits unauthenticated topology or stream control. See the [API documentation](api/overview) for details diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 160d73d9..2b2e9838 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -48,6 +48,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger return err } } + applyTransportAPISecurityPolicy(transport, &s.APIServerConfig) ctx, cancel := context.WithCancel(ctx) stopTray := tray.Run(ctx, cancel) @@ -82,13 +83,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger return fmt.Errorf("failed to write new API password to file: %w", err) } s.APIServerConfig.Password = newPwd - logger.Info("Generated API server password", "path", keyFilePath) - logger.Info("-------------------------------------") - logger.Info("Your VIIPER API server password is:") - logger.Info("-------------------------------------") - logger.Info(newPwd) - logger.Info("-------------------------------------") - logger.Info("You can change this password at any time by editing the file") + logGeneratedAPICredential(logger, keyFilePath) } usbSrv := usb.New(s.USBServerConfig, logger, rawLogger) @@ -117,8 +112,8 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger } if s.APIServerConfig.Addr == "" { - logger.Error("API server address must be set (default :3242).") - return fmt.Errorf("API server address must be set (default :3242).") // nolint + logger.Error("API server address must be set", "default", api.DefaultListenAddress) + return fmt.Errorf("API server address must be set (default %s)", api.DefaultListenAddress) } apiSrv := api.New(usbSrv, s.APIServerConfig.Addr, s.APIServerConfig, logger) @@ -174,6 +169,20 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger } } +func applyTransportAPISecurityPolicy(transport string, config *api.ServerConfig) { + // The native broker owns local kernel topology and live controller streams. + // It must never inherit the historical unauthenticated-localhost exemption, + // particularly when the broker is eventually hosted as LocalSystem. + if transport == "native-ude" { + config.RequireLocalHostAuth = true + } +} + +func logGeneratedAPICredential(logger *slog.Logger, path string) { + logger.Info("Generated API server credential", "path", path) + logger.Info("API clients must authenticate with the credential stored in that file") +} + func nativeDone(session nativeUDETransport) <-chan error { if session == nil { return nil diff --git a/internal/cmd/server_security_test.go b/internal/cmd/server_security_test.go new file mode 100644 index 00000000..28e7912c --- /dev/null +++ b/internal/cmd/server_security_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "bytes" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyTransportAPISecurityPolicy(t *testing.T) { + tests := []struct { + name string + transport string + initial bool + expected bool + }{ + {name: "native forces local authentication", transport: "native-ude", expected: true}, + {name: "usbip preserves explicit local opt-out", transport: "usbip", expected: false}, + {name: "usbip preserves local authentication", transport: "usbip", initial: true, expected: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := api.ServerConfig{RequireLocalHostAuth: test.initial} + applyTransportAPISecurityPolicy(test.transport, &config) + assert.Equal(t, test.expected, config.RequireLocalHostAuth) + }) + } +} + +func TestGeneratedAPICredentialLogDoesNotExposeSecret(t *testing.T) { + const secret = "do-not-print-this-api-secret" + keyPath := filepath.Join(t.TempDir(), "viiper.key.txt") + require.NoError(t, os.WriteFile(keyPath, []byte(secret), 0o600)) + + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + logGeneratedAPICredential(logger, keyPath) + + assert.Contains(t, output.String(), keyPath) + assert.NotContains(t, output.String(), secret) +} diff --git a/internal/server/api/config.go b/internal/server/api/config.go index 3c446c47..b4b5fb1f 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -4,12 +4,12 @@ import "time" // ServerConfig represents the server subcommand configuration. type ServerConfig struct { - Addr string `help:"API server listen address" default:":3242" env:"VIIPER_API_ADDR"` + Addr string `help:"API server listen address" default:"127.0.0.1:3242" env:"VIIPER_API_ADDR"` DeviceHandlerConnectTimeout time.Duration `help:"Time before auto-cleanup occurs when device handler has no active connection" default:"5s" env:"VIIPER_API_DEVICE_HANDLER_TIMEOUT"` AutoAttachLocalClient bool `help:"Controls usbip-client on localhost to auto-attach devices added to the virtual bus" default:"true" env:"VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT"` - RequireLocalHostAuth bool `help:"Require authentication for clients connecting from localhost" default:"false" env:"VIIPER_API_REQUIRE_LOCALHOST_AUTH"` + RequireLocalHostAuth bool `help:"Require authentication for clients connecting from localhost" default:"true" env:"VIIPER_API_REQUIRE_LOCALHOST_AUTH"` ConnectionTimeout time.Duration `kong:"-"` PlatformOpts `embed:""` - // password for api (remote) server auth (ALWAYS read from file) + // Password authenticates API clients and is always read from the credential file. Password string `kong:"-"` } diff --git a/internal/server/api/security_test.go b/internal/server/api/security_test.go new file mode 100644 index 00000000..87e490b0 --- /dev/null +++ b/internal/server/api/security_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "log/slog" + "testing" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerConfigSecureDefaults(t *testing.T) { + var options struct { + API ServerConfig `embed:"" prefix:"api."` + } + parser, err := kong.New(&options) + require.NoError(t, err) + _, err = parser.Parse(nil) + require.NoError(t, err) + + assert.Equal(t, DefaultListenAddress, options.API.Addr) + assert.True(t, options.API.RequireLocalHostAuth) +} + +func TestServerConfigExplicitLocalDevelopmentOptOut(t *testing.T) { + var options struct { + API ServerConfig `embed:"" prefix:"api."` + } + parser, err := kong.New(&options) + require.NoError(t, err) + _, err = parser.Parse([]string{ + "--api.addr=:43242", + "--api.require-local-host-auth=false", + }) + require.NoError(t, err) + + assert.Equal(t, ":43242", options.API.Addr) + assert.False(t, options.API.RequireLocalHostAuth) +} + +func TestNewUsesLoopbackWhenAddressIsEmpty(t *testing.T) { + server := New(nil, " ", ServerConfig{}, slog.Default()) + + assert.Equal(t, DefaultListenAddress, server.Addr()) + assert.Equal(t, DefaultListenAddress, server.Config().Addr) +} + +func TestLoopbackListenAddress(t *testing.T) { + tests := map[string]bool{ + "127.0.0.1:3242": true, + "127.99.1.2:0": true, + "localhost:3242": true, + "[::1]:3242": true, + "[::1%1]:3242": true, + ":3242": false, + "0.0.0.0:3242": false, + "[::]:3242": false, + "192.0.2.1:3242": false, + "viiper.test:42": false, + "not-an-address": false, + } + + for addr, expected := range tests { + t.Run(addr, func(t *testing.T) { + assert.Equal(t, expected, isLoopbackListenAddress(addr)) + }) + } +} + +func TestValidateSecurityConfiguration(t *testing.T) { + tests := []struct { + name string + addr string + config ServerConfig + wantError string + }{ + { + name: "explicit local development opt-out", + addr: "127.0.0.1:3242", + config: ServerConfig{}, + }, + { + name: "authenticated localhost needs credential", + addr: "127.0.0.1:3242", + config: ServerConfig{RequireLocalHostAuth: true}, + wantError: "authentication is required for localhost", + }, + { + name: "authenticated localhost", + addr: "127.0.0.1:3242", + config: ServerConfig{RequireLocalHostAuth: true, Password: "secret"}, + }, + { + name: "wildcard needs credential", + addr: ":3242", + config: ServerConfig{}, + wantError: "may accept remote connections", + }, + { + name: "specific remote interface needs credential", + addr: "192.0.2.10:3242", + config: ServerConfig{}, + wantError: "may accept remote connections", + }, + { + name: "explicit authenticated remote listener", + addr: ":3242", + config: ServerConfig{Password: "secret"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityConfiguration(test.addr, test.config) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} diff --git a/internal/server/api/server.go b/internal/server/api/server.go index e5ac4592..e66fc73d 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -32,6 +32,11 @@ type Server struct { deviceStreams deviceStreamCoordinator } +// DefaultListenAddress deliberately names one loopback interface. An empty +// host (for example, ":3242") asks the operating system to listen on every +// interface and must only be selected explicitly by an administrator. +const DefaultListenAddress = "127.0.0.1:3242" + // microphonePCMResetter is implemented by audio-capable virtual controllers. // Its reset is coordinated with stream ownership instead of individual device // handlers so a same-device replacement can retain already-buffered capture. @@ -48,6 +53,11 @@ const deviceStreamReconnectGrace = 250 * time.Millisecond // New creates a new ApiServer bound to a server.Server instance. func New(s *usb.Server, addr string, config ServerConfig, logger *slog.Logger) *Server { cfg := config + addr = strings.TrimSpace(addr) + if addr == "" { + addr = DefaultListenAddress + } + cfg.Addr = addr a := &Server{ usbs: s, addr: addr, @@ -96,6 +106,9 @@ func (s *Server) Addr() string { // Start listens on the configured address and serves incoming API commands. func (s *Server) Start() error { + if err := validateSecurityConfiguration(s.addr, *s.config); err != nil { + return err + } ln, err := net.Listen("tcp", s.addr) if err != nil { return err @@ -109,6 +122,33 @@ func (s *Server) Start() error { return nil } +func validateSecurityConfiguration(addr string, config ServerConfig) error { + passwordPresent := strings.TrimSpace(config.Password) != "" + if config.RequireLocalHostAuth && !passwordPresent { + return errors.New("API authentication is required for localhost, but no API credential is configured") + } + if !isLoopbackListenAddress(addr) && !passwordPresent { + return fmt.Errorf("API address %q may accept remote connections, but no API credential is configured", addr) + } + return nil +} + +func isLoopbackListenAddress(addr string) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return false + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if strings.EqualFold(host, "localhost") { + return true + } + if zone := strings.LastIndexByte(host, '%'); zone >= 0 { + host = host[:zone] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + // Close stops the API server. func (s *Server) Close() { if s.ln != nil { From cfb56b66b054f468afc95bff3d210a45abd7c006 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 03:59:54 -0500 Subject: [PATCH 121/240] Gate releases on native driver validation Run native UdeCx validation for main and release tags, cover transport-sensitive source paths, validate the built INF under WHQL-aligned and Universal rules, and prevent test-signed packages from leaking into public releases. Release creation now waits for the reusable native gate while retaining controlled test artifact uploads on branch and manual runs. --- .github/workflows/native-ude.yml | 38 ++++++++++++++++++++++++++++++-- .github/workflows/release.yml | 24 +++++++++++++------- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 350a5858..449f1e28 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -2,28 +2,51 @@ name: Native UdeCx bus on: push: - branches: [feature/native-udecx-bus] + branches: [main, feature/native-udecx-bus] paths: + - "go.mod" + - "go.sum" - "native/udecx/**" - "internal/transport/udecx/**" - "internal/server/usb/**" + - "internal/server/api/**" - "internal/cmd/**" + - "internal/configpaths/**" + - "viipertypes/**" - "device/**" - "usb/**" + - "scripts/**" - "_testing/e2e/**" - "docs/testing/e2e_latency.md" + - ".github/workflows/build_base.yml" - ".github/workflows/native-ude.yml" + - ".github/workflows/release.yml" pull_request: paths: + - "go.mod" + - "go.sum" - "native/udecx/**" - "internal/transport/udecx/**" - "internal/server/usb/**" + - "internal/server/api/**" - "internal/cmd/**" + - "internal/configpaths/**" + - "viipertypes/**" - "device/**" - "usb/**" + - "scripts/**" - "_testing/e2e/**" - "docs/testing/e2e_latency.md" + - ".github/workflows/build_base.yml" - ".github/workflows/native-ude.yml" + - ".github/workflows/release.yml" + workflow_call: + inputs: + upload_artifacts: + description: Upload the test-signed native package for controlled testing. + required: false + type: boolean + default: false workflow_dispatch: permissions: @@ -118,7 +141,17 @@ jobs: if (-not $stampInf) { throw "Restored WDK package did not contain stampinf.exe" } $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Build x64 driver - run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign /p:InfVerif_AdditionalOptions="/w" + run: msbuild native/udecx/ViiperUde.sln /m /p:Configuration=Release /p:Platform=x64 /p:SignMode=TestSign + - name: Enforce WHQL-aligned and Universal INF rules + shell: pwsh + run: | + $inf = (Resolve-Path ./native/udecx/x64/Release/ViiperUde.inf).Path + foreach ($mode in @('/h', '/u')) { + & infverif.exe $mode $inf + if ($LASTEXITCODE -ne 0) { + throw "InfVerif $mode rejected the stamped native INF (exit $LASTEXITCODE)." + } + } - name: Verify stamped KMDF and DriverVer contract shell: pwsh run: >- @@ -175,6 +208,7 @@ jobs: with: category: /language:c-cpp - uses: actions/upload-artifact@v4 + if: ${{ github.event_name != 'workflow_call' || inputs.upload_artifacts }} with: name: ViiperUde-x64-test-signed path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 605b1162..f689549f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,20 @@ on: tags: - "v*.*.*" -permissions: - contents: write - id-token: write +permissions: + actions: read + contents: write + id-token: write + security-events: write -jobs: - build: +jobs: + native-validation: + name: Native UdeCx release gate + uses: ./.github/workflows/native-ude.yml + with: + upload_artifacts: false + + build: uses: ./.github/workflows/build_base.yml secrets: inherit with: @@ -33,9 +41,9 @@ jobs: upload_artifacts: true version: ${{ github.ref_name }} - create-release: - name: Create Release - needs: [build, generate-changelog, client-libraries] + create-release: + name: Create Release + needs: [native-validation, build, generate-changelog, client-libraries] runs-on: ubuntu-latest steps: - name: Checkout code From 64a8f1814a43dea1301819aba5adf195e610bbbf Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:02:53 -0500 Subject: [PATCH 122/240] Fence saturated native UDE endpoint lanes Match each endpoint inbox to the broker's 512-operation child contract and make central dispatch admission nonblocking so a stalled controller cannot starve independent devices or cancellation traffic. Give lanes synchronized terminal state, cancel and tombstone fatal generations, remove only the exact routed lane instance, and prioritize fatal shutdown over queued dequeue results or blocking failure completion. Cover saturated-controller isolation, fatal lanes with queued work, terminal non-recreation, and prompt Serve cancellation with deterministic regression tests. --- internal/transport/udecx/host.go | 182 ++++++++++++--- internal/transport/udecx/host_test.go | 316 ++++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 26 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 317534d7..2fb37e69 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -13,8 +13,12 @@ import ( ) const ( - defaultDequeueWorkers = 8 - laneQueueDepth = 128 + defaultDequeueWorkers = 8 + // A child cannot expose more operations than the pending-operation + // contract published to the kernel. Matching that bound here lets a busy + // endpoint absorb every operation the broker can legally own without ever + // making the central dispatcher wait for one controller. + laneQueueDepth = defaultDevicePendingOperations completionTimeout = 2 * time.Second terminalCleanupTimeout = 30 * time.Second completedTokenHistory = MaxPendingOperations * 2 @@ -84,11 +88,13 @@ type laneKey struct { } type operationLane struct { - key laneKey - ctx context.Context - cancel context.CancelFunc - input chan Operation - done chan struct{} + key laneKey + ctx context.Context + cancel context.CancelFunc + input chan Operation + done chan struct{} + stateMu sync.Mutex + terminalErr error } type operationState struct { @@ -120,6 +126,7 @@ type Host struct { devices map[uint64]*registeredDevice generations map[uint64]uint32 lanes map[laneKey]*operationLane + failedLanes map[laneKey]error runCtx context.Context runCancel context.CancelFunc fatal chan error @@ -143,6 +150,7 @@ func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, e devices: make(map[uint64]*registeredDevice), generations: make(map[uint64]uint32), lanes: make(map[laneKey]*operationLane), + failedLanes: make(map[laneKey]error), lifecycles: make(map[uint64]*deviceLifecycleGate), operations: make(map[uint64]*operationState), } @@ -336,16 +344,21 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { delete(h.lanes, key) } } + for key := range h.failedLanes { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(h.failedLanes, key) + } + } h.mu.Unlock() // Mark operations cancelled before their processing contexts are stopped. // This prevents a processor waking on lane cancellation and racing a // completion through an intentionally cancelled driver handle. h.cancelDeviceOperations(identity) - entry.cancel() for _, lane := range stoppingLanes { - lane.cancel() + stopLane(lane) } + entry.cancel() for _, lane := range stoppingLanes { select { case <-lane.done: @@ -533,11 +546,16 @@ func (h *Host) Serve(ctx context.Context) error { defer func() { cancel() h.mu.Lock() + stoppingLanes := make([]*operationLane, 0, len(h.lanes)) for key, lane := range h.lanes { - lane.cancel() + stoppingLanes = append(stoppingLanes, lane) delete(h.lanes, key) } + h.failedLanes = make(map[laneKey]error) h.mu.Unlock() + for _, lane := range stoppingLanes { + stopLane(lane) + } h.laneWG.Wait() h.cancelAllOperations() h.mu.Lock() @@ -572,17 +590,36 @@ func (h *Host) Serve(ctx context.Context) error { } }() } + finishFatal := func(err error) error { + cancel() + workers.Wait() + return fmt.Errorf("native UDE host session failed: %w", err) + } for { + // Once a lane or publisher reports a fatal error, do not let an always- + // ready dequeue stream win repeated select lotteries. Cancelling here + // also releases every worker before another result is dispatched. + select { + case err := <-fatal: + return finishFatal(err) + default: + } select { case <-runCtx.Done(): workers.Wait() return nil case err := <-fatal: - cancel() - workers.Wait() - return fmt.Errorf("native UDE host session failed: %w", err) + return finishFatal(err) case result := <-results: + // A worker result and a fatal lane notification can become ready in + // the same scheduling turn. Fatal is terminal; observe it before + // touching the newly dequeued operation. + select { + case err := <-fatal: + return finishFatal(err) + default: + } if result.err != nil { cancel() workers.Wait() @@ -606,6 +643,14 @@ func (h *Host) Serve(ctx context.Context) error { } } if err := h.dispatch(runCtx, result.op); err != nil { + // Saturation terminates the affected lane and publishes fatal + // synchronously. Do not wait up to completionTimeout trying to + // reject that final request before cancelling the owner session. + select { + case fatalErr := <-fatal: + return finishFatal(fatalErr) + default: + } if isLifecycleOperation(result.op.Kind) && result.op.Token != 0 { if completeErr := h.completeLifecycle(runCtx, result.op, statusUnsuccessful); completeErr != nil { h.reportFatal(fmt.Errorf("reject lifecycle token %d after dispatch failure %v: %w", @@ -635,14 +680,21 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { if op.EndpointSequence == 0 { return errors.New("native UDE operation has zero endpoint sequence") } + if err := ctx.Err(); err != nil { + return err + } key := laneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} h.mu.Lock() entry := h.devices[op.DeviceID] - if entry == nil || entry.stopping || entry.identity.Generation != op.Generation { + if entry == nil || entry.stopping || entry.identity.Generation != op.Generation || entry.ctx.Err() != nil { h.mu.Unlock() return errors.New("native UDE operation targets a stale device generation") } + if terminalErr := h.failedLanes[key]; terminalErr != nil { + h.mu.Unlock() + return terminalErr + } lane := h.lanes[key] if lane == nil { laneCtx, cancel := context.WithCancel(entry.ctx) @@ -656,19 +708,94 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { } h.mu.Unlock() + // Admission is deliberately nonblocking. A queue at the full kernel + // pending-operation contract means either an ABI/driver contract violation + // or a terminal endpoint; waiting here would let that one endpoint stall + // cancellations, lifecycle traffic, and every other controller. + lane.stateMu.Lock() + if lane.terminalErr != nil { + err := lane.terminalErr + lane.stateMu.Unlock() + return err + } + if err := lane.ctx.Err(); err != nil { + lane.stateMu.Unlock() + return err + } + if err := ctx.Err(); err != nil { + lane.stateMu.Unlock() + return err + } select { case lane.input <- op: + lane.stateMu.Unlock() return nil - case <-lane.ctx.Done(): - return lane.ctx.Err() - case <-ctx.Done(): - return ctx.Err() + default: + err := fmt.Errorf( + "native UDE device %d generation %d endpoint 0x%02x lane is saturated at the %d-operation pending contract", + key.deviceID, key.generation, key.endpoint, laneQueueDepth) + lane.terminalErr = err + lane.cancel() + lane.stateMu.Unlock() + h.removeFailedLane(lane, err) + h.reportFatal(err) + return err + } +} + +func stopLane(lane *operationLane) { + lane.stateMu.Lock() + if lane.terminalErr == nil { + lane.terminalErr = context.Canceled + } + lane.cancel() + lane.stateMu.Unlock() +} + +// removeFailedLane installs a tombstone only when lane is still the exact +// routed instance. An older goroutine can therefore never remove or poison a +// replacement lane created for a later lifecycle. +func (h *Host) removeFailedLane(lane *operationLane, err error) { + h.mu.Lock() + if h.lanes[lane.key] == lane { + delete(h.lanes, lane.key) + if h.failedLanes[lane.key] == nil { + h.failedLanes[lane.key] = err + } } + h.mu.Unlock() +} + +func (h *Host) failLane(lane *operationLane, err error) { + if err == nil { + return + } + lane.stateMu.Lock() + if lane.terminalErr != nil { + lane.stateMu.Unlock() + return + } + lane.terminalErr = err + lane.cancel() + lane.stateMu.Unlock() + + h.removeFailedLane(lane, err) + h.reportFatal(err) +} + +func (h *Host) retireLane(lane *operationLane) { + stopLane(lane) + h.mu.Lock() + if h.lanes[lane.key] == lane { + delete(h.lanes, lane.key) + } + h.mu.Unlock() + close(lane.done) + h.laneWG.Done() } func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { - defer h.laneWG.Done() - defer close(lane.done) + defer h.retireLane(lane) expected := uint64(1) pending := make(map[uint64]Operation) for { @@ -676,19 +803,22 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { case <-lane.ctx.Done(): return case op := <-lane.input: + if lane.ctx.Err() != nil { + return + } if op.EndpointSequence < expected { - h.reportFatal(fmt.Errorf("endpoint 0x%02x sequence regressed from %d to %d", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x sequence regressed from %d to %d", lane.key.endpoint, expected, op.EndpointSequence)) return } if _, duplicate := pending[op.EndpointSequence]; duplicate { - h.reportFatal(fmt.Errorf("endpoint 0x%02x repeated pending sequence %d", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x repeated pending sequence %d", lane.key.endpoint, op.EndpointSequence)) return } pending[op.EndpointSequence] = op if len(pending) > laneQueueDepth { - h.reportFatal(fmt.Errorf("endpoint 0x%02x exceeded the %d-operation reorder bound while waiting for sequence %d", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x exceeded the %d-operation reorder bound while waiting for sequence %d", lane.key.endpoint, laneQueueDepth, expected)) return } @@ -742,13 +872,13 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { status = statusUnsuccessful } if err := h.completeLifecycle(lane.ctx, current, status); err != nil { - h.reportFatal(fmt.Errorf("endpoint 0x%02x acknowledge lifecycle sequence %d: %w", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x acknowledge lifecycle sequence %d: %w", current.EndpointAddress, current.EndpointSequence, err)) return } } if lifecycleErr != nil { - h.reportFatal(fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", lane.key.endpoint, current.EndpointSequence, lifecycleErr)) return } @@ -791,7 +921,7 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { } } else { if err := h.process(lane.ctx, entry.device, current); err != nil { - h.reportFatal(fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", + h.failLane(lane, fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", lane.key.endpoint, current.EndpointSequence, err)) return } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index bd4093fa..e4e7d535 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -3,6 +3,7 @@ package udecx import ( "context" "errors" + "fmt" "strings" "sync" "testing" @@ -176,6 +177,78 @@ func (*noopProcessor) Process(context.Context, usb.Device, Operation) (Completio func (*noopProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} +type deviceGateProcessor struct { + blockedDevice uint64 + started chan struct{} + independent chan uint64 + startOnce sync.Once +} + +func (p *deviceGateProcessor) Process( + ctx context.Context, _ usb.Device, op Operation, +) (Completion, error) { + if op.DeviceID == p.blockedDevice { + p.startOnce.Do(func() { close(p.started) }) + <-ctx.Done() + return Completion{}, ctx.Err() + } + select { + case p.independent <- op.DeviceID: + case <-ctx.Done(): + return Completion{}, ctx.Err() + } + return Completion{TransferLength: op.TransferLength}, nil +} +func (*deviceGateProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*deviceGateProcessor) Reset(usb.Device, DeviceIdentity) {} + +type fatalLaneProcessor struct { + failingDevice uint64 + started chan struct{} + release chan struct{} + independent chan uint64 + queuedProcessed chan struct{} + startOnce sync.Once +} + +func (*fatalLaneProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, nil +} +func (p *fatalLaneProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + if op.DeviceID != p.failingDevice { + select { + case p.independent <- op.DeviceID: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + if op.EndpointSequence != 1 { + select { + case p.queuedProcessed <- struct{}{}: + default: + } + return nil + } + p.startOnce.Do(func() { close(p.started) }) + select { + case <-p.release: + return errors.New("injected lane failure") + case <-ctx.Done(): + return ctx.Err() + } +} +func (*fatalLaneProcessor) Reset(usb.Device, DeviceIdentity) {} + +type cancellationOnlyCompletionDriver struct { + *fakeHostDriver +} + +func (*cancellationOnlyCompletionDriver) Complete(ctx context.Context, _ Completion) error { + <-ctx.Done() + return ctx.Err() +} + type resetGateProcessor struct { started chan struct{} release chan struct{} @@ -1128,6 +1201,249 @@ func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t } } +func trackAndDispatch(host *Host, op Operation) error { + if err := host.trackOperation(op); err != nil { + return fmt.Errorf("track token %d: %w", op.Token, err) + } + return host.dispatch(context.Background(), op) +} + +func TestHostSaturatedLaneDoesNotBlockIndependentController(t *testing.T) { + if laneQueueDepth != defaultDevicePendingOperations { + t.Fatalf("lane queue depth=%d want kernel pending contract=%d", + laneQueueDepth, defaultDevicePendingOperations) + } + driver := newFakeHostDriver() + processor := &deviceGateProcessor{ + blockedDevice: 91, + started: make(chan struct{}), + independent: make(chan uint64, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + blocked, err := host.Register(context.Background(), processor.blockedDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), 92, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), blocked) + _ = host.Unregister(context.Background(), independent) + }) + + first := Operation{ + Token: 1, DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + } + if err = trackAndDispatch(host, first); err != nil { + t.Fatal(err) + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("blocked lane did not start processing") + } + + for sequence := uint64(2); sequence <= uint64(laneQueueDepth)+1; sequence++ { + op := Operation{ + Token: sequence, DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: sequence, Kind: OperationTransfer, + } + if err = trackAndDispatch(host, op); err != nil { + t.Fatalf("fill blocked lane at sequence %d: %v", sequence, err) + } + } + overflow := Operation{ + Token: uint64(laneQueueDepth) + 2, + DeviceID: blocked.DeviceID, Generation: blocked.Generation, + EndpointAddress: 0x02, EndpointSequence: uint64(laneQueueDepth) + 2, + Kind: OperationTransfer, + } + if err = trackAndDispatch(host, overflow); err == nil || !strings.Contains(err.Error(), "lane is saturated") { + t.Fatalf("overflow dispatch error=%v, want terminal saturation", err) + } + + dispatched := make(chan error, 1) + go func() { + dispatched <- trackAndDispatch(host, Operation{ + Token: 10000, DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + }) + }() + select { + case err = <-dispatched: + if err != nil { + t.Fatalf("independent dispatch failed: %v", err) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("saturated controller blocked the central dispatcher") + } + select { + case deviceID := <-processor.independent: + if deviceID != independent.DeviceID { + t.Fatalf("processed independent device=%d want=%d", deviceID, independent.DeviceID) + } + case <-time.After(time.Second): + t.Fatal("independent controller was not processed") + } +} + +func TestHostFatalLaneWithQueuedWorkStaysTerminal(t *testing.T) { + driver := newFakeHostDriver() + processor := &fatalLaneProcessor{ + failingDevice: 93, + started: make(chan struct{}), + release: make(chan struct{}), + independent: make(chan uint64, 1), + queuedProcessed: make(chan struct{}, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + failing, err := host.Register(context.Background(), processor.failingDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), 94, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = host.Unregister(context.Background(), failing) + _ = host.Unregister(context.Background(), independent) + }) + + first := Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationSetInterface, + } + if err = host.dispatch(context.Background(), first); err != nil { + t.Fatal(err) + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("failing lane did not enter its lifecycle processor") + } + key := laneKey{ + deviceID: failing.DeviceID, generation: failing.Generation, endpoint: first.EndpointAddress, + } + host.mu.RLock() + failedLane := host.lanes[key] + host.mu.RUnlock() + if failedLane == nil { + t.Fatal("failing lane was not installed") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 2, Kind: OperationSetInterface, + }); err != nil { + t.Fatalf("queue work behind failing operation: %v", err) + } + close(processor.release) + select { + case <-failedLane.done: + case <-time.After(time.Second): + t.Fatal("fatal lane did not cancel and stop") + } + select { + case <-processor.queuedProcessed: + t.Fatal("work queued behind a fatal operation was processed") + default: + } + + host.mu.RLock() + routedLane := host.lanes[key] + terminalErr := host.failedLanes[key] + host.mu.RUnlock() + if routedLane != nil { + t.Fatal("fatal lane remained in the routing map") + } + if terminalErr == nil || !strings.Contains(terminalErr.Error(), "injected lane failure") { + t.Fatalf("terminal lane error=%v, want injected failure", terminalErr) + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: failing.DeviceID, Generation: failing.Generation, + EndpointAddress: 0x02, EndpointSequence: 3, Kind: OperationSetInterface, + }); err == nil || !strings.Contains(err.Error(), "injected lane failure") { + t.Fatalf("dispatch to terminal lane error=%v, want original failure", err) + } + host.mu.RLock() + recreated := host.lanes[key] + host.mu.RUnlock() + if recreated != nil { + t.Fatal("dispatch recreated a terminal lane") + } + + if err = host.dispatch(context.Background(), Operation{ + DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationSetInterface, + }); err != nil { + t.Fatalf("independent lifecycle dispatch failed: %v", err) + } + select { + case deviceID := <-processor.independent: + if deviceID != independent.DeviceID { + t.Fatalf("processed independent device=%d want=%d", deviceID, independent.DeviceID) + } + case <-time.After(time.Second): + t.Fatal("independent lane did not run after another lane failed") + } +} + +func TestHostServeReturnsPromptlyOnLaneSaturation(t *testing.T) { + baseDriver := newFakeHostDriver() + baseDriver.operations = make(chan Operation, laneQueueDepth+4) + driver := &cancellationOnlyCompletionDriver{fakeHostDriver: baseDriver} + processor := &deviceGateProcessor{ + blockedDevice: 95, + started: make(chan struct{}), + independent: make(chan uint64, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), processor.blockedDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- host.Serve(context.Background()) }() + + baseDriver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("saturation test lane did not start processing") + } + for sequence := uint64(2); sequence <= uint64(laneQueueDepth)+2; sequence++ { + baseDriver.operations <- Operation{ + Token: sequence, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: sequence, Kind: OperationTransfer, + } + } + + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "lane is saturated") { + t.Fatalf("Serve error=%v, want lane saturation failure", err) + } + case <-time.After(time.Second): + t.Fatal("Serve waited on failure completion instead of promptly observing lane fatal") + } +} + func TestHostPreservesEndpointSequenceAcrossDequeueWorkers(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 2), resets: make(chan DeviceIdentity, 1)} From 0e1eec2803ad5489b50dc818a55d5bf75068a346 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:04:27 -0500 Subject: [PATCH 123/240] Expose negotiated native backend health Extend ping additively with the active transport, readiness, exact native ABI, capability mask, expected package version, and negotiated bounds. Native session health now drops as soon as its host exits or closes, while legacy ping clients retain their original response fields. Pin the Go package expectation to the driver project version in CI so DS4Windows can fail closed on backend drift instead of guessing from process presence. --- docs/api/overview.md | 28 ++++++++++++++- internal/cmd/native_transport.go | 12 +++++++ internal/cmd/native_transport_test.go | 18 ++++++++++ internal/cmd/native_transport_windows.go | 15 +++++++- internal/cmd/server.go | 11 +++++- internal/server/api/handler/ping.go | 23 +++++++++++-- internal/server/api/handler/ping_test.go | 34 +++++++++++++++++++ internal/transport/udecx/protocol.go | 5 +++ .../transport/udecx/release_contract_test.go | 23 +++++++++++++ viipertypes/structs.go | 23 +++++++++++-- 10 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 internal/transport/udecx/release_contract_test.go diff --git a/docs/api/overview.md b/docs/api/overview.md index f020ebaf..4a4e67b0 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -159,7 +159,33 @@ The exception to this are the device-control and feedback streams, which are raw ??? info "ping - Simple identity and version check" **Request:** `ping` - **Response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` + **Legacy response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` + + The packaged server also reports its active transport and readiness. Native + UDE mode includes the exact negotiated ABI, capability mask, package + version expected by the service, and negotiated limits. Clients opting in + to the native backend should fail closed unless these fields match their + required contract: + + ```json + { + "server": "VIIPER", + "version": "1.2.3", + "transport": "native-ude", + "ready": true, + "nativeUde": { + "abiMajor": 1, + "abiMinor": 8, + "capabilities": 13, + "expectedDriverPackageVersion": "0.1.0.0", + "maxDevices": 32, + "maxDescriptorBytes": 262144, + "maxTransferBytes": 1048576, + "maxIsoPackets": 1024, + "maxPendingOperations": 4096 + } + } + ``` #### `bus/list` {.toc-anchor} diff --git a/internal/cmd/native_transport.go b/internal/cmd/native_transport.go index c9d58d4c..8eb11a34 100644 --- a/internal/cmd/native_transport.go +++ b/internal/cmd/native_transport.go @@ -4,11 +4,15 @@ import ( "context" "errors" "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/viipertypes" ) type nativeUDETransport interface { Done() <-chan error Close() error + Status() (bool, *viipertypes.NativeUDEInfo) } // nativeUDETransportSession owns the lifetime boundary between the Go host @@ -20,14 +24,22 @@ type nativeUDETransportSession struct { cancel context.CancelFunc closeClient func() error done chan error + ready atomic.Bool + info viipertypes.NativeUDEInfo closeOnce sync.Once closeErr error } func (s *nativeUDETransportSession) Done() <-chan error { return s.done } +func (s *nativeUDETransportSession) Status() (bool, *viipertypes.NativeUDEInfo) { + info := s.info + return s.ready.Load(), &info +} + func (s *nativeUDETransportSession) Close() error { s.closeOnce.Do(func() { + s.ready.Store(false) s.cancel() serveErr := <-s.done s.closeErr = errors.Join(serveErr, s.closeClient()) diff --git a/internal/cmd/native_transport_test.go b/internal/cmd/native_transport_test.go index 9b26087c..777a2f7a 100644 --- a/internal/cmd/native_transport_test.go +++ b/internal/cmd/native_transport_test.go @@ -25,6 +25,7 @@ func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { return nil }, } + session.ready.Store(true) go func() { <-ctx.Done() hostStopped.Store(true) @@ -50,6 +51,23 @@ func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { } } +func TestNativeUDETransportStatusIsSnapshot(t *testing.T) { + session := &nativeUDETransportSession{} + session.info.ABIMajor = 1 + session.info.ABIMinor = 8 + session.ready.Store(true) + + ready, first := session.Status() + if !ready || first.ABIMajor != 1 || first.ABIMinor != 8 { + t.Fatalf("unexpected native status: ready=%v info=%+v", ready, first) + } + first.ABIMinor = 99 + _, second := session.Status() + if second.ABIMinor != 8 { + t.Fatal("Status exposed mutable session state") + } +} + func TestNativeUDETransportClosePreservesHostAndClientErrors(t *testing.T) { hostErr := errors.New("host failed") clientErr := errors.New("client close failed") diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go index f57e03f7..5263a353 100644 --- a/internal/cmd/native_transport_windows.go +++ b/internal/cmd/native_transport_windows.go @@ -7,6 +7,7 @@ import ( serverusb "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" ) func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nativeUDETransport, error) { @@ -29,11 +30,23 @@ func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nat return nil, err } sessionCtx, cancel := context.WithCancel(ctx) + limits := client.Limits() session := &nativeUDETransportSession{ cancel: cancel, closeClient: client.Close, done: make(chan error, 1), + info: viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(client.Capabilities()), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + MaxDevices: limits.MaxDevices, MaxDescriptorBytes: limits.MaxDescriptorBytes, + MaxTransferBytes: limits.MaxTransferBytes, MaxIsoPackets: limits.MaxIsoPackets, + MaxPendingOperations: limits.MaxPendingOperations, + }, } + session.ready.Store(true) go func() { - session.done <- host.Serve(sessionCtx) + err := host.Serve(sessionCtx) + session.ready.Store(false) + session.done <- err close(session.done) }() return session, nil diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 2b2e9838..1a143b9f 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -19,6 +19,7 @@ import ( "github.com/Alia5/VIIPER/internal/server/api/handler" "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/tray" + "github.com/Alia5/VIIPER/viipertypes" ) const keyFileName = "viiper.key.txt" @@ -118,7 +119,15 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger apiSrv := api.New(usbSrv, s.APIServerConfig.Addr, s.APIServerConfig, logger) r := apiSrv.Router() - r.Register("ping", handler.Ping()) + r.Register("ping", handler.Ping(handler.PingOptions{ + Transport: transport, + Status: func() (bool, *viipertypes.NativeUDEInfo) { + if nativeSession != nil { + return nativeSession.Status() + } + return true, nil + }, + })) r.Register("bus/list", handler.BusList(usbSrv)) r.Register("bus/create", handler.BusCreate(usbSrv)) r.Register("bus/remove", handler.BusRemove(usbSrv)) diff --git a/internal/server/api/handler/ping.go b/internal/server/api/handler/ping.go index b4773b49..6930b56a 100644 --- a/internal/server/api/handler/ping.go +++ b/internal/server/api/handler/ping.go @@ -9,9 +9,21 @@ import ( "github.com/Alia5/VIIPER/viipertypes" ) +// PingOptions adds live backend proof to the legacy identity response. The +// variadic form intentionally preserves source compatibility for embedded API +// users that still call Ping() without transport metadata. +type PingOptions struct { + Transport string + Status func() (ready bool, native *viipertypes.NativeUDEInfo) +} + // Ping returns a handler for the "ping" endpoint. // It provides a minimal identity + version response. -func Ping() api.HandlerFunc { +func Ping(options ...PingOptions) api.HandlerFunc { + var option PingOptions + if len(options) != 0 { + option = options[0] + } return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { ver, err := common.GetVersion() if err != nil { @@ -22,7 +34,14 @@ func Ping() api.HandlerFunc { logger.Error("ping: invalid version format", "error", err, "version", ver) } - payload := viipertypes.PingResponse{Server: "VIIPER", Version: ver} + payload := viipertypes.PingResponse{ + Server: "VIIPER", Version: ver, Transport: option.Transport, + } + if option.Status != nil { + ready, native := option.Status() + payload.Ready = &ready + payload.NativeUDE = native + } b, err := json.Marshal(payload) if err != nil { return err diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 74bf3748..a0817639 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -29,4 +29,38 @@ func TestPing(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "VIIPER", out.Server) assert.NotEmpty(t, out.Version) + assert.Empty(t, out.Transport) + assert.Nil(t, out.Ready) + assert.Nil(t, out.NativeUDE) +} + +func TestPingReportsNegotiatedNativeBackend(t *testing.T) { + want := &viipertypes.NativeUDEInfo{ + ABIMajor: 1, ABIMinor: 8, Capabilities: 0x0d, + ExpectedDriverPackageVersion: "0.1.0.0", + MaxDevices: 32, MaxDescriptorBytes: 262144, + MaxTransferBytes: 1048576, MaxIsoPackets: 1024, + MaxPendingOperations: 4096, + } + addr, _, done := handlerTest.StartAPIServer(t, func(r *api.Router, _ *usb.Server, _ *api.Server) { + r.Register("ping", handler.Ping(handler.PingOptions{ + Transport: "native-ude", + Status: func() (bool, *viipertypes.NativeUDEInfo) { + copy := *want + return true, © + }, + })) + }) + defer done() + + c := viiperclient.NewTransport(addr) + line, err := c.Do("ping", nil, nil) + assert.NoError(t, err) + var out viipertypes.PingResponse + assert.NoError(t, json.Unmarshal([]byte(line), &out)) + assert.Equal(t, "native-ude", out.Transport) + if assert.NotNil(t, out.Ready) { + assert.True(t, *out.Ready) + } + assert.Equal(t, want, out.NativeUDE) } diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 90faf6cf..066a7ba8 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -14,6 +14,11 @@ const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 ABIMinor uint16 = 8 + // DriverPackageVersion is the native driver package version built and + // shipped with this service. Runtime negotiation proves the installed + // driver speaks the exact ABI below; package installation additionally + // verifies this release version and its signed catalog. + DriverPackageVersion = "0.1.0.0" HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/internal/transport/udecx/release_contract_test.go b/internal/transport/udecx/release_contract_test.go new file mode 100644 index 00000000..6b1610e3 --- /dev/null +++ b/internal/transport/udecx/release_contract_test.go @@ -0,0 +1,23 @@ +package udecx + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestExpectedDriverPackageVersionMatchesProject(t *testing.T) { + projectPath := filepath.Join("..", "..", "..", "native", "udecx", "driver", "ViiperUde.vcxproj") + project, err := os.ReadFile(projectPath) + if err != nil { + t.Fatalf("read native driver project: %v", err) + } + matches := regexp.MustCompile(`([^<]+)`).FindAllSubmatch(project, -1) + if len(matches) != 1 || len(matches[0]) != 2 { + t.Fatal("native driver project has no single release version contract") + } + if got := string(matches[0][1]); got != DriverPackageVersion { + t.Fatalf("native package version drift: Go=%q project=%q", DriverPackageVersion, got) + } +} diff --git a/viipertypes/structs.go b/viipertypes/structs.go index c3b5dc71..55db7be8 100644 --- a/viipertypes/structs.go +++ b/viipertypes/structs.go @@ -33,8 +33,27 @@ func (e APIError) Error() string { // -- type PingResponse struct { - Server string `json:"server"` - Version string `json:"version"` + Server string `json:"server"` + Version string `json:"version"` + Transport string `json:"transport,omitempty"` + Ready *bool `json:"ready,omitempty"` + NativeUDE *NativeUDEInfo `json:"nativeUde,omitempty"` +} + +// NativeUDEInfo is the negotiated kernel contract for the active native +// transport. It is additive to the historical ping response so older clients +// continue to work while safety-conscious clients can fail closed unless the +// exact ABI and capabilities they require are live. +type NativeUDEInfo struct { + ABIMajor uint16 `json:"abiMajor"` + ABIMinor uint16 `json:"abiMinor"` + Capabilities uint32 `json:"capabilities"` + ExpectedDriverPackageVersion string `json:"expectedDriverPackageVersion"` + MaxDevices uint32 `json:"maxDevices"` + MaxDescriptorBytes uint32 `json:"maxDescriptorBytes"` + MaxTransferBytes uint32 `json:"maxTransferBytes"` + MaxIsoPackets uint32 `json:"maxIsoPackets"` + MaxPendingOperations uint32 `json:"maxPendingOperations"` } type BusListResponse struct { From 30bdd940bbb70c83fbf325feab161efbab720688 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:19:40 -0500 Subject: [PATCH 124/240] Host native broker as a managed Windows service --- cmd/viiper/viiper.go | 5 +- internal/cmd/server.go | 27 +++++-- internal/cmd/service.go | 9 +++ internal/cmd/service_other.go | 14 ++++ internal/cmd/service_windows.go | 111 +++++++++++++++++++++++++++ internal/cmd/service_windows_test.go | 88 +++++++++++++++++++++ internal/config/config.go | 5 +- 7 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/service.go create mode 100644 internal/cmd/service_other.go create mode 100644 internal/cmd/service_windows.go create mode 100644 internal/cmd/service_windows_test.go diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index 7107912e..471b6239 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -56,7 +56,10 @@ func main() { ctx.Bind(logger) ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) - if cli.UpdateNotify != config.UpdateNotifyNone { + // A broker hosted by Service Control Manager has no interactive desktop. + // Update UI belongs to DS4Windows/the package installer, never session 0. + isServiceCommand := strings.HasPrefix(ctx.Command(), "service") + if !isServiceCommand && cli.UpdateNotify != config.UpdateNotifyNone { go func() { time.Sleep(10 * time.Second) updater.CheckUpdate(Version, cli.UpdateNotify) diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 1a143b9f..5d2c22e3 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -29,6 +29,8 @@ type Server struct { APIServerConfig api.ServerConfig `embed:"" prefix:"api."` ConnectionTimeout time.Duration `help:"ConnectionTimeout operation timeout" default:"30s" env:"VIIPER_CONNECTION_TIMEOUT"` Transport string `help:"Virtual USB transport: usbip or native-ude" default:"usbip" env:"VIIPER_TRANSPORT"` + KeyFile string `help:"Path to the API credential file." env:"VIIPER_KEY_FILE" type:"path"` + serviceMode bool } // Run is called by Kong when the server command is executed. @@ -52,7 +54,10 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger applyTransportAPISecurityPolicy(transport, &s.APIServerConfig) ctx, cancel := context.WithCancel(ctx) - stopTray := tray.Run(ctx, cancel) + stopTray := func() {} + if !s.serviceMode { + stopTray = tray.Run(ctx, cancel) + } defer func() { cancel() stopTray() @@ -65,14 +70,26 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Info("Starting VIIPER virtual USB server", "transport", transport, "usbipAddr", s.USBServerConfig.Addr) - keyFileDir, err := configpaths.KeyFileDir() - if err != nil { - return fmt.Errorf("failed to resolve key file path: %w", err) + keyFilePath := strings.TrimSpace(s.KeyFile) + if keyFilePath == "" { + keyFileDir, err := configpaths.KeyFileDir() + if err != nil { + return fmt.Errorf("failed to resolve key file path: %w", err) + } + keyFilePath = filepath.Join(keyFileDir, keyFileName) + } else if !filepath.IsAbs(keyFilePath) { + return fmt.Errorf("API credential path must be absolute: %s", keyFilePath) } - keyFilePath := filepath.Join(keyFileDir, keyFileName) + keyFileDir := filepath.Dir(keyFilePath) if pwd, err := os.ReadFile(keyFilePath); err == nil { s.APIServerConfig.Password = strings.TrimSpace(string(pwd)) + if s.APIServerConfig.Password == "" { + return fmt.Errorf("API credential file is empty: %s", keyFilePath) + } } else { + if s.serviceMode { + return fmt.Errorf("managed service API credential is missing or unreadable at %s: %w", keyFilePath, err) + } newPwd, err := auth.GenerateKey() if err != nil { return fmt.Errorf("failed to generate new API password: %w", err) diff --git a/internal/cmd/service.go b/internal/cmd/service.go new file mode 100644 index 00000000..d8b52c00 --- /dev/null +++ b/internal/cmd/service.go @@ -0,0 +1,9 @@ +package cmd + +// ServiceCommand hosts the native UDE broker under the Windows Service +// Control Manager. It is intentionally a distinct command from Server so an +// interactive VIIPER instance can never accidentally claim the privileged +// native driver session. +type ServiceCommand struct { + Server `embed:""` +} diff --git a/internal/cmd/service_other.go b/internal/cmd/service_other.go new file mode 100644 index 00000000..84e69334 --- /dev/null +++ b/internal/cmd/service_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "errors" + "log/slog" + + "github.com/Alia5/VIIPER/internal/log" +) + +func (c *ServiceCommand) Run(_ *slog.Logger, _ log.RawLogger) error { + return errors.New("the VIIPER native broker service is available only on Windows") +} diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go new file mode 100644 index 00000000..51232d01 --- /dev/null +++ b/internal/cmd/service_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Alia5/VIIPER/internal/log" + "golang.org/x/sys/windows/svc" +) + +const NativeBrokerServiceName = "VIIPERNativeBroker" + +const serviceStopTimeout = 30 * time.Second + +type nativeBrokerService struct { + run func(context.Context) error +} + +func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error { + isService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("detect Windows service context: %w", err) + } + if !isService { + return errors.New("the VIIPER native broker service command may only be started by Windows Service Control Manager") + } + if !strings.EqualFold(strings.TrimSpace(c.Transport), "native-ude") { + return fmt.Errorf("the VIIPER native broker service requires --transport native-ude, got %q", c.Transport) + } + if strings.TrimSpace(c.KeyFile) == "" { + path, pathErr := nativeServiceKeyFilePath() + if pathErr != nil { + return pathErr + } + c.KeyFile = path + } + c.serviceMode = true + handler := &nativeBrokerService{run: func(ctx context.Context) error { + return c.StartServer(ctx, logger, rawLogger) + }} + return svc.Run(NativeBrokerServiceName, handler) +} + +func nativeServiceKeyFilePath() (string, error) { + programData := strings.TrimSpace(os.Getenv("ProgramData")) + if programData == "" { + return "", errors.New("ProgramData is not set; refusing to place a machine service credential in a user profile") + } + if !filepath.IsAbs(programData) { + return "", fmt.Errorf("ProgramData must be an absolute path: %s", programData) + } + return filepath.Join(filepath.Clean(programData), "VIIPER", keyFileName), nil +} + +func (s *nativeBrokerService) Execute( + _ []string, + requests <-chan svc.ChangeRequest, + changes chan<- svc.Status, +) (bool, uint32) { + changes <- svc.Status{State: svc.StartPending, WaitHint: 15_000} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.run(ctx) }() + + running := svc.Status{ + State: svc.Running, + Accepts: svc.AcceptStop | svc.AcceptShutdown, + } + changes <- running + + for { + select { + case err := <-done: + changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} + if err != nil { + return true, 1 + } + return false, 0 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + changes <- running + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, WaitHint: uint32(serviceStopTimeout / time.Millisecond)} + cancel() + timer := time.NewTimer(serviceStopTimeout) + select { + case err := <-done: + if !timer.Stop() { + <-timer.C + } + if err != nil { + return true, 1 + } + return false, 0 + case <-timer.C: + return true, 2 + } + } + } + } +} diff --git a/internal/cmd/service_windows_test.go b/internal/cmd/service_windows_test.go new file mode 100644 index 00000000..2f580b7c --- /dev/null +++ b/internal/cmd/service_windows_test.go @@ -0,0 +1,88 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows/svc" +) + +func TestNativeServiceKeyFileUsesMachineData(t *testing.T) { + t.Setenv("ProgramData", `C:\ProgramData`) + got, err := nativeServiceKeyFilePath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(`C:\ProgramData`, "VIIPER", keyFileName) + if got != want { + t.Fatalf("key path=%q want=%q", got, want) + } +} + +func TestNativeServiceStopsCooperatively(t *testing.T) { + started := make(chan struct{}) + stopped := make(chan struct{}) + handler := &nativeBrokerService{run: func(ctx context.Context) error { + close(started) + <-ctx.Done() + close(stopped) + return nil + }} + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + result := make(chan struct { + specific bool + code uint32 + }, 1) + go func() { + specific, code := handler.Execute(nil, requests, changes) + result <- struct { + specific bool + code uint32 + }{specific, code} + }() + + waitForServiceState(t, changes, svc.StartPending) + waitForServiceState(t, changes, svc.Running) + <-started + requests <- svc.ChangeRequest{Cmd: svc.Stop} + waitForServiceState(t, changes, svc.StopPending) + <-stopped + + select { + case got := <-result: + if got.specific || got.code != 0 { + t.Fatalf("service result=(specific=%v code=%d), want clean stop", got.specific, got.code) + } + case <-time.After(2 * time.Second): + t.Fatal("service did not stop after cancellation") + } +} + +func TestNativeServiceReportsUnexpectedBrokerFailure(t *testing.T) { + handler := &nativeBrokerService{run: func(context.Context) error { + return errors.New("broker failed") + }} + changes := make(chan svc.Status, 8) + specific, code := handler.Execute(nil, make(chan svc.ChangeRequest), changes) + if !specific || code != 1 { + t.Fatalf("service result=(specific=%v code=%d), want service-specific failure 1", specific, code) + } +} + +func waitForServiceState(t *testing.T, changes <-chan svc.Status, want svc.State) { + t.Helper() + select { + case got := <-changes: + if got.State != want { + t.Fatalf("service state=%v want=%v", got.State, want) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for service state %v", want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1a4215cd..31cd047b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,8 +27,9 @@ type CLI struct { Log `embed:"" prefix:"log."` codegenCommand - Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server" default:""` - Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` + Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server" default:""` + Service cmd.ServiceCommand `cmd:"" help:"Run the managed Windows native UDE broker service" hidden:""` + Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` From 15fbefdde6dd1ecd63ae16ef83ef340cf899b1d8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:21:03 -0500 Subject: [PATCH 125/240] Report native broker service readiness accurately --- internal/cmd/server.go | 4 ++ internal/cmd/service_windows.go | 66 +++++++++++++++++++++------- internal/cmd/service_windows_test.go | 40 ++++++++++++++++- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 5d2c22e3..0765441d 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -31,6 +31,7 @@ type Server struct { Transport string `help:"Virtual USB transport: usbip or native-ude" default:"usbip" env:"VIIPER_TRANSPORT"` KeyFile string `help:"Path to the API credential file." env:"VIIPER_KEY_FILE" type:"path"` serviceMode bool + ready func() } // Run is called by Kong when the server command is executed. @@ -168,6 +169,9 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Error("failed to start API server", "error", err) return err } + if s.ready != nil { + s.ready() + } select { case <-ctx.Done(): diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go index 51232d01..675c2121 100644 --- a/internal/cmd/service_windows.go +++ b/internal/cmd/service_windows.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/Alia5/VIIPER/internal/log" @@ -21,7 +22,7 @@ const NativeBrokerServiceName = "VIIPERNativeBroker" const serviceStopTimeout = 30 * time.Second type nativeBrokerService struct { - run func(context.Context) error + run func(context.Context, func()) error } func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error { @@ -43,7 +44,8 @@ func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error c.KeyFile = path } c.serviceMode = true - handler := &nativeBrokerService{run: func(ctx context.Context) error { + handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { + c.ready = ready return c.StartServer(ctx, logger, rawLogger) }} return svc.Run(NativeBrokerServiceName, handler) @@ -68,15 +70,43 @@ func (s *nativeBrokerService) Execute( changes <- svc.Status{State: svc.StartPending, WaitHint: 15_000} ctx, cancel := context.WithCancel(context.Background()) defer cancel() + ready := make(chan struct{}) + var readyOnce sync.Once done := make(chan error, 1) - go func() { done <- s.run(ctx) }() + go func() { + done <- s.run(ctx, func() { readyOnce.Do(func() { close(ready) }) }) + }() running := svc.Status{ State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown, } - changes <- running + starting := svc.Status{State: svc.StartPending, WaitHint: 15_000, CheckPoint: 1} + for { + select { + case <-ready: + changes <- running + goto Running + case err := <-done: + changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} + if err != nil { + return true, 1 + } + return true, 3 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + starting.CheckPoint++ + changes <- starting + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, WaitHint: uint32(serviceStopTimeout / time.Millisecond)} + cancel() + return waitForServiceStop(done) + } + } + } +Running: for { select { case err := <-done: @@ -92,20 +122,22 @@ func (s *nativeBrokerService) Execute( case svc.Stop, svc.Shutdown: changes <- svc.Status{State: svc.StopPending, WaitHint: uint32(serviceStopTimeout / time.Millisecond)} cancel() - timer := time.NewTimer(serviceStopTimeout) - select { - case err := <-done: - if !timer.Stop() { - <-timer.C - } - if err != nil { - return true, 1 - } - return false, 0 - case <-timer.C: - return true, 2 - } + return waitForServiceStop(done) } } } } + +func waitForServiceStop(done <-chan error) (bool, uint32) { + timer := time.NewTimer(serviceStopTimeout) + defer timer.Stop() + select { + case err := <-done: + if err != nil { + return true, 1 + } + return false, 0 + case <-timer.C: + return true, 2 + } +} diff --git a/internal/cmd/service_windows_test.go b/internal/cmd/service_windows_test.go index 2f580b7c..e4853f44 100644 --- a/internal/cmd/service_windows_test.go +++ b/internal/cmd/service_windows_test.go @@ -27,8 +27,9 @@ func TestNativeServiceKeyFileUsesMachineData(t *testing.T) { func TestNativeServiceStopsCooperatively(t *testing.T) { started := make(chan struct{}) stopped := make(chan struct{}) - handler := &nativeBrokerService{run: func(ctx context.Context) error { + handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { close(started) + ready() <-ctx.Done() close(stopped) return nil @@ -64,8 +65,43 @@ func TestNativeServiceStopsCooperatively(t *testing.T) { } } +func TestNativeServiceDoesNotReportRunningBeforeBrokerReady(t *testing.T) { + releaseReady := make(chan struct{}) + handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { + select { + case <-releaseReady: + ready() + case <-ctx.Done(): + return ctx.Err() + } + <-ctx.Done() + return nil + }} + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + result := make(chan uint32, 1) + go func() { + _, code := handler.Execute(nil, requests, changes) + result <- code + }() + + waitForServiceState(t, changes, svc.StartPending) + select { + case got := <-changes: + t.Fatalf("service reported state %v before broker readiness", got.State) + case <-time.After(50 * time.Millisecond): + } + close(releaseReady) + waitForServiceState(t, changes, svc.Running) + requests <- svc.ChangeRequest{Cmd: svc.Stop} + waitForServiceState(t, changes, svc.StopPending) + if code := <-result; code != 0 { + t.Fatalf("service exit code=%d want=0", code) + } +} + func TestNativeServiceReportsUnexpectedBrokerFailure(t *testing.T) { - handler := &nativeBrokerService{run: func(context.Context) error { + handler := &nativeBrokerService{run: func(context.Context, func()) error { return errors.New("broker failed") }} changes := make(chan svc.Status, 8) From a4824e519b8a9907679f7cbaab17bddda2d30a46 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:26:33 -0500 Subject: [PATCH 126/240] Fix native UDE callback and teardown lifetime contracts Complete every UdeCx URB at PASSIVE_LEVEL through explicit passive queues or one preallocated controller work item. Replace the sibling WDF device-table lock with an embedded FAST_MUTEX, close admissions in terminal self-managed I/O cleanup, join file cleanup and broker operations, flush the worker, and leave UdeCx child deletion asynchronous. Keep parent cleanup invariant-only and extend source validation to enforce the IRQL and lifetime rules. --- docs/architecture/native-udecx.md | 62 +++-- native/udecx/driver/Broker.c | 224 +++++++++--------- native/udecx/driver/Controller.c | 168 +++++++++++-- native/udecx/driver/Device.c | 166 +++++++++---- native/udecx/driver/Ioctl.c | 19 ++ native/udecx/driver/ViiperUde.h | 21 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 148 +++++++++++- 7 files changed, 607 insertions(+), 201 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f18be9e6..bc7198c5 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -116,20 +116,16 @@ cannot replay itself into multiple successor polls. Live validation requires both forward publication and a completed Windows poll without inventing a strict one-to-one relationship. -UDE URB completion deliberately keeps a separate DPC boundary. The individual -`UdecxUrbComplete` API reference currently lists `PASSIVE_LEVEL`, but -Microsoft's complete UDE client-driver guide is more specific: existing USB -drivers require completion at `DISPATCH_LEVEL`, a synchronously processed URB -must not be completed on its submitting thread, and cancellation must complete -on a separate DPC. usbip-win2 0.9.7.8 independently follows that same contract. -VIIPER therefore copies data and transfers ownership at PASSIVE level, then -uses one preallocated controller DPC to finish bounded broker slots. Direct -producer input arrives on a separate user I/O path and completes at -`DISPATCH_LEVEL` without allocating per report. A late cached poll first crosses -a preallocated endpoint work-item boundary before the same DISPATCH-level -completion, because `WdfIoQueueReadyNotify` is permitted to run inline on the -UdeCx submitter thread. This is not optional scheduler padding; removing either -boundary would violate the documented UDE compatibility contract. +Both `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` require +`PASSIVE_LEVEL`. VIIPER therefore uses one preallocated controller work item to +finish bounded broker slots, including cancellations that can originate at +dispatch level. The worker drains every ready slot per invocation, so the +PASSIVE transition neither allocates per request nor creates one work item per +packet. Direct producer input already runs on an explicitly passive queue and +can complete there. A late cached poll first crosses the preallocated endpoint +work-item boundary because `WdfIoQueueReadyNotify` is permitted to run inline on +the UdeCx submitter thread; the boundary avoids recursive successor-poll +completion while preserving the documented passive completion contract. Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before @@ -227,7 +223,33 @@ interface fields are only hints for alternates that contain no endpoints. ## Synchronization model -- A controller-level lock protects the device table and owner registration. +- Separate controller locks protect the device table and broker-owner + registration; the broker spin lock is the operation-admission boundary. +- Broker file callbacks explicitly run at `WdfExecutionLevelPassive`, matching + their wait-lock, synchronous-queue-purge, and pageable cleanup operations. +- Every UdeCx USB-device and endpoint object explicitly requests + `WdfExecutionLevelPassive`. Microsoft permits the device power/reset, + endpoint-configuration, start, purge, and reset callbacks at up to + `DISPATCH_LEVEL`, but VIIPER's callbacks create WDF/UdeCx objects and acquire + the embedded device-table `FAST_MUTEX`, operations whose contract is below + dispatch level. The KMDF controller default is dispatch execution, so relying + on inherited or presently observed callback context is not a valid safety + contract. +- Controller removal closes a single `ShuttingDown` admission gate in + `EvtDeviceSelfManagedIoCleanup`, while the controller's queues, timer, + passive completion worker, locks, and broker storage are still valid. + Cleanup first joins any file cleanup that crossed the owner lock before the + gate, then purges user-mode queues, aborts every admitted broker operation, + waits for the tracked operation count to reach zero, and flushes the worker + before revoking device-table handles. The final controller + `EvtCleanupCallback` performs only invariant checks because KMDF has already + cleaned up child objects by then. +- UdeCx USB-device deletion remains asynchronous. Shutdown snapshots and + revokes each device under the embedded `FAST_MUTEX`, invokes + `UdecxUsbDevicePlugOutAndDelete` after dropping the lock, and never waits for + child cleanup from the PnP cleanup callback. Embedding the mutex in the + controller context keeps endpoint/device cleanup independent of sibling WDF + child deletion order. - Removal atomically revokes the UDE handle from the device table before `UdecxUsbDevicePlugOutAndDelete`; that slot remains reserved until the asynchronous object cleanup runs. Once that API returns, success or failure, @@ -324,8 +346,8 @@ interface fields are only hints for alternates that contain no endpoints. threads. - Every mark-cancelable transition revalidates its prior state under the broker lock. If KMDF invokes cancellation before that lock is reacquired, the cancel - callback's DPC ownership is final and cannot be overwritten by admission or - publication. + callback's passive-completion ownership is final and cannot be overwritten + by admission or publication. - Broker dequeue validation, wait-count admission, and transfer into the manual inverted-call queue share the owner lock with file cleanup. No close can finish purging that queue and then have an already-validated request @@ -435,6 +457,12 @@ validation contract is documented in - Microsoft, *Write a UDE client driver* - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` +- Microsoft, `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` + +- Microsoft, `EvtDeviceSelfManagedIoCleanup` + +- Microsoft, `WdfWorkItemEnqueue` and `WdfWorkItemFlush` + - Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 8260a4df..c8c18091 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -14,79 +14,19 @@ EVT_WDF_REQUEST_CANCEL ViiperEvtUrbCancel; static VOID ViiperDispatchAvailable(_In_ WDFDEVICE Controller); -typedef struct VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT { - WDFREQUEST Request; - NTSTATUS Status; -} VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT; - -WDF_DECLARE_CONTEXT_TYPE_WITH_NAME( - VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT, - ViiperGetOrphanCompletionContext) - -static EVT_WDF_DPC ViiperEvtOrphanCompletionDpc; - -static VOID -ViiperEvtOrphanCompletionDpc( - _In_ WDFDPC Dpc - ) -{ - VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT *context = - ViiperGetOrphanCompletionContext(Dpc); - WDFREQUEST request = context->Request; - - UdecxUrbCompleteWithNtStatus(request, context->Status); - WdfObjectDereference(request); - WdfObjectDelete(Dpc); -} - -VOID -ViiperCompleteUnownedUrbAsync( +ViiperCompleteUnownedUrb( _In_ WDFDEVICE Controller, _In_ WDFREQUEST Request, _In_ NTSTATUS Status ) { - WDF_DPC_CONFIG config; - WDF_OBJECT_ATTRIBUTES attributes; - VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT *context; - WDFDPC dpc; - NTSTATUS createStatus; - - WDF_DPC_CONFIG_INIT(&config, ViiperEvtOrphanCompletionDpc); - config.AutomaticSerialization = FALSE; - WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE( - &attributes, VIIPER_UDE_ORPHAN_COMPLETION_CONTEXT); - attributes.ParentObject = Controller; - createStatus = WdfDpcCreate(&config, &attributes, &dpc); - if (!NT_SUCCESS(createStatus)) { - KIRQL previousIrql = KeGetCurrentIrql(); - if (previousIrql < DISPATCH_LEVEL) { - KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); - UdecxUrbCompleteWithNtStatus(Request, Status); - KeLowerIrql(previousIrql); - } else { - UdecxUrbCompleteWithNtStatus(Request, Status); - } - return; - } - - context = ViiperGetOrphanCompletionContext(dpc); - context->Request = Request; - context->Status = Status; - WdfObjectReference(Request); - if (!WdfDpcEnqueue(dpc)) { - KIRQL previousIrql = KeGetCurrentIrql(); - WdfObjectDereference(Request); - WdfObjectDelete(dpc); - if (previousIrql < DISPATCH_LEVEL) { - KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); - UdecxUrbCompleteWithNtStatus(Request, Status); - KeLowerIrql(previousIrql); - } else { - UdecxUrbCompleteWithNtStatus(Request, Status); - } - } + UNREFERENCED_PARAMETER(Controller); + // This request never entered the broker's tracked slot set. Completing it + // directly is safe because every endpoint queue explicitly runs passive. + // UdeCx's completion APIs require PASSIVE_LEVEL; never raise to dispatch. + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + UdecxUrbCompleteWithNtStatus(Request, Status); } static @@ -162,7 +102,14 @@ ViiperDispatchNotificationEvents( VIIPER_UDE_NOTIFICATION event = {0}; NTSTATUS status; + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + break; + } WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } if (controllerContext->NotificationCount == 0) { WdfSpinLockRelease(controllerContext->BrokerLock); break; @@ -225,6 +172,34 @@ ViiperDispatchNotificationEvents( } } +static +VOID +ViiperPendingOperationStartedLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + // All callers hold BrokerLock. Clear before publishing the 0 -> 1 + // transition so teardown can never observe a stale signaled event. + if (InterlockedCompareExchange(&ControllerContext->PendingOperations, 0, 0) == 0) { + KeClearEvent(&ControllerContext->BrokerOperationsDrained); + } + (VOID)InterlockedIncrement(&ControllerContext->PendingOperations); +} + +static +VOID +ViiperPendingOperationCompletedLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + LONG remaining = InterlockedDecrement(&ControllerContext->PendingOperations); + + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&ControllerContext->BrokerOperationsDrained, IO_NO_INCREMENT, FALSE); + } +} + static VOID ViiperClearManagementSlotLocked( @@ -241,7 +216,7 @@ ViiperClearManagementSlotLocked( pending->State = ViiperUdePendingEmpty; pending->Kind = 0; pending->EndpointAddress = 0; - InterlockedDecrement(&ControllerContext->PendingOperations); + ViiperPendingOperationCompletedLocked(ControllerContext); } static @@ -255,7 +230,7 @@ ViiperSetDeviceResettingByIdentity( { ULONG index; - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&ControllerContext->DeviceLock); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -269,7 +244,7 @@ ViiperSetDeviceResettingByIdentity( break; } } - WdfWaitLockRelease(ControllerContext->DeviceLock); + ExReleaseFastMutex(&ControllerContext->DeviceLock); } static @@ -284,7 +259,7 @@ ViiperSetEndpointResettingByIdentity( { ULONG index; - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&ControllerContext->DeviceLock); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -303,7 +278,7 @@ ViiperSetEndpointResettingByIdentity( } break; } - WdfWaitLockRelease(ControllerContext->DeviceLock); + ExReleaseFastMutex(&ControllerContext->DeviceLock); } static @@ -335,7 +310,7 @@ ViiperClearSlotLocked( pending->CompletionStatus = STATUS_SUCCESS; pending->CompletionUsbdStatus = USBD_STATUS_SUCCESS; pending->CompleteWithNtStatus = FALSE; - InterlockedDecrement(&ControllerContext->PendingOperations); + ViiperPendingOperationCompletedLocked(ControllerContext); if (deviceContext != NULL) { InterlockedDecrement(&deviceContext->PendingOperations); } @@ -398,7 +373,8 @@ ViiperValidateBrokerOwner( } fileContext = ViiperGetFileContext(fileObject); WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); - if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; @@ -414,7 +390,7 @@ ViiperInitializeBroker( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Device); WDF_OBJECT_ATTRIBUTES attributes; - WDF_DPC_CONFIG dpcConfig; + WDF_WORKITEM_CONFIG workItemConfig; NTSTATUS status; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -479,21 +455,24 @@ ViiperInitializeBroker( controllerContext->ManagementSlots, sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT); - WDF_DPC_CONFIG_INIT(&dpcConfig, ViiperEvtCompletionDpc); - dpcConfig.AutomaticSerialization = FALSE; + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtCompletionWorkItem); WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = Device; - return WdfDpcCreate(&dpcConfig, &attributes, &controllerContext->CompletionDpc); + return WdfWorkItemCreate( + &workItemConfig, &attributes, &controllerContext->CompletionWorkItem); } VOID -ViiperEvtCompletionDpc( - _In_ WDFDPC Dpc +ViiperEvtCompletionWorkItem( + _In_ WDFWORKITEM WorkItem ) { - WDFDEVICE controller = (WDFDEVICE)WdfDpcGetParentObject(Dpc); + WDFDEVICE controller = (WDFDEVICE)WdfWorkItemGetParentObject(WorkItem); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + for (;;) { WDFREQUEST request = WDF_NO_HANDLE; ULONGLONG token = 0; @@ -508,7 +487,7 @@ ViiperEvtCompletionDpc( ULONG candidate = (controllerContext->NextCompletionSlot + index) % VIIPER_UDE_MAX_PENDING_OPERATIONS; VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; - if (pending->State != ViiperUdePendingDpcCompletion) { + if (pending->State != ViiperUdePendingPassiveCompletion) { continue; } request = pending->Request; @@ -601,14 +580,15 @@ ViiperQueueEndpointLifecycleEvent( BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = ViiperQueueLifecycleEventLocked( - controllerContext, - deviceContext, - &endpointContext->Descriptor, - Kind, - 0, - 0, - 0); + queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + &endpointContext->Descriptor, + Kind, + 0, + 0, + 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -629,8 +609,9 @@ ViiperQueueDeviceLifecycleEvent( BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = ViiperQueueLifecycleEventLocked( - controllerContext, deviceContext, NULL, Kind, 0, 0, 0); + queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperQueueLifecycleEventLocked( + controllerContext, deviceContext, NULL, Kind, 0, 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -652,14 +633,15 @@ ViiperQueueInterfaceLifecycleEvent( BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = ViiperQueueLifecycleEventLocked( - controllerContext, - deviceContext, - NULL, - ViiperUdeOperationSetInterface, - InterfaceNumber, - InterfaceSetting, - 0); + queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperQueueLifecycleEventLocked( + controllerContext, + deviceContext, + NULL, + ViiperUdeOperationSetInterface, + InterfaceNumber, + InterfaceSetting, + 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; @@ -692,7 +674,8 @@ ViiperQueueAcknowledgedLifecycleEvent( } WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { status = STATUS_DEVICE_NOT_READY; canAllocate = FALSE; @@ -744,7 +727,7 @@ ViiperQueueAcknowledgedLifecycleEvent( } controllerContext->NextManagementSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_MANAGEMENT; - InterlockedIncrement(&controllerContext->PendingOperations); + ViiperPendingOperationStartedLocked(controllerContext); status = STATUS_SUCCESS; break; } @@ -814,7 +797,8 @@ ViiperAllocatePendingSlot( NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; WdfSpinLockAcquire(ControllerContext->BrokerLock); - if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { @@ -853,7 +837,7 @@ ViiperAllocatePendingSlot( pending->AbortStatus = STATUS_SUCCESS; ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ViiperEndpointOperationStarted(Endpoint); - InterlockedIncrement(&ControllerContext->PendingOperations); + ViiperPendingOperationStartedLocked(ControllerContext); InterlockedIncrement(&deviceContext->PendingOperations); *Slot = index; *Token = pending->Token; @@ -888,7 +872,7 @@ ViiperHasEarlierUnpublishedAdmissionLocked( if (other->State == ViiperUdePendingEmpty || other->PublishedToOwner || other->AbortPending || other->State == ViiperUdePendingCompleting || - other->State == ViiperUdePendingDpcCompletion || + other->State == ViiperUdePendingPassiveCompletion || other->DeviceId != candidate->DeviceId || other->DeviceGeneration != candidate->DeviceGeneration || other->EndpointAddress != candidate->EndpointAddress || @@ -921,7 +905,7 @@ ViiperEvtUrbCancel( pending->CompletionStatus = STATUS_CANCELLED; pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; pending->CompleteWithNtStatus = TRUE; - pending->State = ViiperUdePendingDpcCompletion; + pending->State = ViiperUdePendingPassiveCompletion; ownsRequest = TRUE; } } @@ -929,7 +913,7 @@ ViiperEvtUrbCancel( if (ownsRequest) { InterlockedIncrement64(&controllerContext->OperationsCancelled); - (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); + WdfWorkItemEnqueue(controllerContext->CompletionWorkItem); if (notifyOwner) { ViiperDispatchNotificationEvents(requestContext->Controller); } @@ -1351,12 +1335,12 @@ ViiperQueueOwnedCompletion( pending->CompletionUsbdStatus = UsbdStatus; pending->CompleteWithNtStatus = CompleteWithNtStatus; } - pending->State = ViiperUdePendingDpcCompletion; + pending->State = ViiperUdePendingPassiveCompletion; queued = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (queued) { - (VOID)WdfDpcEnqueue(ControllerContext->CompletionDpc); + WdfWorkItemEnqueue(ControllerContext->CompletionWorkItem); } return queued; } @@ -1372,7 +1356,7 @@ ViiperExpectedLateAbortLocked( if (Pending->Token != Token || (Pending->State != ViiperUdePendingCompleting && - Pending->State != ViiperUdePendingDpcCompletion) || + Pending->State != ViiperUdePendingPassiveCompletion) || (!Pending->AbortPending && !Pending->CompleteWithNtStatus)) { return FALSE; } @@ -1410,12 +1394,12 @@ ViiperRemovePublishingRequest( ControllerContext->PendingSlots[Slot].CompletionStatus = Status; ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = USBD_STATUS_CANCELED; ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = TRUE; - ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; + ControllerContext->PendingSlots[Slot].State = ViiperUdePendingPassiveCompletion; ownsRequest = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (ownsRequest) { - (VOID)WdfDpcEnqueue(ControllerContext->CompletionDpc); + WdfWorkItemEnqueue(ControllerContext->CompletionWorkItem); if (notifyOwner) { ViiperDispatchNotificationEvents(ViiperGetRequestContext(Request)->Controller); } @@ -1443,8 +1427,15 @@ ViiperDispatchAvailable( BOOLEAN cancelClaimed = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + break; + } ViiperDispatchNotificationEvents(Controller); WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { ULONG candidate = (controllerContext->NextPendingSlot + index) % VIIPER_UDE_MAX_PENDING_OPERATIONS; @@ -1597,7 +1588,8 @@ ViiperQueueDequeueOperation( // that same ownership transaction so a request cannot be forwarded after // cleanup has already finished purging the queue. WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); - if (controllerContext->OwnerFile != fileObject || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { @@ -1666,7 +1658,7 @@ ViiperQueueUrb( VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; if (pending->State != ViiperUdePendingPreparing) { // An immediate cancel callback already moved this slot to its - // DPC completion state and owns the request. + // passive completion state and owns the request. cancelClaimed = TRUE; } else { abortPending = pending->AbortPending; @@ -2060,8 +2052,8 @@ ViiperAbortMatchingOperations( if (pending->State == ViiperUdePendingPublishing) { pending->AbortPending = TRUE; pending->AbortStatus = Status; - } else if (pending->State == ViiperUdePendingDpcCompletion) { - /* The request is already owned by the completion DPC. */ + } else if (pending->State == ViiperUdePendingPassiveCompletion) { + /* The request is already owned by the passive completion worker. */ } else if (pending->State != ViiperUdePendingPreparing && pending->State != ViiperUdePendingCompleting) { request = pending->Request; diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index b103194c..ec37bdc5 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -7,7 +7,8 @@ DEFINE_GUID( #ifdef ALLOC_PRAGMA #pragma alloc_text(PAGE, ViiperEvtDeviceAdd) -#pragma alloc_text(PAGE, ViiperEvtControllerCleanup) +#pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoInit) +#pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) #pragma alloc_text(PAGE, ViiperEvtFileCleanup) #pragma alloc_text(PAGE, ViiperEvtOwnerCleanupRetry) @@ -27,6 +28,9 @@ ViiperFinishOwnerCleanup( BOOLEAN releaseOwner = FALSE; PAGED_CODE(); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return FALSE; + } WdfWaitLockAcquire(context->OwnerLock, NULL); if (context->OwnerFile != OwnerFile || !context->CleanupInProgress) { WdfWaitLockRelease(context->OwnerLock); @@ -65,6 +69,9 @@ ViiperEvtOwnerCleanupRetry( WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; PAGED_CODE(); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return; + } WdfWaitLockAcquire(context->OwnerLock, NULL); if (context->CleanupInProgress && context->OwnerFile != WDF_NO_HANDLE) { ownerFile = context->OwnerFile; @@ -85,8 +92,10 @@ ViiperEvtOwnerCleanupRetry( return; } - InterlockedIncrement(&context->CleanupRetries); - (VOID)WdfTimerStart(Timer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0) { + InterlockedIncrement(&context->CleanupRetries); + (VOID)WdfTimerStart(Timer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); + } WdfObjectDereference(ownerFile); } @@ -134,6 +143,7 @@ ViiperEvtDeviceAdd( WDF_FILEOBJECT_CONFIG fileConfig; WDF_TIMER_CONFIG timerConfig; UDECX_WDF_DEVICE_CONFIG udeConfig; + WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; VIIPER_UDE_CONTROLLER_CONTEXT *context; UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); UNICODE_STRING brokerReference; @@ -153,11 +163,17 @@ ViiperEvtDeviceAdd( WDF_NO_EVENT_CALLBACK, ViiperEvtFileCleanup); WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&fileAttributes, VIIPER_UDE_FILE_CONTEXT); + fileAttributes.ExecutionLevel = WdfExecutionLevelPassive; WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, &fileAttributes); WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&requestAttributes, VIIPER_UDE_REQUEST_CONTEXT); WdfDeviceInitSetRequestAttributes(DeviceInit, &requestAttributes); + WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpCallbacks); + pnpCallbacks.EvtDeviceSelfManagedIoInit = ViiperEvtDeviceSelfManagedIoInit; + pnpCallbacks.EvtDeviceSelfManagedIoCleanup = ViiperEvtDeviceSelfManagedIoCleanup; + WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpCallbacks); + status = UdecxInitializeWdfDeviceInit(DeviceInit); if (!NT_SUCCESS(status)) { return status; @@ -172,6 +188,9 @@ ViiperEvtDeviceAdd( context = ViiperGetControllerContext(device); RtlZeroMemory(context, sizeof(*context)); + ExInitializeFastMutex(&context->DeviceLock); + KeInitializeEvent(&context->BrokerOperationsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = device; @@ -179,10 +198,6 @@ ViiperEvtDeviceAdd( if (!NT_SUCCESS(status)) { return status; } - status = WdfWaitLockCreate(&attributes, &context->DeviceLock); - if (!NT_SUCCESS(status)) { - return status; - } status = ViiperInitializeBroker(device); if (!NT_SUCCESS(status)) { return status; @@ -230,12 +245,74 @@ ViiperEvtControllerCleanup( { VIIPER_UDE_CONTROLLER_CONTEXT *context; - PAGED_CODE(); context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); + // Every active operation belongs in SelfManagedIoCleanup, while the + // controller's child queues, locks, memory objects, timer, and work item are + // still callable. WDF invokes child cleanup before parent cleanup, so this + // callback is deliberately limited to invariant checks over context data. + NT_ASSERT(InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ActiveDevices, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->OwnerReferenced, 0, 0) == 0); +} + +NTSTATUS +ViiperEvtDeviceSelfManagedIoInit( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + + PAGED_CODE(); + InterlockedExchange(&context->ShuttingDown, FALSE); + return STATUS_SUCCESS; +} + +VOID +ViiperEvtDeviceSelfManagedIoCleanup( + _In_ WDFDEVICE Device + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(Device); + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; + BOOLEAN releaseOwner = FALSE; + + PAGED_CODE(); + + // Close every user/UdeCx admission path before draining work that already + // crossed the boundary. Interlocked operations also provide the ordering + // barrier consumed by the queue and broker callbacks. + InterlockedExchange(&context->ShuttingDown, TRUE); + + if (context->OwnerLock != WDF_NO_HANDLE) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + ownerFile = context->OwnerFile; + if (ownerFile != WDF_NO_HANDLE) { + InterlockedExchange(&ViiperGetFileContext(ownerFile)->Closing, TRUE); + context->CleanupInProgress = TRUE; + } + WdfWaitLockRelease(context->OwnerLock); + } + if (context->OwnerCleanupTimer != WDF_NO_HANDLE) { WdfTimerStop(context->OwnerCleanupTimer, TRUE); } - ViiperPurgeOwnerOperations((WDFDEVICE)ControllerObject, STATUS_DEVICE_REMOVED); + + // A file cleanup that crossed OwnerLock before ShuttingDown may still be + // using the controller's queue and lock children. The gate prevents any + // successor, so this event is a finite rundown join before those objects + // are purged. A cleanup that reaches OwnerLock after the gate never enters. + (VOID)KeWaitForSingleObject( + &context->FileCleanupsDrained, + Executive, + KernelMode, + FALSE, + NULL); + + // These queues are non-power-managed. KMDF purges them before this + // callback on normal removal, but an explicit idempotent purge also covers + // initialization failure and documents the driver's teardown boundary. if (context->DefaultQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->DefaultQueue); } @@ -249,6 +326,27 @@ ViiperEvtControllerCleanup( WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); } + // Create-device owner admissions execute on ControlQueue and therefore + // must have returned before its synchronous purge completes. + NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); + + ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); + if (context->CompletionWorkItem != WDF_NO_HANDLE) { + if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { + WdfWorkItemEnqueue(context->CompletionWorkItem); + (VOID)KeWaitForSingleObject( + &context->BrokerOperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + } + // BrokerOperationsDrained closes the admission race; Flush then joins + // the passive callback after its final request dereference. UdeCx URB + // completion is PASSIVE-only, so no DPC may own this work. + WdfWorkItemFlush(context->CompletionWorkItem); + } + if (context->BrokerLock != WDF_NO_HANDLE) { WdfSpinLockAcquire(context->BrokerLock); context->NotificationHead = 0; @@ -257,19 +355,23 @@ ViiperEvtControllerCleanup( InterlockedExchange(&context->BrokerFaulted, FALSE); WdfSpinLockRelease(context->BrokerLock); } - if (context->OwnerLock != WDF_NO_HANDLE) { - WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; - BOOLEAN releaseOwner = FALSE; + // PlugOutAndDelete owns asynchronous UdeCx cleanup. Do not wait here: a + // synchronous wait can deadlock the same PnP/UdeCx worker that must deliver + // the endpoint and device cleanup callbacks. + ViiperBeginControllerShutdown(Device); + + if (context->OwnerLock != WDF_NO_HANDLE) { WdfWaitLockAcquire(context->OwnerLock, NULL); - ownerFile = context->OwnerFile; - context->OwnerFile = WDF_NO_HANDLE; + if (context->OwnerFile == ownerFile) { + context->OwnerFile = WDF_NO_HANDLE; + } context->CleanupInProgress = FALSE; releaseOwner = InterlockedExchange(&context->OwnerReferenced, FALSE) != FALSE; WdfWaitLockRelease(context->OwnerLock); - if (releaseOwner && ownerFile != WDF_NO_HANDLE) { - WdfObjectDereference(ownerFile); - } + } + if (releaseOwner && ownerFile != WDF_NO_HANDLE) { + WdfObjectDereference(ownerFile); } } @@ -310,7 +412,9 @@ ViiperEvtFileCreate( } WdfWaitLockAcquire(context->OwnerLock, NULL); - if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + } else if (context->OwnerFile != WDF_NO_HANDLE || context->CleanupInProgress) { status = STATUS_SHARING_VIOLATION; } else { InterlockedExchange(&fileContext->BrokerOwner, TRUE); @@ -333,6 +437,8 @@ ViiperEvtFileCleanup( VIIPER_UDE_CONTROLLER_CONTEXT *context; VIIPER_UDE_FILE_CONTEXT *fileContext; BOOLEAN ownsController = FALSE; + BOOLEAN cleanupAdmitted = FALSE; + LONG remainingCleanups; PAGED_CODE(); device = WdfFileObjectGetDevice(FileObject); @@ -340,14 +446,26 @@ ViiperEvtFileCleanup( fileContext = ViiperGetFileContext(FileObject); InterlockedExchange(&fileContext->Closing, TRUE); + // Self-managed cleanup owns controller-wide rundown once this gate closes. + // In particular, do not reach through sibling WDF lock/queue children from + // a file cleanup callback that can outlive their normal I/O lifetime. + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + return; + } if (InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0) { return; } WdfWaitLockAcquire(context->OwnerLock, NULL); - if (context->OwnerFile == FileObject) { + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0 && + context->OwnerFile == FileObject) { + if (InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0) { + KeClearEvent(&context->FileCleanupsDrained); + } + (VOID)InterlockedIncrement(&context->ActiveFileCleanups); context->CleanupInProgress = TRUE; ownsController = TRUE; + cleanupAdmitted = TRUE; } WdfWaitLockRelease(context->OwnerLock); @@ -364,13 +482,23 @@ ViiperEvtFileCleanup( WdfSpinLockRelease(context->BrokerLock); } if (ownsController) { - if (!ViiperFinishOwnerCleanup(device, FileObject)) { + if (!ViiperFinishOwnerCleanup(device, FileObject) && + InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0) { InterlockedIncrement(&context->CleanupRetries); (VOID)WdfTimerStart( context->OwnerCleanupTimer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); } } + if (cleanupAdmitted) { + WdfWaitLockAcquire(context->OwnerLock, NULL); + remainingCleanups = InterlockedDecrement(&context->ActiveFileCleanups); + NT_ASSERT(remainingCleanups >= 0); + if (remainingCleanups == 0) { + KeSetEvent(&context->FileCleanupsDrained, IO_NO_INCREMENT, FALSE); + } + WdfWaitLockRelease(context->OwnerLock); + } } NTSTATUS diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 4d108789..1e30b297 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -13,6 +13,7 @@ #pragma alloc_text(PAGE, ViiperCreateVirtualDevice) #pragma alloc_text(PAGE, ViiperDestroyVirtualDevice) #pragma alloc_text(PAGE, ViiperDestroyOwnedDevices) +#pragma alloc_text(PAGE, ViiperBeginControllerShutdown) #pragma alloc_text(PAGE, ViiperEvtEndpointAdd) #pragma alloc_text(PAGE, ViiperEvtDefaultEndpointAdd) #pragma alloc_text(PAGE, ViiperEvtVirtualDeviceCleanup) @@ -244,7 +245,8 @@ ViiperValidateOwner( } fileContext = ViiperGetFileContext(fileObject); WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); - if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; @@ -274,7 +276,8 @@ ViiperBeginOwnerAdmission( } fileContext = ViiperGetFileContext(fileObject); WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); - if (controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; @@ -340,7 +343,11 @@ ViiperClaimDeviceSlot( ULONG freeSlot = VIIPER_UDE_MAX_DEVICES; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&ControllerContext->DeviceLock); + if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + goto Exit; + } for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; if (current == WDF_NO_HANDLE) { @@ -364,7 +371,7 @@ ViiperClaimDeviceSlot( } Exit: - WdfWaitLockRelease(ControllerContext->DeviceLock); + ExReleaseFastMutex(&ControllerContext->DeviceLock); return status; } @@ -376,7 +383,7 @@ ViiperReleaseDeviceSlot( _In_ ULONG Slot ) { - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&ControllerContext->DeviceLock); if (Slot < VIIPER_UDE_MAX_DEVICES) { if (ControllerContext->Devices[Slot] == Device) { ControllerContext->Devices[Slot] = WDF_NO_HANDLE; @@ -385,7 +392,7 @@ ViiperReleaseDeviceSlot( ControllerContext->RemovingSlots[Slot] = FALSE; } } - WdfWaitLockRelease(ControllerContext->DeviceLock); + ExReleaseFastMutex(&ControllerContext->DeviceLock); } NTSTATUS @@ -456,6 +463,12 @@ ViiperCreateVirtualDevice( WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); attributes.ParentObject = controller; attributes.EvtCleanupCallback = ViiperEvtVirtualDeviceCleanup; + // UdeCx permits its USB-device callbacks at <= DISPATCH_LEVEL, while + // endpoint creation and the DeviceLock snapshot used by power/reset + // callbacks are PASSIVE_LEVEL-only. KMDF controller objects otherwise + // default to dispatch execution, so make the child callback contract + // explicit instead of relying on the current UdeCx call context. + attributes.ExecutionLevel = WdfExecutionLevelPassive; status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); if (!NT_SUCCESS(status)) { UdecxUsbDeviceInitFree(deviceInit); @@ -519,7 +532,7 @@ ViiperBeginRemoveDevice( NTSTATUS status = STATUS_NOT_FOUND; ULONG index; - WdfWaitLockAcquire(ControllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&ControllerContext->DeviceLock); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -547,7 +560,7 @@ ViiperBeginRemoveDevice( status = STATUS_SUCCESS; break; } - WdfWaitLockRelease(ControllerContext->DeviceLock); + ExReleaseFastMutex(&ControllerContext->DeviceLock); return status; } @@ -617,7 +630,7 @@ ViiperDestroyOwnedDevices( BOOLEAN removalPending = FALSE; ULONG index; - WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&controllerContext->DeviceLock); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { device = controllerContext->Devices[index]; if (device != WDF_NO_HANDLE && @@ -631,7 +644,7 @@ ViiperDestroyOwnedDevices( removalPending = TRUE; } } - WdfWaitLockRelease(controllerContext->DeviceLock); + ExReleaseFastMutex(&controllerContext->DeviceLock); if (deviceId == 0) { return !removalPending; } @@ -652,6 +665,51 @@ ViiperDestroyOwnedDevices( } } +VOID +ViiperBeginControllerShutdown( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + UDECXUSBDEVICE devices[VIIPER_UDE_MAX_DEVICES] = {0}; + ULONG deviceCount = 0; + ULONG index; + + PAGED_CODE(); + + // Revoke all table handles in one transaction. PlugOutAndDelete can invoke + // asynchronous UdeCx cleanup, so no controller lock may be held across it. + ExAcquireFastMutex(&controllerContext->DeviceLock); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + UDECXUSBDEVICE device = controllerContext->Devices[index]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&deviceContext->Purging, TRUE); + WdfSpinLockRelease(controllerContext->BrokerLock); + controllerContext->Devices[index] = WDF_NO_HANDLE; + controllerContext->RemovingSlots[index] = TRUE; + devices[deviceCount++] = device; + } + ExReleaseFastMutex(&controllerContext->DeviceLock); + + for (index = 0; index < deviceCount; ++index) { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]); + if (deviceContext->Plugged) { + // A successful call starts UdeCx-owned asynchronous deletion. If + // UdeCx rejects the request during controller removal, ordinary + // parent teardown still owns and deletes the child object. + (VOID)UdecxUsbDevicePlugOutAndDelete(devices[index]); + } else { + WdfObjectDelete(devices[index]); + } + } +} + VOID ViiperEvtVirtualDeviceCleanup( _In_ WDFOBJECT DeviceObject @@ -725,12 +783,12 @@ ViiperInvalidateDeviceInputReports( // outside DeviceLock because asynchronous UdeCx cleanup owns its lifetime. for (index = 0; index < RTL_NUMBER_OF(deviceContext->Endpoints); ++index) { UDECXUSBENDPOINT endpoint; - WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&controllerContext->DeviceLock); endpoint = deviceContext->Endpoints[index]; if (endpoint != WDF_NO_HANDLE) { WdfObjectReference(endpoint); } - WdfWaitLockRelease(controllerContext->DeviceLock); + ExReleaseFastMutex(&controllerContext->DeviceLock); if (endpoint != WDF_NO_HANDLE) { ViiperInvalidateEndpointInputReport(endpoint); WdfObjectDereference(endpoint); @@ -749,6 +807,10 @@ ViiperEvtUsbDeviceD0Entry( // This callback is the exact UdeCx power boundary. Open direct input // admission before publishing the ordered advisory event to user mode. WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { + WdfSpinLockRelease(controllerContext->BrokerLock); + return STATUS_DEVICE_REMOVED; + } InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); @@ -818,7 +880,8 @@ ViiperBeginAcknowledgedDeviceReset( // User mode stops and joins the publishers before acknowledging the // operation; completion then reopens this exact kernel gate. WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { status = STATUS_DEVICE_BUSY; } else { @@ -915,7 +978,10 @@ ViiperEvtEndpointCleanup( } controllerContext = ViiperGetControllerContext(deviceContext->Controller); address = endpointContext->Descriptor.bEndpointAddress; - WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&controllerContext->DeviceLock); + if (deviceContext->DefaultEndpoint == endpoint) { + deviceContext->DefaultEndpoint = WDF_NO_HANDLE; + } if (deviceContext->Endpoints[address] == endpoint) { deviceContext->Endpoints[address] = WDF_NO_HANDLE; // The user-mode latest-state publisher is stopped by the ordered @@ -925,7 +991,7 @@ ViiperEvtEndpointCleanup( // endpoint that never existed in this device generation. deviceContext->RetiredEndpoints[address] = TRUE; } - WdfWaitLockRelease(controllerContext->DeviceLock); + ExReleaseFastMutex(&controllerContext->DeviceLock); } NTSTATUS @@ -944,6 +1010,13 @@ ViiperEvtEndpointAdd( NTSTATUS status; PAGED_CODE(); + if (InterlockedCompareExchange( + &ViiperGetControllerContext( + ViiperGetDeviceContext(Device)->Controller)->ShuttingDown, + 0, + 0) != 0) { + return STATUS_DEVICE_REMOVED; + } RtlZeroMemory(&descriptor, sizeof(descriptor)); if (EndpointData->EndpointDescriptor != NULL) { if (EndpointData->EndpointDescriptorBufferLength < sizeof(USB_ENDPOINT_DESCRIPTOR)) { @@ -989,7 +1062,6 @@ ViiperEvtEndpointAdd( return status; } if (descriptor.bEndpointAddress == 0) { - ViiperGetDeviceContext(Device)->DefaultEndpoint = endpoint; dispatchType = WdfIoQueueDispatchSequential; } else if ((descriptor.bEndpointAddress & USB_ENDPOINT_DIRECTION_MASK) != 0 && (descriptor.bmAttributes & USB_ENDPOINT_TYPE_MASK) == USB_ENDPOINT_TYPE_INTERRUPT) { @@ -1033,12 +1105,23 @@ ViiperEvtEndpointAdd( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); - WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); - deviceContext->Endpoints[descriptor.bEndpointAddress] = endpoint; - deviceContext->RetiredEndpoints[descriptor.bEndpointAddress] = FALSE; - WdfWaitLockRelease(controllerContext->DeviceLock); + ExAcquireFastMutex(&controllerContext->DeviceLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0) { + if (descriptor.bEndpointAddress == 0) { + deviceContext->DefaultEndpoint = endpoint; + } + deviceContext->Endpoints[descriptor.bEndpointAddress] = endpoint; + deviceContext->RetiredEndpoints[descriptor.bEndpointAddress] = FALSE; + status = STATUS_SUCCESS; + } else { + // UdeCx owns the just-created child and will reclaim it when this + // endpoint-add callback rejects publication at the removal gate. + status = STATUS_DEVICE_REMOVED; + } + ExReleaseFastMutex(&controllerContext->DeviceLock); } - return STATUS_SUCCESS; + return status; } NTSTATUS @@ -1062,23 +1145,15 @@ ViiperCompleteRetrievedInputUrb( _In_ NTSTATUS Status ) { - KIRQL previousIrql = KeGetCurrentIrql(); - BOOLEAN raised = previousIrql < DISPATCH_LEVEL; - // The URB was parked in a manual queue and is therefore completed from a - // different call path than UdeCx's submit callback. Match usbip-win2's - // documented compatibility pattern without allocating a DPC per report. - if (raised) { - KeRaiseIrql(DISPATCH_LEVEL, &previousIrql); - } + // passive endpoint work item. UdeCx requires PASSIVE_LEVEL for both + // completion APIs, so completing from a DPC or raising IRQL is invalid. + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); if (NT_SUCCESS(Status)) { UdecxUrbComplete(Request, USBD_STATUS_SUCCESS); } else { UdecxUrbCompleteWithNtStatus(Request, Status); } - if (raised) { - KeLowerIrql(previousIrql); - } } static @@ -1163,7 +1238,8 @@ ViiperEvtFastInputWorkItem( PAGED_CODE(); WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && @@ -1257,7 +1333,7 @@ ViiperSubmitInputReport( return STATUS_INVALID_PARAMETER; } - WdfWaitLockAcquire(controllerContext->DeviceLock, NULL); + ExAcquireFastMutex(&controllerContext->DeviceLock); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = controllerContext->Devices[index]; if (device == WDF_NO_HANDLE) { @@ -1283,7 +1359,7 @@ ViiperSubmitInputReport( } break; } - WdfWaitLockRelease(controllerContext->DeviceLock); + ExReleaseFastMutex(&controllerContext->DeviceLock); if (lifecycleDrop) { // A report already submitted by the owner may cross the D0/unplug // boundary before the ordered lifecycle notification cancels its @@ -1307,7 +1383,8 @@ ViiperSubmitInputReport( // a faulty or hostile owner submits concurrent updates for the same pad. WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || @@ -1378,7 +1455,8 @@ ViiperEvtEndpointReset( NTSTATUS status; WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, TRUE, FALSE) != FALSE) { @@ -1449,8 +1527,8 @@ ViiperEvtEndpointPurgeWorkItem( PAGED_CODE(); // UdeCx requires every request forwarded out of the endpoint queue to be - // completed before PurgeComplete. The broker DPC and the direct input path - // signal this event only after their last owned URB has been completed. + // completed before PurgeComplete. The broker completion worker and direct + // input path signal this event only after their last owned URB completes. (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, Executive, @@ -1508,9 +1586,13 @@ ViiperEvtEndpointStart( // sequence after resume. InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); WdfSpinLockAcquire(controllerContext->BrokerLock); - InterlockedExchange(&endpointContext->Purging, FALSE); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0) { + InterlockedExchange(&endpointContext->Purging, FALSE); + } WdfSpinLockRelease(controllerContext->BrokerLock); - (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0) { + (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); + } } VOID @@ -1562,7 +1644,7 @@ ViiperEvtEndpointIoInternalControl( if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB) { NTSTATUS status = ViiperQueueUrb(Queue, Request); if (status != STATUS_PENDING) { - ViiperCompleteUnownedUrbAsync( + ViiperCompleteUnownedUrb( WdfIoQueueGetDevice(Queue), Request, status); } } else { diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 6d8397fb..22886dea 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -185,6 +185,11 @@ ViiperEvtIoDeviceControlRoute( UNREFERENCED_PARAMETER(OutputBufferLength); UNREFERENCED_PARAMETER(InputBufferLength); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); + return; + } + // The default queue performs routing only. Keeping it parallel prevents a // large media completion or lifecycle mutation on the serialized control // queue from delaying an already encoded interrupt-IN report. @@ -203,11 +208,18 @@ ViiperEvtInputIoDeviceControl( _In_ ULONG IoControlCode ) { + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); NTSTATUS status; UNREFERENCED_PARAMETER(OutputBufferLength); UNREFERENCED_PARAMETER(InputBufferLength); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); + return; + } + status = IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT ? ViiperSubmitInputReport(Queue, Request) : STATUS_INVALID_DEVICE_REQUEST; @@ -223,11 +235,18 @@ ViiperEvtIoDeviceControl( _In_ ULONG IoControlCode ) { + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); NTSTATUS status; UNREFERENCED_PARAMETER(OutputBufferLength); UNREFERENCED_PARAMETER(InputBufferLength); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { + WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); + return; + } + switch (IoControlCode) { case IOCTL_VIIPER_UDE_NEGOTIATE: status = ViiperHandleNegotiate(Request); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index d35ccfdc..93857619 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -26,7 +26,7 @@ typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingPublishing, ViiperUdePendingInFlight, ViiperUdePendingCompleting, - ViiperUdePendingDpcCompletion + ViiperUdePendingPassiveCompletion } VIIPER_UDE_PENDING_STATE; typedef struct VIIPER_UDE_PENDING_SLOT { @@ -88,7 +88,11 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestC typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFWAITLOCK OwnerLock; - WDFWAITLOCK DeviceLock; + // This lock is embedded in the controller context instead of being a WDF + // child object. UdeCx endpoint/device cleanup can run while the framework + // is deleting sibling controller children, but the parent context remains + // alive until every child cleanup callback has returned. + FAST_MUTEX DeviceLock; WDFSPINLOCK BrokerLock; WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; @@ -99,7 +103,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { VIIPER_UDE_NOTIFICATION *Notifications; WDFMEMORY ManagementStorage; VIIPER_UDE_MANAGEMENT_SLOT *ManagementSlots; - WDFDPC CompletionDpc; + WDFWORKITEM CompletionWorkItem; ULONG NotificationHead; ULONG NotificationTail; ULONG NotificationCount; @@ -109,10 +113,14 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFQUEUE InputQueue; WDFQUEUE WaitingDequeues; WDFTIMER OwnerCleanupTimer; + KEVENT BrokerOperationsDrained; + KEVENT FileCleanupsDrained; BOOLEAN CleanupInProgress; + volatile LONG ShuttingDown; volatile LONG BrokerFaulted; volatile LONG OwnerReferenced; volatile LONG ActiveOwnerAdmissions; + volatile LONG ActiveFileCleanups; volatile LONG CleanupRetries; volatile LONG ActiveDevices; volatile LONG PendingOperations; @@ -201,6 +209,8 @@ DRIVER_INITIALIZE DriverEntry; EVT_WDF_DRIVER_DEVICE_ADD ViiperEvtDeviceAdd; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtDriverCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtControllerCleanup; +EVT_WDF_DEVICE_SELF_MANAGED_IO_INIT ViiperEvtDeviceSelfManagedIoInit; +EVT_WDF_DEVICE_SELF_MANAGED_IO_CLEANUP ViiperEvtDeviceSelfManagedIoCleanup; EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; EVT_WDF_TIMER ViiperEvtOwnerCleanupRetry; @@ -223,7 +233,7 @@ EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; EVT_WDF_WORKITEM ViiperEvtFastInputWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; -EVT_WDF_DPC ViiperEvtCompletionDpc; +EVT_WDF_WORKITEM ViiperEvtCompletionWorkItem; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; @@ -232,10 +242,11 @@ NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); BOOLEAN ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); +VOID ViiperBeginControllerShutdown(_In_ WDFDEVICE Controller); NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperQueueUrb(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); -VOID ViiperCompleteUnownedUrbAsync( +VOID ViiperCompleteUnownedUrb( _In_ WDFDEVICE Controller, _In_ WDFREQUEST Request, _In_ NTSTATUS Status); diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index ef6be179..a066b987 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -14,6 +14,7 @@ if ([string]::IsNullOrWhiteSpace($InfPath)) { } $projectPathResolved = (Resolve-Path -LiteralPath $ProjectPath).Path $infPathResolved = (Resolve-Path -LiteralPath $InfPath).Path +$driverSourceDirectory = Split-Path -Parent $projectPathResolved [xml]$project = Get-Content -LiteralPath $projectPathResolved -Raw $namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) @@ -96,5 +97,150 @@ if ($inf -notmatch $kmdfLibraryPattern) { throw "The INF KmdfLibraryVersion must be '$expectedKmdfLibraryVersion'." } +# Keep the reviewed UdeCx callback and teardown contracts machine-verifiable. +# These checks intentionally target small invariants rather than formatting so +# a refactor cannot silently restore dispatch-level pageable callbacks or make +# parent cleanup call framework children that KMDF has already deleted. +$header = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'ViiperUde.h') -Raw +$controllerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Controller.c') -Raw +$deviceSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Device.c') -Raw +$brokerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Broker.c') -Raw +$allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter '*.c' | + Sort-Object -Property FullName | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" + +foreach ($requiredHeaderContract in @( + 'FAST_MUTEX DeviceLock;', + 'KEVENT BrokerOperationsDrained;', + 'KEVENT FileCleanupsDrained;', + 'volatile LONG ShuttingDown;')) { + if (-not $header.Contains($requiredHeaderContract)) { + throw "Missing native teardown contract in ViiperUde.h: $requiredHeaderContract" + } +} +if ($controllerSource -notmatch + 'KeWaitForSingleObject\s*\(\s*&context->FileCleanupsDrained') { + throw 'Terminal rundown must join any file cleanup admitted before ShuttingDown.' +} +if ($controllerSource -notmatch + 'pnpCallbacks\.EvtDeviceSelfManagedIoInit\s*=\s*ViiperEvtDeviceSelfManagedIoInit\s*;' -or + $controllerSource -notmatch + 'pnpCallbacks\.EvtDeviceSelfManagedIoCleanup\s*=\s*ViiperEvtDeviceSelfManagedIoCleanup\s*;') { + throw 'The controller must register both self-managed I/O initialization and terminal rundown callbacks.' +} +if ($controllerSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&fileAttributes,\s*VIIPER_UDE_FILE_CONTEXT\);\s*fileAttributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;') { + throw 'File create/cleanup callbacks must explicitly run at PASSIVE_LEVEL.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*VIIPER_UDE_DEVICE_CONTEXT\);[\s\S]{0,600}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?UdecxUsbDeviceCreate') { + throw 'Every UdeCx USB-device object must explicitly request passive callback execution.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*VIIPER_UDE_ENDPOINT_CONTEXT\);[\s\S]{0,600}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?UdecxUsbEndpointCreate') { + throw 'Every UdeCx endpoint object must explicitly request passive callback execution.' +} +if ($deviceSource -notmatch + 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*UDECXUSBENDPOINT\);[\s\S]{0,300}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?WdfIoQueueCreate') { + throw 'Every endpoint queue must explicitly run at PASSIVE_LEVEL for direct UdeCx completion.' +} +if (($controllerSource + $deviceSource + $brokerSource) -match + 'WdfWaitLock(?:Acquire|Release)\s*\([^;\r\n]*DeviceLock') { + throw 'DeviceLock must remain embedded; a sibling WDF lock is unsafe during UdeCx child cleanup.' +} +if ($header -notmatch 'WDFWORKITEM\s+CompletionWorkItem\s*;' -or + $brokerSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtCompletionWorkItem\s*\)') { + throw 'UdeCx broker completion must use the preallocated passive completion work item.' +} +if ($allDriverCSource -match + 'CompletionDpc|ViiperEvtCompletionDpc|WdfDpc(?:Create|Enqueue|Cancel)|KeRaiseIrql') { + throw 'UdeCx completion must never execute through a DPC or synthetic DISPATCH_LEVEL transition.' +} +$dpcCallbackNames = [regex]::Matches($header, 'EVT_WDF_DPC\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*;') +foreach ($dpcCallbackName in $dpcCallbackNames) { + $callbackName = [regex]::Escape($dpcCallbackName.Groups['name'].Value) + $dpcBody = [regex]::Match( + $allDriverCSource, + "(?ms)^VOID\s+$callbackName\s*\([^)]*\)\s*\{(?.*?)^\}") + if ($dpcBody.Success -and $dpcBody.Groups['body'].Value -match 'UdecxUrbComplete') { + throw "WDF DPC callback '$($dpcCallbackName.Groups['name'].Value)' must not complete a UdeCx URB." + } +} +$completionWorkerMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtCompletionWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionWorkerMatch.Success -or + $completionWorkerMatch.Groups['body'].Value -notmatch 'UdecxUrbComplete' -or + $completionWorkerMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { + throw 'Could not verify that broker UdeCx completion is owned by the passive work item.' +} +$unownedCompletionMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperCompleteUnownedUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $unownedCompletionMatch.Success -or + $unownedCompletionMatch.Groups['body'].Value -notmatch 'UdecxUrbComplete' -or + $unownedCompletionMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { + throw 'Unowned endpoint URBs must complete directly under an asserted PASSIVE_LEVEL contract.' +} +$retrievedInputCompletionMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperCompleteRetrievedInputUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $retrievedInputCompletionMatch.Success -or + $retrievedInputCompletionMatch.Groups['body'].Value -match 'KeRaiseIrql' -or + $retrievedInputCompletionMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { + throw 'Retrieved input URBs must complete directly under an asserted PASSIVE_LEVEL contract.' +} +$allUdeCxCompletionCalls = [regex]::Matches( + $allDriverCSource, + 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count +$approvedUdeCxCompletionCalls = + [regex]::Matches($completionWorkerMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count + + [regex]::Matches($unownedCompletionMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count + + [regex]::Matches($retrievedInputCompletionMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count +if ($allUdeCxCompletionCalls -ne $approvedUdeCxCompletionCalls) { + throw 'Every UdeCx completion call must remain inside an approved PASSIVE_LEVEL completion surface.' +} +$controllerCleanupMatch = [regex]::Match( + $controllerSource, + '(?ms)^VOID\s+ViiperEvtControllerCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $controllerCleanupMatch.Success) { + throw 'Could not locate ViiperEvtControllerCleanup for teardown validation.' +} +$forbiddenCleanupCalls = @( + 'WdfTimerStop', + 'WdfIoQueuePurgeSynchronously', + 'WdfSpinLockAcquire', + 'WdfWaitLockAcquire', + 'WdfWorkItemFlush', + 'ViiperPurgeOwnerOperations', + 'ViiperBeginControllerShutdown') +foreach ($forbiddenCall in $forbiddenCleanupCalls) { + if ($controllerCleanupMatch.Groups['body'].Value.Contains($forbiddenCall)) { + throw "Controller EvtCleanup must not call child-backed teardown routine '$forbiddenCall'." + } +} +if ($controllerCleanupMatch.Groups['body'].Value -match '\b(?:Wdf|Udecx)[A-Za-z0-9_]*\s*\(') { + throw 'Controller EvtCleanup must not call any WDF/UdeCx child-backed API.' +} +$selfManagedCleanupMatch = [regex]::Match( + $controllerSource, + '(?ms)^VOID\s+ViiperEvtDeviceSelfManagedIoCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $selfManagedCleanupMatch.Success -or + $selfManagedCleanupMatch.Groups['body'].Value -notmatch + 'ViiperPurgeOwnerOperations[\s\S]*BrokerOperationsDrained[\s\S]*WdfWorkItemFlush[\s\S]*ViiperBeginControllerShutdown') { + throw 'Terminal rundown must drain and flush broker completion before asynchronous child teardown.' +} +$controllerShutdownMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperBeginControllerShutdown\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $controllerShutdownMatch.Success -or + $controllerShutdownMatch.Groups['body'].Value -match 'KeWaitForSingleObject') { + throw 'UdeCx child teardown must remain asynchronous and must not synchronously await child cleanup.' +} + $stampState = if ($RequireStampedInf) { 'stamped output' } else { 'source template' } -Write-Host "VIIPER UDE target contract is aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." +Write-Host "VIIPER UDE target and teardown contracts are aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." From 6427a730d6d28ea9cef8aa79ce1c26f82a91776b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:30:07 -0500 Subject: [PATCH 127/240] Fence native UDE device lifecycle generations Serialize DeviceReset, D0, and SET_CONFIGURATION boundaries per device generation while retaining endpoint concurrency between barriers. Announced barriers cancel and join earlier callbacks and completions, park later mic/HID/media operations, and never serialize another controller. Explicitly retire token-bearing endpoint lifecycle requests that a device barrier supersedes, join direct input publishers through configuration boundaries, and clear native media clock/session state on SET_CONFIGURATION. Add deterministic blocked-speaker, in-flight completion, active/queued management token, failed-admission, D0, reset, and configuration tests. --- internal/server/usb/native.go | 17 + internal/server/usb/native_test.go | 65 ++ internal/transport/udecx/device_barrier.go | 235 +++++++ internal/transport/udecx/host.go | 341 ++++++++--- internal/transport/udecx/host_test.go | 681 ++++++++++++++++++++- 5 files changed, 1235 insertions(+), 104 deletions(-) create mode 100644 internal/transport/udecx/device_barrier.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 2512baa3..496474bb 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -96,6 +96,11 @@ func (p *NativeProcessor) lockSession(key nativeSessionKey) *nativeSessionState func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity, session *nativeSessionState) { p.server.resetInterfaceAlts(dev) + p.clearDeviceTransportLocked(identity, session) +} + +func (p *NativeProcessor) clearDeviceTransportLocked(identity udecx.DeviceIdentity, + session *nativeSessionState) { p.mu.Lock() for key := range p.next { if key.deviceID == identity.DeviceID && key.generation == identity.Generation { @@ -329,6 +334,18 @@ func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op func (p *NativeProcessor) processControl(ctx context.Context, dev usbdevice.Device, op udecx.Operation, ep, dir uint32) (udecx.Completion, error) { + if op.SetupPacket[0] == usbReqTypeStandardToDevice && op.SetupPacket[1] == usbReqSetConfiguration { + identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} + session := p.lockSession(nativeSessionKey{ + deviceID: op.DeviceID, generation: op.Generation, + }) + // Server.processSubmit applies the USB request's interface reset and + // publishes that notification exactly once. Retire the native endpoint + // activity and media-clock state here after the host's device barrier has + // joined every pre-configuration callback. + p.clearDeviceTransportLocked(identity, session) + session.mu.Unlock() + } setup := op.SetupPacket[:] response := p.server.processSubmit(ctx, dev, ep, dir, setup, op.Payload) if err := ctx.Err(); err != nil { diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 8913bb9f..17faa8fd 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -180,6 +180,71 @@ func TestNativeProcessorPreservesAlternateSettingAcrossLinkPower(t *testing.T) { } } +func TestNativeProcessorSetConfigurationRetiresGenerationTransportState(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 0}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x05, + WMaxPacketSize: 196, BInterval: 4, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + identity := udecx.DeviceIdentity{DeviceID: 5, Generation: 9} + endpoint := udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + Kind: udecx.OperationEndpointStart, + EndpointAddress: 0x02, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 196, + } + if err := processor.Lifecycle(context.Background(), dev, endpoint); err != nil { + t.Fatal(err) + } + key := nativeLaneKey{ + deviceID: identity.DeviceID, generation: identity.Generation, endpoint: endpoint.EndpointAddress, + } + processor.mu.Lock() + processor.next[key] = time.Now() + processor.lastIn[key] = []byte{1, 2, 3} + processor.mu.Unlock() + + _, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + Kind: udecx.OperationControl, EndpointAddress: 0, + SetupPacket: [8]byte{usbReqTypeStandardToDevice, usbReqSetConfiguration, 1}, + }) + if err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("SET_CONFIGURATION left interface 2 at alternate %d", got) + } + processor.mu.Lock() + _, hasClock := processor.next[key] + _, hasCachedInput := processor.lastIn[key] + session := processor.sessions[nativeSessionKey{ + deviceID: identity.DeviceID, generation: identity.Generation, + }] + processor.mu.Unlock() + if hasClock || hasCachedInput { + t.Fatalf("SET_CONFIGURATION retained clock=%v cachedInput=%v", hasClock, hasCachedInput) + } + if session == nil { + t.Fatal("SET_CONFIGURATION lost the registered generation session") + } + session.mu.Lock() + activeEndpoints := len(session.active) + session.mu.Unlock() + if activeEndpoints != 0 { + t.Fatalf("SET_CONFIGURATION retained %d active endpoint signatures", activeEndpoints) + } +} + func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { desc := &usbdevice.Descriptor{ Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, diff --git a/internal/transport/udecx/device_barrier.go b/internal/transport/udecx/device_barrier.go new file mode 100644 index 00000000..180f3be7 --- /dev/null +++ b/internal/transport/udecx/device_barrier.go @@ -0,0 +1,235 @@ +package udecx + +import ( + "context" + "errors" + "fmt" + "sync" +) + +var errSupersededByDeviceBarrier = errors.New("native UDE operation superseded by device lifecycle barrier") + +const ( + usbRequestTypeStandardToDevice = 0x00 + usbRequestSetConfiguration = 0x09 +) + +// deviceSequenceBarrier preserves the kernel's generation-scoped +// DeviceSequence while still allowing operations on independent endpoints to +// execute concurrently. Device-wide lifecycle boundaries announce themselves +// as soon as they are dispatched, cancel every earlier callback, join those +// callbacks, and hold every later sequence until the boundary is applied. +type deviceSequenceBarrier struct { + mu sync.Mutex + next uint64 + changed chan struct{} + active map[uint64]*deviceSequenceLease + pendingBarriers map[uint64]struct{} + activeBarrier *deviceSequenceLease +} + +type deviceSequenceLease struct { + gate *deviceSequenceBarrier + sequence uint64 + barrier bool + cancel context.CancelCauseFunc + once sync.Once +} + +func newDeviceSequenceBarrier() *deviceSequenceBarrier { + return &deviceSequenceBarrier{ + next: 1, + changed: make(chan struct{}), + active: make(map[uint64]*deviceSequenceLease), + pendingBarriers: make(map[uint64]struct{}), + } +} + +func isDeviceBarrierOperation(op Operation) bool { + switch op.Kind { + case OperationDeviceReset, OperationDeviceD0Entry, OperationDeviceD0Exit: + return true + case OperationControl: + return isSetConfigurationOperation(op) + default: + return false + } +} + +func isSetConfigurationOperation(op Operation) bool { + return op.Kind == OperationControl && op.EndpointAddress == 0 && + op.SetupPacket[0] == usbRequestTypeStandardToDevice && + op.SetupPacket[1] == usbRequestSetConfiguration +} + +func (g *deviceSequenceBarrier) signalLocked() { + close(g.changed) + g.changed = make(chan struct{}) +} + +func (g *deviceSequenceBarrier) firstPendingBarrierLocked() uint64 { + var first uint64 + for sequence := range g.pendingBarriers { + if first == 0 || sequence < first { + first = sequence + } + } + return first +} + +func (g *deviceSequenceBarrier) announce(sequence uint64) error { + if sequence == 0 { + return nil + } + var cancels []context.CancelCauseFunc + g.mu.Lock() + if _, announced := g.pendingBarriers[sequence]; announced { + g.mu.Unlock() + return nil + } + if sequence < g.next { + next := g.next + g.mu.Unlock() + return fmt.Errorf("device lifecycle sequence %d arrived after sequence %d was admitted", sequence, next-1) + } + g.pendingBarriers[sequence] = struct{}{} + for activeSequence, lease := range g.active { + if activeSequence < sequence { + cancels = append(cancels, lease.cancel) + } + } + g.signalLocked() + g.mu.Unlock() + for _, cancel := range cancels { + cancel(errSupersededByDeviceBarrier) + } + return nil +} + +func (g *deviceSequenceBarrier) withdraw(sequence uint64) { + g.mu.Lock() + if g.activeBarrier == nil || g.activeBarrier.sequence != sequence { + if _, pending := g.pendingBarriers[sequence]; pending { + delete(g.pendingBarriers, sequence) + g.signalLocked() + } + } + g.mu.Unlock() +} + +func (g *deviceSequenceBarrier) enter( + parent context.Context, op Operation, +) (context.Context, *deviceSequenceLease, bool, error) { + if op.DeviceSequence == 0 { + return parent, &deviceSequenceLease{}, false, nil + } + barrier := isDeviceBarrierOperation(op) + if barrier { + if err := g.announce(op.DeviceSequence); err != nil { + return parent, nil, false, err + } + } + + for { + g.mu.Lock() + if op.DeviceSequence < g.next { + next := g.next + g.mu.Unlock() + if barrier { + g.withdraw(op.DeviceSequence) + } + return parent, nil, false, fmt.Errorf( + "device sequence regressed from %d to %d", next, op.DeviceSequence) + } + if op.DeviceSequence != g.next || g.activeBarrier != nil { + changed := g.changed + g.mu.Unlock() + select { + case <-changed: + continue + case <-parent.Done(): + if barrier { + g.withdraw(op.DeviceSequence) + } + return parent, nil, false, parent.Err() + } + } + + if !barrier { + firstBarrier := g.firstPendingBarrierLocked() + if firstBarrier != 0 && op.DeviceSequence <= firstBarrier { + if op.DeviceSequence == firstBarrier { + g.mu.Unlock() + return parent, nil, false, fmt.Errorf( + "device sequence %d was announced as both lifecycle barrier and ordinary work", + op.DeviceSequence) + } + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, cancel: cancel, + } + g.active[op.DeviceSequence] = lease + g.next++ + g.signalLocked() + g.mu.Unlock() + // Announcement predated admission, so this callback must never + // enter the processor. Keep a canceled lease active until its host- + // side token/management cleanup joins; the barrier waits on it just + // like a callback that was already running when announced. + cancel(errSupersededByDeviceBarrier) + return ctx, lease, true, nil + } + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, cancel: cancel, + } + g.active[op.DeviceSequence] = lease + g.next++ + g.signalLocked() + g.mu.Unlock() + return ctx, lease, false, nil + } + + ctx, cancel := context.WithCancelCause(parent) + lease := &deviceSequenceLease{ + gate: g, sequence: op.DeviceSequence, barrier: true, cancel: cancel, + } + g.activeBarrier = lease + g.next++ + g.signalLocked() + for len(g.active) != 0 { + changed := g.changed + g.mu.Unlock() + <-changed + g.mu.Lock() + } + g.mu.Unlock() + if err := parent.Err(); err != nil { + lease.finish() + return parent, nil, false, err + } + return ctx, lease, false, nil + } +} + +func (l *deviceSequenceLease) finish() { + if l == nil || l.gate == nil { + return + } + l.once.Do(func() { + l.cancel(context.Canceled) + g := l.gate + g.mu.Lock() + if l.barrier { + if g.activeBarrier == l { + g.activeBarrier = nil + delete(g.pendingBarriers, l.sequence) + g.signalLocked() + } + } else if g.active[l.sequence] == l { + delete(g.active, l.sequence) + g.signalLocked() + } + g.mu.Unlock() + }) +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 2fb37e69..6edb8e61 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -59,6 +59,7 @@ type OperationProcessor interface { type registeredDevice struct { identity DeviceIdentity device usb.Device + sequence *deviceSequenceBarrier ctx context.Context cancel context.CancelFunc stopping bool @@ -238,7 +239,8 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D identity := DeviceIdentity{DeviceID: deviceID, Generation: generation} deviceCtx, cancel := context.WithCancel(context.Background()) entry := ®isteredDevice{ - identity: identity, device: dev, ctx: deviceCtx, cancel: cancel, + identity: identity, device: dev, sequence: newDeviceSequenceBarrier(), + ctx: deviceCtx, cancel: cancel, fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), activeInput: make(map[uint8]bool), resettingInput: make(map[uint8]bool), inputSequences: make(map[uint8]*atomic.Uint64), inD0: true, @@ -707,6 +709,20 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { go h.runLane(lane, entry) } h.mu.Unlock() + announcedBarrier := false + if isDeviceBarrierOperation(op) { + if err := entry.sequence.announce(op.DeviceSequence); err != nil { + h.failLane(lane, err) + return err + } + announcedBarrier = op.DeviceSequence != 0 + } + withdrawBarrier := func() { + if announcedBarrier { + entry.sequence.withdraw(op.DeviceSequence) + announcedBarrier = false + } + } // Admission is deliberately nonblocking. A queue at the full kernel // pending-operation contract means either an ABI/driver contract violation @@ -716,14 +732,17 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { if lane.terminalErr != nil { err := lane.terminalErr lane.stateMu.Unlock() + withdrawBarrier() return err } if err := lane.ctx.Err(); err != nil { lane.stateMu.Unlock() + withdrawBarrier() return err } if err := ctx.Err(); err != nil { lane.stateMu.Unlock() + withdrawBarrier() return err } select { @@ -737,6 +756,7 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { lane.terminalErr = err lane.cancel() lane.stateMu.Unlock() + withdrawBarrier() h.removeFailedLane(lane, err) h.reportFatal(err) return err @@ -829,98 +849,13 @@ func (h *Host) runLane(lane *operationLane, entry *registeredDevice) { } delete(pending, expected) if isLifecycleOperation(current.Kind) { - applyPowerTransition := false - applyDeviceReset := false - switch current.Kind { - case OperationEndpointPurge: - h.mu.Lock() - entry.activeInput[current.EndpointAddress] = false - delete(entry.resettingInput, current.EndpointAddress) - h.mu.Unlock() - h.stopInputPublisher(entry, current.EndpointAddress) - case OperationEndpointReset: - h.mu.Lock() - entry.resettingInput[current.EndpointAddress] = true - h.mu.Unlock() - h.stopInputPublisher(entry, current.EndpointAddress) - case OperationDeviceD0Exit: - h.mu.Lock() - if current.DeviceSequence > entry.powerSequence { - entry.powerSequence = current.DeviceSequence - entry.inD0 = false - applyPowerTransition = true - } - h.mu.Unlock() - if applyPowerTransition { - h.stopAllInputPublishers(entry) - } - case OperationDeviceReset: - h.mu.Lock() - if !entry.resetting { - entry.resetting = true - applyDeviceReset = true - } - h.mu.Unlock() - if applyDeviceReset { - h.stopAllInputPublishers(entry) - } - } - lifecycleErr := h.processor.Lifecycle(lane.ctx, entry.device, current) - if current.Token != 0 { - status := int32(0) - if lifecycleErr != nil { - status = statusUnsuccessful - } - if err := h.completeLifecycle(lane.ctx, current, status); err != nil { - h.failLane(lane, fmt.Errorf("endpoint 0x%02x acknowledge lifecycle sequence %d: %w", - current.EndpointAddress, current.EndpointSequence, err)) - return - } - } - if lifecycleErr != nil { + if err := h.processLifecycle(lane.ctx, entry, current); err != nil { h.failLane(lane, fmt.Errorf("endpoint 0x%02x lifecycle sequence %d: %w", - lane.key.endpoint, current.EndpointSequence, lifecycleErr)) + lane.key.endpoint, current.EndpointSequence, err)) return } - switch current.Kind { - case OperationEndpointStart: - h.mu.Lock() - entry.activeInput[current.EndpointAddress] = true - h.mu.Unlock() - h.startInputPublisher(entry, current.EndpointAddress) - case OperationEndpointReset: - h.mu.Lock() - delete(entry.resettingInput, current.EndpointAddress) - restart := entry.activeInput[current.EndpointAddress] - h.mu.Unlock() - if restart { - h.startInputPublisher(entry, current.EndpointAddress) - } - case OperationDeviceD0Entry: - h.mu.Lock() - if current.DeviceSequence > entry.powerSequence { - entry.powerSequence = current.DeviceSequence - entry.inD0 = true - applyPowerTransition = true - } - h.mu.Unlock() - if applyPowerTransition { - for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) - } - } - case OperationDeviceReset: - if applyDeviceReset { - h.mu.Lock() - entry.resetting = false - h.mu.Unlock() - for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) - } - } - } } else { - if err := h.process(lane.ctx, entry.device, current); err != nil { + if err := h.process(lane.ctx, entry, current); err != nil { h.failLane(lane, fmt.Errorf("endpoint 0x%02x complete sequence %d: %w", lane.key.endpoint, current.EndpointSequence, err)) return @@ -942,6 +877,169 @@ func isLifecycleOperation(kind OperationKind) bool { } } +func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op Operation) error { + gateCtx, lease, superseded, err := entry.sequence.enter(ctx, op) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + if superseded { + defer lease.finish() + // Endpoint lifecycle notifications describe durable UdeCx state even + // when their pre-barrier callback must not run. Preserve only the host's + // minimal publisher bookkeeping; the device-wide barrier owns all actual + // controller/processor state from this point forward. + switch op.Kind { + case OperationEndpointStart: + h.mu.Lock() + entry.activeInput[op.EndpointAddress] = true + h.mu.Unlock() + case OperationEndpointPurge: + h.mu.Lock() + entry.activeInput[op.EndpointAddress] = false + delete(entry.resettingInput, op.EndpointAddress) + h.mu.Unlock() + case OperationEndpointReset: + h.mu.Lock() + delete(entry.resettingInput, op.EndpointAddress) + h.mu.Unlock() + } + return h.completeSupersededLifecycle(ctx, entry, op) + } + defer lease.finish() + + applyPowerTransition := false + applyDeviceReset := false + switch op.Kind { + case OperationEndpointPurge: + h.mu.Lock() + entry.activeInput[op.EndpointAddress] = false + delete(entry.resettingInput, op.EndpointAddress) + h.mu.Unlock() + h.stopInputPublisher(entry, op.EndpointAddress) + case OperationEndpointReset: + h.mu.Lock() + entry.resettingInput[op.EndpointAddress] = true + h.mu.Unlock() + h.stopInputPublisher(entry, op.EndpointAddress) + case OperationDeviceD0Exit: + h.mu.Lock() + if op.DeviceSequence > entry.powerSequence { + entry.powerSequence = op.DeviceSequence + entry.inD0 = false + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + h.stopAllInputPublishers(entry) + } + case OperationDeviceReset: + h.mu.Lock() + if !entry.resetting { + entry.resetting = true + entry.resettingInput = make(map[uint8]bool) + applyDeviceReset = true + } + h.mu.Unlock() + if applyDeviceReset { + h.stopAllInputPublishers(entry) + } + } + + lifecycleErr := h.processor.Lifecycle(gateCtx, entry.device, op) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + return h.completeSupersededLifecycle(ctx, entry, op) + } + if op.Token != 0 { + status := int32(0) + if lifecycleErr != nil { + status = statusUnsuccessful + } + if err := h.completeLifecycle(gateCtx, op, status); err != nil { + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + return h.completeSupersededLifecycle(ctx, entry, op) + } + return fmt.Errorf("acknowledge lifecycle: %w", err) + } + } + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.discardSupersededLifecycle(entry, op) + return nil + } + if lifecycleErr != nil { + return lifecycleErr + } + + switch op.Kind { + case OperationEndpointStart: + h.mu.Lock() + entry.activeInput[op.EndpointAddress] = true + h.mu.Unlock() + h.startInputPublisher(entry, op.EndpointAddress) + case OperationEndpointReset: + h.mu.Lock() + delete(entry.resettingInput, op.EndpointAddress) + restart := entry.activeInput[op.EndpointAddress] + h.mu.Unlock() + if restart { + h.startInputPublisher(entry, op.EndpointAddress) + } + case OperationDeviceD0Entry: + h.mu.Lock() + if op.DeviceSequence > entry.powerSequence { + entry.powerSequence = op.DeviceSequence + entry.inD0 = true + applyPowerTransition = true + } + h.mu.Unlock() + if applyPowerTransition { + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + } + case OperationDeviceReset: + if applyDeviceReset { + h.mu.Lock() + entry.resetting = false + h.mu.Unlock() + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + } + } + return nil +} + +func (h *Host) discardSupersededLifecycle(entry *registeredDevice, op Operation) { + if op.Kind != OperationEndpointReset { + return + } + h.mu.Lock() + delete(entry.resettingInput, op.EndpointAddress) + h.mu.Unlock() +} + +func (h *Host) completeSupersededLifecycle( + ctx context.Context, entry *registeredDevice, op Operation, +) error { + h.discardSupersededLifecycle(entry, op) + if op.Token == 0 { + return nil + } + // A token-bearing lifecycle notification owns a live UdeCx management + // request. Device barriers cancel the old processor callback, but the kernel + // intentionally retains that request until user mode acknowledges it (owner + // teardown is the only kernel-side bulk abort). Complete it outside the + // canceled sequence context while the old lease is still held, so the next + // barrier cannot start with a stranded endpoint-reset/interface request. + if err := h.completeLifecycle(ctx, op, statusUnsuccessful); err != nil { + return fmt.Errorf("cancel superseded lifecycle: %w", err) + } + return nil +} + func (h *Host) completeLifecycle(ctx context.Context, op Operation, status int32) error { completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) defer cancel() @@ -950,15 +1048,62 @@ func (h *Host) completeLifecycle(ctx context.Context, op Operation, status int32 }) } -func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) error { - opCtx, cancel, active := h.beginOperation(ctx, op) +func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operation) error { + gateCtx, lease, superseded, err := entry.sequence.enter(ctx, op) + if err != nil { + if ctx.Err() != nil { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + h.finishOperation(op.Token) + return err + } + if superseded { + defer lease.finish() + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } + defer lease.finish() + configurationBarrier := isSetConfigurationOperation(op) + if configurationBarrier { + // SET_CONFIGURATION replaces the child's active USB configuration. The + // global sequence gate has already joined every brokered endpoint lane; + // close and join the direct interrupt-IN lane as part of the same barrier + // so an old report cannot cross the configuration request either. + h.mu.Lock() + applyConfigurationBarrier := !entry.resetting + if applyConfigurationBarrier { + entry.resetting = true + } + h.mu.Unlock() + if applyConfigurationBarrier { + h.stopAllInputPublishers(entry) + defer func() { + h.mu.Lock() + entry.resetting = false + h.mu.Unlock() + for _, endpoint := range h.activeInputEndpoints(entry) { + h.startInputPublisher(entry, endpoint) + } + }() + } + } + + opCtx, cancel, active := h.beginOperation(gateCtx, op) if !active { h.finishOperation(op.Token) return nil } defer cancel() - completion, err := h.processor.Process(opCtx, dev, op) + completion, err := h.processor.Process(opCtx, entry.device, op) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } if err != nil { completion = failureCompletion(op) } @@ -969,9 +1114,19 @@ func (h *Host) process(ctx context.Context, dev usb.Device, op Operation) error completion.Token = op.Token completion.DeviceID = op.DeviceID completion.Generation = op.Generation - completionCtx, completionCancel := context.WithTimeout(ctx, completionTimeout) + // Keep the completion inside the same cancellable device-sequence lease as + // the controller callback. A reset announced after Process returns must be + // able to cancel a blocked driver completion and join it before the reset is + // applied; using the lane context here would leave that old callback outside + // the barrier. + completionCtx, completionCancel := context.WithTimeout(gateCtx, completionTimeout) defer completionCancel() err = h.driver.Complete(completionCtx, completion) + if errors.Is(context.Cause(gateCtx), errSupersededByDeviceBarrier) { + h.cancelOperation(op) + h.finishOperation(op.Token) + return nil + } h.finishOperation(op.Token) return err } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index e4e7d535..fed29148 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -249,6 +249,100 @@ func (*cancellationOnlyCompletionDriver) Complete(ctx context.Context, _ Complet return ctx.Err() } +type deviceBarrierCompletionDriver struct { + *fakeHostDriver + started chan struct{} + canceled chan struct{} + release chan struct{} +} + +type managementBarrierDriver struct { + *fakeHostDriver + management chan Completion + release chan struct{} +} + +func (d *managementBarrierDriver) Complete(ctx context.Context, completion Completion) error { + if completion.Token != 1 { + return d.fakeHostDriver.Complete(ctx, completion) + } + select { + case d.management <- completion: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-d.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *deviceBarrierCompletionDriver) Complete(ctx context.Context, completion Completion) error { + if completion.Token != 1 { + return d.fakeHostDriver.Complete(ctx, completion) + } + close(d.started) + <-ctx.Done() + close(d.canceled) + <-d.release + return ctx.Err() +} + +type deviceBarrierProcessor struct { + targetDevice uint64 + speakerStarted chan struct{} + speakerCanceled chan struct{} + speakerRelease chan struct{} + barrierStarted chan Operation + barrierRelease chan struct{} + processed chan Operation + speakerOnce sync.Once +} + +func (p *deviceBarrierProcessor) Process( + ctx context.Context, _ usb.Device, op Operation, +) (Completion, error) { + if op.DeviceID != p.targetDevice { + p.processed <- op + return Completion{TransferLength: op.TransferLength}, nil + } + if isDeviceBarrierOperation(op) { + p.barrierStarted <- op + select { + case <-p.barrierRelease: + return Completion{TransferLength: op.TransferLength}, nil + case <-ctx.Done(): + return Completion{}, ctx.Err() + } + } + if op.EndpointAddress == 0x02 { + p.speakerOnce.Do(func() { close(p.speakerStarted) }) + <-ctx.Done() + close(p.speakerCanceled) + <-p.speakerRelease + return Completion{}, ctx.Err() + } + p.processed <- op + return Completion{TransferLength: op.TransferLength}, nil +} + +func (p *deviceBarrierProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Operation) error { + if isDeviceBarrierOperation(op) { + p.barrierStarted <- op + select { + case <-p.barrierRelease: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + p.processed <- op + return nil +} +func (*deviceBarrierProcessor) Reset(usb.Device, DeviceIdentity) {} + type resetGateProcessor struct { started chan struct{} release chan struct{} @@ -276,6 +370,37 @@ func (p *resetGateProcessor) Lifecycle(ctx context.Context, _ usb.Device, op Ope } func (*resetGateProcessor) Reset(usb.Device, DeviceIdentity) {} +type supersededManagementProcessor struct { + endpointStarted chan struct{} + endpointCanceled chan struct{} + endpointRelease chan struct{} + barrierStarted chan struct{} +} + +func (*supersededManagementProcessor) Process( + context.Context, usb.Device, Operation, +) (Completion, error) { + return Completion{}, nil +} + +func (p *supersededManagementProcessor) Lifecycle( + ctx context.Context, _ usb.Device, op Operation, +) error { + switch op.Kind { + case OperationEndpointReset: + close(p.endpointStarted) + <-ctx.Done() + close(p.endpointCanceled) + <-p.endpointRelease + return ctx.Err() + case OperationDeviceReset: + close(p.barrierStarted) + } + return nil +} + +func (*supersededManagementProcessor) Reset(usb.Device, DeviceIdentity) {} + func hostTestDevice() usb.Device { return &snapshotDevice{descriptor: usb.Descriptor{ Device: usb.DeviceDescriptor{ @@ -581,7 +706,8 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { go func() { done <- host.Serve(ctx) }() driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, } select { case <-processor.lifecycle: @@ -641,7 +767,8 @@ func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { go func() { done <- host.Serve(ctx) }() driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, } select { case <-processor.lifecycle: @@ -833,7 +960,8 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, } select { case <-processor.lifecycle: @@ -914,18 +1042,14 @@ func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { go func() { done <- host.Serve(ctx) }() // Multiple dequeue workers may deliver the device-wide D0 exit before an - // older endpoint-start notification. DeviceSequence must prevent that old - // start from resurrecting the publisher while the child is outside D0. + // older endpoint-start notification. The announced barrier must retire that + // pre-D0 callback without applying it, then process the exit once the device + // sequence is contiguous. driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, Kind: OperationDeviceD0Exit, } - select { - case <-processor.lifecycle: - case <-time.After(time.Second): - t.Fatal("D0 exit was not processed") - } driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, @@ -934,7 +1058,12 @@ func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { select { case <-processor.lifecycle: case <-time.After(time.Second): - t.Fatal("older endpoint start was not processed") + t.Fatal("D0 exit was not processed after the older sequence was retired") + } + select { + case sequence := <-processor.lifecycle: + t.Fatalf("superseded endpoint start reached lifecycle processor as sequence %d", sequence) + default: } device.reports <- []byte{1} select { @@ -1048,6 +1177,99 @@ func TestHostPausesDirectInputAcrossAcknowledgedDeviceReset(t *testing.T) { } } +func TestHostPausesDirectInputAcrossSetConfigurationBarrier(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &deviceBarrierProcessor{ + targetDevice: 50, + speakerStarted: make(chan struct{}), + speakerCanceled: make(chan struct{}), + speakerRelease: make(chan struct{}), + barrierStarted: make(chan Operation, 1), + barrierRelease: make(chan struct{}), + processed: make(chan Operation, 2), + } + host, _ := NewHost(driver, processor, 4) + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), processor.targetDevice, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case op := <-processor.processed: + if op.Kind != OperationEndpointStart { + t.Fatalf("processed %+v before endpoint start", op) + } + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 { + t.Fatalf("first sequence=%d want=1", report.Sequence) + } + case <-time.After(time.Second): + t.Fatal("first input report was not submitted") + } + + driver.operations <- Operation{ + Token: 2, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationControl, + SetupPacket: [8]byte{ + usbRequestTypeStandardToDevice, usbRequestSetConfiguration, 1, + }, + } + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("SET_CONFIGURATION did not reach the processor") + } + device.reports <- []byte{2} + select { + case report := <-driver.reports: + t.Fatalf("input crossed an active SET_CONFIGURATION barrier: %+v", report) + case <-time.After(25 * time.Millisecond): + } + + close(processor.barrierRelease) + select { + case completion := <-driver.completions: + if completion.Token != 2 || completion.Status != 0 { + t.Fatalf("SET_CONFIGURATION completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("SET_CONFIGURATION was not completed") + } + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{2}) { + t.Fatalf("configuration-restored publisher report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after SET_CONFIGURATION") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostPausesDirectInputAcrossAcknowledgedEndpointReset(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &resetGateProcessor{ @@ -1208,6 +1430,443 @@ func trackAndDispatch(host *Host, op Operation) error { return host.dispatch(context.Background(), op) } +func TestHostDeviceBarriersJoinBlockedSpeakerBeforeLaterMicAndHID(t *testing.T) { + tests := []struct { + name string + kind OperationKind + control bool + }{ + {name: "device_reset", kind: OperationDeviceReset}, + {name: "D0_exit", kind: OperationDeviceD0Exit}, + {name: "D0_entry", kind: OperationDeviceD0Entry}, + {name: "set_configuration", kind: OperationControl, control: true}, + } + + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + driver := newFakeHostDriver() + processor := &deviceBarrierProcessor{ + targetDevice: uint64(96 + index*2), + speakerStarted: make(chan struct{}), + speakerCanceled: make(chan struct{}), + speakerRelease: make(chan struct{}), + barrierStarted: make(chan Operation, 1), + barrierRelease: make(chan struct{}), + processed: make(chan Operation, 4), + } + host, err := NewHost(driver, processor, 4) + if err != nil { + t.Fatal(err) + } + target, err := host.Register(context.Background(), processor.targetDevice, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + independent, err := host.Register(context.Background(), processor.targetDevice+1, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), target) + _ = host.Unregister(context.Background(), independent) + }) + + speaker := Operation{ + Token: 1, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + } + if err = trackAndDispatch(host, speaker); err != nil { + t.Fatal(err) + } + select { + case <-processor.speakerStarted: + case <-time.After(time.Second): + t.Fatal("speaker callback did not start") + } + + // Multiple dequeue workers may deliver later endpoint work before the + // device-wide boundary. These lanes must remain parked at the global + // device sequence instead of overtaking the reset/configuration change. + for _, op := range []Operation{ + { + Token: 3, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x83, EndpointSequence: 1, DeviceSequence: 3, + Kind: OperationTransfer, + }, + { + Token: 4, DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0x04, EndpointSequence: 1, DeviceSequence: 4, + Kind: OperationTransfer, + }, + } { + if err = trackAndDispatch(host, op); err != nil { + t.Fatal(err) + } + } + + barrier := Operation{ + DeviceID: target.DeviceID, Generation: target.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: test.kind, + } + if test.control { + barrier.Token = 2 + barrier.SetupPacket = [8]byte{ + usbRequestTypeStandardToDevice, usbRequestSetConfiguration, 1, + } + err = trackAndDispatch(host, barrier) + } else { + err = host.dispatch(context.Background(), barrier) + } + if err != nil { + t.Fatal(err) + } + select { + case <-processor.speakerCanceled: + case <-time.After(time.Second): + t.Fatal("device barrier did not cancel the older speaker callback") + } + select { + case op := <-processor.barrierStarted: + t.Fatalf("barrier sequence %d ran before the older callback joined", op.DeviceSequence) + case <-time.After(25 * time.Millisecond): + } + + if err = trackAndDispatch(host, Operation{ + Token: 100, DeviceID: independent.DeviceID, Generation: independent.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + }); err != nil { + t.Fatal(err) + } + select { + case op := <-processor.processed: + if op.DeviceID != independent.DeviceID { + t.Fatalf("device barrier leaked target operation %+v before release", op) + } + case <-time.After(time.Second): + t.Fatal("blocked target device serialized an independent controller") + } + + close(processor.speakerRelease) + select { + case op := <-processor.barrierStarted: + if op.DeviceSequence != barrier.DeviceSequence { + t.Fatalf("started barrier sequence=%d want=%d", op.DeviceSequence, barrier.DeviceSequence) + } + case <-time.After(time.Second): + t.Fatal("device barrier did not start after the older callback joined") + } + select { + case op := <-processor.processed: + t.Fatalf("later endpoint 0x%02x overtook the active device barrier", op.EndpointAddress) + case <-time.After(25 * time.Millisecond): + } + + close(processor.barrierRelease) + seen := make(map[uint8]bool) + for len(seen) != 2 { + select { + case op := <-processor.processed: + if op.DeviceID != target.DeviceID || (op.EndpointAddress != 0x83 && op.EndpointAddress != 0x04) { + t.Fatalf("unexpected post-barrier operation %+v", op) + } + seen[op.EndpointAddress] = true + case <-time.After(time.Second): + t.Fatalf("post-barrier endpoints processed=%v want mic 0x83 and HID 0x04", seen) + } + } + + wantCompletions := 3 + if test.control { + wantCompletions++ + } + for range wantCompletions { + select { + case completion := <-driver.completions: + if completion.Token == speaker.Token { + t.Fatal("canceled pre-barrier speaker callback published after the boundary") + } + case <-time.After(time.Second): + t.Fatal("expected post-barrier completion was not published") + } + } + select { + case completion := <-driver.completions: + if completion.Token == speaker.Token { + t.Fatal("canceled pre-barrier speaker callback published late") + } + t.Fatalf("unexpected extra completion %+v", completion) + case <-time.After(25 * time.Millisecond): + } + }) + } +} + +func TestHostDeviceBarrierCancelsAndJoinsBlockedCompletion(t *testing.T) { + driver := &deviceBarrierCompletionDriver{ + fakeHostDriver: newFakeHostDriver(), + started: make(chan struct{}), + canceled: make(chan struct{}), + release: make(chan struct{}), + } + processor := &resetGateProcessor{started: make(chan struct{}), release: make(chan struct{})} + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 105, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + if err = trackAndDispatch(host, Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationTransfer, + }); err != nil { + t.Fatal(err) + } + select { + case <-driver.started: + case <-time.After(time.Second): + t.Fatal("pre-reset driver completion did not start") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-driver.canceled: + case <-time.After(time.Second): + t.Fatal("device reset did not cancel the older blocked completion") + } + select { + case <-processor.started: + t.Fatal("device reset ran before the canceled completion callback joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("device reset did not run after the completion callback joined") + } + close(processor.release) + select { + case completion := <-driver.completions: + t.Fatalf("canceled pre-reset completion was published: %+v", completion) + case <-time.After(25 * time.Millisecond): + } +} + +func TestHostDeviceBarrierCompletesSupersededManagementRequest(t *testing.T) { + driver := &managementBarrierDriver{ + fakeHostDriver: newFakeHostDriver(), + management: make(chan Completion, 1), + release: make(chan struct{}), + } + processor := &supersededManagementProcessor{ + endpointStarted: make(chan struct{}), + endpointCanceled: make(chan struct{}), + endpointRelease: make(chan struct{}), + barrierStarted: make(chan struct{}), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 106, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + if err = host.dispatch(context.Background(), Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-processor.endpointStarted: + case <-time.After(time.Second): + t.Fatal("token-bearing endpoint reset did not start") + } + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + select { + case <-processor.endpointCanceled: + case <-time.After(time.Second): + t.Fatal("device reset did not cancel the older endpoint reset callback") + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before the older endpoint reset callback joined") + case <-time.After(25 * time.Millisecond): + } + + close(processor.endpointRelease) + select { + case completion := <-driver.management: + if completion.Token != 1 || completion.Status != statusUnsuccessful { + t.Fatalf("superseded management completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("superseded endpoint reset left its UdeCx management token stranded") + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before the management completion joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("device reset did not run after the superseded management request completed") + } +} + +func TestHostDeviceBarrierJoinsQueuedSupersededManagementRequest(t *testing.T) { + driver := &managementBarrierDriver{ + fakeHostDriver: newFakeHostDriver(), + management: make(chan Completion, 1), + release: make(chan struct{}), + } + processor := &supersededManagementProcessor{ + endpointStarted: make(chan struct{}), + endpointCanceled: make(chan struct{}), + endpointRelease: make(chan struct{}), + barrierStarted: make(chan struct{}), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 108, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + host.cancelAllOperations() + _ = host.Unregister(context.Background(), identity) + }) + + // A multi-worker dequeue may announce the later device reset before the + // earlier endpoint reset reaches its endpoint lane. + if err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 2, + Kind: OperationDeviceReset, + }); err != nil { + t.Fatal(err) + } + if err = host.dispatch(context.Background(), Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointReset, + }); err != nil { + t.Fatal(err) + } + select { + case completion := <-driver.management: + if completion.Token != 1 || completion.Status != statusUnsuccessful { + t.Fatalf("queued superseded management completion=%+v", completion) + } + case <-time.After(time.Second): + t.Fatal("queued superseded endpoint reset left its management token stranded") + } + select { + case <-processor.endpointStarted: + t.Fatal("queued pre-barrier endpoint reset reached the processor") + default: + } + select { + case <-processor.barrierStarted: + t.Fatal("device reset ran before queued management cancellation joined") + case <-time.After(25 * time.Millisecond): + } + + close(driver.release) + select { + case <-processor.barrierStarted: + case <-time.After(time.Second): + t.Fatal("device reset did not run after queued management cancellation joined") + } +} + +func TestHostWithdrawsAnnouncedBarrierWhenLaneAdmissionFails(t *testing.T) { + driver := newFakeHostDriver() + host, err := NewHost(driver, &noopProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 107, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + + host.mu.RLock() + entry := host.devices[identity.DeviceID] + host.mu.RUnlock() + laneCtx, cancelLane := context.WithCancel(entry.ctx) + key := laneKey{deviceID: identity.DeviceID, generation: identity.Generation, endpoint: 0} + lane := &operationLane{ + key: key, ctx: laneCtx, cancel: cancelLane, + input: make(chan Operation, 1), done: make(chan struct{}), + terminalErr: errors.New("injected terminal lane"), + } + host.mu.Lock() + host.lanes[key] = lane + host.mu.Unlock() + + err = host.dispatch(context.Background(), Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationDeviceReset, + }) + if err == nil || !strings.Contains(err.Error(), "injected terminal lane") { + t.Fatalf("barrier admission error=%v want injected terminal lane", err) + } + entry.sequence.mu.Lock() + pendingBarriers := len(entry.sequence.pendingBarriers) + entry.sequence.mu.Unlock() + if pendingBarriers != 0 { + t.Fatalf("failed barrier admission retained %d pending barriers", pendingBarriers) + } + + host.mu.Lock() + if host.lanes[key] == lane { + delete(host.lanes, key) + } + host.mu.Unlock() + cancelLane() + if err = host.Unregister(context.Background(), identity); err != nil { + t.Fatal(err) + } +} + func TestHostSaturatedLaneDoesNotBlockIndependentController(t *testing.T) { if laneQueueDepth != defaultDevicePendingOperations { t.Fatalf("lane queue depth=%d want kernel pending contract=%d", From cc3dd51c569bc461fdb5a77fb7c9263894d8bcaf Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 04:31:05 -0500 Subject: [PATCH 128/240] Make native driver setup transactional Require source-bound manifests, signed exact INF contracts, and monotonic DriverVer before driver-store mutation. Capture published packages and the prior binding, verify the native ABI after install, and roll back on any post-mutation failure. Remove only exact signed VIIPER devnodes/packages, with self-contained rollback backups and structured reboot/error exits. Forced selection is limited to exact-version controlled downgrade and rollback. --- native/udecx/README.md | 32 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 61 + native/udecx/tools/ViiperUdeCtl.cpp | 2212 +++++++++++++++-- 3 files changed, 2097 insertions(+), 208 deletions(-) create mode 100644 native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 diff --git a/native/udecx/README.md b/native/udecx/README.md index 1fc323df..deae5414 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -10,7 +10,17 @@ Directory contract: - `driver/` is the KMDF/UdeCx controller driver. - `package/` contains INF and installation metadata. - `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root - controller without creating duplicates or leaving a failed devnode behind. + controller as a driver-store transaction. Installation requires the + source-revision submission manifest, verifies the catalog signature and + four-part `DriverVer`, rejects same-version replacement and implicit + downgrade, records the prior published INF, negotiates the broker ABI after + start, and restores the prior binding on failure. Removal backs up every + exact signed VIIPER package before deleting only exact owned devnodes and + packages; unrelated driver-store entries are never force-deleted. +- `tools/Test-ViiperUdeCtlTransaction.ps1` deterministically guards the + transaction, rollback, ownership, downgrade, and structured-reboot source + contracts. Passing a compiled tool through `-BinaryPath` also runs its pure + parser/version self-test without changing driver state. - `tools/New-ViiperUdeAttestationPackage.ps1` creates and hash-verifies the exact controlled-test Hardware Dev Center CAB structure and requires an explicit testing-only acknowledgement. Microsoft currently restricts @@ -84,6 +94,26 @@ The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in `docs/architecture/native-udecx-signing.md`. +Install a validated package only after stopping the broker that owns the native +interface. Production mode accepts only a release-eligible `HLK/WHCP` manifest; +controlled-test attestation must be named explicitly: + +```powershell +.\ViiperUdeCtl.exe install C:\ViiperUde\Signed\ViiperUde.inf ` + --manifest C:\ViiperUde\ViiperUde.cab.sha256.json ` + --source-revision 0123456789abcdef0123456789abcdef01234567 ` + --validation-mode production +``` + +The only forced selection available to an operator is an intentional downgrade +guarded by the exact currently installed version, for example +`--allow-controlled-downgrade 0.2.0.0`. Rollback may internally force the exact +previously captured signed INF because returning to that known state is the +transaction's recovery operation. Exit `0` means verified success, `3010` +means verified installation/removal requires a restart, `4` is a preflight +rejection, and `3` means rollback itself failed. Every command emits one final +key/value result line including `rebootRequired` and rollback status. + After a Microsoft-signed native driver package has been installed and verified, the developer-only standalone registration can persist the preview transport: diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 new file mode 100644 index 00000000..ddde71a8 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [string]$SourcePath, + [string]$BinaryPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($SourcePath)) { + $SourcePath = Join-Path $PSScriptRoot 'ViiperUdeCtl.cpp' +} + +$source = Get-Content -LiteralPath $SourcePath -Raw +$requiredContracts = [ordered]@{ + 'source-manifest preflight' = 'ValidateManifest\(' + 'catalog signature preflight' = 'SetupVerifyInfFileW\(' + 'published INF capture' = 'SetupGetInfPublishedNameW\(' + 'driver-store source capture' = 'SetupGetInfDriverStoreLocationW\(' + 'installed INF ownership' = 'DEVPKEY_Device_DriverInfPath' + 'installed version ownership' = 'DEVPKEY_Device_DriverVersion' + 'documented package install' = 'DiInstallDriverW\(' + 'documented package removal' = 'DiUninstallDriverW\(' + 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' + 'install rollback' = 'RollbackInstall\(' + 'remove rollback backup' = 'BackupPackages\(' + 'transaction mutex' = 'VIIPER_UDE_DRIVER_TRANSACTION_V1' + 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' + 'guarded downgrade' = '--allow-controlled-downgrade' +} + +foreach ($entry in $requiredContracts.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl is missing its $($entry.Key) contract." + } +} + +if ($source -match 'SUOI_FORCEDELETE') { + throw 'ViiperUdeCtl must never force-delete a published INF.' +} + +$forceInfUses = [regex]::Matches($source, '\bDIIRFLAG_FORCE_INF\b').Count +if ($forceInfUses -ne 1 -or + $source -notmatch 'const DWORD installFlags = downgrade \? DIIRFLAG_FORCE_INF : 0;') { + throw 'Forced package selection must exist only behind the validated downgrade decision.' +} + +$forceBindUses = [regex]::Matches($source, '\bINSTALLFLAG_FORCE\b').Count +if ($forceBindUses -ne 3) { + throw "Expected force binding only in controlled downgrade and the two rollback paths; found $forceBindUses uses." +} + +if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { + $resolvedBinary = Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop + $output = & $resolvedBinary.Path self-test 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or $output -notmatch 'result=success operation=self-test') { + throw "ViiperUdeCtl deterministic self-test failed (exit $LASTEXITCODE):`n$output" + } +} + +Write-Host 'ViiperUdeCtl transaction contract is deterministic and fail-closed.' diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 9f76c6d1..7238b262 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -1,28 +1,56 @@ /* * Copyright (c) 2026 VIIPER Project contributors * - * Root-devnode creation follows the SetupAPI sequence documented by the - * Microsoft DevCon sample and usbip-win2's BSD-2-Clause devnode utility. - * See ../THIRD_PARTY_NOTICES.md. + * Driver-store mutation follows the documented SetupAPI/NewDev contracts. + * Installs are source-manifest bound, signature checked, version ordered, and + * rolled back to the exact previously published INF if post-install health + * verification fails. Removal touches only the exact signed VIIPER package + * contract and exact ROOT\VIIPER\UDE devnodes. */ #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #include #include #include -#include #include #include #include +#include + +#include "../include/ViiperUdeProtocol.h" #include +#include +#include +#include #include #include +#include #include #include +#include +#include +#include +#include #include +#include +#include +#include #include +// MinGW's setupapi/newdev headers lag these Vista/Windows 10 declarations. +// Keep the signatures identical to the Windows SDK so the independent +// Windows-target syntax gate can compile the same source as MSVC CI. +#if defined(__MINGW32__) +extern "C" { +WINSETUPAPI BOOL WINAPI SetupGetInfPublishedNameW(PCWSTR, PWSTR, DWORD, PDWORD); +WINSETUPAPI BOOL WINAPI SetupGetInfDriverStoreLocationW( + PCWSTR, PSP_ALTPLATFORM_INFO, PCWSTR, PWSTR, DWORD, PDWORD); +BOOL WINAPI DiUninstallDriverW(HWND, LPCWSTR, DWORD, PBOOL); +} +#endif + #pragma comment(lib, "Cfgmgr32.lib") #pragma comment(lib, "Newdev.lib") #pragma comment(lib, "Setupapi.lib") @@ -33,10 +61,126 @@ namespace { constexpr wchar_t kHardwareId[] = L"ROOT\\VIIPER\\UDE"; constexpr wchar_t kEnumerator[] = L"ROOT"; constexpr wchar_t kServiceName[] = L"ViiperUde"; +constexpr wchar_t kProviderName[] = L"VIIPER Project"; +constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; +constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; +constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; +constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; +constexpr wchar_t kTransactionMutex[] = L"Global\\VIIPER_UDE_DRIVER_TRANSACTION_V1"; +constexpr size_t kMaximumManifestBytes = 1024U * 1024U; + +constexpr GUID kViiperInterfaceGuid = { + 0x32d03f48, 0x725b, 0x4baa, {0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}}; + +enum class ExitCode : int { + Success = 0, + Failure = 1, + Usage = 2, + RollbackFailed = 3, + PreflightRejected = 4, + RebootRequired = ERROR_SUCCESS_REBOOT_REQUIRED, +}; + +struct Error { + DWORD code = ERROR_SUCCESS; + std::wstring phase; + std::wstring message; +}; + +struct Outcome { + bool success = false; + bool changed = false; + bool rebootRequired = false; + ExitCode exitCode = ExitCode::Failure; + Error error; + std::wstring rollback = L"not-needed"; +}; + +std::wstring FormatError(DWORD error) { + wchar_t* raw = nullptr; + const DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS; + const DWORD count = FormatMessageW( + flags, nullptr, error, 0, reinterpret_cast(&raw), 0, nullptr); + std::wstring message = count != 0 && raw != nullptr ? std::wstring(raw, count) : L"unknown error"; + if (raw != nullptr) { + LocalFree(raw); + } + while (!message.empty() && + (message.back() == L'\r' || message.back() == L'\n' || + message.back() == L' ' || message.back() == L'.')) { + message.pop_back(); + } + return message; +} + +bool SetError(Error* error, const wchar_t* phase, DWORD code, std::wstring message = {}) { + if (error != nullptr) { + error->code = code; + error->phase = phase; + error->message = message.empty() ? FormatError(code) : std::move(message); + } + SetLastError(code); + return false; +} + +bool SetLastErrorDetail(Error* error, const wchar_t* phase, std::wstring message = {}) { + return SetError(error, phase, GetLastError(), std::move(message)); +} + +void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { + std::wostream& stream = outcome.success ? std::wcout : std::wcerr; + stream << L"result=" << (outcome.success ? L"success" : L"error") + << L" operation=" << operation + << L" changed=" << (outcome.changed ? 1 : 0) + << L" rebootRequired=" << (outcome.rebootRequired ? 1 : 0) + << L" rollback=" << outcome.rollback + << L" exitCode=" << static_cast(outcome.exitCode); + if (!outcome.success) { + stream << L" phase=" << std::quoted(outcome.error.phase) + << L" win32Error=" << outcome.error.code + << L" message=" << std::quoted(outcome.error.message); + } + stream << L"\n"; +} + +class WinHandle final { +public: + WinHandle() noexcept = default; + explicit WinHandle(HANDLE value) noexcept : value_(value) {} + ~WinHandle() { reset(); } + WinHandle(const WinHandle&) = delete; + WinHandle& operator=(const WinHandle&) = delete; + WinHandle(WinHandle&& other) noexcept : value_(other.release()) {} + WinHandle& operator=(WinHandle&& other) noexcept { + if (this != &other) { + reset(other.release()); + } + return *this; + } + HANDLE get() const noexcept { return value_; } + explicit operator bool() const noexcept { + return value_ != nullptr && value_ != INVALID_HANDLE_VALUE; + } + HANDLE release() noexcept { + HANDLE value = value_; + value_ = INVALID_HANDLE_VALUE; + return value; + } + void reset(HANDLE value = INVALID_HANDLE_VALUE) noexcept { + if (*this) { + CloseHandle(value_); + } + value_ = value; + } + +private: + HANDLE value_ = INVALID_HANDLE_VALUE; +}; class DeviceInfoSet final { public: - explicit DeviceInfoSet(HDEVINFO value) noexcept : value_(value) {} + explicit DeviceInfoSet(HDEVINFO value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} ~DeviceInfoSet() { if (value_ != INVALID_HANDLE_VALUE) { SetupDiDestroyDeviceInfoList(value_); @@ -64,40 +208,916 @@ class DeviceInfoSet final { HDEVINFO value_; }; -std::wstring FormatError(DWORD error) { - wchar_t* raw = nullptr; - const DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | - FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS; - const DWORD count = FormatMessageW( - flags, nullptr, error, 0, reinterpret_cast(&raw), 0, nullptr); - std::wstring message = count != 0 && raw != nullptr ? std::wstring(raw, count) : L"unknown error"; - if (raw != nullptr) { - LocalFree(raw); +class InfHandle final { +public: + explicit InfHandle(HINF value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} + ~InfHandle() { + if (value_ != INVALID_HANDLE_VALUE) { + SetupCloseInfFile(value_); + } } - while (!message.empty() && (message.back() == L'\r' || message.back() == L'\n' || - message.back() == L' ' || message.back() == L'.')) { - message.pop_back(); + InfHandle(const InfHandle&) = delete; + InfHandle& operator=(const InfHandle&) = delete; + HINF get() const noexcept { return value_; } + explicit operator bool() const noexcept { return value_ != INVALID_HANDLE_VALUE; } + +private: + HINF value_; +}; + +class TransactionMutex final { +public: + bool Acquire(Error* error) { + WinHandle handle(CreateMutexW(nullptr, FALSE, kTransactionMutex)); + if (!handle) { + return SetLastErrorDetail(error, L"transaction-mutex"); + } + if (GetLastError() == ERROR_ALREADY_EXISTS) { + return SetError(error, L"transaction-mutex", ERROR_INSTALL_ALREADY_RUNNING, + L"another VIIPER native driver transaction is active"); + } + handle_ = std::move(handle); + return true; } - return message; + +private: + WinHandle handle_; +}; + +bool IsElevated() { + WinHandle token; + HANDLE raw = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) { + return false; + } + token.reset(raw); + TOKEN_ELEVATION elevation{}; + DWORD returned = 0; + return GetTokenInformation(token.get(), TokenElevation, &elevation, sizeof(elevation), &returned) && + elevation.TokenIsElevated != 0; } -bool Fail(const wchar_t* operation, DWORD error = GetLastError()) { - std::wcerr << L"error: " << operation << L" failed (" << error << L"): " - << FormatError(error) << L"\n"; - return false; +struct Version { + std::array parts{}; + + friend bool operator==(const Version&, const Version&) = default; + friend bool operator<(const Version& left, const Version& right) { + return left.parts < right.parts; + } +}; + +std::wstring VersionToString(const Version& version) { + std::wostringstream stream; + stream << version.parts[0] << L'.' << version.parts[1] << L'.' + << version.parts[2] << L'.' << version.parts[3]; + return stream.str(); } -bool IsElevated() { - HANDLE token = nullptr; - if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { +bool ParseVersion(std::wstring_view text, Version* version) { + Version parsed{}; + size_t start = 0; + for (size_t index = 0; index < parsed.parts.size(); ++index) { + const size_t end = text.find(L'.', start); + if ((end == std::wstring_view::npos) != (index == parsed.parts.size() - 1)) { + return false; + } + const size_t limit = end == std::wstring_view::npos ? text.size() : end; + if (limit == start) { + return false; + } + uint32_t value = 0; + for (size_t cursor = start; cursor < limit; ++cursor) { + if (text[cursor] < L'0' || text[cursor] > L'9') { + return false; + } + const uint32_t digit = static_cast(text[cursor] - L'0'); + if (value > (65535U - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + parsed.parts[index] = value; + start = limit + 1; + } + if (version != nullptr) { + *version = parsed; + } + return true; +} + +std::string LowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; +} + +bool IsHexRevision(const std::string& value) { + if (value.size() < 40 || value.size() > 64) { + return false; + } + return std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isxdigit(character) != 0; + }); +} + +struct JsonValue { + using Object = std::map; + using Array = std::vector; + std::variant value; +}; + +class JsonParser final { +public: + explicit JsonParser(std::string_view text) : text_(text) {} + + bool Parse(JsonValue* value, std::string* message) { + SkipWhitespace(); + if (!ParseValue(value, 0, message)) { + return false; + } + SkipWhitespace(); + if (position_ != text_.size()) { + *message = "trailing data after JSON value"; + return false; + } + return true; + } + +private: + void SkipWhitespace() { + while (position_ < text_.size() && + (text_[position_] == ' ' || text_[position_] == '\t' || + text_[position_] == '\r' || text_[position_] == '\n')) { + ++position_; + } + } + + bool ParseValue(JsonValue* value, unsigned depth, std::string* message) { + if (depth > 16) { + *message = "JSON nesting limit exceeded"; + return false; + } + SkipWhitespace(); + if (position_ >= text_.size()) { + *message = "unexpected end of JSON"; + return false; + } + const char current = text_[position_]; + if (current == '{') { + JsonValue::Object object; + if (!ParseObject(&object, depth + 1, message)) { + return false; + } + value->value = std::move(object); + return true; + } + if (current == '[') { + JsonValue::Array array; + if (!ParseArray(&array, depth + 1, message)) { + return false; + } + value->value = std::move(array); + return true; + } + if (current == '"') { + std::string stringValue; + if (!ParseString(&stringValue, message)) { + return false; + } + value->value = std::move(stringValue); + return true; + } + if (Match("true")) { + value->value = true; + return true; + } + if (Match("false")) { + value->value = false; + return true; + } + if (Match("null")) { + value->value = nullptr; + return true; + } + return ParseInteger(value, message); + } + + bool ParseObject(JsonValue::Object* object, unsigned depth, std::string* message) { + ++position_; + SkipWhitespace(); + if (Consume('}')) { + return true; + } + for (;;) { + std::string key; + if (!ParseString(&key, message)) { + return false; + } + SkipWhitespace(); + if (!Consume(':')) { + *message = "expected ':' in JSON object"; + return false; + } + JsonValue child; + if (!ParseValue(&child, depth, message)) { + return false; + } + if (!object->emplace(std::move(key), std::move(child)).second) { + *message = "duplicate JSON object key"; + return false; + } + SkipWhitespace(); + if (Consume('}')) { + return true; + } + if (!Consume(',')) { + *message = "expected ',' in JSON object"; + return false; + } + SkipWhitespace(); + } + } + + bool ParseArray(JsonValue::Array* array, unsigned depth, std::string* message) { + ++position_; + SkipWhitespace(); + if (Consume(']')) { + return true; + } + for (;;) { + JsonValue child; + if (!ParseValue(&child, depth, message)) { + return false; + } + array->push_back(std::move(child)); + SkipWhitespace(); + if (Consume(']')) { + return true; + } + if (!Consume(',')) { + *message = "expected ',' in JSON array"; + return false; + } + SkipWhitespace(); + } + } + + static void AppendUtf8(uint32_t codePoint, std::string* value) { + if (codePoint <= 0x7fU) { + value->push_back(static_cast(codePoint)); + } else if (codePoint <= 0x7ffU) { + value->push_back(static_cast(0xc0U | (codePoint >> 6U))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } else { + value->push_back(static_cast(0xe0U | (codePoint >> 12U))); + value->push_back(static_cast(0x80U | ((codePoint >> 6U) & 0x3fU))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } + } + + bool ParseString(std::string* value, std::string* message) { + if (!Consume('"')) { + *message = "expected JSON string"; + return false; + } + value->clear(); + while (position_ < text_.size()) { + const unsigned char character = static_cast(text_[position_++]); + if (character == '"') { + return true; + } + if (character < 0x20U) { + *message = "control character in JSON string"; + return false; + } + if (character != '\\') { + value->push_back(static_cast(character)); + continue; + } + if (position_ >= text_.size()) { + *message = "unterminated JSON escape"; + return false; + } + const char escaped = text_[position_++]; + switch (escaped) { + case '"': value->push_back('"'); break; + case '\\': value->push_back('\\'); break; + case '/': value->push_back('/'); break; + case 'b': value->push_back('\b'); break; + case 'f': value->push_back('\f'); break; + case 'n': value->push_back('\n'); break; + case 'r': value->push_back('\r'); break; + case 't': value->push_back('\t'); break; + case 'u': { + if (position_ + 4 > text_.size()) { + *message = "short JSON unicode escape"; + return false; + } + uint32_t codePoint = 0; + for (unsigned index = 0; index < 4; ++index) { + const char digit = text_[position_++]; + codePoint <<= 4U; + if (digit >= '0' && digit <= '9') codePoint |= static_cast(digit - '0'); + else if (digit >= 'a' && digit <= 'f') codePoint |= static_cast(digit - 'a' + 10); + else if (digit >= 'A' && digit <= 'F') codePoint |= static_cast(digit - 'A' + 10); + else { + *message = "invalid JSON unicode escape"; + return false; + } + } + if (codePoint >= 0xd800U && codePoint <= 0xdfffU) { + *message = "surrogate JSON escapes are not permitted in install manifests"; + return false; + } + AppendUtf8(codePoint, value); + break; + } + default: + *message = "invalid JSON escape"; + return false; + } + } + *message = "unterminated JSON string"; + return false; + } + + bool ParseInteger(JsonValue* value, std::string* message) { + const size_t start = position_; + bool negative = false; + if (position_ < text_.size() && text_[position_] == '-') { + negative = true; + ++position_; + } + if (position_ >= text_.size() || text_[position_] < '0' || text_[position_] > '9') { + *message = "expected JSON value"; + return false; + } + if (text_[position_] == '0' && position_ + 1 < text_.size() && + text_[position_ + 1] >= '0' && text_[position_ + 1] <= '9') { + *message = "leading zero in JSON integer"; + return false; + } + uint64_t magnitude = 0; + while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') { + const uint64_t digit = static_cast(text_[position_++] - '0'); + if (magnitude > (static_cast(INT64_MAX) - digit) / 10U) { + *message = "JSON integer out of range"; + return false; + } + magnitude = magnitude * 10U + digit; + } + if (position_ < text_.size() && + (text_[position_] == '.' || text_[position_] == 'e' || text_[position_] == 'E')) { + *message = "non-integer JSON number is not permitted in install manifests"; + return false; + } + if (position_ == start) { + *message = "expected JSON integer"; + return false; + } + const int64_t signedValue = negative ? -static_cast(magnitude) : static_cast(magnitude); + value->value = signedValue; + return true; + } + + bool Match(std::string_view expected) { + if (text_.substr(position_, expected.size()) != expected) { + return false; + } + position_ += expected.size(); + return true; + } + + bool Consume(char expected) { + if (position_ >= text_.size() || text_[position_] != expected) { + return false; + } + ++position_; + return true; + } + + std::string_view text_; + size_t position_ = 0; +}; + +const JsonValue* ObjectField(const JsonValue::Object& object, const char* name) { + const auto iterator = object.find(name); + return iterator == object.end() ? nullptr : &iterator->second; +} + +bool ReadSmallFile(const std::filesystem::path& path, std::string* contents, Error* error) { + WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"manifest-open"); + } + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file.get(), &size)) { + return SetLastErrorDetail(error, L"manifest-size"); + } + if (size.QuadPart <= 0 || static_cast(size.QuadPart) > kMaximumManifestBytes) { + return SetError(error, L"manifest-size", ERROR_FILE_TOO_LARGE, + L"manifest must be nonempty and no larger than one MiB"); + } + contents->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), contents->data(), static_cast(contents->size()), &read, nullptr) || + static_cast(read) != contents->size()) { + return SetLastErrorDetail(error, L"manifest-read"); + } + if (contents->size() >= 3 && + static_cast((*contents)[0]) == 0xefU && + static_cast((*contents)[1]) == 0xbbU && + static_cast((*contents)[2]) == 0xbfU) { + contents->erase(0, 3); + } + return true; +} + +bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* error) { + HCRYPTPROV provider = 0; + HCRYPTHASH hash = 0; + if (!CryptAcquireContextW(&provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { + return SetLastErrorDetail(error, L"sha256-provider"); + } + const auto releaseProvider = [&]() { CryptReleaseContext(provider, 0); }; + if (!CryptCreateHash(provider, CALG_SHA_256, 0, 0, &hash)) { + const DWORD code = GetLastError(); + releaseProvider(); + return SetError(error, L"sha256-create", code); + } + WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!file) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-open", code); + } + std::array buffer{}; + for (;;) { + DWORD read = 0; + if (!ReadFile(file.get(), buffer.data(), static_cast(buffer.size()), &read, nullptr)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-read", code); + } + if (read == 0) { + break; + } + if (!CryptHashData(hash, buffer.data(), read, 0)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-update", code); + } + } + std::array bytes{}; + DWORD length = static_cast(bytes.size()); + if (!CryptGetHashParam(hash, HP_HASHVAL, bytes.data(), &length, 0) || length != bytes.size()) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-finish", code); + } + CryptDestroyHash(hash); + releaseProvider(); + static constexpr char digits[] = "0123456789ABCDEF"; + digest->clear(); + digest->reserve(bytes.size() * 2); + for (BYTE byte : bytes) { + digest->push_back(digits[byte >> 4U]); + digest->push_back(digits[byte & 0x0fU]); + } + return true; +} + +bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* error) { + std::error_code fileError; + const uintmax_t size = std::filesystem::file_size(path, fileError); + if (fileError) { + return SetError(error, L"manifest-file-size", static_cast(fileError.value())); + } + *length = static_cast(size); + return true; +} + +bool ValidateManifest( + const std::filesystem::path& manifestPath, + const std::string& expectedRevision, + bool production, + const std::filesystem::path& packageDirectory, + Error* error) { + std::string raw; + if (!ReadSmallFile(manifestPath, &raw, error)) { + return false; + } + JsonValue root; + std::string parseMessage; + if (!JsonParser(raw).Parse(&root, &parseMessage)) { + return SetError(error, L"manifest-parse", ERROR_INVALID_DATA, + std::wstring(parseMessage.begin(), parseMessage.end())); + } + const auto* object = std::get_if(&root.value); + if (object == nullptr) { + return SetError(error, L"manifest-contract", ERROR_INVALID_DATA, L"manifest root must be an object"); + } + const JsonValue* schema = ObjectField(*object, "schema"); + const JsonValue* revision = ObjectField(*object, "sourceRevision"); + const JsonValue* releaseEligible = ObjectField(*object, "releaseEligible"); + const JsonValue* signingRoute = ObjectField(*object, "signingRoute"); + const JsonValue* files = ObjectField(*object, "files"); + const auto* schemaValue = schema == nullptr ? nullptr : std::get_if(&schema->value); + const auto* revisionValue = revision == nullptr ? nullptr : std::get_if(&revision->value); + const auto* releaseValue = releaseEligible == nullptr ? nullptr : std::get_if(&releaseEligible->value); + const auto* routeValue = signingRoute == nullptr ? nullptr : std::get_if(&signingRoute->value); + const auto* fileArray = files == nullptr ? nullptr : std::get_if(&files->value); + if (schemaValue == nullptr || *schemaValue != 1 || revisionValue == nullptr || + LowerAscii(*revisionValue) != LowerAscii(expectedRevision) || releaseValue == nullptr || + routeValue == nullptr || fileArray == nullptr) { + return SetError(error, L"manifest-contract", ERROR_INVALID_DATA, + L"manifest schema, source revision, release route, or file list is invalid"); + } + if (production) { + if (!*releaseValue || *routeValue != "HLK/WHCP") { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"production installation requires a release-eligible HLK/WHCP manifest"); + } + } else if (*releaseValue || *routeValue != "ControlledTestAttestation") { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"controlled-test installation requires its testing-only attestation manifest"); + } + const std::set expectedNames = { + "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.pdb", "ViiperUde.cat"}; + if (fileArray->size() != expectedNames.size()) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, + L"manifest must describe exactly the four VIIPER package files"); + } + std::set seen; + for (const JsonValue& entry : *fileArray) { + const auto* entryObject = std::get_if(&entry.value); + if (entryObject == nullptr) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, L"manifest file entry is not an object"); + } + const JsonValue* nameNode = ObjectField(*entryObject, "name"); + const JsonValue* lengthNode = ObjectField(*entryObject, "length"); + const JsonValue* hashNode = ObjectField(*entryObject, "sha256"); + const auto* name = nameNode == nullptr ? nullptr : std::get_if(&nameNode->value); + const auto* length = lengthNode == nullptr ? nullptr : std::get_if(&lengthNode->value); + const auto* hash = hashNode == nullptr ? nullptr : std::get_if(&hashNode->value); + if (name == nullptr || length == nullptr || *length < 0 || hash == nullptr || + !expectedNames.contains(*name) || !seen.insert(*name).second) { + return SetError(error, L"manifest-files", ERROR_INVALID_DATA, + L"manifest has an unexpected, duplicate, or malformed file entry"); + } + if (*name == "ViiperUde.inf" || *name == "ViiperUde.pdb") { + const std::filesystem::path filePath = packageDirectory / std::wstring(name->begin(), name->end()); + uint64_t actualLength = 0; + std::string actualHash; + if (!FileLength(filePath, &actualLength, error) || !Sha256File(filePath, &actualHash, error)) { + return false; + } + if (actualLength != static_cast(*length) || + LowerAscii(actualHash) != LowerAscii(*hash)) { + return SetError(error, L"manifest-hash", ERROR_CRC, + L"INF or PDB does not match the source-bound submission manifest"); + } + } + } + return seen == expectedNames; +} + +bool GetInfField( + HINF inf, + const wchar_t* section, + const wchar_t* key, + DWORD field, + std::wstring* value, + Error* error) { + INFCONTEXT context{}; + if (!SetupFindFirstLineW(inf, section, key, &context)) { + return SetLastErrorDetail(error, L"inf-contract-line"); + } + DWORD required = 0; + SetupGetStringFieldW(&context, field, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"inf-contract-field"); + } + std::vector buffer(required); + if (!SetupGetStringFieldW(&context, field, buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-contract-field"); + } + *value = buffer.data(); + return true; +} + +bool ValidateSingleModelLine(HINF inf, Error* error) { + INFCONTEXT context{}; + if (!SetupFindFirstLineW(inf, kModelSection, nullptr, &context)) { + return SetLastErrorDetail(error, L"inf-model"); + } + std::wstring install; + std::wstring hardware; + if (SetupGetFieldCount(&context) != 2) { + return SetError(error, L"inf-model", ERROR_INVALID_DATA, + L"VIIPER model entry must contain only install section and exact hardware ID"); + } + DWORD required = 0; + SetupGetStringFieldW(&context, 1, nullptr, 0, &required); + std::vector installBuffer(required); + if (required == 0 || !SetupGetStringFieldW(&context, 1, installBuffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-model-install"); + } + install = installBuffer.data(); + required = 0; + SetupGetStringFieldW(&context, 2, nullptr, 0, &required); + std::vector hardwareBuffer(required); + if (required == 0 || !SetupGetStringFieldW(&context, 2, hardwareBuffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"inf-model-hardware-id"); + } + hardware = hardwareBuffer.data(); + INFCONTEXT next{}; + if (_wcsicmp(install.c_str(), kInstallSection) != 0 || + _wcsicmp(hardware.c_str(), kHardwareId) != 0 || + SetupFindNextLine(&context, &next)) { + return SetError(error, L"inf-model", ERROR_INVALID_DATA, + L"INF must contain exactly one VIIPER root model entry"); + } + return true; +} + +struct PackageInfo { + std::filesystem::path infPath; + std::wstring publishedName; + Version version{}; + std::string infSha256; +}; + +bool InspectInfContract( + const std::filesystem::path& infPath, + bool* owned, + Version* version, + Error* error) { + *owned = false; + UINT errorLine = 0; + InfHandle inf(SetupOpenInfFileW(infPath.c_str(), nullptr, INF_STYLE_WIN4, &errorLine)); + if (!inf) { + return true; + } + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr) || + !IsEqualGUID(classGuid, GUID_DEVCLASS_USB)) { + return true; + } + std::wstring provider; + std::wstring catalog; + std::wstring driverVersion; + std::wstring pnpLockdown; + std::wstring copyFile; + std::wstring sourceDisk; + std::wstring service; + Error local; + if (!GetInfField(inf.get(), L"Version", L"Provider", 1, &provider, &local) || + !GetInfField(inf.get(), L"Version", L"CatalogFile", 1, &catalog, &local) || + !GetInfField(inf.get(), L"Version", L"DriverVer", 2, &driverVersion, &local) || + !GetInfField(inf.get(), L"Version", L"PnpLockDown", 1, &pnpLockdown, &local) || + !GetInfField(inf.get(), L"ViiperUde_Install.NT", L"CopyFiles", 1, ©File, &local) || + !GetInfField(inf.get(), L"SourceDisksFiles", kDriverFileName, 1, &sourceDisk, &local) || + !GetInfField(inf.get(), L"ViiperUde_Install.NT.Services", L"AddService", 1, &service, &local)) { + return true; + } + if (_wcsicmp(provider.c_str(), kProviderName) != 0 || + _wcsicmp(catalog.c_str(), kCatalogName) != 0 || + pnpLockdown != L"1" || _wcsicmp(copyFile.c_str(), L"@ViiperUde.sys") != 0 || + sourceDisk != L"1" || _wcsicmp(service.c_str(), kServiceName) != 0) { + return true; + } + Version parsed{}; + if (!ParseVersion(driverVersion, &parsed)) { + return SetError(error, L"inf-version", ERROR_INVALID_DATA, + L"VIIPER DriverVer must contain a four-component numeric version"); + } + if (!ValidateSingleModelLine(inf.get(), error)) { + return false; + } + *owned = true; + *version = parsed; + return true; +} + +bool VerifyInfSignature( + const std::filesystem::path& infPath, + std::filesystem::path* catalogPath, + Error* error) { + SP_INF_SIGNER_INFO_W signer{}; + signer.cbSize = sizeof(signer); + if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { + return SetLastErrorDetail(error, L"inf-signature"); + } + if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { + return SetError(error, L"inf-signature", ERROR_INVALID_DATA, + L"signed INF did not report a catalog and signer"); + } + if (catalogPath != nullptr) { + *catalogPath = signer.CatalogFile; + } + return true; +} + +bool LoadOwnedPackage( + const std::filesystem::path& rawPath, + bool requireOwned, + PackageInfo* package, + bool* owned, + Error* error) { + std::error_code pathError; + const std::filesystem::path path = std::filesystem::canonical(rawPath, pathError); + const bool regular = !pathError && std::filesystem::is_regular_file(path, pathError); + if (pathError || !regular) { + if (requireOwned) { + return SetError(error, L"package-path", ERROR_FILE_NOT_FOUND); + } + *owned = false; + return true; + } + Version version{}; + bool exact = false; + if (!InspectInfContract(path, &exact, &version, error)) { + return false; + } + if (!exact) { + if (requireOwned) { + return SetError(error, L"package-contract", ERROR_INVALID_DATA, + L"INF does not match the exact VIIPER native driver contract"); + } + *owned = false; + return true; + } + if (!VerifyInfSignature(path, nullptr, error)) { + return false; + } + std::string hash; + if (!Sha256File(path, &hash, error)) { + return false; + } + package->infPath = path; + package->version = version; + package->infSha256 = std::move(hash); + *owned = true; + return true; +} + +bool IsSafePublishedInfName(const std::wstring& value) { + const std::filesystem::path path(value); + if (path.has_parent_path() || path.filename().wstring() != value || value.size() < 9) { + return false; + } + std::wstring lower = value; + std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { + return static_cast(towlower(character)); + }); + if (!lower.starts_with(L"oem") || !lower.ends_with(L".inf")) { + return false; + } + return std::all_of(lower.begin() + 3, lower.end() - 4, [](wchar_t character) { + return character >= L'0' && character <= L'9'; + }); +} + +bool GetSystemInfDirectory(std::filesystem::path* directory, Error* error) { + std::vector buffer(MAX_PATH); + const UINT length = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + if (length == 0) { + return SetLastErrorDetail(error, L"windows-directory"); + } + if (static_cast(length) >= buffer.size()) { + buffer.resize(static_cast(length) + 1); + const UINT retry = GetWindowsDirectoryW(buffer.data(), static_cast(buffer.size())); + if (retry == 0 || static_cast(retry) >= buffer.size()) { + return SetLastErrorDetail(error, L"windows-directory"); + } + } + *directory = std::filesystem::path(buffer.data()) / L"INF"; + return true; +} + +bool GetPublishedInfPath( + const std::filesystem::path& infPath, + std::filesystem::path* publishedPath, + Error* error) { + DWORD required = 0; + SetupGetInfPublishedNameW(infPath.c_str(), nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"published-inf"); + } + std::vector buffer(required); + if (!SetupGetInfPublishedNameW(infPath.c_str(), buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"published-inf"); + } + const std::filesystem::path result(buffer.data()); + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + std::error_code parentError; + std::error_code systemError; + const std::filesystem::path canonicalParent = std::filesystem::canonical(result.parent_path(), parentError); + const std::filesystem::path canonicalSystemInf = std::filesystem::canonical(systemInf, systemError); + if (parentError || systemError || !IsSafePublishedInfName(result.filename().wstring()) || + _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) != 0) { + return SetError(error, L"published-inf", ERROR_INVALID_NAME, + L"SetupAPI returned a published INF outside the system INF directory"); + } + *publishedPath = result; + return true; +} + +bool GetDriverStoreInfPath( + const std::filesystem::path& publishedPath, + std::filesystem::path* storePath, + Error* error) { + DWORD required = 0; + SetupGetInfDriverStoreLocationW(publishedPath.c_str(), nullptr, nullptr, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"driver-store-inf"); + } + std::vector buffer(required); + if (!SetupGetInfDriverStoreLocationW( + publishedPath.c_str(), nullptr, nullptr, buffer.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"driver-store-inf"); + } + *storePath = buffer.data(); + return true; +} + +bool EnumerateOwnedPackages(std::vector* packages, Error* error) { + packages->clear(); + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + const std::wstring pattern = (infDirectory / L"oem*.inf").wstring(); + WIN32_FIND_DATAW data{}; + HANDLE rawFind = FindFirstFileW(pattern.c_str(), &data); + if (rawFind == INVALID_HANDLE_VALUE) { + if (GetLastError() == ERROR_FILE_NOT_FOUND) { + return true; + } + return SetLastErrorDetail(error, L"enumerate-published-inf"); + } + do { + if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || + !IsSafePublishedInfName(data.cFileName)) { + continue; + } + PackageInfo package; + bool owned = false; + Error packageError; + if (!LoadOwnedPackage(infDirectory / data.cFileName, false, &package, &owned, &packageError)) { + FindClose(rawFind); + *error = std::move(packageError); + return false; + } + if (owned) { + package.publishedName = data.cFileName; + packages->push_back(std::move(package)); + } + } while (FindNextFileW(rawFind, &data)); + const DWORD enumerationError = GetLastError(); + FindClose(rawFind); + if (enumerationError != ERROR_NO_MORE_FILES) { + return SetError(error, L"enumerate-published-inf", enumerationError); + } + std::sort(packages->begin(), packages->end(), [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), right.publishedName.c_str()) < 0; + }); + return true; +} + +bool FindPublishedCandidate( + const PackageInfo& candidate, + PackageInfo* published, + Error* error) { + std::vector packages; + if (!EnumerateOwnedPackages(&packages, error)) { return false; } - TOKEN_ELEVATION elevation{}; - DWORD returned = 0; - const BOOL ok = GetTokenInformation( - token, TokenElevation, &elevation, sizeof(elevation), &returned); - CloseHandle(token); - return ok && elevation.TokenIsElevated != 0; + size_t matches = 0; + for (const PackageInfo& package : packages) { + if (package.version == candidate.version && package.infSha256 == candidate.infSha256) { + *published = package; + ++matches; + } + } + if (matches != 1) { + return SetError(error, L"published-candidate", + matches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"driver store must contain exactly one published copy of the candidate package"); + } + return true; } bool MultiSzContains(const std::vector& value, const wchar_t* expected) { @@ -139,310 +1159,1088 @@ bool HasExactHardwareId(HDEVINFO set, SP_DEVINFO_DATA& data) { return type == REG_MULTI_SZ && MultiSzContains(value, kHardwareId); } -bool ReadDevicePresence(HDEVINFO set, SP_DEVINFO_DATA& data, bool* present) { +bool ReadDevicePresence(HDEVINFO set, SP_DEVINFO_DATA& data, bool* present, Error* error) { DEVPROPTYPE type = 0; DEVPROP_BOOLEAN value = DEVPROP_FALSE; DWORD required = 0; if (!SetupDiGetDevicePropertyW( set, &data, &DEVPKEY_Device_IsPresent, &type, reinterpret_cast(&value), sizeof(value), &required, 0)) { - return Fail(L"SetupDiGetDeviceProperty(IsPresent)"); + return SetLastErrorDetail(error, L"device-presence"); } if (type != DEVPROP_TYPE_BOOLEAN || required != sizeof(value)) { - SetLastError(ERROR_INVALID_DATA); - return Fail(L"validate DEVPKEY_Device_IsPresent"); + return SetError(error, L"device-presence", ERROR_INVALID_DATA); } *present = value == DEVPROP_TRUE; return true; } -bool HasExactService(HDEVINFO set, SP_DEVINFO_DATA& data) { +bool ReadDevicePropertyString( + HDEVINFO set, + SP_DEVINFO_DATA& data, + const DEVPROPKEY& key, + std::wstring* value, + Error* error) { + DEVPROPTYPE type = 0; + DWORD required = 0; + if (SetupDiGetDevicePropertyW(set, &data, &key, &type, nullptr, 0, &required, 0)) { + return SetError(error, L"device-property", ERROR_INVALID_DATA); + } + const DWORD code = GetLastError(); + if (code == ERROR_NOT_FOUND) { + value->clear(); + return true; + } + if (code != ERROR_INSUFFICIENT_BUFFER || required < sizeof(wchar_t) || type != DEVPROP_TYPE_STRING) { + return SetError(error, L"device-property", code); + } + std::vector buffer(required); + if (!SetupDiGetDevicePropertyW( + set, &data, &key, &type, buffer.data(), static_cast(buffer.size()), nullptr, 0)) { + return SetLastErrorDetail(error, L"device-property"); + } + *value = reinterpret_cast(buffer.data()); + return true; +} + +bool ReadService(HDEVINFO set, SP_DEVINFO_DATA& data, std::wstring* service, Error* error) { DWORD type = 0; - wchar_t value[128]{}; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW(set, &data, SPDRP_SERVICE, &type, nullptr, 0, &required)) { + return SetError(error, L"device-service", ERROR_INVALID_DATA); + } + const DWORD code = GetLastError(); + if (code == ERROR_INVALID_DATA) { + service->clear(); + return true; + } + if (code != ERROR_INSUFFICIENT_BUFFER || required < sizeof(wchar_t) || type != REG_SZ) { + return SetError(error, L"device-service", code); + } + std::vector buffer(required); if (!SetupDiGetDeviceRegistryPropertyW( - set, &data, SPDRP_SERVICE, &type, reinterpret_cast(value), - sizeof(value), nullptr)) { - return false; + set, &data, SPDRP_SERVICE, &type, buffer.data(), static_cast(buffer.size()), nullptr)) { + return SetLastErrorDetail(error, L"device-service"); } - return type == REG_SZ && _wcsicmp(value, kServiceName) == 0; + *service = reinterpret_cast(buffer.data()); + return true; } -struct DeviceMatch { - SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; +struct DeviceState { + std::wstring instanceId; bool present = false; bool started = false; - bool exactService = false; ULONG problem = 0; + std::wstring service; + std::wstring publishedInf; + Version version{}; + PackageInfo package; }; -bool FindDevices(HDEVINFO set, std::vector* matches) { - matches->clear(); +DeviceInfoSet OpenRootDevices() { + return DeviceInfoSet(SetupDiGetClassDevsW(nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES)); +} + +bool FindExactDevices(HDEVINFO set, std::vector>* devices, Error* error) { + devices->clear(); for (DWORD index = 0;; ++index) { - SP_DEVINFO_DATA data{sizeof(SP_DEVINFO_DATA)}; + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); if (!SetupDiEnumDeviceInfo(set, index, &data)) { if (GetLastError() != ERROR_NO_MORE_ITEMS) { - Fail(L"SetupDiEnumDeviceInfo"); - return false; + return SetLastErrorDetail(error, L"enumerate-root-devices"); } break; } if (!HasExactHardwareId(set, data)) { continue; } - bool present = false; - if (!ReadDevicePresence(set, data, &present)) { + DeviceState state; + if (!ReadDevicePresence(set, data, &state.present, error) || + !ReadService(set, data, &state.service, error) || + !ReadDevicePropertyString(set, data, DEVPKEY_Device_DriverInfPath, &state.publishedInf, error)) { + return false; + } + std::wstring driverVersion; + if (!ReadDevicePropertyString(set, data, DEVPKEY_Device_DriverVersion, &driverVersion, error)) { return false; } + if (!driverVersion.empty() && !ParseVersion(driverVersion, &state.version)) { + return SetError(error, L"device-version", ERROR_INVALID_DATA, + L"installed device exposes a malformed driver version"); + } + DWORD required = 0; + SetupDiGetDeviceInstanceIdW(set, &data, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"device-instance-id"); + } + std::vector instance(required); + if (!SetupDiGetDeviceInstanceIdW(set, &data, instance.data(), required, nullptr)) { + return SetLastErrorDetail(error, L"device-instance-id"); + } + state.instanceId = instance.data(); ULONG status = 0; ULONG problem = 0; - const CONFIGRET result = CM_Get_DevNode_Status(&status, &problem, data.DevInst, 0); - matches->push_back(DeviceMatch{ - data, - present, - present && result == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0, - HasExactService(set, data), - result == CR_SUCCESS ? problem : static_cast(result), - }); + const CONFIGRET configuration = CM_Get_DevNode_Status(&status, &problem, data.DevInst, 0); + state.started = state.present && configuration == CR_SUCCESS && (status & DN_STARTED) != 0 && problem == 0; + state.problem = configuration == CR_SUCCESS ? problem : static_cast(configuration); + devices->emplace_back(data, std::move(state)); } return true; } -DeviceInfoSet OpenRootDevices() { - return DeviceInfoSet(SetupDiGetClassDevsW( - nullptr, kEnumerator, nullptr, DIGCF_ALLCLASSES)); +struct Snapshot { + std::vector devices; + std::vector packages; +}; + +bool CaptureSnapshot(Snapshot* snapshot, Error* error) { + snapshot->devices.clear(); + if (!EnumerateOwnedPackages(&snapshot->packages, error)) { + return false; + } + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"open-root-devices"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error)) { + return false; + } + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + for (auto& match : matches) { + DeviceState& device = match.second; + if (_wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf)) { + return SetError(error, L"device-ownership", ERROR_NOT_FOUND, + L"ROOT\\VIIPER\\UDE is bound to an unowned service or package; refusing mutation"); + } + bool owned = false; + PackageInfo package; + if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, &package, &owned, error)) { + return false; + } + package.publishedName = device.publishedInf; + if (!owned || !(device.version == package.version)) { + return SetError(error, L"device-ownership", ERROR_REVISION_MISMATCH, + L"devnode version does not match its exact signed published INF"); + } + device.package = std::move(package); + snapshot->devices.push_back(std::move(device)); + } + return true; } -bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired) { +bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired, Error* error) { BOOL reboot = FALSE; if (!DiUninstallDevice(nullptr, set, &data, 0, &reboot)) { - return Fail(L"DiUninstallDevice"); + return SetLastErrorDetail(error, L"remove-devnode"); } *rebootRequired = *rebootRequired || reboot != FALSE; return true; } +bool RemoveAllExactDevices(bool* rebootRequired, Error* error) { + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"open-root-devices"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error)) { + return false; + } + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + for (auto& match : matches) { + DeviceState& device = match.second; + if (_wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf)) { + return SetError(error, L"remove-ownership", ERROR_ACCESS_DENIED, + L"refusing to remove an exact hardware ID not owned by the signed VIIPER package"); + } + PackageInfo package; + bool owned = false; + if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, &package, &owned, error) || !owned) { + return false; + } + } + for (auto& match : matches) { + if (!RemoveDevice(set.get(), match.first, rebootRequired, error)) { + return false; + } + } + return true; +} + bool RegisterRootDevice( const GUID& classGuid, const std::wstring& className, - DeviceInfoSet& set, - SP_DEVINFO_DATA* data) { - set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); - if (!set) { - return Fail(L"SetupDiCreateDeviceInfoList"); + DeviceInfoSet* set, + SP_DEVINFO_DATA* data, + Error* error) { + *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); + if (!*set) { + return SetLastErrorDetail(error, L"create-device-info-list"); } - *data = SP_DEVINFO_DATA{sizeof(SP_DEVINFO_DATA)}; + *data = SP_DEVINFO_DATA{}; + data->cbSize = sizeof(*data); if (!SetupDiCreateDeviceInfoW( - set.get(), className.c_str(), &classGuid, nullptr, nullptr, + set->get(), className.c_str(), &classGuid, nullptr, nullptr, DICD_GENERATE_ID, data)) { - return Fail(L"SetupDiCreateDeviceInfo"); + return SetLastErrorDetail(error, L"create-root-devnode"); } - const size_t idChars = std::size(kHardwareId) + 1; - std::vector ids(idChars, L'\0'); - std::copy(std::begin(kHardwareId), std::end(kHardwareId), ids.begin()); + const size_t idCharacters = std::size(kHardwareId) + 1; + std::vector identifiers(idCharacters, L'\0'); + std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); if (!SetupDiSetDeviceRegistryPropertyW( - set.get(), data, SPDRP_HARDWAREID, - reinterpret_cast(ids.data()), - static_cast(ids.size() * sizeof(wchar_t)))) { - return Fail(L"SetupDiSetDeviceRegistryProperty(HardwareId)"); + set->get(), data, SPDRP_HARDWAREID, + reinterpret_cast(identifiers.data()), + static_cast(identifiers.size() * sizeof(wchar_t)))) { + return SetLastErrorDetail(error, L"set-root-hardware-id"); } - if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set.get(), data)) { - return Fail(L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)"); + if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { + return SetLastErrorDetail(error, L"register-root-devnode"); } return true; } -bool Install(const wchar_t* rawInfPath) { - if (!IsElevated()) { - SetLastError(ERROR_ELEVATION_REQUIRED); - return Fail(L"administrator check"); +bool VerifyAbiHealth(Error* error) { + DeviceInfoSet set(SetupDiGetClassDevsW( + &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); + if (!set) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); } - std::error_code pathError; - const std::filesystem::path infPath = std::filesystem::canonical(rawInfPath, pathError); - if (pathError || !std::filesystem::is_regular_file(infPath)) { - SetLastError(ERROR_FILE_NOT_FOUND); - return Fail(L"resolve INF path"); + std::wstring interfacePath; + size_t exactCount = 0; + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interfaceData{}; + interfaceData.cbSize = sizeof(interfaceData); + if (!SetupDiEnumDeviceInterfaces(set.get(), nullptr, &kViiperInterfaceGuid, index, &interfaceData)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); + } + break; + } + SP_DEVINFO_DATA deviceData{}; + deviceData.cbSize = sizeof(deviceData); + DWORD required = 0; + SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, nullptr, 0, &required, &deviceData); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + std::vector buffer(required); + auto* detail = reinterpret_cast(buffer.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + if (!SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, detail, required, nullptr, &deviceData)) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + if (!HasExactHardwareId(set.get(), deviceData)) { + continue; + } + std::wstring service; + if (!ReadService(set.get(), deviceData, &service, error)) { + return false; + } + if (_wcsicmp(service.c_str(), kServiceName) != 0) { + return SetError(error, L"abi-interface-ownership", ERROR_ACCESS_DENIED); + } + ++exactCount; + interfacePath = detail->DevicePath; + } + if (exactCount != 1) { + return SetError(error, L"abi-interface-count", + exactCount == 0 ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); + } + WinHandle device(CreateFileW(interfacePath.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!device) { + return SetLastErrorDetail(error, L"abi-interface-open", + L"native broker interface is unavailable or still owned by another process"); + } + LARGE_INTEGER counter{}; + QueryPerformanceCounter(&counter); + VIIPER_UDE_NEGOTIATE_REQUEST request{}; + request.Header.Magic = VIIPER_UDE_MAGIC; + request.Header.Major = VIIPER_UDE_ABI_MAJOR; + request.Header.Minor = VIIPER_UDE_ABI_MINOR; + request.Header.Size = sizeof(request); + request.ClientNonce = static_cast(counter.QuadPart) ^ GetTickCount64(); + if (request.ClientNonce == 0) request.ClientNonce = 1; + request.RequestedCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | + VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | + VIIPER_UDE_CAP_INPUT_REPORTS; + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + DWORD returned = 0; + if (!DeviceIoControl(device.get(), IOCTL_VIIPER_UDE_NEGOTIATE, + &request, sizeof(request), &response, sizeof(response), &returned, nullptr)) { + return SetLastErrorDetail(error, L"abi-negotiate"); + } + const VIIPER_UDE_UINT32 requiredCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | + VIIPER_UDE_CAP_DEVICE_LIFECYCLE | VIIPER_UDE_CAP_INPUT_REPORTS; + if (returned != sizeof(response) || response.Header.Magic != VIIPER_UDE_MAGIC || + response.Header.Major != VIIPER_UDE_ABI_MAJOR || + response.Header.Minor != VIIPER_UDE_ABI_MINOR || + response.Header.Size != sizeof(response) || response.Header.Flags != 0 || + response.ClientNonce != request.ClientNonce || response.DriverNonce == 0 || + (response.Capabilities & requiredCapabilities) != requiredCapabilities || + response.MaxDevices != VIIPER_UDE_MAX_DEVICES || + response.MaxDescriptorBytes != VIIPER_UDE_MAX_DESCRIPTOR_BYTES || + response.MaxTransferBytes != VIIPER_UDE_MAX_TRANSFER_BYTES || + response.MaxIsoPackets != VIIPER_UDE_MAX_ISO_PACKETS || + response.MaxPendingOperations != VIIPER_UDE_MAX_PENDING_OPERATIONS) { + return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, + L"driver health response does not match the compiled broker ABI"); + } + return true; +} + +bool VerifyInstalled( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool allowStopped, + Error* error) { + Snapshot snapshot; + if (!CaptureSnapshot(&snapshot, error)) { + return false; } + if (snapshot.devices.size() != 1 || !snapshot.devices[0].present || + _wcsicmp(snapshot.devices[0].publishedInf.c_str(), publishedName.c_str()) != 0 || + !(snapshot.devices[0].version == candidate.version) || + snapshot.devices[0].package.infSha256 != candidate.infSha256) { + return SetError(error, L"install-verification", ERROR_REVISION_MISMATCH, + L"installed devnode is not bound to the exact candidate package"); + } + if (!allowStopped && !snapshot.devices[0].started) { + return SetError(error, L"install-start", ERROR_DEVICE_NOT_AVAILABLE, + L"installed driver did not start; problem=" + std::to_wstring(snapshot.devices[0].problem)); + } + return allowStopped || VerifyAbiHealth(error); +} + +bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* error) { + BOOL reboot = FALSE; + if (!DiUninstallDriverW(nullptr, package.infPath.c_str(), 0, &reboot)) { + return SetLastErrorDetail(error, L"remove-driver-package"); + } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} + +std::set PublishedNames(const std::vector& packages) { + std::set names; + for (const PackageInfo& package : packages) { + std::wstring lower = package.publishedName; + std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { + return static_cast(towlower(character)); + }); + names.insert(std::move(lower)); + } + return names; +} + +std::vector NewPackageIndices( + const std::vector& prior, + const std::vector& current) { + const std::set priorNames = PublishedNames(prior); + std::vector indices; + for (size_t index = 0; index < current.size(); ++index) { + std::wstring lower = current[index].publishedName; + std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { + return static_cast(towlower(character)); + }); + if (!priorNames.contains(lower)) { + indices.push_back(index); + } + } + return indices; +} +bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* error) { + bool ignored = false; + if (!RemoveAllExactDevices(&ignored, error)) { + return false; + } + *rebootRequired = *rebootRequired || ignored; + if (prior.devices.empty()) { + return true; + } + const PackageInfo& package = prior.devices[0].package; GUID classGuid{}; wchar_t className[MAX_CLASS_NAME_LEN]{}; - if (!SetupDiGetINFClassW( - infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { - return Fail(L"SetupDiGetINFClass"); + if (!SetupDiGetINFClassW(package.infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { + return SetLastErrorDetail(error, L"rollback-inf-class"); + } + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); + if (!RegisterRootDevice(classGuid, className, &created, &createdData, error)) { + return false; } - if (!IsEqualGUID(classGuid, GUID_DEVCLASS_USB)) { - SetLastError(ERROR_CLASS_MISMATCH); - return Fail(L"validate INF class"); + BOOL reboot = FALSE; + if (!UpdateDriverForPlugAndPlayDevicesW( + nullptr, kHardwareId, package.infPath.c_str(), INSTALLFLAG_FORCE, &reboot)) { + return SetLastErrorDetail(error, L"rollback-bind-prior"); } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} - DeviceInfoSet existing = OpenRootDevices(); - if (!existing) { - return Fail(L"SetupDiGetClassDevs(ROOT)"); +bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) { + if (!RestorePriorBinding(prior, rebootRequired, error)) { + return false; } - std::vector matches; - if (!FindDevices(existing.get(), &matches)) { + std::vector current; + if (!EnumerateOwnedPackages(¤t, error)) { return false; } + for (size_t index : NewPackageIndices(prior.packages, current)) { + if (!UninstallPackage(current[index], rebootRequired, error)) { + return false; + } + } + if (!prior.devices.empty() && !*rebootRequired) { + return VerifyInstalled( + prior.devices[0].package, prior.devices[0].publishedInf, false, error); + } + return true; +} - size_t presentCount = 0; - bool staleRemovalNeedsReboot = false; - for (auto& match : matches) { - if (match.present) { - ++presentCount; - continue; +bool LockPackageFiles( + const std::filesystem::path& directory, + std::vector* locks, + Error* error) { + locks->clear(); + for (const wchar_t* name : {L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.pdb", L"ViiperUde.cat"}) { + WinHandle file(CreateFileW((directory / name).c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"package-lock", + L"all four package files must exist and remain immutable during installation"); } - if (!RemoveDevice(existing.get(), match.data, &staleRemovalNeedsReboot)) { - return false; + locks->push_back(std::move(file)); + } + return true; +} + +struct InstallOptions { + std::filesystem::path infPath; + std::filesystem::path manifestPath; + std::string sourceRevision; + bool production = true; + std::optional expectedDowngradeFrom; +}; + +Outcome Install(const InstallOptions& options) { + Outcome outcome; + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + std::error_code candidatePathError; + const std::filesystem::path lockedInfPath = + std::filesystem::canonical(options.infPath, candidatePathError); + if (candidatePathError || lockedInfPath.filename().wstring() != L"ViiperUde.inf") { + SetError(&outcome.error, L"package-path", ERROR_FILE_NOT_FOUND); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + const std::filesystem::path packageDirectory = lockedInfPath.parent_path(); + std::vector packageLocks; + if (!LockPackageFiles(packageDirectory, &packageLocks, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + PackageInfo candidate; + bool owned = false; + if (!LoadOwnedPackage(lockedInfPath, true, &candidate, &owned, &outcome.error) || !owned || + !ValidateManifest(options.manifestPath, options.sourceRevision, options.production, + packageDirectory, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Snapshot prior; + if (!CaptureSnapshot(&prior, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (prior.devices.size() > 1 || + (!prior.devices.empty() && !prior.devices[0].present)) { + SetError(&outcome.error, L"install-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"installation requires zero devices or one present exact owned root devnode"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + std::optional highest; + for (const PackageInfo& package : prior.packages) { + if (!highest || highest->version < package.version) { + highest = package; } } - if (staleRemovalNeedsReboot) { - std::wcerr << L"error: removing a stale VIIPER UDE devnode requires a restart\n"; - return false; + bool downgrade = false; + if (highest) { + if (candidate.version < highest->version) { + downgrade = true; + if (!options.expectedDowngradeFrom || + !(*options.expectedDowngradeFrom == highest->version)) { + SetError(&outcome.error, L"version-policy", ERROR_REVISION_MISMATCH, + L"downgrade rejected; pass --allow-controlled-downgrade with the exact installed version " + + VersionToString(highest->version)); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + } else if (candidate.version == highest->version) { + const bool conflictingSameVersion = std::any_of( + prior.packages.begin(), prior.packages.end(), [&](const PackageInfo& package) { + return package.version == candidate.version && + package.infSha256 != candidate.infSha256; + }); + if (conflictingSameVersion) { + SetError(&outcome.error, L"version-policy", ERROR_REVISION_MISMATCH, + L"same-version package replacement is rejected; increment DriverVer"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + } } - if (presentCount > 1) { - SetLastError(ERROR_DUPLICATE_SERVICE_NAME); - return Fail(L"validate unique VIIPER UDE controller"); + if (options.expectedDowngradeFrom && !downgrade) { + SetError(&outcome.error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - DeviceInfoSet created(INVALID_HANDLE_VALUE); - SP_DEVINFO_DATA createdData{sizeof(SP_DEVINFO_DATA)}; + outcome.changed = true; + BOOL installReboot = FALSE; + const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; + if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { + const DWORD installCode = GetLastError(); + const Error installError{installCode, L"install-driver-package", FormatError(installCode)}; + Error rollbackError; + bool rollbackReboot = false; + if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = installError; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.rebootRequired = installReboot != FALSE; + + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); bool createdHere = false; - if (presentCount == 0) { - if (!RegisterRootDevice(classGuid, className, created, &createdData)) { - return false; + if (prior.devices.empty()) { + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(candidate.infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { + SetLastErrorDetail(&outcome.error, L"candidate-inf-class"); + } else { + if (RegisterRootDevice(classGuid, className, &created, &createdData, &outcome.error)) { + createdHere = true; + BOOL bindReboot = FALSE; + const DWORD bindFlags = downgrade ? INSTALLFLAG_FORCE : 0; + if (UpdateDriverForPlugAndPlayDevicesW( + nullptr, kHardwareId, candidate.infPath.c_str(), bindFlags, &bindReboot)) { + outcome.rebootRequired = outcome.rebootRequired || bindReboot != FALSE; + outcome.error = {}; + } else { + SetLastErrorDetail(&outcome.error, L"bind-root-devnode"); + } + } } - createdHere = true; } - BOOL rebootRequired = FALSE; - if (!UpdateDriverForPlugAndPlayDevicesW( - nullptr, kHardwareId, infPath.c_str(), INSTALLFLAG_FORCE, &rebootRequired)) { - const DWORD updateError = GetLastError(); + PackageInfo publishedCandidate; + if (outcome.error.code == ERROR_SUCCESS && + !FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { + // Candidate inventory recorded the exact failure. + } + if (outcome.error.code == ERROR_SUCCESS && + !VerifyInstalled(candidate, publishedCandidate.publishedName, outcome.rebootRequired, &outcome.error)) { + // Verification recorded the exact failure. + } + if (outcome.error.code != ERROR_SUCCESS) { + const Error installError = outcome.error; + Error rollbackError; + bool rollbackReboot = outcome.rebootRequired; if (createdHere) { - bool ignoredReboot = false; - RemoveDevice(created.get(), createdData, &ignoredReboot); + Error cleanupError; + if (!RemoveDevice(created.get(), createdData, &rollbackReboot, &cleanupError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(cleanupError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } } - return Fail(L"UpdateDriverForPlugAndPlayDevices", updateError); + if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = installError; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; } - DeviceInfoSet verified = OpenRootDevices(); - if (!verified) { - if (createdHere) { - bool ignoredReboot = false; - RemoveDevice(created.get(), createdData, &ignoredReboot); + outcome.success = true; + outcome.rollback = L"not-needed"; + outcome.exitCode = outcome.rebootRequired ? ExitCode::RebootRequired : ExitCode::Success; + return outcome; +} + +struct PackageBackup { + PackageInfo original; + std::filesystem::path directory; + std::filesystem::path infPath; +}; + +class BackupDirectory final { +public: + ~BackupDirectory() { + if (!path_.empty()) { + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); } - return Fail(L"reopen VIIPER UDE controller"); } - std::vector installed; - if (!FindDevices(verified.get(), &installed)) { - if (createdHere) { - bool ignoredReboot = false; - RemoveDevice(created.get(), createdData, &ignoredReboot); + + bool Create(Error* error) { + std::vector temp(MAX_PATH); + const DWORD length = GetTempPathW(static_cast(temp.size()), temp.data()); + if (length == 0 || static_cast(length) >= temp.size()) { + return SetLastErrorDetail(error, L"rollback-backup-root"); + } + wchar_t candidate[MAX_PATH]{}; + if (!GetTempFileNameW(temp.data(), L"VUC", 0, candidate)) { + return SetLastErrorDetail(error, L"rollback-backup-root"); } + DeleteFileW(candidate); + if (!CreateDirectoryW(candidate, nullptr)) { + return SetLastErrorDetail(error, L"rollback-backup-root"); + } + path_ = candidate; + return true; + } + + const std::filesystem::path& path() const noexcept { return path_; } + +private: + std::filesystem::path path_; +}; + +bool BackupPackages( + const std::vector& packages, + BackupDirectory* root, + std::vector* backups, + Error* error) { + if (!root->Create(error)) { return false; } - if (installed.size() != 1 || !installed[0].present || !installed[0].exactService) { - if (createdHere) { - bool ignoredReboot = false; - RemoveDevice(created.get(), createdData, &ignoredReboot); + backups->clear(); + for (size_t index = 0; index < packages.size(); ++index) { + std::filesystem::path storeInf; + if (!GetDriverStoreInfPath(packages[index].infPath, &storeInf, error)) { + return false; } - if (installed.empty() || (installed.size() == 1 && !installed[0].present)) { - SetLastError(ERROR_DEVICE_NOT_AVAILABLE); - } else if (installed.size() > 1) { - SetLastError(ERROR_DUPLICATE_SERVICE_NAME); - } else { - SetLastError(ERROR_SERVICE_NOT_FOUND); + std::filesystem::path resolvedPublished; + if (!GetPublishedInfPath(storeInf, &resolvedPublished, error) || + _wcsicmp(resolvedPublished.filename().c_str(), packages[index].publishedName.c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-backup-published-inf", ERROR_REVISION_MISMATCH); + } + return false; + } + const std::filesystem::path destination = root->path() / std::to_wstring(index); + std::error_code copyError; + std::filesystem::create_directory(destination, copyError); + if (copyError) { + return SetError(error, L"rollback-backup-create", static_cast(copyError.value())); + } + for (std::filesystem::recursive_directory_iterator iterator(storeInf.parent_path(), copyError), end; + iterator != end && !copyError; iterator.increment(copyError)) { + const std::filesystem::path relative = + std::filesystem::relative(iterator->path(), storeInf.parent_path(), copyError); + if (copyError) break; + const std::filesystem::path target = destination / relative; + if (iterator->is_directory()) { + std::filesystem::create_directories(target, copyError); + } else if (iterator->is_regular_file()) { + std::filesystem::create_directories(target.parent_path(), copyError); + if (!copyError) { + std::filesystem::copy_file(iterator->path(), target, + std::filesystem::copy_options::overwrite_existing, copyError); + } + } + } + if (copyError) { + return SetError(error, L"rollback-backup-copy", static_cast(copyError.value())); + } + std::filesystem::path signerCatalog; + if (!VerifyInfSignature(packages[index].infPath, &signerCatalog, error)) { + return false; + } + const std::filesystem::path adjacentCatalog = destination / kCatalogName; + const bool hasAdjacentCatalog = std::filesystem::is_regular_file(adjacentCatalog, copyError); + if (copyError) { + return SetError(error, L"rollback-backup-catalog", static_cast(copyError.value())); + } + if (!hasAdjacentCatalog) { + if (signerCatalog.empty() || !std::filesystem::is_regular_file(signerCatalog, copyError)) { + return SetError(error, L"rollback-backup-catalog", ERROR_FILE_NOT_FOUND, + L"cannot construct a self-contained signed rollback package"); + } + std::filesystem::copy_file(signerCatalog, adjacentCatalog, + std::filesystem::copy_options::overwrite_existing, copyError); + if (copyError) { + return SetError(error, L"rollback-backup-catalog", static_cast(copyError.value())); + } + } + const std::filesystem::path backupInf = destination / storeInf.filename(); + PackageInfo verified; + bool owned = false; + if (!LoadOwnedPackage(backupInf, true, &verified, &owned, error) || !owned) { + return false; } - return Fail(L"verify installed VIIPER UDE controller"); + backups->push_back(PackageBackup{packages[index], destination, backupInf}); } - if (!rebootRequired && !installed[0].started) { - if (createdHere) { - bool ignoredReboot = false; - RemoveDevice(created.get(), createdData, &ignoredReboot); + return true; +} + +bool RollbackRemove( + const Snapshot& prior, + const std::vector& backups, + bool* rebootRequired, + Error* error) { + for (const PackageBackup& backup : backups) { + BOOL reboot = FALSE; + if (!DiInstallDriverW(nullptr, backup.infPath.c_str(), 0, &reboot)) { + return SetLastErrorDetail(error, L"remove-rollback-package"); } - std::wcerr << L"error: VIIPER UDE controller did not start; problem=" - << installed[0].problem << L"\n"; + *rebootRequired = *rebootRequired || reboot != FALSE; + } + bool removalReboot = false; + if (!RemoveAllExactDevices(&removalReboot, error)) { return false; } - std::wcout << L"installed=1 started=" << (installed[0].started ? 1 : 0) - << L" rebootRequired=" << (rebootRequired ? 1 : 0) << L"\n"; + *rebootRequired = *rebootRequired || removalReboot; + if (!prior.devices.empty()) { + const auto iterator = std::find_if(backups.begin(), backups.end(), [&](const PackageBackup& backup) { + return _wcsicmp(backup.original.publishedName.c_str(), + prior.devices[0].publishedInf.c_str()) == 0; + }); + if (iterator == backups.end()) { + return SetError(error, L"remove-rollback-binding", ERROR_NOT_FOUND); + } + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(iterator->infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { + return SetLastErrorDetail(error, L"remove-rollback-inf-class"); + } + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); + if (!RegisterRootDevice(classGuid, className, &created, &createdData, error)) { + return false; + } + BOOL reboot = FALSE; + if (!UpdateDriverForPlugAndPlayDevicesW( + nullptr, kHardwareId, iterator->infPath.c_str(), INSTALLFLAG_FORCE, &reboot)) { + return SetLastErrorDetail(error, L"remove-rollback-binding"); + } + *rebootRequired = *rebootRequired || reboot != FALSE; + } + if (!*rebootRequired) { + Snapshot restored; + if (!CaptureSnapshot(&restored, error)) { + return false; + } + std::multiset> expectedPackages; + std::multiset> actualPackages; + for (const PackageInfo& package : prior.packages) { + expectedPackages.emplace(package.version, package.infSha256); + } + for (const PackageInfo& package : restored.packages) { + actualPackages.emplace(package.version, package.infSha256); + } + if (expectedPackages != actualPackages || restored.devices.size() != prior.devices.size()) { + return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the exact prior package and devnode set"); + } + if (!prior.devices.empty()) { + if (restored.devices[0].package.infSha256 != prior.devices[0].package.infSha256) { + return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, + L"rollback restored a different active package"); + } + if (!VerifyAbiHealth(error)) { + return false; + } + } + } return true; } -bool Remove() { +Outcome Remove() { + Outcome outcome; if (!IsElevated()) { - SetLastError(ERROR_ELEVATION_REQUIRED); - return Fail(L"administrator check"); + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - DeviceInfoSet set = OpenRootDevices(); - if (!set) { - return Fail(L"SetupDiGetClassDevs(ROOT)"); + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - std::vector matches; - if (!FindDevices(set.get(), &matches)) { - return false; + Snapshot prior; + if (!CaptureSnapshot(&prior, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - bool rebootRequired = false; - for (auto& match : matches) { - if (!RemoveDevice(set.get(), match.data, &rebootRequired)) { - return false; - } + if (prior.devices.empty() && prior.packages.empty()) { + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; } - DeviceInfoSet verified = OpenRootDevices(); - if (!verified) { - return Fail(L"verify removed VIIPER UDE controller"); + BackupDirectory backupRoot; + std::vector backups; + if (!BackupPackages(prior.packages, &backupRoot, &backups, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - std::vector remaining; - if (!FindDevices(verified.get(), &remaining)) { - return false; + outcome.changed = true; + bool reboot = false; + Error mutationError; + bool mutationSucceeded = RemoveAllExactDevices(&reboot, &mutationError); + if (mutationSucceeded) { + for (const PackageInfo& package : prior.packages) { + if (!UninstallPackage(package, &reboot, &mutationError)) { + mutationSucceeded = false; + break; + } + } } - if (!remaining.empty() && !rebootRequired) { - SetLastError(ERROR_DEVICE_IN_USE); - return Fail(L"verify removed VIIPER UDE controller"); + if (mutationSucceeded && !reboot) { + Snapshot verified; + if (!CaptureSnapshot(&verified, &mutationError) || + !verified.devices.empty() || !verified.packages.empty()) { + if (mutationError.code == ERROR_SUCCESS) { + SetError(&mutationError, L"remove-verification", ERROR_DEVICE_IN_USE); + } + mutationSucceeded = false; + } } - std::wcout << L"removed=" << matches.size() - << L" rebootRequired=" << (rebootRequired ? 1 : 0) << L"\n"; - return true; + if (!mutationSucceeded) { + Error rollbackError; + bool rollbackReboot = reboot; + if (RollbackRemove(prior, backups, &rollbackReboot, &rollbackError)) { + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = mutationError; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + outcome.success = true; + outcome.rebootRequired = reboot; + outcome.exitCode = reboot ? ExitCode::RebootRequired : ExitCode::Success; + return outcome; } -bool Status() { - DeviceInfoSet set = OpenRootDevices(); - if (!set) { - return Fail(L"SetupDiGetClassDevs(ROOT)"); +Outcome SelfTest(); + +Outcome Status() { + Outcome outcome; + const Outcome deterministic = SelfTest(); + if (!deterministic.success) { + return deterministic; } - std::vector matches; - if (!FindDevices(set.get(), &matches)) { - return false; + Snapshot snapshot; + if (!CaptureSnapshot(&snapshot, &outcome.error)) { + return outcome; + } + if (snapshot.devices.size() > 1) { + SetError(&outcome.error, L"status-topology", ERROR_DUPLICATE_SERVICE_NAME); + return outcome; } - std::wcout << L"devices=" << matches.size(); - if (matches.size() == 1) { - std::wcout << L" present=" << (matches[0].present ? 1 : 0) - << L" started=" << (matches[0].started ? 1 : 0) - << L" exactService=" << (matches[0].exactService ? 1 : 0) - << L" problem=" << matches[0].problem; + outcome.success = true; + outcome.exitCode = ExitCode::Success; + std::wcout << L"devices=" << snapshot.devices.size() + << L" packages=" << snapshot.packages.size(); + if (snapshot.devices.size() == 1) { + std::wcout << L" present=" << (snapshot.devices[0].present ? 1 : 0) + << L" started=" << (snapshot.devices[0].started ? 1 : 0) + << L" version=" << VersionToString(snapshot.devices[0].version) + << L" publishedInf=" << snapshot.devices[0].publishedInf + << L" problem=" << snapshot.devices[0].problem; } std::wcout << L"\n"; - return matches.size() <= 1; + return outcome; +} + +Outcome SelfTest() { + Outcome outcome; + Version one{}; + Version two{}; + if (!ParseVersion(L"1.2.3.4", &one) || !ParseVersion(L"1.2.4.0", &two) || + !(one < two) || ParseVersion(L"1.2.3", nullptr) || + ParseVersion(L"1.2.3.70000", nullptr)) { + SetError(&outcome.error, L"self-test-version", ERROR_INVALID_DATA); + return outcome; + } + JsonValue value; + std::string message; + if (!JsonParser(R"({"schema":1,"files":[]})").Parse(&value, &message) || + JsonParser(R"({"schema":1,"schema":1})").Parse(&value, &message) || + JsonParser(R"({"schema":1.0})").Parse(&value, &message) || + !IsSafePublishedInfName(L"oem42.inf") || IsSafePublishedInfName(L"..\\oem42.inf")) { + SetError(&outcome.error, L"self-test-contract", ERROR_INVALID_DATA); + return outcome; + } + PackageInfo priorPackage; + priorPackage.publishedName = L"OEM7.INF"; + PackageInfo preservedPackage; + preservedPackage.publishedName = L"oem7.inf"; + PackageInfo newPackage; + newPackage.publishedName = L"oem9.inf"; + const std::vector cleanup = NewPackageIndices( + {priorPackage}, {preservedPackage, newPackage}); + if (cleanup != std::vector{1}) { + SetError(&outcome.error, L"self-test-rollback-cleanup", ERROR_INVALID_DATA); + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Error* error) { + if (argc < 8) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->infPath = argv[2]; + bool manifestSeen = false; + bool revisionSeen = false; + bool modeSeen = false; + for (int index = 3; index < argc; ++index) { + const std::wstring argument = argv[index]; + if (_wcsicmp(argument.c_str(), L"--manifest") == 0 && index + 1 < argc && !manifestSeen) { + options->manifestPath = argv[++index]; + manifestSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--source-revision") == 0 && + index + 1 < argc && !revisionSeen) { + const std::wstring wide = argv[++index]; + options->sourceRevision.assign(wide.begin(), wide.end()); + if (!IsHexRevision(options->sourceRevision)) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"source revision must contain 40 to 64 hexadecimal characters"); + } + revisionSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--validation-mode") == 0 && + index + 1 < argc && !modeSeen) { + const std::wstring mode = argv[++index]; + if (_wcsicmp(mode.c_str(), L"production") == 0) { + options->production = true; + } else if (_wcsicmp(mode.c_str(), L"controlled-test") == 0) { + options->production = false; + } else { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"validation mode must be production or controlled-test"); + } + modeSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--allow-controlled-downgrade") == 0 && + index + 1 < argc && !options->expectedDowngradeFrom) { + Version expected{}; + if (!ParseVersion(argv[++index], &expected)) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"controlled downgrade requires the exact installed four-part version"); + } + options->expectedDowngradeFrom = expected; + } else { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"unknown, duplicate, or incomplete install option"); + } + } + if (!manifestSeen || !revisionSeen || !modeSeen) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest, source revision, and validation mode are all required"); + } + return true; } void Usage() { - std::wcerr << L"usage:\n" - << L" ViiperUdeCtl.exe install \n" - << L" ViiperUdeCtl.exe remove\n" - << L" ViiperUdeCtl.exe status\n"; + std::wcerr + << L"usage:\n" + << L" ViiperUdeCtl.exe install --manifest " + L"--source-revision <40-64 hex> --validation-mode " + L"[--allow-controlled-downgrade ]\n" + << L" ViiperUdeCtl.exe remove\n" + << L" ViiperUdeCtl.exe status\n" + << L" ViiperUdeCtl.exe self-test\n"; } } // namespace int wmain(int argc, wchar_t** argv) { - if (argc == 3 && _wcsicmp(argv[1], L"install") == 0) { - return Install(argv[2]) ? 0 : 1; + if (argc >= 3 && _wcsicmp(argv[1], L"install") == 0) { + InstallOptions options; + Error argumentError; + if (!ParseInstallOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"install", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = Install(options); + EmitOutcome(L"install", outcome); + return static_cast(outcome.exitCode); } if (argc == 2 && _wcsicmp(argv[1], L"remove") == 0) { - return Remove() ? 0 : 1; + Outcome outcome = Remove(); + EmitOutcome(L"remove", outcome); + return static_cast(outcome.exitCode); } if (argc == 2 && _wcsicmp(argv[1], L"status") == 0) { - return Status() ? 0 : 1; + Outcome outcome = Status(); + EmitOutcome(L"status", outcome); + return static_cast(outcome.exitCode); + } + if (argc == 2 && _wcsicmp(argv[1], L"self-test") == 0) { + Outcome outcome = SelfTest(); + EmitOutcome(L"self-test", outcome); + return static_cast(outcome.exitCode); } Usage(); - return 2; + Outcome outcome; + SetError(&outcome.error, L"arguments", ERROR_INVALID_PARAMETER); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"unknown", outcome); + return static_cast(outcome.exitCode); } From 0d583303ab1388ba387f6712919e6c28699b6c21 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 07:44:03 -0500 Subject: [PATCH 129/240] Fix transactional setup MSVC conversion --- native/udecx/tools/ViiperUdeCtl.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 7238b262..be2a07ae 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -2155,7 +2155,15 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro } else if (_wcsicmp(argument.c_str(), L"--source-revision") == 0 && index + 1 < argc && !revisionSeen) { const std::wstring wide = argv[++index]; - options->sourceRevision.assign(wide.begin(), wide.end()); + options->sourceRevision.clear(); + options->sourceRevision.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"source revision must contain ASCII hexadecimal characters"); + } + options->sourceRevision.push_back(static_cast(value)); + } if (!IsHexRevision(options->sourceRevision)) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, L"source revision must contain 40 to 64 hexadecimal characters"); From 8b68c3d64fa1a987c29c780cc72c5e01cbac81c0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 07:45:44 -0500 Subject: [PATCH 130/240] Fence PlayStation media publication revisions Tag DualSense and DualShock media publications with device-owned revisions and serialize callback replacement, interface transitions, and endpoint resets behind hard publication barriers. Preserve the last explicit host output for late consumers and make bounded feedback lanes retain newest release state under saturation. Add deterministic stale-generation and in-flight reset races for both controller engines. --- device/dualsense/device.go | 193 ++++++++++++++++------- device/dualsense/device_output_test.go | 37 +++++ device/dualsense/ds_handler.go | 15 +- device/dualsense/native_audio_v5_test.go | 88 +++++++++++ device/dualsense/output_writer.go | 36 ++++- device/dualsense/output_writer_test.go | 40 +++++ device/dualshock4/audio_test.go | 77 +++++++++ device/dualshock4/device.go | 112 ++++++++++--- device/dualshock4/handler.go | 35 +++- 9 files changed, 537 insertions(+), 96 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d6f50126..d5275f18 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -66,11 +66,21 @@ type DualSense struct { inputPublishMu sync.Mutex metaState *MetaState + // Output and media publication use independent gates. HID state remains + // valid across an audio-pipe reset, while speaker/haptics data does not. + // Keeping the gates separate prevents an audio reconfiguration from + // discarding the game's final lightbar/trigger/rumble update. + outputPublishMu sync.RWMutex + mediaPublishMu sync.RWMutex + atomicAudioHapticsFunc func(OutputState, []byte) realtimeHapticsFunc func(OutputState) speakerResetFunc func() outputFunc func(OutputState) outputState OutputState + latestOutputState OutputState + outputSeen bool + mediaRevision uint64 descriptor usb.Descriptor subcommand [2]byte @@ -210,9 +220,25 @@ func (d *DualSense) SetMetaState(meta MetaState) { } func (d *DualSense) SetOutputCallback(f func(OutputState)) { + d.outputPublishMu.Lock() + defer d.outputPublishMu.Unlock() + + var latest OutputState + var replay bool d.mtx.Lock() d.outputFunc = f + if f != nil && d.outputSeen { + latest = d.latestOutputState + replay = true + } d.mtx.Unlock() + + // A newly attached stream must observe the last explicit game update even + // if it arrived just before callback registration. The publication gate + // orders this replay against live SET_REPORT callbacks. + if replay { + f(latest) + } } // SetAtomicAudioHapticsCallback installs the V5 transport consumer. Each @@ -221,27 +247,61 @@ func (d *DualSense) SetOutputCallback(f func(OutputState)) { // raw 48 kHz speaker frames and consumes one independently completed rear // haptics sample, or silence when that 512-frame lane has not completed yet. func (d *DualSense) SetAtomicAudioHapticsCallback(f func(OutputState, []byte)) { - d.mtx.Lock() - d.atomicAudioHapticsFunc = f - d.mtx.Unlock() + d.replaceMediaCallbacks(func() { + d.atomicAudioHapticsFunc = f + }) } // SetRealtimeHapticsCallback installs the V5 rear-channel consumer. A // callback is issued as soon as one complete 512-frame haptics interval is // available, independently of the 480-frame speaker clock. func (d *DualSense) SetRealtimeHapticsCallback(f func(OutputState)) { - d.mtx.Lock() - d.realtimeHapticsFunc = f - d.mtx.Unlock() + d.replaceMediaCallbacks(func() { + d.realtimeHapticsFunc = f + }) } // SetSpeakerResetCallback installs the transport-side queue reset paired with // SetAtomicAudioHapticsCallback. USB interface close/reopen and endpoint reset // must discard queued speaker PCM from the previous presentation generation. func (d *DualSense) SetSpeakerResetCallback(f func()) { + d.replaceMediaCallbacks(func() { + d.speakerResetFunc = f + }) +} + +// setV5MediaCallbacks replaces the three coupled V5 media callbacks as one +// transport generation. The stream handler uses this instead of exposing a +// partially installed callback set between three independent setter calls. +func (d *DualSense) setV5MediaCallbacks( + atomic func(OutputState, []byte), + realtime func(OutputState), + reset func(), +) { + d.replaceMediaCallbacks(func() { + d.atomicAudioHapticsFunc = atomic + d.realtimeHapticsFunc = realtime + d.speakerResetFunc = reset + }) +} + +// replaceMediaCallbacks is a hard lifecycle boundary. A callback already in +// progress finishes before the old transport is flushed; a callback assembled +// before this revision can never publish into the replacement transport. +func (d *DualSense) replaceMediaCallbacks(update func()) { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + d.mtx.Lock() - d.speakerResetFunc = f + resetSpeaker := d.speakerResetFunc + d.mediaRevision++ + d.resetSpeakerAudioLocked() + update() d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } } // beginSpeakerStream gives each stream generation independent telemetry. An @@ -342,38 +402,54 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { } func (d *DualSense) SetInterfaceAltSetting(iface, alt uint8) { + if iface == InterfaceHapticsAudio { + d.resetSpeakerPresentation(func() { + d.speakerInterfaceActive = alt != 0 + }) + return + } + d.mtx.Lock() - var resetSpeaker func() switch iface { - case InterfaceHapticsAudio: - d.speakerInterfaceActive = alt != 0 - d.resetSpeakerAudioLocked() - resetSpeaker = d.speakerResetFunc case InterfaceMicrophone: d.microphoneInterfaceActive = alt != 0 d.resetMicrophoneAudioLocked() } d.mtx.Unlock() - - if resetSpeaker != nil { - resetSpeaker() - } } // ResetEndpoint implements usb.EndpointResetDevice. A standard endpoint pipe // reset preserves the selected alternate setting and feature controls while // discarding all transport data from the previous endpoint generation. func (d *DualSense) ResetEndpoint(endpoint uint8) { + if endpoint == EndpointHapticsAudioOut { + d.resetSpeakerPresentation(nil) + return + } + d.mtx.Lock() - var resetSpeaker func() switch endpoint { - case EndpointHapticsAudioOut: - d.resetSpeakerAudioLocked() - resetSpeaker = d.speakerResetFunc case EndpointMicrophoneIn: d.resetMicrophoneAudioLocked() } d.mtx.Unlock() +} + +// resetSpeakerPresentation advances the device-owned revision and the framed +// writer generation under one publication barrier. The optional state update +// applies before the revision is visible to new media callbacks. +func (d *DualSense) resetSpeakerPresentation(update func()) { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + + d.mtx.Lock() + d.mediaRevision++ + if update != nil { + update() + } + d.resetSpeakerAudioLocked() + resetSpeaker := d.speakerResetFunc + d.mtx.Unlock() if resetSpeaker != nil { resetSpeaker() @@ -578,46 +654,51 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { } processed, release := d.speakerAudioFeature.applyPCM(out, USBHapticsAudioChannels) + revision := d.mediaRevision reports := d.consumeDualSenseV5AudioLocked(processed, receivedAt) - // The callback is deliberately completed under the device lock. This makes - // an alternate-setting or endpoint reset a hard generation barrier: once the - // reset acquires the lock, no pre-reset callback can enqueue stale PCM after - // the transport queue has been flushed. + for index := range reports { + reports[index].revision = revision + } d.mtx.Unlock() if release != nil { release() } for _, pending := range reports { - report := pending.feedback.BluetoothCombinedOutputReport[:] - if len(report) == 0 { - continue - } + d.publishV5Media(pending) + } +} - d.mtx.Lock() - outputFunc := d.outputFunc - atomicAudioHapticsFunc := d.atomicAudioHapticsFunc - realtimeHapticsFunc := d.realtimeHapticsFunc - if pending.hapticsOnly { - feedback := pending.feedback - d.mtx.Unlock() - if realtimeHapticsFunc != nil { - realtimeHapticsFunc(feedback) - } - continue - } - if outputFunc != nil || atomicAudioHapticsFunc != nil { - feedback := pending.feedback - d.mtx.Unlock() - if atomicAudioHapticsFunc != nil { - atomicAudioHapticsFunc(feedback, pending.speakerPCM) - } else { - outputFunc(feedback) - } - } else { - d.mtx.Unlock() +func (d *DualSense) publishV5Media(pending pendingBluetoothHapticsReport) bool { + d.mediaPublishMu.RLock() + defer d.mediaPublishMu.RUnlock() + + d.mtx.Lock() + if pending.revision != d.mediaRevision || !d.speakerInterfaceActive { + d.mtx.Unlock() + return false + } + outputFunc := d.outputFunc + atomicAudioHapticsFunc := d.atomicAudioHapticsFunc + realtimeHapticsFunc := d.realtimeHapticsFunc + d.mtx.Unlock() + + if pending.hapticsOnly { + if realtimeHapticsFunc == nil { + return false } + realtimeHapticsFunc(pending.feedback) + return true + } + if atomicAudioHapticsFunc != nil { + atomicAudioHapticsFunc(pending.feedback, pending.speakerPCM) + return true } + if outputFunc != nil { + outputFunc(pending.feedback) + return true + } + return false } type pendingBluetoothHapticsReport struct { @@ -625,6 +706,7 @@ type pendingBluetoothHapticsReport struct { assemblyDelay time.Duration feedback OutputState hapticsOnly bool + revision uint64 } type dualSenseV5HapticsGeneration struct { @@ -856,14 +938,17 @@ func (d *DualSense) handleOutputReport(out []byte) bool { if !ok { return false } + d.outputPublishMu.RLock() + defer d.outputPublishMu.RUnlock() + d.mtx.Lock() + feedback := d.mergeOutputReport(report) + d.latestOutputState = feedback + d.outputSeen = true outputFunc := d.outputFunc + d.mtx.Unlock() if outputFunc != nil { - feedback := d.mergeOutputReport(report) - d.mtx.Unlock() outputFunc(feedback) - } else { - d.mtx.Unlock() } return true } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 0e10e45e..90203ae1 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -624,6 +624,43 @@ func TestDualSenseOutputSnapshotKeepsIndependentGameFieldsAtomic(t *testing.T) { } } +func TestDualSenseLateOutputConsumerReceivesFinalExplicitState(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + report := make([]byte, OutputReportSize) + report[0] = ReportIDOutput + report[1] = outputFlag0RumbleMask + report[3] = 0 + report[4] = 0 + report[2] = outputFlag1Lightbar | outputFlag1PlayerLeds + report[outputPlayerLedsOffset] = 0x04 + report[outputLightbarOffset] = 0x12 + report[outputLightbarOffset+1] = 0x34 + report[outputLightbarOffset+2] = 0x56 + if !device.handleOutputReport(report) { + t.Fatal("final output report was rejected") + } + + var replayed []OutputState + device.SetOutputCallback(func(state OutputState) { + replayed = append(replayed, state) + }) + if len(replayed) != 1 { + t.Fatalf("late consumer replay count=%d want=1", len(replayed)) + } + state := replayed[0] + if state.RumbleSmall != 0 || state.RumbleLarge != 0 || + state.PlayerLeds != 0x04 || state.LedRed != 0x12 || + state.LedGreen != 0x34 || state.LedBlue != 0x56 { + t.Fatalf("late consumer received wrong final state: %+v", state) + } + if !bytes.Equal(state.RawOutputReport[:], report) { + t.Fatal("late consumer did not receive the exact final report") + } +} + func TestDualSenseTouchTrackingBytes(t *testing.T) { state := &InputState{} data, err := state.MarshalBinary() diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index f97089d7..94869358 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -144,28 +144,27 @@ func dualSenseV5StreamHandler(deviceName string) api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) { + atomicAudioHapticsCallback := func(feedback OutputState, speakerPCM []byte) { data, err := marshalFeedback(feedback) if err != nil { logger.Error("failed to marshal V5 atomic audio/haptics feedback", "error", err) return } writer.EnqueueAtomicAudioHaptics(data, speakerPCM) - }) - dse.SetRealtimeHapticsCallback(func(feedback OutputState) { + } + realtimeHapticsCallback := func(feedback OutputState) { data, err := marshalFeedback(feedback) if err != nil { logger.Error("failed to marshal V5 realtime haptics feedback", "error", err) return } writer.EnqueueRealtimeHaptics(data) - }) - dse.SetSpeakerResetCallback(writer.ResetSpeaker) + } + dse.setV5MediaCallbacks(atomicAudioHapticsCallback, + realtimeHapticsCallback, writer.ResetSpeaker) defer func() { + dse.setV5MediaCallbacks(nil, nil, nil) dse.SetOutputCallback(nil) - dse.SetAtomicAudioHapticsCallback(nil) - dse.SetRealtimeHapticsCallback(nil) - dse.SetSpeakerResetCallback(nil) writer.Stop() }() diff --git a/device/dualsense/native_audio_v5_test.go b/device/dualsense/native_audio_v5_test.go index cd513fbc..1759c32b 100644 --- a/device/dualsense/native_audio_v5_test.go +++ b/device/dualsense/native_audio_v5_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "net" "testing" + "time" "github.com/Alia5/VIIPER/usbip" ) @@ -293,6 +294,93 @@ func TestDualSenseV5EndpointResetIsHardBoundaryForBothMediaClocks(t *testing.T) } } +func TestDualSenseV5RejectsPublicationFromPreResetRevision(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + device.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + published := 0 + device.setV5MediaCallbacks(func(OutputState, []byte) { + published++ + }, nil, nil) + + device.mtx.Lock() + revision := device.mediaRevision + device.mtx.Unlock() + pending := pendingBluetoothHapticsReport{ + revision: revision, + feedback: OutputState{BluetoothCombinedOutputReport: [BluetoothCombinedHapticsReportSize]byte{BluetoothCombinedHapticsReportID}}, + speakerPCM: make([]byte, dualSenseV5SpeakerPayloadSize), + } + + device.ResetEndpoint(EndpointHapticsAudioOut) + if device.publishV5Media(pending) { + t.Fatal("pre-reset media revision was published") + } + if published != 0 { + t.Fatalf("pre-reset media callback count=%d", published) + } +} + +func TestDualSenseV5ResetWaitsForInFlightDevicePublication(t *testing.T) { + device, err := New(nil) + if err != nil { + t.Fatal(err) + } + device.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + callbackDone := make(chan struct{}) + resetCalls := 0 + device.setV5MediaCallbacks(func(OutputState, []byte) { + close(entered) + <-release + close(callbackDone) + }, nil, func() { + resetCalls++ + }) + + pcm := makeV5USBPCM(0, dualSenseV5SpeakerFrames, 12000) + transferDone := make(chan struct{}) + go func() { + device.HandleTransfer(context.Background(), EndpointHapticsAudioOut, + usbip.DirOut, pcm) + close(transferDone) + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("media callback did not start") + } + + resetDone := make(chan struct{}) + go func() { + device.ResetEndpoint(EndpointHapticsAudioOut) + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("endpoint reset crossed an in-flight device publication") + case <-time.After(20 * time.Millisecond): + } + close(release) + select { + case <-callbackDone: + case <-time.After(time.Second): + t.Fatal("media callback did not finish") + } + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not finish after publication") + } + <-transferDone + if resetCalls != 1 { + t.Fatalf("transport reset calls=%d want=1", resetCalls) + } +} + func TestDualSenseV5SpeakerCombinesFreshStateWithCompletedRearSample(t *testing.T) { device, captured := newV5CaptureDevice(t) setV5TestLightbar(t, device, 0x11) diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go index 308baa57..370df777 100644 --- a/device/dualsense/output_writer.go +++ b/device/dualsense/output_writer.go @@ -119,6 +119,8 @@ type dualSenseOutputWriter struct { done chan struct{} stopOnce sync.Once enqueueLock sync.RWMutex + controlEnqueue sync.Mutex + realtimeEnqueue sync.Mutex audioEnqueue sync.Mutex audioWrite sync.Mutex stopped bool @@ -171,7 +173,9 @@ func (w *dualSenseOutputWriter) EnqueueRealtimeHaptics(payload []byte) { if w.stopped { return } - w.enqueueFrameLocked(w.realtimeHaptics, dualSenseOutputFrame{ + w.realtimeEnqueue.Lock() + defer w.realtimeEnqueue.Unlock() + w.enqueueNewestFrameLocked(w.realtimeHaptics, dualSenseOutputFrame{ frameType: StreamFrameRealtimeHaptics, payload: append([]byte(nil), payload...), }) @@ -186,12 +190,40 @@ func (w *dualSenseOutputWriter) EnqueueControl(frameType byte, payload []byte) { if w.stopped { return } - w.enqueueFrameLocked(w.control, dualSenseOutputFrame{ + w.controlEnqueue.Lock() + defer w.controlEnqueue.Unlock() + w.enqueueNewestFrameLocked(w.control, dualSenseOutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), }) } +// enqueueNewestFrameLocked makes bounded state lanes latest-state preserving. +// A release/zero-rumble/lightbar update must not be discarded merely because +// older state filled the queue. The per-lane producer mutex makes eviction and +// replacement atomic with respect to other callback producers; the sole +// consumer may only create more room. +func (w *dualSenseOutputWriter) enqueueNewestFrameLocked( + queue chan dualSenseOutputFrame, frame dualSenseOutputFrame, +) { + select { + case queue <- frame: + return + default: + } + select { + case <-queue: + default: + } + // With producers serialized, either the eviction above or a concurrent + // consumer has made room. Keep a defensive nonblocking send so an output + // callback can never inherit socket backpressure. + select { + case queue <- frame: + default: + } +} + // EnqueueAtomicAudioHaptics publishes one V5 generation. A little-endian // feedback length prefixes the native combined feedback; the remaining bytes // are exactly 480 matching stereo PCM frames. diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go index 6d1226cb..03375d36 100644 --- a/device/dualsense/output_writer_test.go +++ b/device/dualsense/output_writer_test.go @@ -113,6 +113,46 @@ func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) { _ = client.Close() } +func TestDualSenseV5WriterRetainsNewestFinalControlState(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + for marker := 0; marker < dualSenseOutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueControl(StreamFrameOutputState, []byte{0xFF}) + if len(writer.control) != dualSenseOutputControlQueueCapacity { + t.Fatalf("control depth=%d want=%d", len(writer.control), + dualSenseOutputControlQueueCapacity) + } + for index := 0; index < dualSenseOutputControlQueueCapacity; index++ { + frame := <-writer.control + want := byte(index + 1) + if index == dualSenseOutputControlQueueCapacity-1 { + want = 0xFF + } + if len(frame.payload) != 1 || frame.payload[0] != want { + t.Fatalf("control[%d]=% x want=%02x", index, frame.payload, want) + } + } +} + +func TestDualSenseV5WriterRetainsNewestRealtimeHapticsState(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + for marker := 0; marker < dualSenseOutputControlQueueCapacity; marker++ { + writer.EnqueueRealtimeHaptics([]byte{byte(marker)}) + } + writer.EnqueueRealtimeHaptics([]byte{0xFF}) + for index := 0; index < dualSenseOutputControlQueueCapacity; index++ { + frame := <-writer.realtimeHaptics + want := byte(index + 1) + if index == dualSenseOutputControlQueueCapacity-1 { + want = 0xFF + } + if len(frame.payload) != 1 || frame.payload[0] != want { + t.Fatalf("realtime[%d]=% x want=%02x", index, frame.payload, want) + } + } +} + func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) { server, client := net.Pipe() writer := newDualSenseOutputWriter(server, nil, nil) diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index a1bbcd77..7a3ab97b 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -286,6 +286,83 @@ func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) writer.Stop() } +func TestSpeakerRejectsPublicationFromPreResetRevision(t *testing.T) { + device, err := New(nil) + require.NoError(t, err) + device.SetInterfaceAltSetting(InterfaceSpeaker, 1) + published := 0 + device.SetSpeakerCallback(func([]byte) { published++ }) + + device.mtx.Lock() + revision := device.speakerRevision + device.mtx.Unlock() + device.ResetEndpoint(EndpointAudioOut) + assert.False(t, device.publishSpeakerPCM(revision, []byte{1, 2, 3, 4})) + assert.Equal(t, 0, published) +} + +func TestSpeakerResetWaitsForInFlightDevicePublication(t *testing.T) { + device, err := New(nil) + require.NoError(t, err) + device.SetInterfaceAltSetting(InterfaceSpeaker, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + device.SetSpeakerCallback(func([]byte) { + close(entered) + <-release + }) + resetCalls := 0 + device.SetSpeakerResetCallback(func() { resetCalls++ }) + + transferDone := make(chan struct{}) + go func() { + device.HandleTransfer(context.Background(), EndpointAudioOut, + usbip.DirOut, []byte{1, 2, 3, 4}) + close(transferDone) + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("speaker callback did not start") + } + resetDone := make(chan struct{}) + go func() { + device.ResetEndpoint(EndpointAudioOut) + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("endpoint reset crossed an in-flight speaker publication") + case <-time.After(20 * time.Millisecond): + } + close(release) + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not finish after speaker publication") + } + <-transferDone + assert.Equal(t, 1, resetCalls) +} + +func TestDualShock4WriterRetainsNewestFinalControlState(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + for marker := 0; marker < cap(writer.control); marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueControl(StreamFrameOutputState, []byte{0xFF}) + depth := cap(writer.control) + require.Len(t, writer.control, depth) + for index := 0; index < depth; index++ { + frame := <-writer.control + want := byte(index + 1) + if index == depth-1 { + want = 0xFF + } + require.Equal(t, []byte{want}, frame.payload) + } +} + type dualShock4WriteGateConn struct { net.Conn started chan struct{} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index a131e6c3..738cda62 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -29,11 +29,15 @@ type DualShock4 struct { inputPublishMu sync.Mutex metaState *MetaState + outputPublishMu sync.RWMutex + speakerPublishMu sync.RWMutex + outputFunc func(OutputState) speakerFunc func([]byte) speakerResetFunc func() outputState OutputState outputSeen bool + speakerRevision uint64 descriptor usb.Descriptor probeSelector [3]byte @@ -132,6 +136,9 @@ func (d *DualShock4) SetMetaState(meta MetaState) { } func (d *DualShock4) SetOutputCallback(f func(OutputState)) { + d.outputPublishMu.Lock() + defer d.outputPublishMu.Unlock() + var latest OutputState var replay bool @@ -149,18 +156,36 @@ func (d *DualShock4) SetOutputCallback(f func(OutputState)) { } func (d *DualShock4) SetSpeakerCallback(f func([]byte)) { - d.mtx.Lock() - d.speakerFunc = f - d.mtx.Unlock() + d.replaceSpeakerCallbacks(func() { d.speakerFunc = f }) } // SetSpeakerResetCallback installs the transport-side queue reset paired with // SetSpeakerCallback. Interface transitions and endpoint pipe resets must drop // speaker PCM from the previous USB presentation generation. func (d *DualShock4) SetSpeakerResetCallback(f func()) { + d.replaceSpeakerCallbacks(func() { d.speakerResetFunc = f }) +} + +func (d *DualShock4) setSpeakerCallbacks(speaker func([]byte), reset func()) { + d.replaceSpeakerCallbacks(func() { + d.speakerFunc = speaker + d.speakerResetFunc = reset + }) +} + +func (d *DualShock4) replaceSpeakerCallbacks(update func()) { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + d.mtx.Lock() - d.speakerResetFunc = f + resetSpeaker := d.speakerResetFunc + d.speakerRevision++ + update() d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } } func (d *DualShock4) UpdateInputState(state *InputState) { @@ -232,15 +257,15 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { } func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { + if iface == InterfaceSpeaker { + d.resetSpeakerPresentation(func() { + d.speakerInterfaceActive = alt != 0 + }) + return + } + d.mtx.Lock() - var resetSpeaker func() switch iface { - case InterfaceSpeaker: - wasActive := d.speakerInterfaceActive - d.speakerInterfaceActive = alt != 0 - if wasActive != d.speakerInterfaceActive { - resetSpeaker = d.speakerResetFunc - } case InterfaceMicrophone: wasActive := d.microphoneInterfaceActive d.microphoneInterfaceActive = alt != 0 @@ -250,29 +275,41 @@ func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { } } d.mtx.Unlock() - - // The transport reset may wait for an in-flight socket write. Never hold the - // device mutex across that wait: output and USB teardown callbacks also need - // to acquire it before the writer can finish shutting down. - if resetSpeaker != nil { - resetSpeaker() - } } // ResetEndpoint implements usb.EndpointResetDevice. CLEAR_FEATURE(HALT) // preserves the selected alternate setting while establishing a hard data // generation boundary for the affected audio pipe. func (d *DualShock4) ResetEndpoint(endpoint uint8) { + if endpoint == EndpointAudioOut { + d.resetSpeakerPresentation(nil) + return + } + d.mtx.Lock() - var resetSpeaker func() switch endpoint { - case EndpointAudioOut: - resetSpeaker = d.speakerResetFunc case EndpointMicrophoneIn: d.microphoneBuffer.Reset() d.drainMicrophoneSignal() } d.mtx.Unlock() +} + +// resetSpeakerPresentation orders the device revision and the framed-writer +// generation as one hard barrier. A callback already publishing completes +// before the queue is flushed; any older callback that has not entered the +// gate observes the new revision and is rejected. +func (d *DualShock4) resetSpeakerPresentation(update func()) { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + + d.mtx.Lock() + d.speakerRevision++ + if update != nil { + update() + } + resetSpeaker := d.speakerResetFunc + d.mtx.Unlock() if resetSpeaker != nil { resetSpeaker() @@ -310,6 +347,7 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { if len(out) >= 11 && out[0] == ReportIDOutput { feedback := parseOutputReport(out) + d.outputPublishMu.RLock() d.mtx.Lock() d.outputState = feedback d.outputSeen = true @@ -318,6 +356,7 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if outputFunc != nil { outputFunc(feedback) } + d.outputPublishMu.RUnlock() } } if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { @@ -325,11 +364,12 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if d.speakerInterfaceActive && d.speakerFunc != nil && len(out) > 0 { // The USB/IP receive buffer is owned by the transfer handler. Give the // device-stream writer an immutable copy; its owned enqueue path then - // forwards this same allocation without making a second copy. Complete - // the synchronous enqueue under the device lock so a subsequent - // interface or endpoint reset cannot flush the queue and then be raced - // by a pre-reset callback publishing stale PCM afterward. - d.speakerFunc(append([]byte(nil), out...)) + // forwards this same allocation without making a second copy. + pcm := append([]byte(nil), out...) + revision := d.speakerRevision + d.mtx.Unlock() + d.publishSpeakerPCM(revision, pcm) + return nil } d.mtx.Unlock() return nil @@ -338,6 +378,26 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, return nil } +func (d *DualShock4) publishSpeakerPCM(revision uint64, pcm []byte) bool { + if len(pcm) == 0 { + return false + } + d.speakerPublishMu.RLock() + defer d.speakerPublishMu.RUnlock() + + d.mtx.Lock() + if revision != d.speakerRevision || !d.speakerInterfaceActive || + d.speakerFunc == nil { + d.mtx.Unlock() + return false + } + speakerFunc := d.speakerFunc + d.mtx.Unlock() + + speakerFunc(pcm) + return true +} + // ReadInterruptInput implements usb.InterruptInputDevice for native UDE. It // writes the controller's next HID sample into caller-owned storage; USB/IP // continues to use HandleTransfer and its independently owned report slice. diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 5b7deaa8..c6bac016 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -140,15 +140,14 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - ds4.SetSpeakerCallback(func(pcm []byte) { + speakerCallback := func(pcm []byte) { writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, pcm) - }) - ds4.SetSpeakerResetCallback(writer.ResetSpeaker) + } + ds4.setSpeakerCallbacks(speakerCallback, writer.ResetSpeaker) go writer.Run() defer func() { ds4.SetOutputCallback(nil) - ds4.SetSpeakerCallback(nil) - ds4.SetSpeakerResetCallback(nil) + ds4.setSpeakerCallbacks(nil, nil) writer.Stop() }() } else { @@ -196,6 +195,7 @@ type dualShock4OutputWriter struct { done chan struct{} stopOnce sync.Once enqueueLock sync.RWMutex + controlEnqueue sync.Mutex audioWrite sync.Mutex stopped bool audioGeneration atomic.Uint64 @@ -222,12 +222,35 @@ func (w *dualShock4OutputWriter) EnqueueControl(frameType byte, payload []byte) if w.stopped { return } - w.enqueueFrameLocked(w.control, dualShock4OutputFrame{ + w.controlEnqueue.Lock() + defer w.controlEnqueue.Unlock() + w.enqueueNewestControlLocked(dualShock4OutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), }) } +// enqueueNewestControlLocked preserves an explicit final controller state +// when the bounded feedback lane is saturated. Old intermediate feedback can +// be coalesced; the newest release/LED/rumble state cannot be silently lost. +func (w *dualShock4OutputWriter) enqueueNewestControlLocked( + frame dualShock4OutputFrame, +) { + select { + case w.control <- frame: + return + default: + } + select { + case <-w.control: + default: + } + select { + case w.control <- frame: + default: + } +} + func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { if len(payload) == 0 { return From eb517889464b83bd5ef681e498ab59601d5ef025 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 07:47:24 -0500 Subject: [PATCH 131/240] Require clean concurrent native media soak --- .../server/usb/native_live_windows_test.go | 134 ++++++++++--- .../udecx/live_validation_contract_test.go | 77 ++++++++ native/udecx/README.md | 25 ++- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 57 +++++- native/udecx/tools/ViiperUdeMediaProbe.cpp | 179 ++++++++++++++++-- 5 files changed, 413 insertions(+), 59 deletions(-) create mode 100644 internal/transport/udecx/live_validation_contract_test.go diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index 56f0d081..33130c7e 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -35,6 +35,7 @@ const ( liveNativeTestIterations = "VIIPER_UDE_LIVE_ITERATIONS" liveNativeCrashChild = "VIIPER_UDE_LIVE_CRASH_CHILD" liveNativeMediaProbe = "VIIPER_UDE_LIVE_MEDIA_PROBE" + liveNativeMediaSeconds = "VIIPER_UDE_LIVE_MEDIA_SECONDS" liveNativeInputProbe = "VIIPER_UDE_LIVE_INPUT_PROBE" liveNativeRestartInstance = "VIIPER_UDE_LIVE_RESTART_INSTANCE_ID" liveNativeCrashExitCode = 86 @@ -254,6 +255,31 @@ func liveNativeIterationCount(t *testing.T) int { return iterations } +func liveNativeMediaDuration(t *testing.T) time.Duration { + t.Helper() + raw := os.Getenv(liveNativeMediaSeconds) + if raw == "" { + return 3 * time.Second + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds < 1 || seconds > 300 { + t.Fatalf("%s must be an integer from 1 through 300, got %q", + liveNativeMediaSeconds, raw) + } + return time.Duration(seconds) * time.Second +} + +func TestNativeLiveMediaDurationContract(t *testing.T) { + t.Setenv(liveNativeMediaSeconds, "") + if got := liveNativeMediaDuration(t); got != 3*time.Second { + t.Fatalf("default native media duration=%s want 3s", got) + } + t.Setenv(liveNativeMediaSeconds, "180") + if got := liveNativeMediaDuration(t); got != 3*time.Minute { + t.Fatalf("release native media duration=%s want 3m", got) + } +} + func waitForNativeStats(ctx context.Context, client *udecx.Client, description string, accept func(udecx.Stats) bool) (udecx.Stats, error) { ticker := time.NewTicker(25 * time.Millisecond) @@ -299,6 +325,20 @@ func runLiveMediaProbe(t *testing.T, ctx context.Context, probe string, argument return string(output) } +type liveProbeResult struct { + output string + err error +} + +func startLiveProbe(ctx context.Context, probe string, arguments ...string) <-chan liveProbeResult { + done := make(chan liveProbeResult, 1) + go func() { + output, err := exec.CommandContext(ctx, probe, arguments...).CombinedOutput() + done <- liveProbeResult{output: string(output), err: err} + }() + return done +} + var queryPerformanceCounter = windows.NewLazySystemDLL("kernel32.dll"). NewProc("QueryPerformanceCounter") @@ -433,8 +473,9 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { } iterations := liveNativeIterationCount(t) + mediaDuration := liveNativeMediaDuration(t) testCtx, cancelTest := context.WithTimeout(context.Background(), - time.Duration(iterations)*5*time.Minute) + time.Duration(iterations)*5*time.Minute+2*mediaDuration+2*time.Minute) defer cancelTest() client, err := udecx.Open(testCtx) @@ -564,29 +605,8 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { t.Fatal(waitErr) } - if mediaController { - mediaBefore, mediaErr := client.QueryStats(testCtx) - if mediaErr != nil { - t.Fatalf("query %s media baseline: %v", controller.name, mediaErr) - } - probeOutput := runLiveMediaProbe( - t, testCtx, mediaProbe, "exercise", mediaSnapshot, "3") - mediaAfter, mediaErr := client.QueryStats(testCtx) - if mediaErr != nil { - t.Fatalf("query %s media result: %v", controller.name, mediaErr) - } - if mediaAfter.IsoPackets <= mediaBefore.IsoPackets || - mediaAfter.BytesToDevice <= mediaBefore.BytesToDevice || - mediaAfter.BytesFromDevice <= mediaBefore.BytesFromDevice { - t.Fatalf("%s CoreAudio did not exercise full-duplex ISO media: before=%+v after=%+v probe=%s", - controller.name, mediaBefore, mediaAfter, probeOutput) - } - } - if inputController { - runLiveInputLatencyProbe( - t, testCtx, inputProbe, inputSnapshot, controller, publishMarker) - } - if feedbackController { + feedbackVerified := false + verifyFeedback := func() { feedbackBefore, feedbackErr := client.QueryStats(testCtx) if feedbackErr != nil { t.Fatalf("query %s feedback baseline: %v", controller.name, feedbackErr) @@ -597,22 +617,78 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { fmt.Sprintf("0x%04X", controller.productID), controller.feedbackProbeKind, "hid-output-v1") feedbackCtx, cancelFeedback := context.WithTimeout(testCtx, 10*time.Second) + defer cancelFeedback() if feedbackErr = waitForFeedback(feedbackCtx); feedbackErr != nil { - cancelFeedback() t.Fatalf("%s HID output was not preserved end to end: %v; probe=%s", controller.name, feedbackErr, probeOutput) } - feedbackAfter, waitErr := waitForNativeStats(feedbackCtx, client, + feedbackAfter, feedbackWaitErr := waitForNativeStats(feedbackCtx, client, controller.name+" HID output completion", func(stats udecx.Stats) bool { return stats.OperationsDequeued > feedbackBefore.OperationsDequeued && stats.OperationsCompleted > feedbackBefore.OperationsCompleted && stats.BytesToDevice >= feedbackBefore.BytesToDevice+controller.feedbackReportLen }) - cancelFeedback() - if waitErr != nil { + if feedbackWaitErr != nil { t.Fatalf("%s HID output did not complete through the native driver: %v; before=%+v after=%+v probe=%s", - controller.name, waitErr, feedbackBefore, feedbackAfter, probeOutput) + controller.name, feedbackWaitErr, feedbackBefore, feedbackAfter, probeOutput) + } + feedbackVerified = true + } + + if mediaController { + mediaBefore, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media baseline: %v", controller.name, mediaErr) + } + mediaCtx, cancelMedia := context.WithCancel(testCtx) + defer cancelMedia() + probeDone := startLiveProbe( + mediaCtx, mediaProbe, "exercise", mediaSnapshot, + strconv.Itoa(int(mediaDuration/time.Second)), + strings.ToLower(controller.name)) + stressCtx, cancelStress := context.WithCancel(testCtx) + defer cancelStress() + stressDone := make(chan struct{}) + go func() { + defer close(stressDone) + for sequence := uint64(1); ; sequence++ { + select { + case <-stressCtx.Done(): + return + default: + publishInput(sequence) + time.Sleep(time.Millisecond) + } + } + }() + if feedbackController { + verifyFeedback() + } + probeResult := <-probeDone + cancelMedia() + cancelStress() + <-stressDone + if probeResult.err != nil { + t.Fatalf("run native CoreAudio probe: %v\n%s", + probeResult.err, probeResult.output) } + mediaAfter, mediaErr := client.QueryStats(testCtx) + if mediaErr != nil { + t.Fatalf("query %s media result: %v", controller.name, mediaErr) + } + if mediaAfter.IsoPackets <= mediaBefore.IsoPackets || + mediaAfter.BytesToDevice <= mediaBefore.BytesToDevice || + mediaAfter.BytesFromDevice <= mediaBefore.BytesFromDevice { + t.Fatalf("%s CoreAudio did not exercise full-duplex ISO media: before=%+v after=%+v probe=%s", + controller.name, mediaBefore, mediaAfter, probeResult.output) + } + } + if inputController { + runLiveInputLatencyProbe( + t, testCtx, inputProbe, inputSnapshot, controller, publishMarker) + } + if feedbackController && !feedbackVerified { + verifyFeedback() } inputDeadline := time.Now().Add(750 * time.Millisecond) diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go new file mode 100644 index 00000000..ab815b5d --- /dev/null +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -0,0 +1,77 @@ +package udecx + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { + root := filepath.Join("..", "..", "..", "native", "udecx", "tools") + script, err := os.ReadFile(filepath.Join(root, "Invoke-ViiperUdeLiveValidation.ps1")) + if err != nil { + t.Fatalf("read native live validator: %v", err) + } + contract := string(script) + for _, required := range []string{ + "[switch]$ReleaseGate", + "$SignatureValidationMode -ne 'Production'", + "-RequireDriverVerifier is required", + "-MediaProbePath is required", + "-InputProbePath is required", + "-RestartRootDevice is required", + "-DisposableTestMachine is required", + "$Iterations -lt 3", + "$MediaDurationSeconds -lt 180", + "VIIPER_UDE_LIVE_MEDIA_SECONDS", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native release gate omitted %q", required) + } + } +} + +func TestNativeMediaProbeRejectsObservableDiscontinuity(t *testing.T) { + path := filepath.Join("..", "..", "..", "native", "udecx", "tools", + "ViiperUdeMediaProbe.cpp") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native media probe: %v", err) + } + contract := string(source) + for _, required := range []string{ + "AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY", + "AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR", + "positionRegressions", + "qpcRegressions", + "renderStats.underruns != 0", + "ValidateFrameCount(\"render\"", + "ValidateFrameCount(\"capture\"", + "seconds > 300", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native media probe omitted %q", required) + } + } +} + +func TestNativeLiveSoakKeepsMediaInputAndFeedbackConcurrent(t *testing.T) { + path := filepath.Join("..", "..", "server", "usb", "native_live_windows_test.go") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native live integration test: %v", err) + } + contract := string(source) + for _, required := range []string{ + "startLiveProbe(", + "mediaCtx, mediaProbe, \"exercise\"", + "publishInput(sequence)", + "if feedbackController {\n\t\t\t\t\t\tverifyFeedback()", + "2*mediaDuration+2*time.Minute", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native concurrent media soak omitted %q", required) + } + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index deae5414..ea2e796c 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -137,6 +137,7 @@ Microsoft-signed package with: -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` -SignatureValidationMode Production ` -Iterations 10 ` + -MediaDurationSeconds 30 ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ``` @@ -151,10 +152,12 @@ without running cleanup; the driver must remove its child, drain pending URBs, release exclusive ownership, and accept a fresh session. Normal CI never opts into this live test. When `-MediaProbePath` is supplied, the first DualShock 4 and DualSense -generation must also create one new render/capture endpoint pair; three seconds -of simultaneous WASAPI render/capture must increase native ISO, host-to-device, -and device-to-host byte counters. The baseline snapshot prevents a connected -physical controller from being mistaken for the virtual device. +generation must also create one new render/capture endpoint pair. Simultaneous +WASAPI render/capture must preserve the controller's declared format and frame +cadence, keep the render buffer nonempty, preserve monotonic capture clocks, +report no capture discontinuity/timestamp flags, and increase native ISO, +host-to-device, and device-to-host byte counters. The baseline snapshot prevents +a connected physical controller from being mistaken for the virtual device. When `-InputProbePath` is supplied, the first DualShock 4, DualSense, and DualSense Edge generations each publish 256 alternating stick markers. QPC is sampled immediately before publication and when a continuous HID `ReadFile` @@ -189,12 +192,22 @@ The Driver Verifier pass is a separate, explicit disposable-machine gate: -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` -SignatureValidationMode Production ` - -Iterations 10 ` + -Iterations 3 ` + -ReleaseGate ` -RequireDriverVerifier ` -RestartRootDevice ` - -DisposableTestMachine + -DisposableTestMachine ` + -MediaDurationSeconds 180 ` + -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ``` +`-ReleaseGate` is fail-closed: it requires a production Microsoft signature, +Driver Verifier, three lifecycle generations, both independent probes, an +active root-device restart, the disposable-machine acknowledgement, and a +three-minute clean duplex media run for each PlayStation controller. Omitting +any one of those inputs cannot print a production-pass result. + Microsoft warns that Driver Verifier can intentionally bugcheck a machine; this workflow is never run by ordinary CI, an installer, or DS4Windows. diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 89185032..3fcb5f99 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -26,7 +26,12 @@ param( [switch]$RestartRootDevice, - [switch]$DisposableTestMachine + [switch]$DisposableTestMachine, + + [ValidateRange(1, 300)] + [int]$MediaDurationSeconds = 3, + + [switch]$ReleaseGate ) Set-StrictMode -Version Latest @@ -54,6 +59,38 @@ function Resolve-DriverImagePath { if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' } + +if ($ReleaseGate) { + $releaseGateFailures = [Collections.Generic.List[string]]::new() + if ($SignatureValidationMode -ne 'Production') { + [void]$releaseGateFailures.Add('-SignatureValidationMode must be Production') + } + if (-not $RequireDriverVerifier) { + [void]$releaseGateFailures.Add('-RequireDriverVerifier is required') + } + if ([string]::IsNullOrWhiteSpace($MediaProbePath)) { + [void]$releaseGateFailures.Add('-MediaProbePath is required') + } + if ([string]::IsNullOrWhiteSpace($InputProbePath)) { + [void]$releaseGateFailures.Add('-InputProbePath is required') + } + if (-not $RestartRootDevice) { + [void]$releaseGateFailures.Add('-RestartRootDevice is required') + } + if (-not $DisposableTestMachine) { + [void]$releaseGateFailures.Add('-DisposableTestMachine is required') + } + if ($Iterations -lt 3) { + [void]$releaseGateFailures.Add('-Iterations must be at least 3') + } + if ($MediaDurationSeconds -lt 180) { + [void]$releaseGateFailures.Add('-MediaDurationSeconds must be at least 180') + } + if ($releaseGateFailures.Count -ne 0) { + throw "Release-gate validation is incomplete:`n - $($releaseGateFailures -join "`n - ")" + } +} + $repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' & $signatureGate ` @@ -134,6 +171,7 @@ $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') $oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', 'Process') +$oldMediaSeconds = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', 'Process') $oldInputProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', 'Process') $oldRestartInstance = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', 'Process') try { @@ -141,9 +179,11 @@ try { $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations if ($null -ne $resolvedMediaProbe) { $env:VIIPER_UDE_LIVE_MEDIA_PROBE = $resolvedMediaProbe + $env:VIIPER_UDE_LIVE_MEDIA_SECONDS = [string]$MediaDurationSeconds } else { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $null, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', $null, 'Process') } if ($null -ne $resolvedInputProbe) { $env:VIIPER_UDE_LIVE_INPUT_PROBE = $resolvedInputProbe @@ -157,7 +197,11 @@ try { else { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $null, 'Process') } - $timeoutMinutes = ($Iterations * 5) + $(if ($RestartRootDevice) { 5 } else { 2 }) + $mediaMinutes = if ($null -ne $resolvedMediaProbe) { + [Math]::Ceiling(($MediaDurationSeconds * 2) / 60.0) + } + else { 0 } + $timeoutMinutes = ($Iterations * 5) + $mediaMinutes + $(if ($RestartRootDevice) { 5 } else { 2 }) Push-Location $repository try { & $go.Source test -count=1 -timeout "${timeoutMinutes}m" ` @@ -174,12 +218,17 @@ finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') + [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', $oldMediaSeconds, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $oldInputProbe, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $oldRestartInstance, 'Process') } $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } -$mediaSuffix = if ($null -ne $resolvedMediaProbe) { ' with full-duplex CoreAudio media' } else { '' } +$mediaSuffix = if ($null -ne $resolvedMediaProbe) { + " with $MediaDurationSeconds-second full-duplex CoreAudio media per PlayStation controller" +} +else { '' } $inputSuffix = if ($null -ne $resolvedInputProbe) { ' with end-to-end HID input latency and output feedback' } else { '' } $restartSuffix = if ($RestartRootDevice) { ' with active root-device restart recovery' } else { '' } -Write-Host "VIIPER UDE live lifecycle/HID/media validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix." +$releaseSuffix = if ($ReleaseGate) { ' under the complete production release contract' } else { '' } +Write-Host "VIIPER UDE live lifecycle/HID/media validation passed for $Iterations iteration(s)$verifierSuffix$mediaSuffix$inputSuffix$restartSuffix$releaseSuffix." diff --git a/native/udecx/tools/ViiperUdeMediaProbe.cpp b/native/udecx/tools/ViiperUdeMediaProbe.cpp index d7538039..9ff3c24d 100644 --- a/native/udecx/tools/ViiperUdeMediaProbe.cpp +++ b/native/udecx/tools/ViiperUdeMediaProbe.cpp @@ -129,6 +129,54 @@ struct EndpointSet { std::set capture; }; +struct MediaFormat final { + DWORD sampleRate = 0; + WORD channels = 0; +}; + +struct RenderStats final { + uint64_t frames = 0; + uint64_t bufferFrames = 0; + uint64_t events = 0; + uint64_t underruns = 0; + double maximumEventGapMilliseconds = 0.0; + MediaFormat format{}; +}; + +struct CaptureStats final { + uint64_t frames = 0; + uint64_t packets = 0; + uint64_t discontinuities = 0; + uint64_t timestampErrors = 0; + uint64_t positionRegressions = 0; + uint64_t qpcRegressions = 0; + double maximumEventGapMilliseconds = 0.0; + MediaFormat format{}; +}; + +struct ExpectedMediaFormat final { + DWORD renderSampleRate = 0; + WORD renderChannels = 0; + DWORD captureSampleRate = 0; + WORD captureChannels = 0; +}; + +ExpectedMediaFormat ExpectedFormatFor(const std::wstring& controller) { + if (_wcsicmp(controller.c_str(), L"dualsense") == 0 || + _wcsicmp(controller.c_str(), L"dualsenseedge") == 0) { + return ExpectedMediaFormat{48000, 4, 48000, 2}; + } + if (_wcsicmp(controller.c_str(), L"dualshock4") == 0) { + return ExpectedMediaFormat{32000, 2, 16000, 1}; + } + throw std::runtime_error("unsupported controller media contract: " + WideToUtf8(controller)); +} + +double EventGapMilliseconds(std::chrono::steady_clock::time_point previous, + std::chrono::steady_clock::time_point current) { + return std::chrono::duration(current - previous).count(); +} + std::set Enumerate(EDataFlow flow) { ComApartment apartment; ComPtr enumerator; @@ -260,7 +308,7 @@ void FillTone(BYTE* data, UINT32 frames, const WAVEFORMATEX* format, double& pha } } -uint64_t ExerciseRender(const std::wstring& endpointId, std::chrono::seconds duration) { +RenderStats ExerciseRender(const std::wstring& endpointId, std::chrono::seconds duration) { ComApartment apartment; auto device = OpenEndpoint(endpointId); ComPtr client; @@ -289,26 +337,41 @@ uint64_t ExerciseRender(const std::wstring& endpointId, std::chrono::seconds dur CheckHRESULT("IAudioRenderClient::ReleaseBuffer(prime)", render->ReleaseBuffer(bufferFrames, 0)); CheckHRESULT("IAudioClient::Start(render)", client->Start()); - uint64_t framesWritten = bufferFrames; + RenderStats stats{}; + stats.frames = bufferFrames; + stats.bufferFrames = bufferFrames; + stats.format = MediaFormat{format->nSamplesPerSec, format->nChannels}; + auto previousEvent = std::chrono::steady_clock::now(); + bool warmedUp = false; const auto deadline = std::chrono::steady_clock::now() + duration; while (std::chrono::steady_clock::now() < deadline) { const DWORD wait = WaitForSingleObject(event.get(), 2000); if (wait != WAIT_OBJECT_0) throw std::runtime_error("render event timed out"); + const auto eventTime = std::chrono::steady_clock::now(); + if (stats.events != 0) { + stats.maximumEventGapMilliseconds = std::max( + stats.maximumEventGapMilliseconds, + EventGapMilliseconds(previousEvent, eventTime)); + } + previousEvent = eventTime; + ++stats.events; UINT32 padding = 0; CheckHRESULT("IAudioClient::GetCurrentPadding", client->GetCurrentPadding(&padding)); if (padding > bufferFrames) throw std::runtime_error("render padding exceeds buffer size"); + if (warmedUp && padding == 0) ++stats.underruns; + warmedUp = true; const UINT32 available = bufferFrames - padding; if (available == 0) continue; CheckHRESULT("IAudioRenderClient::GetBuffer", render->GetBuffer(available, &data)); FillTone(data, available, format.get(), phase); CheckHRESULT("IAudioRenderClient::ReleaseBuffer", render->ReleaseBuffer(available, 0)); - framesWritten += available; + stats.frames += available; } CheckHRESULT("IAudioClient::Stop(render)", client->Stop()); - return framesWritten; + return stats; } -uint64_t ExerciseCapture(const std::wstring& endpointId, std::chrono::seconds duration) { +CaptureStats ExerciseCapture(const std::wstring& endpointId, std::chrono::seconds duration) { ComApartment apartment; auto device = OpenEndpoint(endpointId); ComPtr client; @@ -330,28 +393,71 @@ uint64_t ExerciseCapture(const std::wstring& endpointId, std::chrono::seconds du __uuidof(IAudioCaptureClient), reinterpret_cast(capture.put()))); CheckHRESULT("IAudioClient::Start(capture)", client->Start()); - uint64_t framesRead = 0; + CaptureStats stats{}; + stats.format = MediaFormat{format->nSamplesPerSec, format->nChannels}; + auto previousEvent = std::chrono::steady_clock::now(); + uint64_t previousDevicePosition = 0; + uint64_t previousQpcPosition = 0; + bool havePosition = false; + bool firstPacket = true; const auto deadline = std::chrono::steady_clock::now() + duration; while (std::chrono::steady_clock::now() < deadline) { const DWORD wait = WaitForSingleObject(event.get(), 2000); if (wait != WAIT_OBJECT_0) throw std::runtime_error("capture event timed out"); + const auto eventTime = std::chrono::steady_clock::now(); + if (stats.packets != 0) { + stats.maximumEventGapMilliseconds = std::max( + stats.maximumEventGapMilliseconds, + EventGapMilliseconds(previousEvent, eventTime)); + } + previousEvent = eventTime; for (;;) { UINT32 packetFrames = 0; CheckHRESULT("IAudioCaptureClient::GetNextPacketSize", capture->GetNextPacketSize(&packetFrames)); if (packetFrames == 0) break; BYTE* data = nullptr; DWORD flags = 0; + uint64_t devicePosition = 0; + uint64_t qpcPosition = 0; CheckHRESULT("IAudioCaptureClient::GetBuffer", capture->GetBuffer( - &data, &packetFrames, &flags, nullptr, nullptr)); - framesRead += packetFrames; + &data, &packetFrames, &flags, &devicePosition, &qpcPosition)); + if (!firstPacket && (flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) { + ++stats.discontinuities; + } + if (!firstPacket && (flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) { + ++stats.timestampErrors; + } + if (havePosition) { + if (devicePosition < previousDevicePosition) ++stats.positionRegressions; + if (qpcPosition < previousQpcPosition) ++stats.qpcRegressions; + } + previousDevicePosition = devicePosition; + previousQpcPosition = qpcPosition; + havePosition = true; + firstPacket = false; + ++stats.packets; + stats.frames += packetFrames; CheckHRESULT("IAudioCaptureClient::ReleaseBuffer", capture->ReleaseBuffer(packetFrames)); } } CheckHRESULT("IAudioClient::Stop(capture)", client->Stop()); - return framesRead; + return stats; } -int Exercise(const std::filesystem::path& snapshotPath, int seconds) { +void ValidateFrameCount(const char* lane, uint64_t frames, DWORD sampleRate, + int seconds, uint64_t allowance) { + const uint64_t expected = static_cast(sampleRate) * + static_cast(seconds); + const uint64_t minimum = expected * 95 / 100; + const uint64_t maximum = expected * 105 / 100 + allowance; + if (frames < minimum || frames > maximum) { + throw std::runtime_error(std::string(lane) + " frame cadence is outside the 5% contract: got " + + std::to_string(frames) + ", expected approximately " + std::to_string(expected)); + } +} + +int Exercise(const std::filesystem::path& snapshotPath, int seconds, + const std::wstring& controller) { const EndpointSet baseline = ReadSnapshot(snapshotPath); std::vector render; std::vector capture; @@ -372,25 +478,58 @@ int Exercise(const std::filesystem::path& snapshotPath, int seconds) { std::exception_ptr renderError; std::exception_ptr captureError; - uint64_t renderFrames = 0; - uint64_t captureFrames = 0; + RenderStats renderStats{}; + CaptureStats captureStats{}; const auto duration = std::chrono::seconds(seconds); std::thread renderThread([&] { - try { renderFrames = ExerciseRender(render[0], duration); } + try { renderStats = ExerciseRender(render[0], duration); } catch (...) { renderError = std::current_exception(); } }); std::thread captureThread([&] { - try { captureFrames = ExerciseCapture(capture[0], duration); } + try { captureStats = ExerciseCapture(capture[0], duration); } catch (...) { captureError = std::current_exception(); } }); renderThread.join(); captureThread.join(); if (renderError) std::rethrow_exception(renderError); if (captureError) std::rethrow_exception(captureError); - if (renderFrames == 0 || captureFrames == 0) { + if (renderStats.frames == 0 || captureStats.frames == 0) { throw std::runtime_error("CoreAudio endpoint completed no frames"); } - std::cout << "renderFrames=" << renderFrames << " captureFrames=" << captureFrames << "\n"; + const ExpectedMediaFormat expected = ExpectedFormatFor(controller); + if (renderStats.format.sampleRate != expected.renderSampleRate || + renderStats.format.channels != expected.renderChannels) { + throw std::runtime_error("render mix format does not match the virtual controller descriptor"); + } + if (captureStats.format.sampleRate != expected.captureSampleRate || + captureStats.format.channels != expected.captureChannels) { + throw std::runtime_error("capture mix format does not match the virtual controller descriptor"); + } + ValidateFrameCount("render", renderStats.frames, renderStats.format.sampleRate, + seconds, renderStats.bufferFrames); + ValidateFrameCount("capture", captureStats.frames, captureStats.format.sampleRate, + seconds, 0); + if (renderStats.underruns != 0) { + throw std::runtime_error("render stream exhausted its CoreAudio buffer " + + std::to_string(renderStats.underruns) + " time(s)"); + } + if (captureStats.discontinuities != 0 || captureStats.timestampErrors != 0 || + captureStats.positionRegressions != 0 || captureStats.qpcRegressions != 0) { + throw std::runtime_error("capture stream reported a discontinuity or non-monotonic clock"); + } + std::cout << "renderFrames=" << renderStats.frames + << " renderEvents=" << renderStats.events + << " renderBufferFrames=" << renderStats.bufferFrames + << " renderUnderruns=" << renderStats.underruns + << " renderMaxEventGapMs=" << renderStats.maximumEventGapMilliseconds + << " captureFrames=" << captureStats.frames + << " capturePackets=" << captureStats.packets + << " captureDiscontinuities=" << captureStats.discontinuities + << " captureTimestampErrors=" << captureStats.timestampErrors + << " capturePositionRegressions=" << captureStats.positionRegressions + << " captureQpcRegressions=" << captureStats.qpcRegressions + << " captureMaxEventGapMs=" << captureStats.maximumEventGapMilliseconds + << "\n"; return 0; } @@ -402,14 +541,14 @@ int wmain(int argc, wchar_t** argv) { WriteSnapshot(argv[2], EnumerateEndpoints()); return 0; } - if (argc == 4 && _wcsicmp(argv[1], L"exercise") == 0) { + if (argc == 5 && _wcsicmp(argv[1], L"exercise") == 0) { const int seconds = _wtoi(argv[3]); - if (seconds < 1 || seconds > 30) throw std::runtime_error("duration must be 1 through 30 seconds"); - return Exercise(argv[2], seconds); + if (seconds < 1 || seconds > 300) throw std::runtime_error("duration must be 1 through 300 seconds"); + return Exercise(argv[2], seconds, argv[4]); } std::wcerr << L"Usage:\n" << L" ViiperUdeMediaProbe.exe snapshot \n" - << L" ViiperUdeMediaProbe.exe exercise \n"; + << L" ViiperUdeMediaProbe.exe exercise \n"; return 2; } catch (const std::exception& error) { std::cerr << "VIIPER UDE media probe failed: " << error.what() << "\n"; From 3dd2ea5eadfa8dfc4fa4bb903b80c1c42142e407 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 07:52:09 -0500 Subject: [PATCH 132/240] Normalize native validation source contracts --- internal/transport/udecx/live_validation_contract_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index ab815b5d..b9a25f02 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -13,7 +13,7 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { if err != nil { t.Fatalf("read native live validator: %v", err) } - contract := string(script) + contract := strings.ReplaceAll(string(script), "\r\n", "\n") for _, required := range []string{ "[switch]$ReleaseGate", "$SignatureValidationMode -ne 'Production'", @@ -39,7 +39,7 @@ func TestNativeMediaProbeRejectsObservableDiscontinuity(t *testing.T) { if err != nil { t.Fatalf("read native media probe: %v", err) } - contract := string(source) + contract := strings.ReplaceAll(string(source), "\r\n", "\n") for _, required := range []string{ "AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY", "AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR", @@ -62,7 +62,7 @@ func TestNativeLiveSoakKeepsMediaInputAndFeedbackConcurrent(t *testing.T) { if err != nil { t.Fatalf("read native live integration test: %v", err) } - contract := string(source) + contract := strings.ReplaceAll(string(source), "\r\n", "\n") for _, required := range []string{ "startLiveProbe(", "mediaCtx, mediaProbe, \"exercise\"", From 543fbccfdc9bfe9f072ea824599ab95ccc73d0aa Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 07:58:10 -0500 Subject: [PATCH 133/240] Define Windows device property keys in setup helper --- native/udecx/tools/ViiperUdeCtl.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index be2a07ae..fcd6ec8c 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include From 5f7af61a6bec22f49d9d69f3f0836c6e9dd440fe Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 08:06:35 -0500 Subject: [PATCH 134/240] Install native broker through transactional SCM service Register the native UDE broker as an automatic LocalSystem service with bounded recovery and authenticated readiness verification. Rotate and transactionally restore the ProgramData credential, retain target-user HKU ownership, and migrate legacy Run/task/process state with exact CAS and rollback semantics. Harden the trust boundary by requiring read-only proof of protected managed executables and an already-protected existing SCM object. Preserve prior service configuration, recovery, DACL, executable identity, and running state without adopting weak objects or mutating paths before rollback exists. Cover task cancellation, case-insensitive identity, typed registry values, unloaded-hive handling, absent-owner races, disabled-task structural validation, and rollback ordering with pure/fake tests. Document the production migration contract. --- docs/architecture/native-udecx.md | 108 + internal/cmd/install.go | 14 +- internal/cmd/install_linux.go | 10 +- internal/cmd/install_windows.go | 150 +- internal/cmd/install_windows_test.go | 10 +- .../cmd/native_service_install_windows.go | 3057 +++++++++++++++++ .../native_service_install_windows_test.go | 1483 ++++++++ internal/cmd/service_windows.go | 11 +- internal/cmd/service_windows_test.go | 9 +- 9 files changed, 4762 insertions(+), 90 deletions(-) create mode 100644 internal/cmd/native_service_install_windows.go create mode 100644 internal/cmd/native_service_install_windows_test.go diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bc7198c5..d99c2d9a 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -221,6 +221,98 @@ first ISO URB is also authoritative activation, closing the cross-worker race where media reaches user mode before its start notification. Numeric UdeCx interface fields are only hints for alternates that contain no endpoints. +### Broker service migration transaction + +Windows native mode is owned by the `VIIPERNativeBroker` Service Control +Manager service, never by an HKCU Run entry or tray process. Installation and +update use one machine-wide mutex and the following fail-closed transaction: + +1. Resolve Program Files and ProgramData through Windows Known Folder APIs. + Resolve one target interactive-user SID before the first mutation and use + that same identity for the credential ACL and every legacy HKU/task/process + operation. An elevated caller may supply the bootstrapper-origin SID; + otherwise VIIPER proves the shell or active-console token and fails closed + when no unambiguous interactive user exists. + Native installation is accepted only from the managed + `Program Files\VIIPER\viiper.exe` or + `Program Files\DS4Windows\VIIPER\viiper.exe` layout. Every component is + opened as a non-reparse point and retained without delete sharing through + authenticated startup. The executable and credential must each have one + hard link, and their retained file handles also deny write sharing. Every + managed directory and the PE executable must already carry the exact + protected, administrator-owned package ACL before the service command will + register the executable as LocalSystem code. The command validates and + retains those objects read-only; it never treats an in-place ACL rewrite as + proof because that cannot revoke handles opened under an older weak ACL. +2. Provision a freshly rotated, nonempty credential under + `%ProgramData%\VIIPER` only after every prior broker owner is stopped. An + existing value is retained solely for rollback and is never trusted as the + new secret, preventing a standard user from pre-seeding a known key. The directory + is held open without delete sharing while the key is staged and published + with `MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)`. Its protected DACL + grants full control to SYSTEM and built-in administrators and read access + to the installing user's SID; no localized account name is parsed. +3. Snapshot the prior service configuration, stable running state, failure + actions, SCM object owner/DACL, and legacy startup commands. The prior + service executable is parsed from the SCM command line, validated against + the already-protected managed-file ACL contract without mutating it, and + every path component remains locked against replacement until commit or + exact rollback. Before a LocalSystem command is installed, the service object is + required to already have a protected DACL granting control only to SYSTEM + and built-in administrators. The transaction rejects a permissive existing + service rather than relying on a DACL rewrite that cannot revoke previously + opened service handles; rollback restores and verifies the exact prior + descriptor before any prior service restart. Task Scheduler enumeration is + fail-closed and distinguishes a missing root task from provider/access + failure. Stop the old SCM instance and the exact snapshotted scheduled-task + instance before considering residual HKU Run processes. Only processes + whose full executable path and token-user SID match the target registration + are terminated. Process handles remain open from identity verification + through termination, preventing PID-reuse and cross-user mistakes. +4. Negotiate the packaged native driver, then create or update an automatic, + own-process LocalSystem service with explicit `native-ude`, credential, and + log arguments. Arguments are escaped with Windows command-line rules rather + than passed through a shell. The few legacy Task Scheduler operations use + the absolute, non-reparse system PowerShell path rather than process `PATH`. +5. Apply bounded recovery (two restart attempts followed by no action), start + the service, and require an authenticated ping proving `Ready=true`, exact + native transport, ABI, and negotiated capabilities. The broker's current + package-version field is compile-time metadata rather than installed-driver + attestation and is intentionally not treated as verification. +6. Only after that proof, compare-and-remove the legacy HKCU registration. The + exact `HKU\` hive and existing Run key handles stay open for the entire + transaction, preventing logoff/unload from turning rollback into an orphan + registry subtree. Run ownership is compared as data plus `REG_SZ` versus + `REG_EXPAND_SZ`; both originally present and originally absent Run/task + states are CAS-checked immediately before commit. Task names are matched + with Task Scheduler's case-insensitive identity rules. The + exact legacy scheduled task stays registered but disabled: exported task XML + omits its registered ACL and cannot recreate Password-logon credentials, so + delete/re-register would not be an exact transaction. Disable, stop, wait, + and validation occur in one bounded provider operation; rollback re-enables + only the same retained task. Task XML is transported as explicit UTF-8 + through an ASCII base64 envelope. Re-authenticate the broker after legacy + ownership is disabled, closing a restart race. The service command runs + without a tray in session 0. + +Any failure receives a fresh rollback deadline: a newly created service is +stopped and deleted, or the previous configuration, recovery policy, and +running state are restored and verified before the prior service can restart. +The credential rollback uses the same atomic publication path. A legacy process +is restarted only when it was actually running before migration: scheduled-task +processes are restarted by Task Scheduler in their original security context, +and HKCU processes use the interactive shell token rather than the elevated +installer token. Full scheduled-task XML is restored after partial removal. +Uninstall holds the same mutex across service, startup-registration, and process +cleanup. It resolves and snapshots every target before mutation, stops the +service and exact legacy owners, compare-removes HKU Run ownership while +retaining any exact scheduled task disabled, and marks +the service for deletion only as the final fallible operation. If anything +before successful deletion fails, it restores registrations, the exact service +configuration/recovery policy/stable state, and only the legacy owners that +were previously running. Every Task Scheduler subprocess is context-bounded so +a wedged provider cannot retain the installer mutex indefinitely. + ## Synchronization model - Separate controller locks protect the device table and broker-owner @@ -478,3 +570,19 @@ validation contract is documented in - Microsoft, `SetFileCompletionNotificationModes` +- Microsoft, `CreateService` + +- Microsoft, `ChangeServiceConfig` + +- Microsoft, `ChangeServiceConfig2` + +- Microsoft, `SERVICE_FAILURE_ACTIONS` + +- Microsoft, `DeleteService` + +- Microsoft, `SetSecurityInfo` + +- Microsoft, `MoveFileExW` + +- Microsoft, `CreateProcessAsUserW` + diff --git a/internal/cmd/install.go b/internal/cmd/install.go index f9aa67eb..da9efd8b 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -10,14 +10,18 @@ import ( "strings" ) -// Install sets up VIIPER to run automatically. +// Install sets up VIIPER to run automatically. On Windows native-ude uses an +// SCM-owned LocalSystem broker; the legacy usbip developer path retains its +// historical per-user startup registration. type Install struct { - Transport string `help:"Virtual USB transport to register: usbip or native-ude." default:"usbip"` + Transport string `help:"Virtual USB transport to register: usbip or native-ude." default:"usbip"` + TargetUserSID string `help:"Interactive Windows user SID that owns DS4Windows startup state." hidden:""` } // Uninstall removes VIIPER startup configuration. type Uninstall struct { - Yes bool `help:"Confirm removal without prompting." short:"y"` + Yes bool `help:"Confirm removal without prompting." short:"y"` + TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` } func (c *Install) Run(logger *slog.Logger) error { @@ -35,7 +39,7 @@ func (c *Install) Run(logger *slog.Logger) error { return fmt.Errorf("unsupported VIIPER transport %q (expected usbip or native-ude)", c.Transport) } - return install(logger, transport) + return install(logger, transport, strings.TrimSpace(c.TargetUserSID)) } func (c *Uninstall) Run(logger *slog.Logger) error { @@ -61,7 +65,7 @@ func (c *Uninstall) Run(logger *slog.Logger) error { } } - return uninstall(logger) + return uninstall(logger, strings.TrimSpace(c.TargetUserSID)) } func currentExecutable() (string, error) { diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index e90242a8..350da94c 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -17,7 +17,10 @@ const ( servicePath = "/etc/systemd/system/viiper.service" ) -func install(logger *slog.Logger, transport string) error { +func install(logger *slog.Logger, transport, targetUserSID string) error { + if targetUserSID != "" { + return errors.New("--target-user-sid is supported only by the Windows native broker installer") + } if transport != "usbip" { return fmt.Errorf("transport %q is unavailable on Linux", transport) } @@ -48,7 +51,10 @@ func install(logger *slog.Logger, transport string) error { return nil } -func uninstall(logger *slog.Logger) error { +func uninstall(logger *slog.Logger, targetUserSID string) error { + if targetUserSID != "" { + return errors.New("--target-user-sid is supported only by the Windows native broker installer") + } var errs []error if err := runSystemctl("stop", serviceName); err != nil { diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index a19d1067..1cae04ad 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -18,6 +18,7 @@ import ( "github.com/Alia5/VIIPER/internal/configpaths" "github.com/Alia5/VIIPER/internal/transport/udecx" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) @@ -27,10 +28,21 @@ const ( runScheduledTask = "RunVIIPER" ) -func install(logger *slog.Logger, transport string) error { +func install(logger *slog.Logger, transport, targetUserSID string) error { + if transport == "native-ude" { + return installNativeBroker(logger, targetUserSID) + } + if targetUserSID != "" { + return errors.New("--target-user-sid is valid only with --transport native-ude") + } if os.Getenv("VIIPER_DEVELOPER_STANDALONE") != "1" { return errors.New("standalone VIIPER startup registration is developer-only on Windows; use the signed DS4Windows installer or its built-in VIIPER repair so one verified owner manages VIIPER and USB-IP") } + release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) + if err != nil { + return err + } + defer release() if transport == "usbip" { if err := requireUSBIPRuntime(); err != nil { return err @@ -73,12 +85,6 @@ func install(logger *slog.Logger, transport string) error { return fmt.Errorf("failed to stop previous autorun instance: %w", err) } } - if transport == "native-ude" { - if err := requireNativeUDEBroker(); err != nil { - return err - } - } - value := windowsAutorunCommand(exePath, transport, logFile) key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) if err != nil { @@ -121,63 +127,26 @@ func requireNativeUDEBroker() error { return nil } -func uninstall(logger *slog.Logger) error { - autorunExe, err := currentAutorunExe() - if err != nil { - return err - } - scheduledExe, err := currentScheduledTaskExe() - if err != nil { - return fmt.Errorf("failed to inspect %s scheduled task: %w", runScheduledTask, err) - } - if err := removeScheduledTask(); err != nil { - return fmt.Errorf("failed to remove %s scheduled task; run uninstall as administrator: %w", runScheduledTask, err) - } - if scheduledExe != "" { - if err := killProcessesByExe(scheduledExe, logger); err != nil { - return fmt.Errorf("failed to stop scheduled VIIPER instance: %w", err) - } - } - - key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) - if err != nil { - if !errors.Is(err, registry.ErrNotExist) { - return err - } - } else { - defer key.Close() //nolint:errcheck - - if err := key.DeleteValue(runValueKey); err != nil { - if !errors.Is(err, registry.ErrNotExist) { - return err - } - } - } - - if autorunExe != "" { - if err := killProcessesByExe(autorunExe, logger); err != nil { - return fmt.Errorf("failed to stop autorun instance: %w", err) - } - } - - currentExe, currentErr := currentExecutable() - if currentErr == nil && !strings.EqualFold(currentExe, autorunExe) { - if err := killProcessesByExe(currentExe, logger); err != nil { - return fmt.Errorf("failed to stop installed VIIPER instance: %w", err) - } - } - - logger.Info("VIIPER startup entries removed and server stopped") - return nil +func uninstall(logger *slog.Logger, targetUserSID string) error { + return uninstallNativeBroker(logger, targetUserSID) } func currentScheduledTaskExe() (string, error) { - script := fmt.Sprintf( - "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};$a=@($t.Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};$a[0].Execute", - runScheduledTask, - ) - output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + // Enumerate the exact root task and fail closed on provider errors. A + // targeted Get-ScheduledTask call with SilentlyContinue cannot distinguish + // "not found" from an unavailable or access-denied Task Scheduler provider. + script := `$ErrorActionPreference='Stop';$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($m.Count -eq 0){exit 0};if($m.Count -ne 1){throw 'expected zero or one root RunVIIPER task'};$a=@($m[0].Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};$a[0].Execute` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") if err != nil { + return "", fmt.Errorf("resolve trusted PowerShell: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("scheduled task query timed out: %w", ctx.Err()) + } return "", fmt.Errorf("scheduled task query failed: %w: %s", err, strings.TrimSpace(string(output))) } path := strings.Trim(strings.TrimSpace(string(output)), `"`) @@ -195,14 +164,18 @@ func removeScheduledTask() error { // Get-ScheduledTask makes absence distinguishable from an access-denied // deletion. Never report uninstall success while a highest-privilege task // can silently start VIIPER again at the next logon. - script := fmt.Sprintf( - "$ErrorActionPreference='Stop';$t=Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue;if($null -eq $t){exit 0};Unregister-ScheduledTask -TaskName '%s' -Confirm:$false -ErrorAction Stop;if(Get-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue){throw 'scheduled task still exists'}", - runScheduledTask, - runScheduledTask, - runScheduledTask, - ) - output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + script := `$ErrorActionPreference='Stop';$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($m.Count -eq 0){exit 0};if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};Unregister-ScheduledTask -TaskName $m[0].TaskName -TaskPath '\' -Confirm:$false -ErrorAction Stop;$after=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});if($after.Count -ne 0){throw 'scheduled task still exists'}` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("scheduled task removal timed out: %w", ctx.Err()) + } return fmt.Errorf("scheduled task removal failed: %w: %s", err, strings.TrimSpace(string(output))) } return nil @@ -260,7 +233,11 @@ func killProcessesByExe(target string, logger *slog.Logger) error { "$ErrorActionPreference='SilentlyContinue';$t='%s';Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $t } | Select-Object -ExpandProperty ProcessId", strings.ReplaceAll(target, "'", "''"), ) - cmd := exec.Command("powershell", "-NoProfile", "-Command", script) + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + cmd := exec.Command(powershell, "-NoProfile", "-Command", script) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("process query failed: %w: %s", err, strings.TrimSpace(string(output))) @@ -292,7 +269,11 @@ func killProcessesByExe(target string, logger *slog.Logger) error { if pid == self { continue } - cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + taskkill, pathErr := trustedSystemExecutable("taskkill.exe") + if pathErr != nil { + return fmt.Errorf("resolve trusted taskkill: %w", pathErr) + } + cmd := exec.Command(taskkill, "/PID", strconv.Itoa(pid), "/T", "/F") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("taskkill pid %d failed: %w: %s", pid, err, strings.TrimSpace(string(output))) @@ -302,3 +283,34 @@ func killProcessesByExe(target string, logger *slog.Logger) error { return nil } + +func trustedSystemExecutable(relativeParts ...string) (string, error) { + if len(relativeParts) == 0 { + return "", errors.New("trusted system executable path is empty") + } + for _, part := range relativeParts { + if part == "" || part == "." || part == ".." || filepath.Base(part) != part { + return "", fmt.Errorf("invalid trusted system path component %q", part) + } + } + systemDirectory, err := windows.GetSystemDirectory() + if err != nil { + return "", err + } + current := filepath.Clean(systemDirectory) + root, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return "", fmt.Errorf("open Windows system directory: %w", err) + } + windows.CloseHandle(root) //nolint:errcheck + for index, part := range relativeParts { + current = filepath.Join(current, part) + isDirectory := index < len(relativeParts)-1 + handle, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, isDirectory) + if err != nil { + return "", fmt.Errorf("open trusted system path %s: %w", current, err) + } + windows.CloseHandle(handle) //nolint:errcheck + } + return current, nil +} diff --git a/internal/cmd/install_windows_test.go b/internal/cmd/install_windows_test.go index 1dc0f14a..1203f025 100644 --- a/internal/cmd/install_windows_test.go +++ b/internal/cmd/install_windows_test.go @@ -7,17 +7,17 @@ import ( "testing" ) -func TestNativeInstallPersistsExplicitTransport(t *testing.T) { +func TestLegacyUSBIPInstallPersistsExplicitTransport(t *testing.T) { exe := `C:\Program Files\VIIPER\viiper.exe` logFile := `C:\Users\test user\AppData\Local\VIIPER\viiper.log` - wantArgs := []string{"server", "--transport", "native-ude", "--log.file", logFile} - if got := serverArguments("native-ude", logFile); !reflect.DeepEqual(got, wantArgs) { + wantArgs := []string{"server", "--transport", "usbip", "--log.file", logFile} + if got := serverArguments("usbip", logFile); !reflect.DeepEqual(got, wantArgs) { t.Fatalf("server arguments=%q want=%q", got, wantArgs) } - wantCommand := `"C:\Program Files\VIIPER\viiper.exe" server --transport native-ude --log.file "C:\Users\test user\AppData\Local\VIIPER\viiper.log"` - if got := windowsAutorunCommand(exe, "native-ude", logFile); got != wantCommand { + wantCommand := `"C:\Program Files\VIIPER\viiper.exe" server --transport usbip --log.file "C:\Users\test user\AppData\Local\VIIPER\viiper.log"` + if got := windowsAutorunCommand(exe, "usbip", logFile); got != wantCommand { t.Fatalf("autorun command=%q want=%q", got, wantCommand) } } diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go new file mode 100644 index 00000000..8ba58688 --- /dev/null +++ b/internal/cmd/native_service_install_windows.go @@ -0,0 +1,3057 @@ +//go:build windows + +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + nativeBrokerDisplayName = "VIIPER Native UDE Broker" + nativeBrokerDescription = "Provides authenticated native virtual-controller transport for DS4Windows." + nativeBrokerLogName = "viiper-native-broker.log" + nativeServiceAccount = "LocalSystem" + nativeServiceRecoveryResetSecond = 15 * 60 + nativeServiceInstallTimeout = 45 * time.Second + nativeServiceStatePoll = 100 * time.Millisecond + nativeInstallMutexName = `Global\VIIPER.NativeBroker.Install.v1` + nativeBrokerServiceSDDL = "O:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" + nativeBrokerDirectorySDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)" + nativeBrokerExecutableSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;BU)" +) + +var nativeServiceRecoveryActions = []mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 2 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 15 * time.Second}, + // SCM repeats the final action for later failures. Ending with NoAction is + // what makes this recovery policy bounded instead of a permanent restart loop. + {Type: mgr.NoAction}, +} + +var expandEnvironmentStringsForUserW = windows.NewLazySystemDLL("userenv.dll").NewProc("ExpandEnvironmentStringsForUserW") + +type nativeSCM interface { + OpenService(string) (nativeManagedService, error) + CreateService(string, string, mgr.Config, ...string) (nativeManagedService, error) + Close() error +} + +type nativeManagedService interface { + Config() (mgr.Config, error) + UpdateConfig(mgr.Config) error + SecurityDescriptor() (string, error) + SetSecurityDescriptor(string) error + Query() (svc.Status, error) + ProcessID() (uint32, error) + Start(...string) error + Control(svc.Cmd) (svc.Status, error) + Delete() error + SetRecoveryActions([]mgr.RecoveryAction, uint32) error + SetRecoveryActionsExact([]mgr.RecoveryAction, uint32) error + RecoveryActions() ([]mgr.RecoveryAction, error) + ResetPeriod() (uint32, error) + SetRecoveryActionsOnNonCrashFailures(bool) error + RecoveryActionsOnNonCrashFailures() (bool, error) + Close() error +} + +type windowsNativeSCM struct{ manager *mgr.Mgr } + +func (m *windowsNativeSCM) OpenService(name string) (nativeManagedService, error) { + service, err := m.manager.OpenService(name) + if err != nil { + return nil, err + } + return &windowsNativeService{service: service}, nil +} + +func (m *windowsNativeSCM) CreateService( + name, executable string, + config mgr.Config, + args ...string, +) (nativeManagedService, error) { + service, err := m.manager.CreateService(name, executable, config, args...) + if err != nil { + return nil, err + } + return &windowsNativeService{service: service}, nil +} + +func (m *windowsNativeSCM) Close() error { return m.manager.Disconnect() } + +type windowsNativeService struct{ service *mgr.Service } + +func (s *windowsNativeService) Config() (mgr.Config, error) { return s.service.Config() } +func (s *windowsNativeService) UpdateConfig(config mgr.Config) error { + return updateNativeServiceConfigExact(s.service.Handle, config) +} +func (s *windowsNativeService) SecurityDescriptor() (string, error) { + return nativeObjectSecurityDescriptor(s.service.Handle, windows.SE_SERVICE) +} +func (s *windowsNativeService) SetSecurityDescriptor(sddl string) error { + return setNativeObjectSecurityDescriptor(s.service.Handle, windows.SE_SERVICE, sddl) +} +func (s *windowsNativeService) Query() (svc.Status, error) { return s.service.Query() } +func (s *windowsNativeService) ProcessID() (uint32, error) { + status := windows.SERVICE_STATUS_PROCESS{} + var needed uint32 + if err := windows.QueryServiceStatusEx( + s.service.Handle, + windows.SC_STATUS_PROCESS_INFO, + (*byte)(unsafe.Pointer(&status)), + uint32(unsafe.Sizeof(status)), + &needed, + ); err != nil { + return 0, err + } + return status.ProcessId, nil +} +func (s *windowsNativeService) Start(args ...string) error { return s.service.Start(args...) } +func (s *windowsNativeService) Control(command svc.Cmd) (svc.Status, error) { + return s.service.Control(command) +} +func (s *windowsNativeService) Delete() error { return s.service.Delete() } +func (s *windowsNativeService) SetRecoveryActions(actions []mgr.RecoveryAction, reset uint32) error { + return s.service.SetRecoveryActions(actions, reset) +} +func (s *windowsNativeService) SetRecoveryActionsExact(actions []mgr.RecoveryAction, reset uint32) error { + if len(actions) != 0 { + return s.service.SetRecoveryActions(actions, reset) + } + // SERVICE_FAILURE_ACTIONS does not permit an empty action array with a + // nonzero reset period: a NULL Actions pointer leaves both values unchanged, + // while a non-NULL pointer with ActionsCount == 0 deletes both. Reject the + // unrepresentable state before mutation in openAndSnapshotNativeService. + if reset != 0 { + return errors.New("Windows SCM cannot persist empty recovery actions with a nonzero reset period") + } + dummyAction := windows.SC_ACTION{} + failureActions := windows.SERVICE_FAILURE_ACTIONS{Actions: &dummyAction} + return windows.ChangeServiceConfig2( + s.service.Handle, + windows.SERVICE_CONFIG_FAILURE_ACTIONS, + (*byte)(unsafe.Pointer(&failureActions)), + ) +} +func (s *windowsNativeService) RecoveryActions() ([]mgr.RecoveryAction, error) { + return s.service.RecoveryActions() +} +func (s *windowsNativeService) ResetPeriod() (uint32, error) { return s.service.ResetPeriod() } +func (s *windowsNativeService) SetRecoveryActionsOnNonCrashFailures(value bool) error { + return s.service.SetRecoveryActionsOnNonCrashFailures(value) +} +func (s *windowsNativeService) RecoveryActionsOnNonCrashFailures() (bool, error) { + return s.service.RecoveryActionsOnNonCrashFailures() +} +func (s *windowsNativeService) Close() error { return s.service.Close() } + +func updateNativeServiceConfigExact(handle windows.Handle, config mgr.Config) error { + if strings.TrimSpace(config.BinaryPathName) == "" || strings.IndexByte(config.BinaryPathName, 0) >= 0 { + return errors.New("service binary path must be nonempty and contain no NUL") + } + binaryPath, err := windows.UTF16PtrFromString(config.BinaryPathName) + if err != nil { + return err + } + loadOrderGroup, err := windows.UTF16PtrFromString(config.LoadOrderGroup) + if err != nil { + return err + } + dependencies, err := nativeServiceDependenciesBlock(config.Dependencies) + if err != nil { + return err + } + serviceAccount := config.ServiceStartName + if isLocalSystemServiceAccount(serviceAccount) { + serviceAccount = nativeServiceAccount + } + account, err := windows.UTF16PtrFromString(serviceAccount) + if err != nil { + return err + } + // The LocalSystem password is explicitly empty. NULL would mean "leave the + // old password unchanged", which is not an exact configuration operation. + emptyPassword, err := windows.UTF16PtrFromString("") + if err != nil { + return err + } + displayName, err := windows.UTF16PtrFromString(config.DisplayName) + if err != nil { + return err + } + if err := windows.ChangeServiceConfig( + handle, + config.ServiceType, + config.StartType, + config.ErrorControl, + binaryPath, + loadOrderGroup, + nil, + &dependencies[0], + account, + emptyPassword, + displayName, + ); err != nil { + return err + } + if err := windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_SERVICE_SID_INFO, + (*byte)(unsafe.Pointer(&config.SidType)), + ); err != nil { + return err + } + delayed := windows.SERVICE_DELAYED_AUTO_START_INFO{} + if config.DelayedAutoStart { + delayed.IsDelayedAutoStartUp = 1 + } + if err := windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + (*byte)(unsafe.Pointer(&delayed)), + ); err != nil { + return err + } + descriptionValue, err := windows.UTF16PtrFromString(config.Description) + if err != nil { + return err + } + description := windows.SERVICE_DESCRIPTION{Description: descriptionValue} + return windows.ChangeServiceConfig2( + handle, + windows.SERVICE_CONFIG_DESCRIPTION, + (*byte)(unsafe.Pointer(&description)), + ) +} + +func nativeServiceDependenciesBlock(dependencies []string) ([]uint16, error) { + block := make([]uint16, 0, 2) + for _, dependency := range dependencies { + if dependency == "" || strings.IndexByte(dependency, 0) >= 0 { + return nil, errors.New("service dependency must be nonempty and contain no NUL") + } + value, err := windows.UTF16FromString(dependency) + if err != nil { + return nil, err + } + block = append(block, value...) + } + // ChangeServiceConfig requires a non-NULL empty string to clear existing + // dependencies. Every nonempty block also needs the second terminating NUL. + block = append(block, 0) + if len(block) == 1 { + block = append(block, 0) + } + return block, nil +} + +type nativeServiceSnapshot struct { + exists bool + config mgr.Config + status svc.Status + securityDescriptor string + recoveryActions []mgr.RecoveryAction + recoveryResetSeconds uint32 + recoverNonCrash bool + releaseExecutable func() +} + +type nativeCredential struct { + path string + password string + userSID string + created bool + replaced bool + priorBytes []byte +} + +type nativeLegacyCommand struct { + executable string + arguments []string + workingDirectory string + source nativeLegacyCommandSource + running bool +} + +type nativeLegacyCommandSource uint8 + +const ( + legacyCommandRun nativeLegacyCommandSource = iota + 1 +) + +type nativeLegacyState struct { + userSID string + userHive registry.Key + runKey registry.Key + runKeyExisted bool + runValue *nativeRunRegistration + scheduledAction *nativeLegacyCommand + scheduledXML *string + scheduledCurrentXML *string + scheduledActive bool + scheduledEnabled bool + scheduledDisabled bool + scheduledStopped bool + verifyTaskAction func() error + release func() + commands []nativeLegacyCommand +} + +type nativeRunRegistration struct { + value string + valueType uint32 +} + +type nativeScheduledStopResult struct { + stopped bool + disabled bool + currentXML string +} + +type nativeInstallDependencies struct { + connectSCM func() (nativeSCM, error) + lockExecutable func(string) (func(), error) + lockPriorExecutable func(string) (func(), error) + provisionCredential func() (nativeCredential, error) + rollbackCredential func(nativeCredential) error + preflightDriver func() error + snapshotLegacy func(context.Context) (nativeLegacyState, error) + stopLegacy func(context.Context, *nativeLegacyState, *slog.Logger) error + removeLegacy func(context.Context, nativeLegacyState) error + restoreLegacy func(context.Context, nativeLegacyState) error + restartLegacy func(context.Context, nativeLegacyState) error + verifyBroker func(context.Context, string) error + wait func(context.Context, time.Duration) error +} + +func productionNativeInstallDependencies(userSID string) nativeInstallDependencies { + return nativeInstallDependencies{ + connectSCM: func() (nativeSCM, error) { + manager, err := mgr.Connect() + if err != nil { + return nil, err + } + return &windowsNativeSCM{manager: manager}, nil + }, + lockExecutable: lockNativeServiceExecutable, + lockPriorExecutable: lockNativePriorServiceExecutable, + provisionCredential: func() (nativeCredential, error) { + return provisionNativeServiceCredential(userSID) + }, + rollbackCredential: rollbackNativeServiceCredential, + preflightDriver: requireNativeUDEBroker, + snapshotLegacy: func(ctx context.Context) (nativeLegacyState, error) { + return snapshotNativeLegacyStartup(ctx, userSID) + }, + stopLegacy: stopNativeLegacyStartup, + removeLegacy: removeNativeLegacyRegistrations, + restoreLegacy: func(ctx context.Context, state nativeLegacyState) error { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, nil, true) + }, + restartLegacy: restartNativeLegacyStartup, + verifyBroker: verifyNativeBroker, + wait: func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + }, + } +} + +func installNativeBroker(logger *slog.Logger, explicitUserSID string) error { + release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) + if err != nil { + return err + } + defer release() + userSID, err := resolveNativeInstallingUserSID(explicitUserSID) + if err != nil { + return err + } + executable, err := currentExecutable() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) +} + +func uninstallNativeBroker(logger *slog.Logger, explicitUserSID string) error { + release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) + if err != nil { + return err + } + defer release() + userSID, err := resolveNativeInstallingUserSID(explicitUserSID) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + dependencies := productionNativeInstallDependencies(userSID) + manager, err := dependencies.connectSCM() + if err != nil { + return fmt.Errorf("connect to Windows Service Control Manager: %w", err) + } + defer manager.Close() //nolint:errcheck + return uninstallNativeBrokerTransaction(ctx, logger, manager, dependencies) +} + +func uninstallNativeBrokerTransaction( + ctx context.Context, + logger *slog.Logger, + manager nativeSCM, + dependencies nativeInstallDependencies, +) (resultErr error) { + service, before, err := openAndSnapshotNativeService( + ctx, manager, dependencies.wait, dependencies.lockPriorExecutable, + ) + if err != nil { + return err + } + if before.releaseExecutable != nil { + defer before.releaseExecutable() + } + if before.exists && !isLocalSystemServiceAccount(before.config.ServiceStartName) { + if service != nil { + service.Close() //nolint:errcheck + } + return fmt.Errorf( + "refusing to remove %s because it runs as non-LocalSystem account %q and cannot be transactionally restored", + NativeBrokerServiceName, before.config.ServiceStartName, + ) + } + if service != nil { + defer service.Close() //nolint:errcheck + } + legacy, err := dependencies.snapshotLegacy(ctx) + if err != nil { + return fmt.Errorf("snapshot legacy VIIPER startup before uninstall: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + + serviceChanged := false + legacyStopped := false + registrationsMayHaveChanged := false + defer func() { + if resultErr == nil { + return + } + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancelRollback() + var rollbackErrors []error + safeToRestartLegacy := true + if serviceChanged { + var rollbackErr error + safeToRestartLegacy, rollbackErr = rollbackNativeService( + rollbackCtx, manager, service, before, dependencies.wait, nil, + ) + if rollbackErr != nil { + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + // A restored scheduled task can start immediately through a registration + // trigger or StartWhenAvailable. Do not make any legacy registration live + // until the rejected service has been stopped/deleted or the prior service + // has been restored completely. + if registrationsMayHaveChanged && safeToRestartLegacy { + if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + if legacyStopped && safeToRestartLegacy { + if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart legacy VIIPER after uninstall rollback: %w", rollbackErr)) + } + } + if len(rollbackErrors) != 0 { + resultErr = errors.Join(resultErr, errors.Join(rollbackErrors...)) + } + }() + + if before.exists && before.status.State == svc.Running { + serviceChanged = true + if err := stopNativeService(ctx, service, dependencies.wait); err != nil { + return fmt.Errorf("stop %s before uninstall: %w", NativeBrokerServiceName, err) + } + } + legacyStopped = true + stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) + registrationsMayHaveChanged = legacy.scheduledDisabled + if stopLegacyErr != nil { + return fmt.Errorf("stop legacy VIIPER before uninstall: %w", stopLegacyErr) + } + legacyStopped = hasRunningLegacyCommand(legacy) + registrationsMayHaveChanged = true + if err := dependencies.removeLegacy(ctx, legacy); err != nil { + return fmt.Errorf("remove legacy VIIPER startup during uninstall: %w", err) + } + if before.exists { + serviceChanged = true + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return fmt.Errorf("delete %s during uninstall: %w", NativeBrokerServiceName, err) + } + } + logger.Info("VIIPER native broker service and legacy startup ownership removed", + "service", NativeBrokerServiceName) + return nil +} + +func installNativeBrokerTransaction( + ctx context.Context, + logger *slog.Logger, + executable string, + dependencies nativeInstallDependencies, +) (resultErr error) { + if !filepath.IsAbs(executable) { + return fmt.Errorf("native broker executable must be an absolute path: %s", executable) + } + if strings.IndexByte(executable, 0) >= 0 { + return errors.New("native broker executable contains NUL") + } + releaseExecutable, err := dependencies.lockExecutable(executable) + if err != nil { + return fmt.Errorf("validate protected native broker executable: %w", err) + } + if releaseExecutable == nil { + return errors.New("protected native broker executable lock returned no release function") + } + defer releaseExecutable() + + var credential nativeCredential + credentialProvisioned := false + credentialFinalized := false + rollbackCredential := func() error { + if !credentialProvisioned || credentialFinalized { + return nil + } + if err := dependencies.rollbackCredential(credential); err != nil { + return err + } + credentialFinalized = true + return nil + } + defer func() { + if !credentialProvisioned || credentialFinalized { + return + } + if rollbackErr := rollbackCredential(); rollbackErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("roll back native broker credential: %w", rollbackErr)) + } + }() + + manager, err := dependencies.connectSCM() + if err != nil { + return fmt.Errorf("connect to Windows Service Control Manager: %w", err) + } + defer manager.Close() //nolint:errcheck -- closing a handle cannot invalidate a committed transaction + + service, before, err := openAndSnapshotNativeService( + ctx, manager, dependencies.wait, dependencies.lockPriorExecutable, + ) + if err != nil { + return err + } + if before.releaseExecutable != nil { + defer before.releaseExecutable() + } + if before.exists && !isLocalSystemServiceAccount(before.config.ServiceStartName) { + return fmt.Errorf( + "refusing to replace %s because it runs as non-LocalSystem account %q and its password cannot be transactionally restored", + NativeBrokerServiceName, before.config.ServiceStartName, + ) + } + defer func() { + if service != nil { + service.Close() //nolint:errcheck -- the SCM handle owns no transactional state + } + }() + + legacy, err := dependencies.snapshotLegacy(ctx) + if err != nil { + return fmt.Errorf("snapshot legacy VIIPER startup: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + + serviceChanged := false + legacyStopped := false + registrationsMayHaveChanged := false + defer func() { + if resultErr == nil { + return + } + // The forward operation commonly fails because its deadline elapsed. + // Rollback must have an independent budget or a stopped prior service can + // never be restored once the installation context is canceled. + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancelRollback() + var rollbackErrors []error + safeToRestartLegacy := true + if serviceChanged { + var rollbackErr error + safeToRestartLegacy, rollbackErr = rollbackNativeService( + rollbackCtx, manager, service, before, dependencies.wait, rollbackCredential, + ) + if rollbackErr != nil { + rollbackErrors = append(rollbackErrors, rollbackErr) + } + if !safeToRestartLegacy && credentialProvisioned && !credentialFinalized { + // The replacement could still own the key path. Retain the new + // credential rather than invalidating a service we failed to stop + // or prove restored. This is fail-closed and is reported alongside + // the rollback failure. + credentialFinalized = true + rollbackErrors = append(rollbackErrors, + errors.New("retained native credential because service ownership could not be rolled back safely")) + } + } else if rollbackErr := rollbackCredential(); rollbackErr != nil { + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore native broker credential before legacy restart: %w", rollbackErr)) + } + // Restoring task XML can itself launch the legacy process. Keep legacy + // ownership absent until the service and credential rollback has made it + // safe for that process to exist again. + if registrationsMayHaveChanged && safeToRestartLegacy { + if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, rollbackErr) + } + } + if legacyStopped && safeToRestartLegacy { + if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior legacy VIIPER process: %w", rollbackErr)) + } + } + if len(rollbackErrors) != 0 { + resultErr = errors.Join(resultErr, errors.Join(rollbackErrors...)) + } + }() + + if before.exists && before.status.State != svc.Stopped { + // Control(STOP) is itself a mutation. Even if the subsequent wait or + // status query fails, rollback must reconcile the snapshotted state. + serviceChanged = true + if err := stopNativeService(ctx, service, dependencies.wait); err != nil { + return fmt.Errorf("stop previous %s service: %w", NativeBrokerServiceName, err) + } + } + legacyStopped = true + stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) + registrationsMayHaveChanged = legacy.scheduledDisabled + if stopLegacyErr != nil { + return fmt.Errorf("stop legacy VIIPER process: %w", stopLegacyErr) + } + legacyStopped = hasRunningLegacyCommand(legacy) + + if err := dependencies.preflightDriver(); err != nil { + return err + } + // Rotate the machine credential only after every prior owner is stopped. + // Existing bytes are retained solely for rollback; they are never trusted as + // the new service secret because an unprivileged user may have pre-seeded the + // ProgramData path before its ACL was hardened. + credential, err = dependencies.provisionCredential() + if err != nil { + return fmt.Errorf("provision native broker credential: %w", err) + } + credentialProvisioned = true + if !filepath.IsAbs(credential.path) || strings.TrimSpace(credential.password) == "" { + return errors.New("provisioned native broker credential must have an absolute path and nonempty value") + } + + config, arguments, err := nativeBrokerServiceConfiguration(executable, credential.path) + if err != nil { + return err + } + if before.exists { + // ChangeServiceConfig is followed by ChangeServiceConfig2 calls inside + // x/sys. Mark the service dirty before the call because a later optional + // configuration failure can occur after the base configuration changed. + serviceChanged = true + if err := service.UpdateConfig(config); err != nil { + return fmt.Errorf("update %s service: %w", NativeBrokerServiceName, err) + } + } else { + // x/sys CreateService applies optional fields after the SCM create call + // and ignores a failed cleanup DeleteService. Create only the atomic base + // record first, then mark it owned and apply all optional settings through + // UpdateConfig so every later partial failure is covered by rollback. + baseConfig := config + baseConfig.Description = "" + baseConfig.SidType = windows.SERVICE_SID_TYPE_NONE + baseConfig.DelayedAutoStart = false + service, err = manager.CreateService(NativeBrokerServiceName, executable, baseConfig, arguments...) + if err != nil { + return fmt.Errorf("create %s service: %w", NativeBrokerServiceName, err) + } + serviceChanged = true + if err := protectNativeServiceObject(service); err != nil { + return err + } + if err := service.UpdateConfig(config); err != nil { + return fmt.Errorf("complete %s service configuration: %w", NativeBrokerServiceName, err) + } + } + if err := configureNativeServiceRecovery(service); err != nil { + return err + } + if err := verifyConfiguredNativeService(service, config); err != nil { + return err + } + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return fmt.Errorf("start %s service: %w", NativeBrokerServiceName, err) + } + if err := waitForNativeServiceState(ctx, service, svc.Running, dependencies.wait); err != nil { + return fmt.Errorf("wait for %s service readiness: %w", NativeBrokerServiceName, err) + } + servicePID, err := requireNativeServiceProcess(service, 0) + if err != nil { + return err + } + if err := dependencies.verifyBroker(ctx, credential.password); err != nil { + return fmt.Errorf("authenticate and verify %s: %w", NativeBrokerServiceName, err) + } + if _, err := requireNativeServiceProcess(service, servicePID); err != nil { + return fmt.Errorf("revalidate %s after authenticated ping: %w", NativeBrokerServiceName, err) + } + + // Legacy registrations remain intact through authenticated readiness. They + // are removed last so a failed native migration can still restart the exact + // legacy command without reconstructing startup ownership. + registrationsMayHaveChanged = true + if err := dependencies.removeLegacy(ctx, legacy); err != nil { + return fmt.Errorf("remove legacy VIIPER startup after native verification: %w", err) + } + // Re-authenticate after removing the legacy owner. A task trigger or restart + // policy can race the earlier stop; the migration is committed only while the + // verified native service still owns the exact endpoint contract. + if err := dependencies.verifyBroker(ctx, credential.password); err != nil { + return fmt.Errorf("reverify %s after legacy removal: %w", NativeBrokerServiceName, err) + } + if _, err := requireNativeServiceProcess(service, servicePID); err != nil { + return fmt.Errorf("revalidate %s after legacy removal: %w", NativeBrokerServiceName, err) + } + credentialFinalized = true + logger.Info("VIIPER native broker service installed and authenticated", + "service", NativeBrokerServiceName, "exe", executable, "credential", credential.path) + return nil +} + +func requireNativeServiceProcess(service nativeManagedService, expectedPID uint32) (uint32, error) { + status, err := service.Query() + if err != nil { + return 0, fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err) + } + if status.State != svc.Running { + return 0, fmt.Errorf("%s left Running state after verification (state=%d)", NativeBrokerServiceName, status.State) + } + pid, err := service.ProcessID() + if err != nil { + return 0, fmt.Errorf("query %s process identity: %w", NativeBrokerServiceName, err) + } + if pid == 0 { + return 0, fmt.Errorf("%s reports no running process", NativeBrokerServiceName) + } + if expectedPID != 0 && pid != expectedPID { + return 0, fmt.Errorf("%s process changed during verification (before=%d after=%d)", + NativeBrokerServiceName, expectedPID, pid) + } + return pid, nil +} + +func openAndSnapshotNativeService( + ctx context.Context, + manager nativeSCM, + wait func(context.Context, time.Duration) error, + lockExecutable func(string) (func(), error), +) (nativeManagedService, nativeServiceSnapshot, error) { + var service nativeManagedService + for { + var err error + service, err = manager.OpenService(NativeBrokerServiceName) + if err == nil { + break + } + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil, nativeServiceSnapshot{}, nil + } + if !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return nil, nativeServiceSnapshot{}, fmt.Errorf("open %s service: %w", NativeBrokerServiceName, err) + } + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return nil, nativeServiceSnapshot{}, fmt.Errorf( + "wait for prior %s deletion to finish: %w", NativeBrokerServiceName, err, + ) + } + } + config, err := service.Config() + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("query %s configuration: %w", NativeBrokerServiceName, err) + } + if lockExecutable == nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, errors.New("prior service executable lock is unavailable") + } + priorExecutable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("parse prior %s executable: %w", NativeBrokerServiceName, err) + } + releasePriorExecutable, err := lockExecutable(priorExecutable) + if err != nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, fmt.Errorf("lock prior %s executable: %w", NativeBrokerServiceName, err) + } + if releasePriorExecutable == nil { + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, errors.New("prior service executable lock returned no release function") + } + fail := func(err error) (nativeManagedService, nativeServiceSnapshot, error) { + releasePriorExecutable() + service.Close() //nolint:errcheck + return nil, nativeServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return fail(fmt.Errorf("query %s security descriptor: %w", NativeBrokerServiceName, err)) + } + if _, err := windows.SecurityDescriptorFromString(securityDescriptor); err != nil { + return fail(fmt.Errorf("parse %s security descriptor: %w", NativeBrokerServiceName, err)) + } + // Replacing a permissive DACL does not revoke dangerous service handles + // that another process opened while the old ACL was live. Reuse only an + // already-protected SCM object; an untrusted prior service must be repaired + // by an explicit delete-and-recreate flow, never silently adopted as + // LocalSystem code by this rollback-capable update transaction. + if err := compareNativeSecurityDescriptorStrings(securityDescriptor, nativeBrokerServiceSDDL); err != nil { + return fail(fmt.Errorf("%s has an untrusted service security descriptor: %w", NativeBrokerServiceName, err)) + } + // ChangeServiceConfig can request a load-order tag but cannot restore an + // exact previously assigned TagId. VIIPER does not need a load-order group, + // so reject that unrepresentable preexisting state before any mutation. + if config.LoadOrderGroup != "" || config.TagId != 0 { + return fail(fmt.Errorf( + "%s uses unrepresentable load-order state group=%q tag=%d", + NativeBrokerServiceName, config.LoadOrderGroup, config.TagId, + )) + } + status, err := service.Query() + if err != nil { + return fail(fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err)) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, wait) + if err != nil { + return fail(err) + } + actions, err := service.RecoveryActions() + if err != nil { + return fail(fmt.Errorf("query %s recovery actions: %w", NativeBrokerServiceName, err)) + } + reset, err := service.ResetPeriod() + if err != nil { + return fail(fmt.Errorf("query %s recovery reset period: %w", NativeBrokerServiceName, err)) + } + // Per the SERVICE_FAILURE_ACTIONS contract, an empty action array can only + // be restored with a zero reset period. A malformed/noncanonical preexisting + // state must be rejected before we stop or reconfigure the service because + // exact transactional rollback would otherwise be impossible. + if len(actions) == 0 && reset != 0 { + return fail(fmt.Errorf( + "%s has an unrepresentable recovery policy (no actions, reset=%d); refusing transactional replacement", + NativeBrokerServiceName, reset, + )) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fail(fmt.Errorf("query %s recovery flag: %w", NativeBrokerServiceName, err)) + } + return service, nativeServiceSnapshot{ + exists: true, config: config, status: status, + securityDescriptor: securityDescriptor, + recoveryActions: actions, recoveryResetSeconds: reset, recoverNonCrash: nonCrash, + releaseExecutable: releasePriorExecutable, + }, nil +} + +func nativeServiceExecutableFromCommandLine(commandLine string) (string, error) { + if commandLine == "" || strings.IndexByte(commandLine, 0) >= 0 { + return "", errors.New("service command line is empty or contains NUL") + } + arguments, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + return "", err + } + if len(arguments) == 0 || !filepath.IsAbs(arguments[0]) { + return "", errors.New("service command line does not name an absolute executable") + } + return filepath.Clean(arguments[0]), nil +} + +func nativeBrokerServiceConfiguration(executable, keyPath string) (mgr.Config, []string, error) { + if !filepath.IsAbs(executable) || !filepath.IsAbs(keyPath) { + return mgr.Config{}, nil, errors.New("native broker executable and credential paths must be absolute") + } + logPath := filepath.Join(filepath.Dir(keyPath), nativeBrokerLogName) + arguments := []string{ + "service", "--transport", "native-ude", "--key-file", keyPath, + "--log.file", logPath, + } + binaryPath, err := windowsCommandLine(executable, arguments...) + if err != nil { + return mgr.Config{}, nil, err + } + return mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + BinaryPathName: binaryPath, + ServiceStartName: nativeServiceAccount, + DisplayName: nativeBrokerDisplayName, + Description: nativeBrokerDescription, + SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED, + DelayedAutoStart: false, + }, arguments, nil +} + +func windowsCommandLine(executable string, arguments ...string) (string, error) { + parts := append([]string{executable}, arguments...) + for _, part := range parts { + if strings.IndexByte(part, 0) >= 0 { + return "", errors.New("Windows command-line argument contains NUL") + } + } + commandLine := syscall.EscapeArg(executable) + for _, argument := range arguments { + commandLine += " " + syscall.EscapeArg(argument) + } + return commandLine, nil +} + +func configureNativeServiceRecovery(service nativeManagedService) error { + if err := service.SetRecoveryActions(nativeServiceRecoveryActions, nativeServiceRecoveryResetSecond); err != nil { + return fmt.Errorf("configure %s bounded recovery actions: %w", NativeBrokerServiceName, err) + } + if err := service.SetRecoveryActionsOnNonCrashFailures(true); err != nil { + return fmt.Errorf("configure %s non-crash recovery: %w", NativeBrokerServiceName, err) + } + return nil +} + +func verifyConfiguredNativeService(service nativeManagedService, expected mgr.Config) error { + current, err := service.Config() + if err != nil { + return fmt.Errorf("verify %s configuration: %w", NativeBrokerServiceName, err) + } + if !nativeServiceConfigsEqual(current, expected) { + return fmt.Errorf("%s configuration did not match after update", NativeBrokerServiceName) + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("verify %s service security: %w", NativeBrokerServiceName, err) + } + if err := compareNativeSecurityDescriptorStrings(securityDescriptor, nativeBrokerServiceSDDL); err != nil { + return fmt.Errorf("%s service security did not match after update: %w", NativeBrokerServiceName, err) + } + return verifyNativeServiceRecovery(service, nativeServiceSnapshot{ + recoveryActions: nativeServiceRecoveryActions, + recoveryResetSeconds: nativeServiceRecoveryResetSecond, + recoverNonCrash: true, + }) +} + +func rollbackNativeService( + ctx context.Context, + manager nativeSCM, + service nativeManagedService, + before nativeServiceSnapshot, + wait func(context.Context, time.Duration) error, + beforeResume func() error, +) (bool, error) { + var rollbackErrors []error + if service != nil { + if err := stopNativeService(ctx, service, wait); err != nil { + return false, fmt.Errorf("stop replacement native service before rollback: %w", err) + } + } + if !before.exists { + if service != nil { + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return false, fmt.Errorf("delete replacement native service: %w", err) + } + } + if beforeResume != nil { + if err := beforeResume(); err != nil { + return false, fmt.Errorf("restore native credential after deleting replacement service: %w", err) + } + } + return true, nil + } + if service == nil { + var err error + service, err = manager.OpenService(NativeBrokerServiceName) + if err != nil { + return false, errors.Join(append(rollbackErrors, fmt.Errorf("reopen prior native service: %w", err))...) + } + defer service.Close() //nolint:errcheck + } + if err := service.UpdateConfig(before.config); err != nil { + return false, fmt.Errorf("restore prior native service configuration: %w", err) + } else if current, err := service.Config(); err != nil { + return false, fmt.Errorf("verify prior native service configuration: %w", err) + } else if !nativeServiceConfigsEqual(current, before.config) { + return false, errors.New("prior native service configuration did not verify after rollback") + } + if err := service.SetRecoveryActionsExact(before.recoveryActions, before.recoveryResetSeconds); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native service recovery actions: %w", err)) + } + if err := service.SetRecoveryActionsOnNonCrashFailures(before.recoverNonCrash); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native service recovery flag: %w", err)) + } + if err := verifyNativeServiceRecovery(service, before); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + if before.securityDescriptor == "" { + rollbackErrors = append(rollbackErrors, errors.New("prior native service security descriptor is unavailable")) + } else if err := service.SetSecurityDescriptor(before.securityDescriptor); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore prior native service security descriptor: %w", err)) + } else if current, err := service.SecurityDescriptor(); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("verify prior native service security descriptor: %w", err)) + } else if err := compareNativeSecurityDescriptorStrings(current, before.securityDescriptor); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("prior native service security descriptor did not verify after rollback: %w", err)) + } + if beforeResume != nil { + if err := beforeResume(); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore native credential before prior service restart: %w", err)) + } + } + // Never start a service after an incomplete configuration/recovery restore: + // BinaryPathName may still name the rejected replacement. + if len(rollbackErrors) == 0 && serviceWasOperational(before.status.State) { + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior native service: %w", err)) + } else if err := waitForNativeServiceState(ctx, service, svc.Running, wait); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("wait for prior native service: %w", err)) + } + } + return len(rollbackErrors) == 0, errors.Join(rollbackErrors...) +} + +func nativeServiceConfigsEqual(first, second mgr.Config) bool { + return first.ServiceType == second.ServiceType && + first.StartType == second.StartType && + first.ErrorControl == second.ErrorControl && + first.BinaryPathName == second.BinaryPathName && + first.LoadOrderGroup == second.LoadOrderGroup && + first.TagId == second.TagId && + slices.Equal(first.Dependencies, second.Dependencies) && + isEquivalentServiceAccount(first.ServiceStartName, second.ServiceStartName) && + first.DisplayName == second.DisplayName && + first.Description == second.Description && + first.SidType == second.SidType && + first.DelayedAutoStart == second.DelayedAutoStart +} + +func verifyNativeServiceRecovery(service nativeManagedService, before nativeServiceSnapshot) error { + actions, err := service.RecoveryActions() + if err != nil { + return fmt.Errorf("verify native service recovery actions: %w", err) + } + reset, err := service.ResetPeriod() + if err != nil { + return fmt.Errorf("verify native service recovery reset period: %w", err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fmt.Errorf("verify native service recovery flag: %w", err) + } + if !slices.Equal(actions, before.recoveryActions) || reset != before.recoveryResetSeconds || + nonCrash != before.recoverNonCrash { + return errors.New("prior native service recovery policy did not verify after rollback") + } + return nil +} + +func stopNativeService( + ctx context.Context, + service nativeManagedService, + wait func(context.Context, time.Duration) error, +) error { + status, err := service.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + if _, err := service.Control(svc.Stop); err != nil && !errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) { + return err + } + } + return waitForNativeServiceState(ctx, service, svc.Stopped, wait) +} + +func waitForNativeServiceState( + ctx context.Context, + service nativeManagedService, + want svc.State, + wait func(context.Context, time.Duration) error, +) error { + for { + status, err := service.Query() + if err != nil { + return err + } + if status.State == want { + return nil + } + if want == svc.Running && status.State == svc.Stopped && status.Win32ExitCode != 0 { + return fmt.Errorf("service stopped during startup (win32=%d service=%d)", + status.Win32ExitCode, status.ServiceSpecificExitCode) + } + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return err + } + } +} + +func settleNativeServiceSnapshot( + ctx context.Context, + service nativeManagedService, + status svc.Status, + wait func(context.Context, time.Duration) error, +) (svc.Status, error) { + for { + switch status.State { + case svc.Stopped, svc.Running: + return status, nil + case svc.StartPending, svc.StopPending, svc.ContinuePending: + if err := wait(ctx, nativeServiceStatePoll); err != nil { + return svc.Status{}, fmt.Errorf("wait for %s stable state: %w", NativeBrokerServiceName, err) + } + var err error + status, err = service.Query() + if err != nil { + return svc.Status{}, fmt.Errorf("query %s stable state: %w", NativeBrokerServiceName, err) + } + default: + return svc.Status{}, fmt.Errorf( + "%s is in unsupported state %d; stop or resume it before transactional replacement", + NativeBrokerServiceName, status.State, + ) + } + } +} + +func verifyNativeBroker(ctx context.Context, password string) error { + if strings.TrimSpace(password) == "" { + return errors.New("native broker credential is empty") + } + client := viiperclient.NewWithConfig(api.DefaultListenAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, Password: password, + }) + var lastErr error + for { + response, err := client.PingCtx(ctx) + if err == nil { + err = validateNativeBrokerPing(response) + } + if err == nil { + return nil + } + lastErr = err + if err := waitContext(ctx, 100*time.Millisecond); err != nil { + return fmt.Errorf("broker did not satisfy the native contract: %w (last ping: %v)", err, lastErr) + } + } +} + +func validateNativeBrokerPing(response *viipertypes.PingResponse) error { + if response == nil { + return errors.New("empty ping response") + } + if response.Server != "VIIPER" || !strings.EqualFold(response.Transport, "native-ude") { + return fmt.Errorf("unexpected broker identity server=%q transport=%q", response.Server, response.Transport) + } + if response.Ready == nil || !*response.Ready { + return errors.New("native broker reports not ready") + } + if response.NativeUDE == nil { + return errors.New("native broker omitted its negotiated driver contract") + } + native := response.NativeUDE + requiredCapabilities := uint32( + udecx.CapabilityIsochronous | + udecx.CapabilityDeviceLifecycle | + udecx.CapabilityInputReports, + ) + if native.ABIMajor != udecx.ABIMajor || native.ABIMinor != udecx.ABIMinor { + return fmt.Errorf("native broker ABI=%d.%d expected=%d.%d", + native.ABIMajor, native.ABIMinor, udecx.ABIMajor, udecx.ABIMinor) + } + if native.Capabilities != requiredCapabilities { + return fmt.Errorf("native broker capabilities=%#x expected exact=%#x", native.Capabilities, requiredCapabilities) + } + // ExpectedDriverPackageVersion is currently broker compile-time metadata, + // not an attestation read from the installed driver. ABI and negotiated + // capabilities are authoritative here; do not misrepresent that echoed + // constant as installed-package verification. + return nil +} + +func waitContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { + name, err := windows.UTF16PtrFromString(nativeInstallMutexName) + if err != nil { + return nil, err + } + descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)") + if err != nil { + return nil, fmt.Errorf("create native install mutex security descriptor: %w", err) + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + handle, err := windows.CreateMutex(&attributes, false, name) + if err != nil { + return nil, fmt.Errorf("create native install mutex: %w", err) + } + status, err := windows.WaitForSingleObject(handle, uint32(timeout/time.Millisecond)) + if err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, fmt.Errorf("wait for native install mutex: %w", err) + } + if status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED { + windows.CloseHandle(handle) //nolint:errcheck + return nil, errors.New("another VIIPER native install, update, or uninstall is still running") + } + return func() { + windows.ReleaseMutex(handle) //nolint:errcheck + windows.CloseHandle(handle) //nolint:errcheck + }, nil +} + +type nativeFileAttributeTagInfo struct { + FileAttributes uint32 + ReparseTag uint32 +} + +func lockNativeServiceExecutable(executable string) (func(), error) { + return lockNativeServiceExecutableReadOnly(executable) +} + +// lockNativePriorServiceExecutable proves that a preexisting service already +// points at an installer-owned image without changing any filesystem metadata. +// The snapshot operation runs before transactional rollback is armed, so it +// must be strictly read-only. Older or user-writable layouts fail closed and +// can be repaired explicitly rather than being silently adopted as LocalSystem +// code. +func lockNativePriorServiceExecutable(executable string) (func(), error) { + return lockNativeServiceExecutableReadOnly(executable) +} + +func lockNativeServiceExecutableReadOnly(executable string) (func(), error) { + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, fmt.Errorf("resolve Program Files: %w", err) + } + _, err = nativeServiceExecutableParent(programFiles, executable) + if err != nil { + return nil, err + } + programFiles = filepath.Clean(programFiles) + executable = filepath.Clean(executable) + relative, _ := filepath.Rel(programFiles, executable) + parts := strings.Split(relative, string(filepath.Separator)) + + var handles []windows.Handle + closeHandles := func() { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + handles = nil + } + fail := func(err error) (func(), error) { + closeHandles() + return nil, err + } + + // Reject every reparse point between the known folder and the executable. + // Keep non-delete-shared handles to every component through authenticated + // service startup and require that the package installer already established + // the exact protected owner/DACL contract. Rewriting an ACL here would not + // revoke dangerous handles opened under a former permissive DACL, so trust + // must be proven without mutating the image or its parents. + rootHandle, err := openNativePathWithoutReparse(programFiles, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return nil, fmt.Errorf("open Program Files without reparse traversal: %w", err) + } + handles = append(handles, rootHandle) + current := programFiles + for index, part := range parts { + if part == "" { + continue + } + current = filepath.Join(current, part) + isDirectory := index < len(parts)-1 + access := uint32(windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL) + if isDirectory { + } else { + access |= windows.GENERIC_READ + } + handle, openErr := openNativePathWithoutReparse(current, access, isDirectory) + if openErr != nil { + return fail(fmt.Errorf("open protected broker path %s: %w", current, openErr)) + } + handles = append(handles, handle) + if isDirectory { + if err := validateNativeSecurityDescriptor(handle, nativeBrokerDirectorySDDL); err != nil { + return fail(fmt.Errorf("validate protected broker directory %s: %w", current, err)) + } + } + } + executableHandle := handles[len(handles)-1] + if err := requireSingleNativeFileLink(executableHandle); err != nil { + return fail(fmt.Errorf("reject hard-linked broker executable: %w", err)) + } + if err := validateNativeSecurityDescriptor(executableHandle, nativeBrokerExecutableSDDL); err != nil { + return fail(fmt.Errorf("validate protected broker executable: %w", err)) + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(executableHandle, header, &read, nil); err != nil { + return fail(fmt.Errorf("read broker executable header: %w", err)) + } + if read != uint32(len(header)) || header[0] != 'M' || header[1] != 'Z' { + return fail(errors.New("native broker executable is not a Windows PE image")) + } + return closeHandles, nil +} + +func nativeServiceExecutableParent(programFiles, executable string) (string, error) { + programFiles = filepath.Clean(programFiles) + executable = filepath.Clean(executable) + relative, err := filepath.Rel(programFiles, executable) + if err != nil || relative == "." || filepath.IsAbs(relative) || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("native broker must be installed below Program Files, got %s", executable) + } + parent := filepath.Dir(executable) + parts := strings.Split(relative, string(filepath.Separator)) + allowed := len(parts) == 2 && strings.EqualFold(parts[0], "VIIPER") && + strings.EqualFold(parts[1], "viiper.exe") + allowed = allowed || len(parts) == 3 && strings.EqualFold(parts[0], "DS4Windows") && + strings.EqualFold(parts[1], "VIIPER") && strings.EqualFold(parts[2], "viiper.exe") + if !allowed { + return "", fmt.Errorf("native broker must use a managed Program Files VIIPER path, got %s", executable) + } + return parent, nil +} + +func openNativePathWithoutReparse(path string, access uint32, directory bool) (windows.Handle, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + } + shareMode := uint32(windows.FILE_SHARE_READ) + if directory { + // Directory contents may still be read/written by trusted installers, but + // omitting DELETE keeps every validated ancestor from being renamed or + // removed until the native service transaction commits. + shareMode |= windows.FILE_SHARE_WRITE + } + handle, err := windows.CreateFile( + pointer, + access, + shareMode, + nil, + windows.OPEN_EXISTING, + flags, + 0, + ) + if err != nil { + return 0, err + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, + windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("path is a reparse point") + } + if directory != (info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("path type does not match the expected broker object") + } + return handle, nil +} + +func applyNativeACLToHandle(handle windows.Handle, sddl string) error { + return setNativeObjectSecurityDescriptor(handle, windows.SE_FILE_OBJECT, sddl) +} + +func nativeObjectSecurityDescriptor(handle windows.Handle, objectType windows.SE_OBJECT_TYPE) (string, error) { + descriptor, err := windows.GetSecurityInfo( + handle, + objectType, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return "", err + } + if descriptor == nil || !descriptor.IsValid() { + return "", errors.New("object returned an invalid security descriptor") + } + sddl := descriptor.String() + if sddl == "" { + return "", errors.New("object security descriptor could not be serialized") + } + return sddl, nil +} + +func setNativeObjectSecurityDescriptor( + handle windows.Handle, + objectType windows.SE_OBJECT_TYPE, + sddl string, +) error { + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return err + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + dacl, _, err := descriptor.DACL() + if err != nil { + return err + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + securityInformation := windows.SECURITY_INFORMATION( + windows.OWNER_SECURITY_INFORMATION | windows.DACL_SECURITY_INFORMATION, + ) + if control&windows.SE_DACL_PROTECTED != 0 { + securityInformation |= windows.PROTECTED_DACL_SECURITY_INFORMATION + } else { + securityInformation |= windows.UNPROTECTED_DACL_SECURITY_INFORMATION + } + return windows.SetSecurityInfo( + handle, + objectType, + securityInformation, + owner, + nil, + dacl, + nil, + ) +} + +func protectNativeServiceObject(service nativeManagedService) error { + if err := service.SetSecurityDescriptor(nativeBrokerServiceSDDL); err != nil { + return fmt.Errorf("apply protected %s service DACL: %w", NativeBrokerServiceName, err) + } + actual, err := service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("verify protected %s service DACL: %w", NativeBrokerServiceName, err) + } + return compareNativeSecurityDescriptorStrings(actual, nativeBrokerServiceSDDL) +} + +func compareNativeSecurityDescriptorStrings(actual, expected string) error { + actualDescriptor, err := windows.SecurityDescriptorFromString(actual) + if err != nil { + return fmt.Errorf("parse actual security descriptor: %w", err) + } + expectedDescriptor, err := windows.SecurityDescriptorFromString(expected) + if err != nil { + return fmt.Errorf("parse expected security descriptor: %w", err) + } + return nativeSecurityDescriptorsEqual(actualDescriptor, expectedDescriptor) +} + +func requireSingleNativeFileLink(handle windows.Handle) error { + info := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("query file link identity: %w", err) + } + return validateNativeFileLinkCount(info.NumberOfLinks) +} + +func validateNativeFileLinkCount(numberOfLinks uint32) error { + if numberOfLinks != 1 { + return fmt.Errorf("expected exactly one file link, found %d", numberOfLinks) + } + return nil +} + +func serviceWasOperational(state svc.State) bool { + return state == svc.Running +} + +func provisionNativeServiceCredential(userSID string) (nativeCredential, error) { + path, err := nativeServiceKeyFilePath() + if err != nil { + return nativeCredential{}, err + } + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nativeCredential{}, err + } + directory := filepath.Dir(path) + directoryHandle, err := secureNativeCredentialDirectory(directory, userSID) + if err != nil { + return nativeCredential{}, err + } + defer windows.CloseHandle(directoryHandle) //nolint:errcheck + + prior, existed, err := readNativeCredential(path, userSID) + if err != nil { + return nativeCredential{}, fmt.Errorf("read credential: %w", err) + } + password, err := rotatedNativeServiceKey(prior, auth.GenerateKey) + if err != nil { + return nativeCredential{}, fmt.Errorf("generate credential: %w", err) + } + if err := writeNativeCredentialAtomically(path, []byte(password), userSID); err != nil { + return nativeCredential{}, err + } + return nativeCredential{ + path: path, password: password, userSID: userSID, + created: !existed, replaced: existed, priorBytes: append([]byte(nil), prior...), + }, nil +} + +func resolveNativeInstallingUserSID(explicit string) (string, error) { + if strings.TrimSpace(explicit) != "" { + return validateNativeInstallingUserSID(explicit) + } + currentUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", fmt.Errorf("query installer token user SID: %w", err) + } + currentSID, err := validateNativeInstallingUserSID(currentUser.User.Sid.String()) + if err != nil && !currentUser.User.Sid.IsWellKnown(windows.WinLocalSystemSid) { + return "", err + } + + // Elevation can change the process identity: deferred MSI work commonly runs + // as LocalSystem, and over-the-shoulder UAC runs as a different administrator. + // Prefer the shell owner when it is visible in this session. A session-0 + // LocalSystem installer has no shell window, so use the active-console token. + interactiveSID := "" + interactiveErr := error(nil) + if token, tokenErr := nativeInteractiveUserToken("", windows.TOKEN_QUERY); tokenErr == nil { + defer token.Close() //nolint:errcheck + user, userErr := token.GetTokenUser() + if userErr != nil { + return "", fmt.Errorf("query interactive installer user SID: %w", userErr) + } + interactiveSID, interactiveErr = validateNativeInstallingUserSID(user.User.Sid.String()) + } else { + interactiveErr = tokenErr + } + selected, err := selectNativeInstallingUserSID( + currentSID, + currentUser.User.Sid.IsWellKnown(windows.WinLocalSystemSid), + interactiveSID, + interactiveErr, + ) + if err != nil { + return "", err + } + return validateNativeInstallingUserSID(selected) +} + +func selectNativeInstallingUserSID( + currentSID string, + currentIsLocalSystem bool, + interactiveSID string, + interactiveErr error, +) (string, error) { + if interactiveErr == nil && strings.TrimSpace(interactiveSID) != "" { + return interactiveSID, nil + } + if currentIsLocalSystem { + return "", errors.Join( + interactiveErr, + errors.New("cannot identify the interactive installing user; pass --target-user-sid from the bootstrapper"), + ) + } + if strings.TrimSpace(currentSID) == "" { + return "", errors.Join(interactiveErr, errors.New("installer token has no user SID")) + } + return currentSID, nil +} + +func validateNativeInstallingUserSID(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || strings.ContainsAny(value, `\/`) { + return "", errors.New("installing user SID is missing or invalid") + } + sid, err := windows.StringToSid(value) + if err != nil { + return "", fmt.Errorf("parse installing user SID: %w", err) + } + if sid.IsWellKnown(windows.WinLocalSystemSid) || sid.IsWellKnown(windows.WinLocalServiceSid) || + sid.IsWellKnown(windows.WinNetworkServiceSid) { + return "", errors.New("installing user SID names a service identity") + } + _, _, accountType, err := sid.LookupAccount("") + if err != nil { + return "", fmt.Errorf("resolve installing user SID: %w", err) + } + if accountType != windows.SidTypeUser { + return "", fmt.Errorf("installing user SID is not a user account (type=%d)", accountType) + } + return sid.String(), nil +} + +func nativeInteractiveUserToken(expectedSID string, access uint32) (windows.Token, error) { + var shellErr error + if shellWindow := windows.GetShellWindow(); shellWindow != 0 { + var shellPID uint32 + if _, err := windows.GetWindowThreadProcessId(shellWindow, &shellPID); err != nil { + shellErr = fmt.Errorf("query interactive shell process: %w", err) + } else if shellPID == 0 { + shellErr = errors.New("interactive shell reported no process identifier") + } else if shellProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, shellPID); err != nil { + shellErr = fmt.Errorf("open interactive shell process: %w", err) + } else { + var shellToken windows.Token + err = windows.OpenProcessToken(shellProcess, access, &shellToken) + windows.CloseHandle(shellProcess) //nolint:errcheck + if err != nil { + shellErr = fmt.Errorf("open interactive shell token: %w", err) + } else if err := validateNativeInteractiveToken(shellToken, expectedSID); err != nil { + shellToken.Close() //nolint:errcheck + shellErr = err + } else { + return shellToken, nil + } + } + } + + session := windows.WTSGetActiveConsoleSessionId() + if session == ^uint32(0) { + return 0, errors.Join(shellErr, errors.New("Windows reports no active console session")) + } + var token windows.Token + if err := windows.WTSQueryUserToken(session, &token); err != nil { + return 0, errors.Join(shellErr, fmt.Errorf("query active-console user token: %w", err)) + } + if err := validateNativeInteractiveToken(token, expectedSID); err != nil { + token.Close() //nolint:errcheck + return 0, errors.Join(shellErr, err) + } + return token, nil +} + +func validateNativeInteractiveToken(token windows.Token, expectedSID string) error { + user, err := token.GetTokenUser() + if err != nil { + return fmt.Errorf("query interactive user token: %w", err) + } + actual, err := validateNativeInstallingUserSID(user.User.Sid.String()) + if err != nil { + return err + } + if expectedSID != "" && !strings.EqualFold(actual, expectedSID) { + return fmt.Errorf("interactive user SID %s does not match installer target %s", actual, expectedSID) + } + return nil +} + +func expandNativeUserEnvironment(expectedSID, value string) (string, error) { + if strings.IndexByte(value, 0) >= 0 { + return "", errors.New("target-user environment string contains NUL") + } + token, err := nativeInteractiveUserToken(expectedSID, windows.TOKEN_QUERY) + if err != nil { + return "", err + } + defer token.Close() //nolint:errcheck + source, err := windows.UTF16PtrFromString(value) + if err != nil { + return "", err + } + // Windows paths are bounded to 32,767 UTF-16 code units. The API does not + // expose a size-probe contract, so allocate that maximum once and fail closed + // if userenv.dll rejects it. + destination := make([]uint16, 32768) + result, _, callErr := expandEnvironmentStringsForUserW.Call( + uintptr(token), + uintptr(unsafe.Pointer(source)), + uintptr(unsafe.Pointer(&destination[0])), + uintptr(len(destination)), + ) + if result == 0 { + if callErr == nil || errors.Is(callErr, windows.ERROR_SUCCESS) { + callErr = errors.New("ExpandEnvironmentStringsForUserW returned false") + } + return "", fmt.Errorf("expand environment for target interactive user: %w", callErr) + } + return windows.UTF16ToString(destination), nil +} + +func rotatedNativeServiceKey(prior []byte, generate func() (string, error)) (string, error) { + priorKey := strings.TrimSpace(string(prior)) + for attempt := 0; attempt < 4; attempt++ { + password, err := generate() + if err != nil { + return "", err + } + password = strings.TrimSpace(password) + if password != "" && password != priorKey { + return password, nil + } + } + return "", errors.New("credential generator did not produce a fresh nonempty key") +} + +func secureNativeCredentialDirectory(directory, userSID string) (windows.Handle, error) { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return 0, fmt.Errorf("resolve ProgramData known folder: %w", err) + } + programData = filepath.Clean(programData) + if !strings.EqualFold(filepath.Clean(directory), filepath.Join(programData, "VIIPER")) { + return 0, fmt.Errorf("credential directory escaped ProgramData: %s", directory) + } + programDataHandle, err := openNativePathWithoutReparse(programData, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return 0, fmt.Errorf("open ProgramData without reparse traversal: %w", err) + } + defer windows.CloseHandle(programDataHandle) //nolint:errcheck + sddl := nativeCredentialDirectorySDDL(userSID) + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return 0, fmt.Errorf("build credential directory security descriptor: %w", err) + } + pointer, err := windows.UTF16PtrFromString(directory) + if err != nil { + return 0, err + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, + } + created := false + if err := windows.CreateDirectory(pointer, &attributes); err == nil { + created = true + } else if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return 0, fmt.Errorf("atomically create protected credential directory: %w", err) + } + // Never take ownership of or re-ACL an existing ProgramData directory. A + // standard user can pre-create it and keep an already-authorized directory + // handle even after a later DACL change. Only an atomically created directory + // or an existing directory that already has our exact protected owner/DACL is + // eligible to contain the service credential. + directoryHandle, err := openNativePathWithoutReparse( + directory, + windows.READ_CONTROL, + true, + ) + if err != nil { + return 0, fmt.Errorf("open credential directory without reparse traversal: %w", err) + } + if err := validateNativeSecurityDescriptor(directoryHandle, sddl); err != nil { + windows.CloseHandle(directoryHandle) //nolint:errcheck + origin := "existing" + if created { + origin = "newly created" + } + return 0, fmt.Errorf("reject %s credential directory security: %w", origin, err) + } + return directoryHandle, nil +} + +func validateNativeSecurityDescriptor(handle windows.Handle, expectedSDDL string) error { + actual, err := windows.GetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("query security descriptor: %w", err) + } + expected, err := windows.SecurityDescriptorFromString(expectedSDDL) + if err != nil { + return err + } + return nativeSecurityDescriptorsEqual(actual, expected) +} + +func nativeSecurityDescriptorsEqual(actual, expected *windows.SECURITY_DESCRIPTOR) error { + actualOwner, _, err := actual.Owner() + if err != nil { + return err + } + expectedOwner, _, err := expected.Owner() + if err != nil { + return err + } + if actualOwner == nil || expectedOwner == nil || !actualOwner.Equals(expectedOwner) { + return errors.New("security descriptor owner is not the trusted installer owner") + } + actualDACL, actualDefaulted, err := actual.DACL() + if err != nil { + return err + } + expectedDACL, expectedDefaulted, err := expected.DACL() + if err != nil { + return err + } + actualSDDL := actual.String() + expectedCanonicalSDDL := expected.String() + if actualDACL == nil || expectedDACL == nil || actualDefaulted != expectedDefaulted || + actualSDDL == "" || expectedCanonicalSDDL == "" || actualSDDL != expectedCanonicalSDDL { + return errors.New("security descriptor DACL is not the canonical protected DACL") + } + actualControl, _, err := actual.Control() + if err != nil { + return err + } + expectedControl, _, err := expected.Control() + if err != nil { + return err + } + if actualControl&windows.SE_DACL_PROTECTED != expectedControl&windows.SE_DACL_PROTECTED || + actualControl&windows.SE_DACL_PRESENT != expectedControl&windows.SE_DACL_PRESENT { + return errors.New("security descriptor protection flags do not match") + } + return nil +} + +func readNativeCredential(path, userSID string) ([]byte, bool, error) { + handle, err := openNativePathWithoutReparse( + path, + windows.GENERIC_READ|windows.READ_CONTROL|windows.WRITE_DAC|windows.WRITE_OWNER, + false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + // A standard user can pre-create ProgramData\VIIPER before its DACL is + // hardened. Reject a planted hard link before taking ownership or changing + // its security descriptor, because those operations affect the underlying + // file and every link to it. + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, fmt.Errorf("reject hard-linked credential: %w", err) + } + if err := applyNativeACLToHandle(handle, nativeCredentialFileSDDL(userSID)); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, errors.New("wrap credential file handle") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, false, err + } + if len(contents) > 64*1024 { + return nil, false, fmt.Errorf("credential is unexpectedly large: more than %d bytes", 64*1024) + } + return contents, true, nil +} + +func writeNativeCredentialAtomically(path string, contents []byte, userSID string) error { + directory := filepath.Dir(path) + temporary, err := os.CreateTemp(directory, ".viiper-key-*.tmp") + if err != nil { + return fmt.Errorf("create credential staging file: %w", err) + } + temporaryPath := temporary.Name() + cleanupTemporary := true + defer func() { + temporary.Close() //nolint:errcheck + if cleanupTemporary { + os.Remove(temporaryPath) //nolint:errcheck + } + }() + if err := applyNativeACLToHandle(windows.Handle(temporary.Fd()), nativeCredentialFileSDDL(userSID)); err != nil { + return fmt.Errorf("protect credential staging file: %w", err) + } + if _, err := temporary.Write(contents); err != nil { + return fmt.Errorf("write credential staging file: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("flush credential staging file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close credential staging file: %w", err) + } + if err := replaceFileAtomically(temporaryPath, path); err != nil { + return fmt.Errorf("publish credential atomically: %w", err) + } + cleanupTemporary = false + return nil +} + +func replaceFileAtomically(source, destination string) error { + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(sourcePointer, destinationPointer, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +func rollbackNativeServiceCredential(credential nativeCredential) error { + if credential.created { + if err := os.Remove(credential.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if credential.replaced { + return writeNativeCredentialAtomically(credential.path, credential.priorBytes, credential.userSID) + } + return nil +} + +func nativeCredentialDirectorySDDL(userSID string) string { + return "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")" +} + +func nativeCredentialFileSDDL(userSID string) string { + return "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GR;;;" + userSID + ")" +} + +func snapshotNativeLegacyStartup(ctx context.Context, userSID string) (nativeLegacyState, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nativeLegacyState{}, err + } + state := nativeLegacyState{userSID: userSID} + succeeded := false + defer func() { + if !succeeded && state.release != nil { + state.release() + } + }() + scheduledCommand, scheduledXML, scheduledActive, scheduledEnabled, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return state, err + } + if found { + if err := validateNativeScheduledTaskState(scheduledActive, scheduledEnabled); err != nil { + return state, err + } + if scheduledEnabled || scheduledActive { + verify, release, lockErr := lockNativeLegacyTaskExecutable(scheduledCommand.executable) + if lockErr != nil { + return state, fmt.Errorf("lock RunVIIPER action through migration: %w", lockErr) + } + state.verifyTaskAction = verify + appendNativeLegacyRelease(&state, release) + } + currentXML := scheduledXML + state.scheduledAction = &scheduledCommand + state.scheduledXML = &scheduledXML + state.scheduledCurrentXML = ¤tXML + state.scheduledActive = scheduledActive + state.scheduledEnabled = scheduledEnabled + } + hive, err := registry.OpenKey(registry.USERS, userSID, registry.READ) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return state, fmt.Errorf("target user hive HKU\\%s is not loaded; resume migration after that user signs in", userSID) + } + return state, fmt.Errorf("open target user hive HKU\\%s: %w", userSID, err) + } + state.userHive = hive + appendNativeLegacyRelease(&state, func() { hive.Close() }) //nolint:errcheck + runKey, err := registry.OpenKey(hive, runKeyPath, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return state, fmt.Errorf("open target user Run key: %w", err) + } + if err == nil { + state.runKey = runKey + state.runKeyExisted = true + appendNativeLegacyRelease(&state, func() { runKey.Close() }) //nolint:errcheck + } + runRegistration, found, err := currentNativeRunRegistration(state) + if err != nil { + return state, err + } + if found { + state.runValue = &runRegistration + if strings.TrimSpace(runRegistration.value) != "" { + expand := func(value string) (string, error) { return value, nil } + if runRegistration.valueType == registry.EXPAND_SZ { + expand = func(value string) (string, error) { + return expandNativeUserEnvironment(userSID, value) + } + } + command, err := parseWindowsCommand(runRegistration.value, expand) + if err != nil { + return state, fmt.Errorf("parse VIIPER Run command: %w", err) + } + command.source = legacyCommandRun + state.commands = append(state.commands, command) + } + } + succeeded = true + return state, nil +} + +func validateNativeScheduledTaskState(active, enabled bool) error { + if active && !enabled { + return errors.New("RunVIIPER is active while disabled and cannot be restored transactionally") + } + return nil +} + +func appendNativeLegacyRelease(state *nativeLegacyState, release func()) { + if release == nil { + return + } + prior := state.release + state.release = func() { + release() + if prior != nil { + prior() + } + } +} + +func currentNativeRunRegistration(state nativeLegacyState) (nativeRunRegistration, bool, error) { + if state.userHive == 0 { + return nativeRunRegistration{}, false, errors.New("target user hive is not retained by the transaction") + } + key := state.runKey + closeKey := false + if !state.runKeyExisted { + var err error + key, err = registry.OpenKey(state.userHive, runKeyPath, registry.QUERY_VALUE) + if errors.Is(err, registry.ErrNotExist) { + return nativeRunRegistration{}, false, nil + } + if err != nil { + return nativeRunRegistration{}, false, fmt.Errorf("open retained target-user Run key: %w", err) + } + closeKey = true + } + if key == 0 { + return nativeRunRegistration{}, false, errors.New("retained target-user Run key is unavailable") + } + if closeKey { + defer key.Close() //nolint:errcheck + } + return readNativeRunRegistration(key) +} + +func readNativeRunRegistration(key registry.Key) (nativeRunRegistration, bool, error) { + value, valueType, err := key.GetStringValue(runValueKey) + if errors.Is(err, registry.ErrNotExist) { + return nativeRunRegistration{}, false, nil + } + if err != nil { + return nativeRunRegistration{}, false, err + } + if valueType != registry.SZ && valueType != registry.EXPAND_SZ { + return nativeRunRegistration{}, false, fmt.Errorf("VIIPER Run value has unsupported registry type %d", valueType) + } + return nativeRunRegistration{value: value, valueType: valueType}, true, nil +} + +func nativeRunRegistrationsEqual(first nativeRunRegistration, second nativeRunRegistration) bool { + return first.value == second.value && first.valueType == second.valueType +} + +func validateNativeRunRegistrationSnapshot( + expected *nativeRunRegistration, + current nativeRunRegistration, + found bool, +) error { + if expected == nil { + if found { + return errors.New("VIIPER Run registration appeared during native service migration") + } + return nil + } + if !found || !nativeRunRegistrationsEqual(current, *expected) { + return errors.New("VIIPER Run registration changed or disappeared during native service migration") + } + return nil +} + +func setNativeRunRegistration(key registry.Key, value nativeRunRegistration) error { + switch value.valueType { + case registry.SZ: + return key.SetStringValue(runValueKey, value.value) + case registry.EXPAND_SZ: + return key.SetExpandStringValue(runValueKey, value.value) + default: + return fmt.Errorf("cannot restore VIIPER Run value with registry type %d", value.valueType) + } +} + +func nativeUserRunKeyPath(userSID string) (string, error) { + userSID = strings.TrimSpace(userSID) + if userSID == "" || strings.ContainsAny(userSID, `\/`) { + return "", errors.New("installing user SID is missing or invalid") + } + if _, err := windows.StringToSid(userSID); err != nil { + return "", fmt.Errorf("parse installing user SID: %w", err) + } + return userSID + `\` + runKeyPath, nil +} + +func parseWindowsCommand( + commandLine string, + expand func(string) (string, error), +) (nativeLegacyCommand, error) { + arguments, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + return nativeLegacyCommand{}, err + } + if len(arguments) == 0 || strings.TrimSpace(arguments[0]) == "" { + return nativeLegacyCommand{}, errors.New("startup command has no executable") + } + if expand == nil { + return nativeLegacyCommand{}, errors.New("startup command environment expander is required") + } + executable, err := expand(arguments[0]) + if err != nil { + return nativeLegacyCommand{}, fmt.Errorf("expand target-user startup executable: %w", err) + } + executable = filepath.Clean(executable) + if !strings.EqualFold(filepath.Base(executable), "viiper.exe") { + return nativeLegacyCommand{}, fmt.Errorf("startup command is not VIIPER: %s", executable) + } + return nativeLegacyCommand{executable: executable, arguments: arguments[1:]}, nil +} + +func currentScheduledTaskCommand(ctx context.Context) (nativeLegacyCommand, string, bool, bool, bool, error) { + // The script is a fixed program: no path, account, or other caller-controlled + // text is interpolated into it. JSON preserves spaces and quoting exactly, + // while CommandLineToArgvW below applies Windows' own argument grammar. + const script = `$ErrorActionPreference='Stop';` + + `[Console]::OutputEncoding=[Text.UTF8Encoding]::new();` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -gt 1){throw 'multiple root RunVIIPER tasks found'};$t=$null;if($m.Count -eq 1){$t=$m[0]};` + + `if($null -eq $t){[pscustomobject]@{Found=$false}|ConvertTo-Json -Compress;exit 0};` + + `$a=@($t.Actions);if($a.Count -ne 1){throw 'scheduled task must contain exactly one action'};` + + `$x=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `$s=[string]$t.State;` + + `[pscustomobject]@{Found=$true;Name=$t.TaskName;Active=($s -eq 'Running' -or $s -eq 'Queued');Enabled=[bool]$t.Settings.Enabled;Execute=$a[0].Execute;Arguments=$a[0].Arguments;WorkingDirectory=$a[0].WorkingDirectory;Xml=$x}|ConvertTo-Json -Compress` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("resolve trusted PowerShell: %w", err) + } + output, err := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script).CombinedOutput() + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("scheduled task query failed: %w: %s", err, strings.TrimSpace(string(output))) + } + var action struct { + Found bool + Name string + Active bool + Enabled bool + Execute string + Arguments string + WorkingDirectory string + XML string + } + if err := json.Unmarshal(output, &action); err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("decode scheduled task action: %w", err) + } + if !action.Found { + return nativeLegacyCommand{}, "", false, false, false, nil + } + if !nativeScheduledTaskNameMatches(action.Name) { + return nativeLegacyCommand{}, "", false, false, false, + fmt.Errorf("Task Scheduler returned unexpected task identity %q", action.Name) + } + if strings.TrimSpace(action.XML) == "" { + return nativeLegacyCommand{}, "", false, false, false, errors.New("scheduled task export returned empty XML") + } + // Task Scheduler owns expansion of its action environment. Preserve the raw + // action path for exact comparison and never reinterpret it under the + // elevated installer or LocalSystem environment. + executable := filepath.Clean(strings.Trim(action.Execute, `"`)) + if !strings.EqualFold(filepath.Base(executable), "viiper.exe") { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("%s action is not a VIIPER executable: %s", runScheduledTask, executable) + } + arguments, err := decomposeWindowsArguments(action.Arguments) + if err != nil { + return nativeLegacyCommand{}, "", false, false, false, fmt.Errorf("parse %s arguments: %w", runScheduledTask, err) + } + return nativeLegacyCommand{ + executable: executable, arguments: arguments, + workingDirectory: strings.TrimSpace(action.WorkingDirectory), + }, action.XML, action.Active, action.Enabled, true, nil +} + +func nativeScheduledTaskNameMatches(name string) bool { + return strings.EqualFold(name, runScheduledTask) +} + +func lockNativeLegacyTaskExecutable(executable string) (func() error, func(), error) { + executable = filepath.Clean(executable) + if !filepath.IsAbs(executable) || strings.Contains(executable, "%") { + return nil, nil, fmt.Errorf("RunVIIPER action must use an absolute, already-expanded executable path: %s", executable) + } + volume := filepath.VolumeName(executable) + if len(volume) != 2 || volume[1] != ':' { + return nil, nil, fmt.Errorf("RunVIIPER action must use a local drive path: %s", executable) + } + root := volume + `\` + relative, err := filepath.Rel(root, executable) + if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, `..\`) { + return nil, nil, fmt.Errorf("derive RunVIIPER action path components: %w", err) + } + parts := strings.Split(relative, `\`) + var handles []windows.Handle + closeHandles := func() { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + handles = nil + } + fail := func(err error) (func() error, func(), error) { + closeHandles() + return nil, nil, err + } + current := root + rootHandle, err := openNativePathWithoutReparse(current, windows.FILE_READ_ATTRIBUTES, true) + if err != nil { + return nil, nil, err + } + handles = append(handles, rootHandle) + for index, part := range parts { + if part == "" { + continue + } + current = filepath.Join(current, part) + isDirectory := index < len(parts)-1 + access := uint32(windows.FILE_READ_ATTRIBUTES) + if !isDirectory { + access |= windows.GENERIC_READ + } + handle, openErr := openNativePathWithoutReparse(current, access, isDirectory) + if openErr != nil { + return fail(fmt.Errorf("open locked RunVIIPER component %s: %w", current, openErr)) + } + handles = append(handles, handle) + } + leaf := handles[len(handles)-1] + if err := requireSingleNativeFileLink(leaf); err != nil { + return fail(fmt.Errorf("reject hard-linked RunVIIPER action: %w", err)) + } + verify := func() error { + finalPath, err := nativeFinalPathByHandle(leaf) + if err != nil { + return err + } + if !strings.EqualFold(finalPath, executable) { + return fmt.Errorf("RunVIIPER action path identity changed: requested=%s final=%s", executable, finalPath) + } + return nil + } + if err := verify(); err != nil { + return fail(err) + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(leaf, header, &read, nil); err != nil { + return fail(err) + } + if read != 2 || header[0] != 'M' || header[1] != 'Z' { + return fail(errors.New("RunVIIPER action is not a Windows PE image")) + } + return verify, closeHandles, nil +} + +func nativeFinalPathByHandle(handle windows.Handle) (string, error) { + buffer := make([]uint16, 32768) + length, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0) + if err != nil { + return "", fmt.Errorf("resolve final path by handle: %w", err) + } + if length == 0 || length >= uint32(len(buffer)) { + return "", errors.New("final path by handle exceeded the Windows path bound") + } + path := windows.UTF16ToString(buffer[:length]) + if strings.HasPrefix(path, `\\?\UNC\`) { + path = `\\` + strings.TrimPrefix(path, `\\?\UNC\`) + } else { + path = strings.TrimPrefix(path, `\\?\`) + } + return filepath.Clean(path), nil +} + +func decomposeWindowsArguments(argumentLine string) ([]string, error) { + if strings.TrimSpace(argumentLine) == "" { + return nil, nil + } + arguments, err := windows.DecomposeCommandLine("viiper.exe " + argumentLine) + if err != nil { + return nil, err + } + if len(arguments) == 0 { + return nil, errors.New("argument decomposition returned no executable") + } + return arguments[1:], nil +} + +func stopNativeLegacyStartup(ctx context.Context, state *nativeLegacyState, logger *slog.Logger) error { + return stopNativeLegacyStartupWith(ctx, state, logger, nativeLegacyStopOperations{ + stopScheduled: stopNativeScheduledTask, + openProcesses: openLegacyProcessesByExecutable, + terminate: terminateVerifiedLegacyProcess, + closeHandle: func(handle windows.Handle) { + windows.CloseHandle(handle) //nolint:errcheck + }, + }) +} + +type nativeLegacyStopOperations struct { + stopScheduled func(context.Context, string, bool) (nativeScheduledStopResult, error) + openProcesses func(string, string) ([]nativeLegacyProcess, error) + terminate func(nativeLegacyProcess) error + closeHandle func(windows.Handle) +} + +func stopNativeLegacyStartupWith( + ctx context.Context, + state *nativeLegacyState, + logger *slog.Logger, + operations nativeLegacyStopOperations, +) error { + if state.scheduledAction != nil { + if state.scheduledXML == nil { + return errors.New("cannot stop RunVIIPER without snapshotted task XML") + } + // Once PowerShell is launched, process termination or context cancellation + // can occur after Disable-ScheduledTask but before JSON reaches Go. Mark the + // registration as potentially changed before the call so the outer + // transaction always runs the controlled task rollback probe on failure. + state.scheduledDisabled = true + state.scheduledStopped = state.scheduledActive + result, err := operations.stopScheduled(ctx, *state.scheduledXML, state.scheduledActive) + if err != nil { + return err + } + if strings.TrimSpace(result.currentXML) == "" { + return errors.New("RunVIIPER stop returned no current task XML") + } + currentXML := result.currentXML + state.scheduledCurrentXML = ¤tXML + state.scheduledDisabled = result.disabled + state.scheduledStopped = state.scheduledActive && result.stopped + } + seen := make(map[string]bool) + for index := range state.commands { + key := strings.ToLower(filepath.Clean(state.commands[index].executable)) + if seen[key] { + state.commands[index].running = false + continue + } + seen[key] = true + processes, err := operations.openProcesses(state.commands[index].executable, state.userSID) + if err != nil { + return err + } + // Record the need to restart before the first termination. A later + // termination failure must not lose the fact that migration already + // changed the legacy process set. + state.commands[index].running = len(processes) != 0 + for processIndex, process := range processes { + if err := operations.terminate(process); err != nil { + for closeIndex := processIndex; closeIndex < len(processes); closeIndex++ { + operations.closeHandle(processes[closeIndex].handle) + } + return err + } + operations.closeHandle(process.handle) + logger.Info("terminated legacy VIIPER process", "pid", process.pid) + } + } + return nil +} + +func stopNativeScheduledTask( + ctx context.Context, + expectedXML string, + expectedActive bool, +) (nativeScheduledStopResult, error) { + if strings.TrimSpace(expectedXML) == "" { + return nativeScheduledStopResult{}, errors.New("cannot compare-and-stop RunVIIPER without snapshotted task XML") + } + snapshotCheck := `if($active){throw 'RunVIIPER started after migration snapshot'}` + if expectedActive { + snapshotCheck = `if(-not $active){throw 'RunVIIPER stopped after migration snapshot'}` + } + script := `$ErrorActionPreference='Stop';` + + `[Console]::OutputEncoding=[Text.UTF8Encoding]::new();` + + `$b=[Console]::In.ReadToEnd();$x=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};$t=$m[0];` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $x){throw 'RunVIIPER changed during migration'};` + + `$s=[string]$t.State;$active=($s -eq 'Running' -or $s -eq 'Queued');` + snapshotCheck + `;` + + `$stopped=$false;` + + `if([bool]$t.Settings.Enabled){Disable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null};` + + `$t=Get-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;$s=[string]$t.State;` + + `$nowActive=($s -eq 'Running' -or $s -eq 'Queued');if($nowActive){` + + `Stop-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `$end=[DateTime]::UtcNow.AddSeconds(5);do{Start-Sleep -Milliseconds 50;` + + `$t=Get-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;$s=[string]$t.State;` + + `if($s -ne 'Running' -and $s -ne 'Queued'){$stopped=$true;break}}while([DateTime]::UtcNow -lt $end);` + + `if(-not $stopped){throw 'RunVIIPER did not stop'}};` + + `$current=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `[pscustomobject]@{Stopped=$nowActive;Disabled=$true;CurrentXML=$current}|ConvertTo-Json -Compress` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskXML(expectedXML)) + output, err := command.CombinedOutput() + if err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("compare-disable-and-stop RunVIIPER scheduled task: %w: %s", + err, strings.TrimSpace(string(output))) + } + var result struct { + Stopped bool + Disabled bool + CurrentXML string + } + if err := json.Unmarshal(output, &result); err != nil { + return nativeScheduledStopResult{}, fmt.Errorf("decode RunVIIPER stop result: %w", err) + } + if strings.TrimSpace(result.CurrentXML) == "" { + return nativeScheduledStopResult{}, errors.New("RunVIIPER stop returned empty task XML") + } + return nativeScheduledStopResult{ + stopped: result.Stopped, disabled: result.Disabled, currentXML: result.CurrentXML, + }, nil +} + +func encodeNativeTaskXML(value string) string { + return base64.StdEncoding.EncodeToString([]byte(value)) +} + +func encodeNativeTaskRestorePayload(original, current string) string { + payload, err := json.Marshal(struct { + Original string + Current string + }{Original: original, Current: current}) + if err != nil { + panic("fixed scheduled-task restore payload could not be encoded: " + err.Error()) + } + return base64.StdEncoding.EncodeToString(payload) +} + +type nativeLegacyProcess struct { + handle windows.Handle + pid uint32 +} + +func openLegacyProcessesByExecutable(target, expectedUserSID string) ([]nativeLegacyProcess, error) { + target = filepath.Clean(target) + if _, err := validateNativeInstallingUserSID(expectedUserSID); err != nil { + return nil, err + } + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(snapshot) //nolint:errcheck + entry := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))} + if err := windows.Process32First(snapshot, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, nil + } + return nil, err + } + var result []nativeLegacyProcess + closeResult := func() { + for _, process := range result { + windows.CloseHandle(process.handle) //nolint:errcheck + } + } + for { + entryNameMatches := strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), filepath.Base(target)) + if entryNameMatches && entry.ProcessID != uint32(os.Getpid()) { + process, openErr := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, + false, + entry.ProcessID, + ) + if openErr == nil { + keepHandle := false + buffer := make([]uint16, 32768) + size := uint32(len(buffer)) + if queryErr := windows.QueryFullProcessImageName(process, 0, &buffer[0], &size); queryErr == nil { + actual := filepath.Clean(windows.UTF16ToString(buffer[:size])) + if strings.EqualFold(actual, target) { + var processToken windows.Token + if tokenErr := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &processToken); tokenErr != nil { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("query owner of possible legacy VIIPER pid %d: %w", entry.ProcessID, tokenErr) + } + owner, tokenErr := processToken.GetTokenUser() + processToken.Close() //nolint:errcheck + if tokenErr != nil { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("read owner of possible legacy VIIPER pid %d: %w", entry.ProcessID, tokenErr) + } + if strings.EqualFold(owner.User.Sid.String(), expectedUserSID) { + result = append(result, nativeLegacyProcess{handle: process, pid: entry.ProcessID}) + keepHandle = true + } + } + } else if status, _ := windows.WaitForSingleObject(process, 0); status != windows.WAIT_OBJECT_0 { + windows.CloseHandle(process) //nolint:errcheck + closeResult() + return nil, fmt.Errorf("revalidate possible legacy VIIPER pid %d: %w", entry.ProcessID, queryErr) + } + if !keepHandle { + windows.CloseHandle(process) //nolint:errcheck + } + } else if !errors.Is(openErr, windows.ERROR_INVALID_PARAMETER) { + closeResult() + return nil, fmt.Errorf("open possible legacy VIIPER pid %d: %w", entry.ProcessID, openErr) + } + } + if err := windows.Process32Next(snapshot, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + break + } + closeResult() + return nil, err + } + } + return result, nil +} + +func terminateVerifiedLegacyProcess(process nativeLegacyProcess) error { + status, err := windows.WaitForSingleObject(process.handle, 0) + if err != nil { + return fmt.Errorf("query legacy VIIPER pid %d: %w", process.pid, err) + } + if status == windows.WAIT_OBJECT_0 { + return nil + } + if err := windows.TerminateProcess(process.handle, 1); err != nil { + if status, _ := windows.WaitForSingleObject(process.handle, 0); status == windows.WAIT_OBJECT_0 { + return nil + } + return fmt.Errorf("terminate legacy VIIPER pid %d: %w", process.pid, err) + } + status, err = windows.WaitForSingleObject(process.handle, 5_000) + if err != nil { + return fmt.Errorf("wait for legacy VIIPER pid %d: %w", process.pid, err) + } + if status != windows.WAIT_OBJECT_0 { + return fmt.Errorf("legacy VIIPER pid %d did not terminate within 5 seconds", process.pid) + } + return nil +} + +func removeNativeLegacyRegistrations(ctx context.Context, state nativeLegacyState) error { + currentRun, runFound, err := currentNativeRunRegistration(state) + if err != nil { + return err + } + if err := validateNativeRunRegistrationSnapshot(state.runValue, currentRun, runFound); err != nil { + return err + } + if state.runValue != nil { + if state.runKey == 0 { + return errors.New("cannot remove VIIPER Run registration without its retained key") + } + if err := state.runKey.DeleteValue(runValueKey); err != nil { + return fmt.Errorf("remove VIIPER Run registration: %w", err) + } + if _, found, err := currentNativeRunRegistration(state); err != nil { + return fmt.Errorf("verify VIIPER Run registration removal: %w", err) + } else if found { + return errors.New("VIIPER Run registration still exists after removal") + } + } + currentAction, currentXML, _, _, taskFound, err := currentScheduledTaskCommand(ctx) + if err != nil { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, err, false) + } + if err := validateNativeScheduledTaskSnapshot(state, currentAction, currentXML, taskFound); err != nil { + return restoreNativeLegacyRegistrationsAfterRemoval(ctx, state, err, false) + } + if state.scheduledAction != nil { + // Keep the exact registered task disabled instead of unregistering it. + // Exported task XML omits its registered ACL and cannot recreate a + // Password-logon credential, so delete/re-register cannot be an exact + // transaction. A disabled task has no native-mode ownership and remains + // losslessly reversible on rollback. + if !state.scheduledDisabled { + return restoreNativeLegacyRegistrationsAfterRemoval( + ctx, + state, + errors.New("RunVIIPER was not disabled before native ownership commit"), + false, + ) + } + } + return nil +} + +func validateNativeScheduledTaskSnapshot( + state nativeLegacyState, + currentAction nativeLegacyCommand, + currentXML string, + found bool, +) error { + if state.scheduledAction == nil { + if found { + return errors.New("RunVIIPER scheduled task appeared during native service migration") + } + return nil + } + if state.scheduledXML == nil || state.scheduledCurrentXML == nil { + return errors.New("RunVIIPER task XML state is incomplete") + } + if !found { + return errors.New("RunVIIPER scheduled task disappeared during native service migration") + } + if !nativeLegacyCommandsEqual(currentAction, *state.scheduledAction) || + currentXML != *state.scheduledCurrentXML { + return errors.New("RunVIIPER scheduled task changed during native service migration") + } + return nil +} + +func restoreNativeLegacyRegistrationsAfterRemoval( + ctx context.Context, + state nativeLegacyState, + cause error, + restoreScheduledTask bool, +) error { + var restoreErrors []error + if state.runValue != nil { + if state.runKey == 0 || !state.runKeyExisted { + restoreErrors = append(restoreErrors, + errors.New("restore VIIPER Run registration: retained Run key is unavailable")) + } else { + current, found, restoreErr := currentNativeRunRegistration(state) + switch { + case restoreErr != nil: + case found && nativeRunRegistrationsEqual(current, *state.runValue): + // Another recovery path already restored the exact data and type. + case found: + restoreErr = errors.New("refusing to overwrite a concurrently changed VIIPER Run registration") + default: + restoreErr = setNativeRunRegistration(state.runKey, *state.runValue) + } + if restoreErr != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore VIIPER Run registration: %w", restoreErr)) + } + } + } + if restoreScheduledTask && state.scheduledXML != nil { + currentXML := *state.scheduledXML + if state.scheduledCurrentXML != nil { + currentXML = *state.scheduledCurrentXML + } + if err := restoreNativeScheduledTask(ctx, *state.scheduledXML, currentXML); err != nil { + restoreErrors = append(restoreErrors, err) + } + } + return errors.Join(cause, errors.Join(restoreErrors...)) +} + +func restoreNativeScheduledTask(ctx context.Context, taskXML, expectedCurrentXML string) error { + if strings.TrimSpace(taskXML) == "" { + return errors.New("cannot restore RunVIIPER from empty task XML") + } + if strings.TrimSpace(expectedCurrentXML) == "" { + return errors.New("cannot restore RunVIIPER without its expected current task XML") + } + _, currentXML, _, _, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return fmt.Errorf("query RunVIIPER before rollback: %w", err) + } + if !found { + return errors.New("RunVIIPER disappeared during rollback") + } + if currentXML == taskXML { + return nil + } + if expectedCurrentXML != taskXML && currentXML != expectedCurrentXML { + return errors.New("RunVIIPER changed outside the installer disable transition") + } + if err := validateNativeTaskDisabledOnly(taskXML, currentXML); err != nil { + return fmt.Errorf("refuse to enable unvalidated RunVIIPER task: %w", err) + } + const script = `$ErrorActionPreference='Stop';` + + `$b=[Console]::In.ReadToEnd();$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b))|ConvertFrom-Json;` + + `$x=[string]$p.Original;$expected=[string]$p.Current;` + + `if([string]::IsNullOrWhiteSpace($x) -or [string]::IsNullOrWhiteSpace($expected)){throw 'empty scheduled-task XML'};` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -gt 1){throw 'multiple root RunVIIPER tasks found'};$t=$null;if($m.Count -eq 1){$t=$m[0]};` + + `if($null -eq $t){throw 'RunVIIPER disappeared during rollback'};` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $expected){throw 'RunVIIPER changed after structural rollback validation'};` + + `Enable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null;` + + `$verify=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($verify -cne $x){Disable-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop|Out-Null;throw 'RunVIIPER did not verify after rollback'}` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskRestorePayload(taskXML, currentXML)) + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("restore RunVIIPER scheduled task: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func validateNativeTaskDisabledOnly(original, current string) error { + originalCanonical, originalEnabled, originalHasEnabled, err := canonicalNativeTaskXML(original) + if err != nil { + return fmt.Errorf("parse original task XML: %w", err) + } + currentCanonical, currentEnabled, currentHasEnabled, err := canonicalNativeTaskXML(current) + if err != nil { + return fmt.Errorf("parse current task XML: %w", err) + } + if !originalHasEnabled || !currentHasEnabled || !originalEnabled || currentEnabled { + return errors.New("task does not represent an enabled-to-disabled transition") + } + if originalCanonical != currentCanonical { + return errors.New("task XML differs outside Settings/Enabled") + } + return nil +} + +func canonicalNativeTaskXML(value string) (string, bool, bool, error) { + decoder := xml.NewDecoder(strings.NewReader(value)) + decoder.Strict = true + var canonical strings.Builder + var stack []xml.Name + enabled := false + hasEnabled := false + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", false, false, err + } + switch typed := token.(type) { + case xml.StartElement: + stack = append(stack, typed.Name) + attributes := make([]string, 0, len(typed.Attr)) + for _, attribute := range typed.Attr { + attributes = append(attributes, + attribute.Name.Space+"\x00"+attribute.Name.Local+"="+strconv.Quote(attribute.Value)) + } + sort.Strings(attributes) + canonical.WriteString("S") + canonical.WriteString(typed.Name.Space) + canonical.WriteByte(0) + canonical.WriteString(typed.Name.Local) + canonical.WriteByte('[') + canonical.WriteString(strings.Join(attributes, ",")) + canonical.WriteByte(']') + case xml.EndElement: + canonical.WriteString("E") + canonical.WriteString(typed.Name.Space) + canonical.WriteByte(0) + canonical.WriteString(typed.Name.Local) + if len(stack) == 0 || stack[len(stack)-1] != typed.Name { + return "", false, false, errors.New("task XML element stack is inconsistent") + } + stack = stack[:len(stack)-1] + case xml.CharData: + text := string(typed) + if nativeTaskEnabledElement(stack) { + value := strings.TrimSpace(text) + if value == "" { + continue + } + parsed, err := strconv.ParseBool(value) + if err != nil || hasEnabled { + return "", false, false, errors.New("task XML has an invalid Settings/Enabled value") + } + enabled, hasEnabled = parsed, true + canonical.WriteString("T") + } else if strings.TrimSpace(text) != "" { + canonical.WriteString("T") + canonical.WriteString(strconv.Quote(text)) + } + case xml.Comment: + canonical.WriteString("C") + canonical.WriteString(strconv.Quote(string(typed))) + case xml.ProcInst: + canonical.WriteString("P") + canonical.WriteString(typed.Target) + canonical.WriteString(strconv.Quote(string(typed.Inst))) + case xml.Directive: + canonical.WriteString("D") + canonical.WriteString(strconv.Quote(string(typed))) + } + } + if len(stack) != 0 { + return "", false, false, errors.New("task XML ended with open elements") + } + return canonical.String(), enabled, hasEnabled, nil +} + +func nativeTaskEnabledElement(stack []xml.Name) bool { + return len(stack) >= 3 && stack[len(stack)-1].Local == "Enabled" && + stack[len(stack)-2].Local == "Settings" && stack[0].Local == "Task" +} + +func nativeLegacyCommandsEqual(first, second nativeLegacyCommand) bool { + if !strings.EqualFold(filepath.Clean(first.executable), filepath.Clean(second.executable)) || + !strings.EqualFold(filepath.Clean(first.workingDirectory), filepath.Clean(second.workingDirectory)) || + len(first.arguments) != len(second.arguments) { + return false + } + for index := range first.arguments { + if first.arguments[index] != second.arguments[index] { + return false + } + } + return true +} + +func restartNativeLegacyStartup(ctx context.Context, state nativeLegacyState) error { + if err := ctx.Err(); err != nil { + return err + } + if state.scheduledStopped { + if state.scheduledXML == nil { + return errors.New("cannot restart RunVIIPER without snapshotted task XML") + } + if state.verifyTaskAction == nil { + return errors.New("cannot restart RunVIIPER without its retained action identity") + } + if err := state.verifyTaskAction(); err != nil { + return fmt.Errorf("revalidate RunVIIPER action before rollback restart: %w", err) + } + if err := startNativeScheduledTask(ctx, *state.scheduledXML); err != nil { + return err + } + } + started := make(map[string]bool) + for _, command := range state.commands { + key := strings.ToLower(filepath.Clean(command.executable)) + if !command.running || started[key] { + continue + } + started[key] = true + var err error + switch command.source { + case legacyCommandRun: + current, found, queryErr := currentNativeRunRegistration(state) + if queryErr != nil { + err = fmt.Errorf("verify VIIPER Run registration before restart: %w", queryErr) + } else if state.runValue == nil || !found || !nativeRunRegistrationsEqual(current, *state.runValue) { + err = errors.New("refusing to restart HKCU VIIPER because its registration changed during migration") + } else { + err = startNativeLegacyCommandAsShellUser(command, state.userSID) + } + default: + err = errors.New("legacy VIIPER command has no trusted startup source") + } + if err != nil { + return err + } + } + return nil +} + +func startNativeScheduledTask(ctx context.Context, expectedXML string) error { + if strings.TrimSpace(expectedXML) == "" { + return errors.New("cannot restart RunVIIPER without snapshotted task XML") + } + const script = `$ErrorActionPreference='Stop';` + + `$b=[Console]::In.ReadToEnd();$x=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));` + + `$m=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -ceq '\' -and $_.TaskName -ieq 'RunVIIPER'});` + + `if($m.Count -ne 1){throw 'expected exactly one root RunVIIPER task'};$t=$m[0];` + + `$c=Export-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop;` + + `if($c -cne $x){throw 'RunVIIPER changed before restart'};` + + `$s=[string]$t.State;if($s -eq 'Running' -or $s -eq 'Queued'){exit 0};` + + `Start-ScheduledTask -TaskName 'RunVIIPER' -TaskPath '\' -ErrorAction Stop` + powershell, err := trustedSystemExecutable("WindowsPowerShell", "v1.0", "powershell.exe") + if err != nil { + return fmt.Errorf("resolve trusted PowerShell: %w", err) + } + command := exec.CommandContext(ctx, powershell, "-NoProfile", "-NonInteractive", "-Command", script) + command.Stdin = strings.NewReader(encodeNativeTaskXML(expectedXML)) + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("restart RunVIIPER scheduled task: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func startNativeLegacyCommandAsShellUser(command nativeLegacyCommand, expectedUserSID string) error { + shellToken, err := nativeInteractiveUserToken( + expectedUserSID, + windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY| + windows.TOKEN_ADJUST_DEFAULT|windows.TOKEN_ADJUST_SESSIONID, + ) + if err != nil { + return fmt.Errorf("open target interactive user token: %w", err) + } + defer shellToken.Close() //nolint:errcheck + + commandLine, err := windowsCommandLine(command.executable, command.arguments...) + if err != nil { + return err + } + application, err := windows.UTF16PtrFromString(command.executable) + if err != nil { + return err + } + mutableCommandLine, err := windows.UTF16FromString(commandLine) + if err != nil { + return err + } + var currentDirectory *uint16 + if command.workingDirectory != "" { + currentDirectory, err = windows.UTF16PtrFromString(command.workingDirectory) + if err != nil { + return err + } + } + desktop, err := windows.UTF16PtrFromString(`winsta0\default`) + if err != nil { + return err + } + var environment *uint16 + if err := windows.CreateEnvironmentBlock(&environment, shellToken, false); err != nil { + return fmt.Errorf("create interactive user environment: %w", err) + } + defer windows.DestroyEnvironmentBlock(environment) //nolint:errcheck + startup := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), Desktop: desktop} + process := windows.ProcessInformation{} + if err := windows.CreateProcessAsUser( + shellToken, + application, + &mutableCommandLine[0], + nil, + nil, + false, + windows.CREATE_UNICODE_ENVIRONMENT, + environment, + currentDirectory, + &startup, + &process, + ); err != nil { + return fmt.Errorf("restart HKCU VIIPER under the interactive shell token: %w", err) + } + windows.CloseHandle(process.Thread) //nolint:errcheck + windows.CloseHandle(process.Process) //nolint:errcheck + return nil +} + +func hasRunningLegacyCommand(state nativeLegacyState) bool { + if state.scheduledStopped { + return true + } + for _, command := range state.commands { + if command.running { + return true + } + } + return false +} + +func isLocalSystemServiceAccount(account string) bool { + account = strings.TrimSpace(account) + return account == "" || strings.EqualFold(account, "LocalSystem") || + strings.EqualFold(account, `.\LocalSystem`) || + strings.EqualFold(account, `NT AUTHORITY\SYSTEM`) +} + +func isEquivalentServiceAccount(first, second string) bool { + if isLocalSystemServiceAccount(first) && isLocalSystemServiceAccount(second) { + return true + } + return strings.EqualFold(strings.TrimSpace(first), strings.TrimSpace(second)) +} diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go new file mode 100644 index 00000000..616f659c --- /dev/null +++ b/internal/cmd/native_service_install_windows_test.go @@ -0,0 +1,1483 @@ +//go:build windows + +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestNativeBrokerServiceConfigurationIsExplicitAndEscaped(t *testing.T) { + executable := `C:\Program Files\VIIPER\viiper.exe` + credential := `C:\ProgramData\VIIPER\viiper key.txt` + config, arguments, err := nativeBrokerServiceConfiguration(executable, credential) + if err != nil { + t.Fatal(err) + } + wantArguments := []string{ + "service", "--transport", "native-ude", "--key-file", credential, + "--log.file", filepath.Join(filepath.Dir(credential), nativeBrokerLogName), + } + if !reflect.DeepEqual(arguments, wantArguments) { + t.Fatalf("arguments=%q want=%q", arguments, wantArguments) + } + if config.StartType != mgr.StartAutomatic || config.ServiceType != windows.SERVICE_WIN32_OWN_PROCESS { + t.Fatalf("service config is not an automatic own-process service: %+v", config) + } + if config.ServiceStartName != nativeServiceAccount || config.DelayedAutoStart { + t.Fatalf("service account/start mode=%q/%v", config.ServiceStartName, config.DelayedAutoStart) + } + decomposed, err := windows.DecomposeCommandLine(config.BinaryPathName) + if err != nil { + t.Fatal(err) + } + if want := append([]string{executable}, wantArguments...); !reflect.DeepEqual(decomposed, want) { + t.Fatalf("binary command=%q want=%q", decomposed, want) + } +} + +func TestNativeServiceConfigVerificationDoesNotFoldArgumentCase(t *testing.T) { + first := mgr.Config{BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service --key-file C:\key`} + second := first + second.BinaryPathName = `"C:\Program Files\VIIPER\viiper.exe" service --KEY-FILE C:\key` + if nativeServiceConfigsEqual(first, second) { + t.Fatal("case-only service switch mismatch verified as equal") + } + second = first + second.BinaryPathName += " " + if nativeServiceConfigsEqual(first, second) { + t.Fatal("trailing service-command whitespace mismatch verified as equal") + } +} + +func TestNativeServiceDependenciesBlockCanClearAndRoundTrip(t *testing.T) { + empty, err := nativeServiceDependenciesBlock(nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(empty, []uint16{0, 0}) { + t.Fatalf("empty dependency block=%v", empty) + } + block, err := nativeServiceDependenciesBlock([]string{"Tcpip", "+NetworkProvider"}) + if err != nil { + t.Fatal(err) + } + want := append([]uint16{}, windows.StringToUTF16("Tcpip")...) + want = append(want, windows.StringToUTF16("+NetworkProvider")...) + want = append(want, 0) + if !reflect.DeepEqual(block, want) { + t.Fatalf("dependency block=%v want=%v", block, want) + } + if _, err := nativeServiceDependenciesBlock([]string{""}); err == nil { + t.Fatal("accepted empty dependency name") + } +} + +func TestNativeServiceExecutablePathRejectsPortableAndNonDedicatedLocations(t *testing.T) { + programFiles := `C:\Program Files` + for _, executable := range []string{ + `C:\Users\user\Downloads\viiper.exe`, + `C:\Program Files\viiper.exe`, + `C:\Program Files\DS4Windows\viiper.exe`, + `C:\Program Files\Other\VIIPER\viiper.exe`, + `C:\Program Files\DS4Windows\VIIPER\renamed.exe`, + } { + if _, err := nativeServiceExecutableParent(programFiles, executable); err == nil { + t.Fatalf("accepted unsafe LocalSystem service executable %q", executable) + } + } + parent, err := nativeServiceExecutableParent( + programFiles, + `C:\Program Files\DS4Windows\VIIPER\viiper.exe`, + ) + if err != nil || parent != `C:\Program Files\DS4Windows\VIIPER` { + t.Fatalf("parent=%q error=%v", parent, err) + } + if parent, err := nativeServiceExecutableParent( + programFiles, `C:\Program Files\VIIPER\viiper.exe`, + ); err != nil || parent != `C:\Program Files\VIIPER` { + t.Fatalf("direct parent=%q error=%v", parent, err) + } +} + +func TestRotatedNativeServiceKeyNeverReusesPreseededCredential(t *testing.T) { + generated := []string{"", "attacker-known", "fresh-random"} + index := 0 + key, err := rotatedNativeServiceKey([]byte(" attacker-known\r\n"), func() (string, error) { + value := generated[index] + index++ + return value, nil + }) + if err != nil { + t.Fatal(err) + } + if key != "fresh-random" || index != 3 { + t.Fatalf("rotated key=%q attempts=%d", key, index) + } +} + +func TestNativeFileLinkCountRejectsHardLinks(t *testing.T) { + if err := validateNativeFileLinkCount(1); err != nil { + t.Fatal(err) + } + for _, count := range []uint32{0, 2, 17} { + if err := validateNativeFileLinkCount(count); err == nil { + t.Fatalf("accepted unsafe file link count %d", count) + } + } +} + +func TestNativeTaskXMLUsesExplicitUTF8Base64Transport(t *testing.T) { + xml := `Zoë 日本語 🎮` + decoded, err := base64.StdEncoding.DecodeString(encodeNativeTaskXML(xml)) + if err != nil { + t.Fatal(err) + } + if string(decoded) != xml { + t.Fatalf("decoded XML=%q want=%q", decoded, xml) + } +} + +func TestNativeTaskRestorePayloadPreservesOriginalAndDisabledXML(t *testing.T) { + original := `Zoë 日本語true` + current := `Zoë 日本語false` + decoded, err := base64.StdEncoding.DecodeString(encodeNativeTaskRestorePayload(original, current)) + if err != nil { + t.Fatal(err) + } + var payload struct{ Original, Current string } + if err := json.Unmarshal(decoded, &payload); err != nil { + t.Fatal(err) + } + if payload.Original != original || payload.Current != current { + t.Fatalf("restore payload=%+v", payload) + } +} + +func TestValidateNativeTaskDisabledOnlyRequiresExactStructuralTransition(t *testing.T) { + const original = `VIIPERtrueS-1-5-21-1trueC:\VIIPER\viiper.exe` + disabled := strings.Replace(original, `true`, `false`, 1) + if err := validateNativeTaskDisabledOnly(original, disabled); err != nil { + t.Fatalf("exact enabled-to-disabled transition rejected: %v", err) + } + mutations := map[string]string{ + "action": strings.Replace(disabled, `C:\VIIPER\viiper.exe`, `C:\Evil\viiper.exe`, 1), + "principal": strings.Replace(disabled, `S-1-5-21-1`, `S-1-5-18`, 1), + "trigger": strings.Replace(disabled, ``, ``, 1), + "namespace": strings.Replace(disabled, `urn:task`, `urn:other`, 1), + "missing": strings.Replace(original, `true`, ``, 1), + "duplicate": strings.Replace(disabled, `false`, `falsefalse`, 1), + "invalid": strings.Replace(disabled, `false`, `maybe`, 1), + "malformed": strings.TrimSuffix(disabled, ``), + "unchanged": original, + } + for name, current := range mutations { + t.Run(name, func(t *testing.T) { + if err := validateNativeTaskDisabledOnly(original, current); err == nil { + t.Fatal("accepted non-exact task transition") + } + }) + } +} + +func TestNativeLegacyRegistrationSnapshotRejectsAbsentOwnersAndTypeChanges(t *testing.T) { + snapshot := nativeRunRegistration{value: `"C:\VIIPER\viiper.exe"`, valueType: registry.EXPAND_SZ} + if err := validateNativeRunRegistrationSnapshot(nil, nativeRunRegistration{}, false); err != nil { + t.Fatal(err) + } + if err := validateNativeRunRegistrationSnapshot(nil, snapshot, true); err == nil { + t.Fatal("accepted Run registration that appeared after an absent snapshot") + } + changedType := snapshot + changedType.valueType = registry.SZ + if err := validateNativeRunRegistrationSnapshot(&snapshot, changedType, true); err == nil { + t.Fatal("accepted Run registration type change with identical data") + } + if err := validateNativeRunRegistrationSnapshot(&snapshot, nativeRunRegistration{}, false); err == nil { + t.Fatal("accepted disappeared Run registration") + } + if err := validateNativeScheduledTaskSnapshot(nativeLegacyState{}, nativeLegacyCommand{}, "", true); err == nil { + t.Fatal("accepted RunVIIPER task that appeared after an absent snapshot") + } + if _, _, err := currentNativeRunRegistration(nativeLegacyState{}); err == nil || + !strings.Contains(err.Error(), "hive is not retained") { + t.Fatalf("unretained user hive did not fail closed: %v", err) + } +} + +func TestNativeScheduledTaskIdentityAndStateAreFailClosed(t *testing.T) { + for _, name := range []string{"RunVIIPER", "runviiper", "RUNVIIPER"} { + if !nativeScheduledTaskNameMatches(name) { + t.Fatalf("case-equivalent Task Scheduler name %q was missed", name) + } + } + if nativeScheduledTaskNameMatches("RunVIIPER-Evil") { + t.Fatal("accepted a different Task Scheduler name") + } + if err := validateNativeScheduledTaskState(true, false); err == nil { + t.Fatal("accepted active-but-disabled task snapshot") + } + for _, state := range [][2]bool{{false, false}, {false, true}, {true, true}} { + if err := validateNativeScheduledTaskState(state[0], state[1]); err != nil { + t.Fatalf("active=%v enabled=%v error=%v", state[0], state[1], err) + } + } +} + +func TestNativeServiceExecutableCommandMustRemainRepresentable(t *testing.T) { + got, err := nativeServiceExecutableFromCommandLine(`"C:\Program Files\VIIPER\viiper.exe" service --key-file C:\key`) + if err != nil || got != `C:\Program Files\VIIPER\viiper.exe` { + t.Fatalf("executable=%q error=%v", got, err) + } + for _, commandLine := range []string{"", "viiper.exe service", "\x00"} { + if _, err := nativeServiceExecutableFromCommandLine(commandLine); err == nil { + t.Fatalf("accepted unsafe service command line %q", commandLine) + } + } +} + +func TestInstallingUserSelectionPrefersInteractiveOriginAndFailsClosedForSystem(t *testing.T) { + selected, err := selectNativeInstallingUserSID( + "S-1-5-21-1-2-3-500", false, + "S-1-5-21-1-2-3-1001", nil, + ) + if err != nil || selected != "S-1-5-21-1-2-3-1001" { + t.Fatalf("over-the-shoulder selection=%q error=%v", selected, err) + } + if _, err := selectNativeInstallingUserSID( + "", true, "", errors.New("no active console"), + ); err == nil || !strings.Contains(err.Error(), "--target-user-sid") { + t.Fatalf("LocalSystem without origin did not fail closed: %v", err) + } +} + +func TestNativeInstallRejectsUntrustedExecutableBeforeAnyMutation(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.lockExecutable = func(string) (func(), error) { + return nil, errors.New("user-writable path") + } + credentialProvisioned := false + dependencies.provisionCredential = func() (nativeCredential, error) { + credentialProvisioned = true + return nativeCredential{}, nil + } + err := installNativeBrokerTransaction( + context.Background(), testLogger(), `C:\Users\user\viiper.exe`, dependencies, + ) + if err == nil || !strings.Contains(err.Error(), "user-writable path") { + t.Fatalf("error=%v", err) + } + if credentialProvisioned || len(events) != 0 { + t.Fatalf("unsafe executable mutated state: credential=%v events=%v", credentialProvisioned, events) + } +} + +func TestNativeInstallLocksPriorServiceExecutableBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceStartName: nativeServiceAccount, + BinaryPathName: `"C:\Untrusted\viiper.exe" service`, + }, + status: svc.Status{State: svc.Stopped}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + var currentValidatedPaths []string + dependencies.lockExecutable = func(path string) (func(), error) { + currentValidatedPaths = append(currentValidatedPaths, path) + return func() {}, nil + } + var validatedPaths []string + dependencies.lockPriorExecutable = func(path string) (func(), error) { + validatedPaths = append(validatedPaths, path) + if strings.EqualFold(path, `C:\Untrusted\viiper.exe`) { + return nil, errors.New("prior service path is not protected") + } + return func() {}, nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "prior service path is not protected") { + t.Fatalf("error=%v", err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("untrusted prior service path mutated transaction state: %v", events) + } + if !reflect.DeepEqual(currentValidatedPaths, []string{`C:\Program Files\VIIPER\viiper.exe`}) || + !reflect.DeepEqual(validatedPaths, []string{`C:\Untrusted\viiper.exe`}) { + t.Fatalf("prior proof did not remain isolated: current=%v prior=%v", currentValidatedPaths, validatedPaths) + } +} + +func TestNativeServiceExecutableTrustPathIsReadOnlyByConstruction(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + sourcePath := filepath.Join(filepath.Dir(testFile), "native_service_install_windows.go") + source, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatal(err) + } + text := string(source) + start := strings.Index(text, "func lockNativeServiceExecutableReadOnly(") + end := strings.Index(text, "func nativeServiceExecutableParent(") + if start < 0 || end <= start { + t.Fatal("cannot isolate native executable trust implementation") + } + span := text[start:end] + for _, forbidden := range []string{"applyNativeACLToHandle", "SetSecurityInfo", "WRITE_DAC", "WRITE_OWNER"} { + if strings.Contains(span, forbidden) { + t.Fatalf("read-only executable trust path contains mutating primitive %q", forbidden) + } + } +} + +func TestNativeInstallRejectsWeakPriorServiceSecurityBeforeMutation(t *testing.T) { + events := []string{} + const priorSecurity = "O:BAD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;RPWP;;;BU)" + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, + BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service`, + }, + securityDescriptor: priorSecurity, + status: svc.Status{State: svc.Running}, + events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "untrusted service security descriptor") { + t.Fatalf("error=%v", err) + } + if service.securityDescriptor != priorSecurity { + t.Fatalf("weak prior service DACL was mutated: %q", service.securityDescriptor) + } + if service.status.State != svc.Running { + t.Fatalf("weak prior service was stopped during rejected snapshot: %+v", service.status) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("weak prior service caused mutation before rejection: %v", events) + } +} + +func TestNativeTransactionContextBoundsLegacyProviderCalls(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.snapshotLegacy = func(ctx context.Context) (nativeLegacyState, error) { + <-ctx.Done() + return nativeLegacyState{}, ctx.Err() + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + started := time.Now() + err := installNativeBrokerTransaction(ctx, testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("canceled legacy provider exceeded bound: %s", elapsed) + } +} + +func TestNativeInstallKeepsLegacyRegistrationUntilAuthenticatedReady(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{ + runValue: nativeRunRegistrationPointer(`"C:\Legacy\viiper.exe" server --transport usbip`, registry.SZ), + commands: []nativeLegacyCommand{{executable: `C:\Legacy\viiper.exe`}}, + } + rolledBackCredential := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.rollbackCredential = func(nativeCredential) error { + rolledBackCredential = true + return nil + } + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + dependencies.verifyBroker = func(_ context.Context, password string) error { + events = append(events, "verify") + if password != "credential" { + t.Fatalf("password=%q", password) + } + if manager.service == nil || manager.service.status.State != svc.Running { + t.Fatal("service was not running during authenticated verification") + } + return nil + } + dependencies.removeLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-remove") + if state.runValue == nil { + t.Fatal("legacy registration was not retained through verification") + } + return nil + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err != nil { + t.Fatal(err) + } + if rolledBackCredential { + t.Fatal("committed credential was rolled back") + } + if !beforeEvent(events, "verify", "legacy-remove") { + t.Fatalf("legacy registration was removed before authenticated verification: %v", events) + } + if manager.service == nil || manager.service.deleted { + t.Fatal("native service was not retained") + } + if manager.service.config.StartType != mgr.StartAutomatic { + t.Fatal("native service was not registered for automatic startup") + } + if !reflect.DeepEqual(manager.service.recoveryActions, nativeServiceRecoveryActions) || + manager.service.recoveryReset != nativeServiceRecoveryResetSecond || + !manager.service.recoverNonCrash { + t.Fatalf("bounded recovery policy not applied: %+v", manager.service) + } +} + +func TestNativeInstallHoldsExecutableLockThroughAuthenticatedReady(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + released := false + dependencies.lockExecutable = func(string) (func(), error) { + events = append(events, "executable-lock") + return func() { + released = true + events = append(events, "executable-release") + }, nil + } + dependencies.verifyBroker = func(context.Context, string) error { + if released { + t.Fatal("service executable lock was released before authenticated readiness") + } + events = append(events, "verify") + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err != nil { + t.Fatal(err) + } + if !released || !beforeEvent(events, "verify", "executable-release") { + t.Fatalf("executable handle lifetime was not transactional: %v", events) + } +} + +func TestNativeInstallReverifiesAfterLegacyRemovalAndRestoresOnFailure(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{runValue: nativeRunRegistrationPointer(`"C:\Legacy\viiper.exe" server`, registry.SZ)} + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + verifications := 0 + dependencies.verifyBroker = func(context.Context, string) error { + verifications++ + events = append(events, "verify") + if verifications == 2 { + return errors.New("legacy owner raced endpoint") + } + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "legacy owner raced endpoint") { + t.Fatalf("error=%v", err) + } + if verifications != 2 || !beforeEvent(events, "legacy-remove", "legacy-restore") { + t.Fatalf("post-removal verification/rollback events=%v", events) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("failed migration retained replacement service: %+v", manager.service) + } +} + +func TestNativeInstallRejectsBrokerThatStopsAfterAuthenticatedPing(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.verifyBroker = func(context.Context, string) error { + events = append(events, "verify") + manager.service.status.State = svc.Stopped + manager.service.processID = 0 + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "left Running state") { + t.Fatalf("error=%v events=%v", err, events) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("stopped impersonable broker was committed: %+v", manager.service) + } +} + +func TestNativeInstallRestoresPriorServiceAndLegacyProcessOnPingFailure(t *testing.T) { + events := []string{} + priorConfig := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ErrorControl: mgr.ErrorIgnore, BinaryPathName: `"C:\Old\viiper.exe" service`, + ServiceStartName: nativeServiceAccount, DisplayName: "Prior VIIPER", + } + priorRecovery := []mgr.RecoveryAction{{Type: mgr.ServiceRestart, Delay: time.Minute}, {Type: mgr.NoAction}} + service := &fakeNativeService{ + config: priorConfig, status: svc.Status{State: svc.Running}, + recoveryActions: priorRecovery, recoveryReset: 321, recoverNonCrash: false, + events: &events, + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{commands: []nativeLegacyCommand{{executable: `C:\Legacy\viiper.exe`}}} + credentialRolledBack := false + legacyRestarted := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.rollbackCredential = func(nativeCredential) error { + events = append(events, "credential-restore") + credentialRolledBack = true + return nil + } + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restart") + legacyRestarted = hasRunningLegacyCommand(state) + return nil + } + dependencies.verifyBroker = func(context.Context, string) error { + events = append(events, "verify-failed") + return errors.New("wrong ABI") + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "wrong ABI") { + t.Fatalf("error=%v", err) + } + if !reflect.DeepEqual(service.config, priorConfig) { + t.Fatalf("prior config was not restored: %+v", service.config) + } + if service.status.State != svc.Running { + t.Fatalf("prior running state was not restored: %v", service.status.State) + } + if !reflect.DeepEqual(service.recoveryActions, priorRecovery) || service.recoveryReset != 321 || service.recoverNonCrash { + t.Fatalf("prior recovery policy was not restored: %+v", service) + } + if !legacyRestarted || !credentialRolledBack { + t.Fatalf("rollback incomplete: legacy=%v credential=%v", legacyRestarted, credentialRolledBack) + } + if beforeEvent(events, "legacy-remove", "verify-failed") { + t.Fatalf("legacy startup changed before a failed verification: %v", events) + } + credentialIndex := slices.Index(events, "credential-restore") + priorStartIndex := lastIndex(events, "service-start") + legacyRestartIndex := slices.Index(events, "legacy-restart") + if credentialIndex < 0 || priorStartIndex <= credentialIndex || legacyRestartIndex <= priorStartIndex { + t.Fatalf("rollback did not restore credential before prior owners: %v", events) + } +} + +func TestNativeInstallDeletesNewServiceWhenMigrationFails(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.verifyBroker = func(context.Context, string) error { return errors.New("not ready") } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected verification failure") + } + if manager.service == nil || !manager.service.deleted { + t.Fatal("new service was not deleted during rollback") + } + if manager.service.status.State != svc.Stopped { + t.Fatalf("new service was not stopped before deletion: %v", manager.service.status.State) + } +} + +func TestNativeInstallDeletesNewServiceAfterOptionalConfigFailure(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + manager.newServiceFailUpdate = errors.New("optional service config failed") + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "optional service config failed") { + t.Fatalf("error=%v", err) + } + if manager.service == nil || !manager.service.deleted { + t.Fatalf("partially configured new service was orphaned: events=%v", events) + } +} + +func TestNativeInstallRestoresAfterPartialUpdateConfigFailure(t *testing.T) { + events := []string{} + prior := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Prior\viiper.exe" service`, + } + service := &fakeNativeService{ + config: prior, status: svc.Status{State: svc.Stopped}, events: &events, + failUpdate: errors.New("optional config failed after base config changed"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + updateCalls := 0 + service.updateHook = func() { + updateCalls++ + if updateCalls == 2 { + service.failUpdate = nil + } + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected partial UpdateConfig failure") + } + if !reflect.DeepEqual(service.config, prior) { + t.Fatalf("partially changed service config was not restored: %+v", service.config) + } +} + +func TestNativeInstallRestoresRunningServiceAfterStopWaitFails(t *testing.T) { + events := []string{} + prior := mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service`, + } + service := &fakeNativeService{ + config: prior, status: svc.Status{State: svc.Running}, events: &events, + failControl: errors.New("status wait failed after stop was accepted"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + controlCalls := 0 + service.controlHook = func() { + controlCalls++ + if controlCalls == 2 { + service.failControl = nil + } + } + + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected the forward stop failure") + } + if service.status.State != svc.Running || service.startCalls != 1 { + t.Fatalf("prior running state was not reconciled: status=%v starts=%d", service.status.State, service.startCalls) + } +} + +func TestNativeRollbackDoesNotStartServiceAfterConfigRestoreFailure(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartAutomatic, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Old\viiper.exe" service`, + }, + status: svc.Status{State: svc.Running}, events: &events, + failUpdate: errors.New("configuration write failed"), + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + credentialRolledBack := false + dependencies.rollbackCredential = func(nativeCredential) error { + credentialRolledBack = true + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil { + t.Fatal("expected update and rollback failure") + } + if service.startCalls != 0 || service.status.State != svc.Stopped { + t.Fatalf("service started with unverified config: starts=%d state=%v", service.startCalls, service.status.State) + } + if credentialRolledBack { + t.Fatal("credential was invalidated while the replacement service configuration remained installed") + } +} + +func TestNativeInstallRejectsUnrepresentableRecoveryPolicyBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, StartType: mgr.StartManual, + ServiceStartName: nativeServiceAccount, BinaryPathName: `"C:\Old\viiper.exe" service`, + }, + status: svc.Status{State: svc.Stopped}, events: &events, + recoveryActions: nil, recoveryReset: 777, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "unrepresentable recovery policy") { + t.Fatalf("expected an unrepresentable-policy error, got %v", err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("unrepresentable policy mutated SCM/legacy state: %v", events) + } +} + +func TestNativeInstallRejectsUnrestorableLoadOrderStateBeforeMutation(t *testing.T) { + for _, config := range []mgr.Config{ + {ServiceStartName: nativeServiceAccount, LoadOrderGroup: "legacy-group"}, + {ServiceStartName: nativeServiceAccount, TagId: 7}, + } { + events := []string{} + service := &fakeNativeService{ + config: config, status: svc.Status{State: svc.Stopped}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "load-order") { + t.Fatalf("config=%+v error=%v", config, err) + } + if !reflect.DeepEqual(events, []string{"service-open"}) { + t.Fatalf("unrestorable config mutated state: %v", events) + } + } +} + +func TestNativeInstallWaitsForPriorServiceDeletionBeforeCreating(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + manager.openErrors = []error{ + windows.ERROR_SERVICE_MARKED_FOR_DELETE, + windows.ERROR_SERVICE_MARKED_FOR_DELETE, + } + waits := 0 + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + dependencies.wait = func(context.Context, time.Duration) error { + waits++ + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err != nil { + t.Fatal(err) + } + if waits != 2 || manager.service == nil { + t.Fatalf("deletion retry waits=%d service=%v events=%v", waits, manager.service, events) + } +} + +func TestNativeInstallRejectsPausedPriorServiceBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Paused}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if err == nil || !strings.Contains(err.Error(), "unsupported state") { + t.Fatalf("error=%v", err) + } + if len(events) != 1 || events[0] != "service-open" { + t.Fatalf("paused service was mutated: %v", events) + } +} + +func TestNativeInstallRejectsEmptyCredentialBeforeServiceConfiguration(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + rolledBack := false + dependencies.provisionCredential = func() (nativeCredential, error) { + return nativeCredential{path: `C:\ProgramData\VIIPER\viiper.key.txt`, password: " ", created: true}, nil + } + dependencies.rollbackCredential = func(nativeCredential) error { + rolledBack = true + return nil + } + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("accepted empty credential") + } + for _, event := range events { + if event == "service-create" || event == "service-update" || event == "service-start" { + t.Fatalf("empty credential changed service configuration: events=%v", events) + } + } + if !rolledBack { + t.Fatalf("empty credential was not rolled back: events=%v", events) + } +} + +func TestRollbackUsesIndependentContextAfterForwardTimeout(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, delayStartAfter: 1, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + ctx, cancel := context.WithCancel(context.Background()) + dependencies.verifyBroker = func(context.Context, string) error { + cancel() + return context.Canceled + } + rollbackObservedLiveContext := false + dependencies.wait = func(waitCtx context.Context, _ time.Duration) error { + if ctx.Err() != nil && waitCtx.Err() == nil { + rollbackObservedLiveContext = true + } + service.status.State = svc.Running + return nil + } + if err := installNativeBrokerTransaction(ctx, testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("expected canceled verification") + } + if !rollbackObservedLiveContext { + t.Fatal("rollback reused the canceled forward-operation context") + } +} + +func TestNativeInstallRollsBackPartialLegacyStop(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{commands: []nativeLegacyCommand{ + {executable: `C:\One\viiper.exe`}, {executable: `C:\Two\viiper.exe`}, + }} + restarted := false + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + state.commands[0].running = true + return errors.New("second process query failed") + } + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + restarted = state.commands[0].running + return nil + } + + if err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies); err == nil { + t.Fatal("expected stop failure") + } + if !restarted { + t.Fatal("partially stopped legacy process was not restarted") + } +} + +func TestLegacyTaskAndRunOwnershipAreStoppedByTheirOwnMechanisms(t *testing.T) { + events := []string{} + command := nativeLegacyCommand{ + executable: `C:\Users\user\VIIPER\viiper.exe`, source: legacyCommandRun, + } + state := nativeLegacyState{ + userSID: "S-1-5-21-1-2-3-1001", + scheduledAction: &nativeLegacyCommand{executable: command.executable}, + scheduledXML: stringPointer(""), + scheduledCurrentXML: stringPointer(""), + scheduledActive: true, + commands: []nativeLegacyCommand{command}, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(_ context.Context, xml string, active bool) (nativeScheduledStopResult, error) { + events = append(events, "task-stop") + if xml != "" || !active { + t.Fatalf("task snapshot xml=%q active=%v", xml, active) + } + return nativeScheduledStopResult{stopped: true, disabled: true, currentXML: ""}, nil + }, + openProcesses: func(executable, userSID string) ([]nativeLegacyProcess, error) { + events = append(events, "run-process-query") + if executable != command.executable || userSID != state.userSID { + t.Fatalf("residual query executable=%q user=%q", executable, userSID) + } + return []nativeLegacyProcess{{handle: 123, pid: 456}}, nil + }, + terminate: func(nativeLegacyProcess) error { + events = append(events, "run-process-stop") + return nil + }, + closeHandle: func(windows.Handle) { events = append(events, "run-process-close") }, + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } + if !state.scheduledStopped || !state.commands[0].running { + t.Fatalf("source state not preserved: %+v", state) + } + want := []string{"task-stop", "run-process-query", "run-process-stop", "run-process-close"} + if !reflect.DeepEqual(events, want) { + t.Fatalf("source ordering=%v want=%v", events, want) + } + + // A task-only registration must not enumerate or terminate an unrelated + // manual process merely because it shares the scheduled action's path. + state = nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: command.executable}, + scheduledXML: stringPointer(""), + scheduledCurrentXML: stringPointer(""), + } + operations.stopScheduled = func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{currentXML: ""}, nil + } + operations.openProcesses = func(string, string) ([]nativeLegacyProcess, error) { + t.Fatal("task-only migration enumerated residual same-path processes") + return nil, nil + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } +} + +func TestLegacyTaskStopMarksPossibleDisableBeforeSubprocessResult(t *testing.T) { + original := "" + state := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Legacy\viiper.exe`}, + scheduledXML: &original, + scheduledCurrentXML: &original, + scheduledEnabled: true, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{}, context.DeadlineExceeded + }, + openProcesses: func(string, string) ([]nativeLegacyProcess, error) { return nil, nil }, + terminate: func(nativeLegacyProcess) error { return nil }, + closeHandle: func(windows.Handle) {}, + } + err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if !state.scheduledDisabled || state.scheduledCurrentXML == nil || *state.scheduledCurrentXML != original { + t.Fatalf("partial disable was not admitted to rollback state: %+v", state) + } +} + +func TestKilledTaskStopRollbackRestoresOnlyExactDisabledTaskAndRunningState(t *testing.T) { + const original = `trueC:\Legacy\viiper.exe` + validDisabled := strings.Replace(original, `true`, `false`, 1) + for _, test := range []struct { + name string + current string + wantRestarted bool + }{ + {name: "exact disabled task", current: validDisabled, wantRestarted: true}, + {name: "concurrent replacement", current: strings.Replace(validDisabled, `C:\Legacy`, `C:\Evil`, 1)}, + } { + t.Run(test.name, func(t *testing.T) { + events := []string{} + manager := newFakeNativeSCM(nil, &events) + legacy := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Legacy\viiper.exe`}, + scheduledXML: stringPointer(original), + scheduledActive: true, + scheduledEnabled: true, + } + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + state.scheduledDisabled = true + state.scheduledStopped = state.scheduledActive + return context.DeadlineExceeded + } + dependencies.restoreLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restore") + return validateNativeTaskDisabledOnly(*state.scheduledXML, test.current) + } + restarted := false + dependencies.restartLegacy = func(_ context.Context, state nativeLegacyState) error { + events = append(events, "legacy-restart") + restarted = state.scheduledStopped + return nil + } + err := installNativeBrokerTransaction(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } + if restarted != test.wantRestarted { + t.Fatalf("restarted=%v want=%v events=%v error=%v", restarted, test.wantRestarted, events, err) + } + }) + } +} + +func TestLegacyTaskAlreadyDisabledRemainsValidForNativeOwnership(t *testing.T) { + original := "" + state := nativeLegacyState{ + scheduledAction: &nativeLegacyCommand{executable: `C:\Missing\viiper.exe`}, + scheduledXML: &original, + scheduledCurrentXML: &original, + } + operations := nativeLegacyStopOperations{ + stopScheduled: func(context.Context, string, bool) (nativeScheduledStopResult, error) { + return nativeScheduledStopResult{disabled: true, currentXML: original}, nil + }, + openProcesses: func(string, string) ([]nativeLegacyProcess, error) { + t.Fatal("disabled task action was treated as an active process owner") + return nil, nil + }, + terminate: func(nativeLegacyProcess) error { return nil }, + closeHandle: func(windows.Handle) {}, + } + if err := stopNativeLegacyStartupWith(context.Background(), &state, testLogger(), operations); err != nil { + t.Fatal(err) + } + if !state.scheduledDisabled || state.scheduledStopped { + t.Fatalf("pre-disabled task state=%+v", state) + } +} + +func TestNativeUninstallSnapshotsAndRemovesLegacyBeforeServiceDelete(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{userSID: "S-1-5-21-1-2-3-1001"} + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.snapshotLegacy = func(context.Context) (nativeLegacyState, error) { + events = append(events, "legacy-snapshot") + return legacy, nil + } + if err := uninstallNativeBrokerTransaction( + context.Background(), testLogger(), manager, dependencies, + ); err != nil { + t.Fatal(err) + } + if !service.deleted { + t.Fatal("native service was not deleted") + } + if !beforeEvent(events, "legacy-snapshot", "service-stop") || + !beforeEvent(events, "legacy-remove", "service-delete") { + t.Fatalf("uninstall transaction order=%v", events) + } +} + +func TestNativeUninstallRollsBackServiceRegistrationsAndProcessOnDeleteFailure(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Running}, events: &events, + failDelete: errors.New("delete failed"), + } + manager := newFakeNativeSCM(service, &events) + legacy := nativeLegacyState{ + userSID: "S-1-5-21-1-2-3-1001", + commands: []nativeLegacyCommand{{ + executable: `C:\Legacy\viiper.exe`, source: legacyCommandRun, + }}, + } + dependencies := fakeNativeInstallDependencies(manager, legacy, &events) + dependencies.stopLegacy = func(_ context.Context, state *nativeLegacyState, _ *slog.Logger) error { + events = append(events, "legacy-stop") + state.commands[0].running = true + return nil + } + err := uninstallNativeBrokerTransaction(context.Background(), testLogger(), manager, dependencies) + if err == nil || !strings.Contains(err.Error(), "delete failed") { + t.Fatalf("error=%v", err) + } + if service.deleted || service.status.State != svc.Running { + t.Fatalf("service rollback state=%v deleted=%v", service.status.State, service.deleted) + } + if !beforeEvent(events, "service-start", "legacy-restore") || + !beforeEvent(events, "legacy-restore", "legacy-restart") { + t.Fatalf("uninstall rollback order=%v", events) + } +} + +func TestNativeUninstallRejectsPausedServiceBeforeMutation(t *testing.T) { + events := []string{} + service := &fakeNativeService{ + config: mgr.Config{ServiceStartName: nativeServiceAccount}, + status: svc.Status{State: svc.Paused}, events: &events, + } + manager := newFakeNativeSCM(service, &events) + dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) + err := uninstallNativeBrokerTransaction(context.Background(), testLogger(), manager, dependencies) + if err == nil || !strings.Contains(err.Error(), "unsupported state") { + t.Fatalf("error=%v", err) + } + if service.deleted || slices.Contains(events, "service-stop") { + t.Fatalf("paused service was mutated: %v", events) + } +} + +func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { + ready := true + valid := &viipertypes.PingResponse{ + Server: "VIIPER", Transport: "native-ude", Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(udecx.CapabilityIsochronous | udecx.CapabilityDeviceLifecycle | udecx.CapabilityInputReports), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + }, + } + if err := validateNativeBrokerPing(valid); err != nil { + t.Fatal(err) + } + cases := map[string]func(*viipertypes.PingResponse){ + "not ready": func(p *viipertypes.PingResponse) { value := false; p.Ready = &value }, + "wrong ABI": func(p *viipertypes.PingResponse) { p.NativeUDE.ABIMinor++ }, + "extra caps": func(p *viipertypes.PingResponse) { p.NativeUDE.Capabilities |= uint32(udecx.CapabilityStreams) }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + copyResponse := *valid + copyNative := *valid.NativeUDE + copyResponse.NativeUDE = ©Native + mutate(©Response) + if err := validateNativeBrokerPing(©Response); err == nil { + t.Fatal("expected exact-contract rejection") + } + }) + } +} + +func TestCredentialACLUsesSIDsRatherThanLocalizedAccountNames(t *testing.T) { + const userSID = "S-1-5-21-1-2-3-1001" + for _, sddl := range []string{nativeCredentialDirectorySDDL(userSID), nativeCredentialFileSDDL(userSID)} { + if !strings.Contains(sddl, ";;;SY") || !strings.Contains(sddl, ";;;BA") || !strings.Contains(sddl, userSID) { + t.Fatalf("ACL does not explicitly name SYSTEM, administrators, and installing user by SID: %s", sddl) + } + if _, err := windows.SecurityDescriptorFromString(sddl); err != nil { + t.Fatalf("invalid SDDL %q: %v", sddl, err) + } + } +} + +func TestCredentialDirectorySecurityRejectsPrecreatedOwnerOrDACL(t *testing.T) { + const userSID = "S-1-5-21-1-2-3-1001" + expected, err := windows.SecurityDescriptorFromString(nativeCredentialDirectorySDDL(userSID)) + if err != nil { + t.Fatal(err) + } + identical, _ := windows.SecurityDescriptorFromString(nativeCredentialDirectorySDDL(userSID)) + if err := nativeSecurityDescriptorsEqual(identical, expected); err != nil { + t.Fatalf("exact protected descriptor rejected: %v", err) + } + wrongOwner, _ := windows.SecurityDescriptorFromString( + "O:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", + ) + if err := nativeSecurityDescriptorsEqual(wrongOwner, expected); err == nil { + t.Fatal("accepted user-precreated credential directory with wrong owner") + } + unprotected, _ := windows.SecurityDescriptorFromString( + "O:BAD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", + ) + if err := nativeSecurityDescriptorsEqual(unprotected, expected); err == nil { + t.Fatal("accepted credential directory without protected canonical DACL") + } +} + +func TestParseWindowsCommandRejectsNonViiperAndPreservesArguments(t *testing.T) { + identity := func(value string) (string, error) { return value, nil } + command, err := parseWindowsCommand( + `"C:\Program Files\VIIPER\viiper.exe" server --log.file "C:\logs\native log.txt"`, + identity, + ) + if err != nil { + t.Fatal(err) + } + if command.executable != `C:\Program Files\VIIPER\viiper.exe` || + !reflect.DeepEqual(command.arguments, []string{"server", "--log.file", `C:\logs\native log.txt`}) { + t.Fatalf("command=%+v", command) + } + if _, err := parseWindowsCommand(`"C:\Windows\System32\cmd.exe" /c calc`, identity); err == nil { + t.Fatal("accepted a non-VIIPER startup command") + } + command, err = parseWindowsCommand(`"%LOCALAPPDATA%\VIIPER\viiper.exe" server`, func(value string) (string, error) { + return strings.ReplaceAll(value, `%LOCALAPPDATA%`, `C:\Users\target\AppData\Local`), nil + }) + if err != nil || command.executable != `C:\Users\target\AppData\Local\VIIPER\viiper.exe` { + t.Fatalf("target-user expansion command=%+v error=%v", command, err) + } +} + +func TestNativeUserRunKeyPathUsesExplicitSIDHive(t *testing.T) { + got, err := nativeUserRunKeyPath("S-1-5-21-1-2-3-1001") + if err != nil { + t.Fatal(err) + } + want := `S-1-5-21-1-2-3-1001\` + runKeyPath + if got != want { + t.Fatalf("run key=%q want=%q", got, want) + } + for _, invalid := range []string{"", `S-1-5-21\Software`, "not-a-sid"} { + if _, err := nativeUserRunKeyPath(invalid); err == nil { + t.Fatalf("accepted invalid user SID %q", invalid) + } + } +} + +type fakeNativeSCM struct { + service *fakeNativeService + events *[]string + newServiceFailUpdate error + openErrors []error +} + +func newFakeNativeSCM(service *fakeNativeService, events *[]string) *fakeNativeSCM { + if service != nil { + if service.config.BinaryPathName == "" { + service.config.BinaryPathName = `"C:\Program Files\VIIPER\viiper.exe" service` + } + service.events = events + } + return &fakeNativeSCM{service: service, events: events} +} + +func (m *fakeNativeSCM) OpenService(string) (nativeManagedService, error) { + *m.events = append(*m.events, "service-open") + if len(m.openErrors) != 0 { + err := m.openErrors[0] + m.openErrors = m.openErrors[1:] + return nil, err + } + if m.service == nil || m.service.deleted { + return nil, windows.ERROR_SERVICE_DOES_NOT_EXIST + } + return m.service, nil +} + +func (m *fakeNativeSCM) CreateService(_ string, executable string, config mgr.Config, args ...string) (nativeManagedService, error) { + *m.events = append(*m.events, "service-create") + if m.service != nil && !m.service.deleted { + return nil, windows.ERROR_SERVICE_EXISTS + } + commandLine, err := windowsCommandLine(executable, args...) + if err != nil { + return nil, err + } + config.BinaryPathName = commandLine + m.service = &fakeNativeService{ + config: config, status: svc.Status{State: svc.Stopped}, events: m.events, + failUpdate: m.newServiceFailUpdate, + } + return m.service, nil +} + +func (m *fakeNativeSCM) Close() error { return nil } + +type fakeNativeService struct { + config mgr.Config + securityDescriptor string + status svc.Status + recoveryActions []mgr.RecoveryAction + recoveryReset uint32 + recoverNonCrash bool + deleted bool + events *[]string + failUpdate error + failSecurity error + failRecovery error + failRecoveryFlag error + failControl error + failDelete error + updateHook func() + controlHook func() + processID uint32 + startCalls int + delayStartAfter int +} + +func (s *fakeNativeService) Config() (mgr.Config, error) { return s.config, nil } +func (s *fakeNativeService) UpdateConfig(config mgr.Config) error { + *s.events = append(*s.events, "service-update") + // Model x/sys' multi-call behavior: the base service configuration may be + // committed before an optional service setting reports failure. + s.config = config + if s.updateHook != nil { + s.updateHook() + } + return s.failUpdate +} +func (s *fakeNativeService) SecurityDescriptor() (string, error) { + if s.securityDescriptor == "" { + return nativeBrokerServiceSDDL, nil + } + return s.securityDescriptor, nil +} +func (s *fakeNativeService) SetSecurityDescriptor(sddl string) error { + *s.events = append(*s.events, "service-security") + if s.failSecurity != nil { + return s.failSecurity + } + s.securityDescriptor = sddl + return nil +} +func (s *fakeNativeService) Query() (svc.Status, error) { return s.status, nil } +func (s *fakeNativeService) ProcessID() (uint32, error) { + if s.processID != 0 { + return s.processID, nil + } + if s.status.State == svc.Running { + return 4242, nil + } + return 0, nil +} +func (s *fakeNativeService) Start(...string) error { + *s.events = append(*s.events, "service-start") + s.startCalls++ + if s.delayStartAfter != 0 && s.startCalls > s.delayStartAfter { + s.status.State = svc.StartPending + } else { + s.status.State = svc.Running + } + return nil +} +func (s *fakeNativeService) Control(command svc.Cmd) (svc.Status, error) { + if command != svc.Stop { + return s.status, errors.New("unsupported fake control") + } + *s.events = append(*s.events, "service-stop") + s.status.State = svc.Stopped + if s.controlHook != nil { + s.controlHook() + } + return s.status, s.failControl +} +func (s *fakeNativeService) Delete() error { + *s.events = append(*s.events, "service-delete") + if s.failDelete != nil { + return s.failDelete + } + s.deleted = true + return nil +} +func (s *fakeNativeService) SetRecoveryActions(actions []mgr.RecoveryAction, reset uint32) error { + *s.events = append(*s.events, "service-recovery") + s.recoveryActions = append([]mgr.RecoveryAction(nil), actions...) + s.recoveryReset = reset + return s.failRecovery +} +func (s *fakeNativeService) SetRecoveryActionsExact(actions []mgr.RecoveryAction, reset uint32) error { + return s.SetRecoveryActions(actions, reset) +} +func (s *fakeNativeService) RecoveryActions() ([]mgr.RecoveryAction, error) { + return append([]mgr.RecoveryAction(nil), s.recoveryActions...), nil +} +func (s *fakeNativeService) ResetRecoveryActions() error { + s.recoveryActions = nil + s.recoveryReset = 0 + return nil +} +func (s *fakeNativeService) ResetPeriod() (uint32, error) { return s.recoveryReset, nil } +func (s *fakeNativeService) SetRecoveryActionsOnNonCrashFailures(value bool) error { + *s.events = append(*s.events, "service-recovery-flag") + s.recoverNonCrash = value + return s.failRecoveryFlag +} +func (s *fakeNativeService) RecoveryActionsOnNonCrashFailures() (bool, error) { + return s.recoverNonCrash, nil +} +func (s *fakeNativeService) Close() error { return nil } + +func fakeNativeInstallDependencies( + manager *fakeNativeSCM, + legacy nativeLegacyState, + events *[]string, +) nativeInstallDependencies { + return nativeInstallDependencies{ + connectSCM: func() (nativeSCM, error) { return manager, nil }, + lockExecutable: func(string) (func(), error) { return func() {}, nil }, + lockPriorExecutable: func(string) (func(), error) { return func() {}, nil }, + provisionCredential: func() (nativeCredential, error) { + return nativeCredential{path: `C:\ProgramData\VIIPER\viiper.key.txt`, password: "credential", created: true}, nil + }, + rollbackCredential: func(nativeCredential) error { return nil }, + preflightDriver: func() error { + *events = append(*events, "driver-preflight") + return nil + }, + snapshotLegacy: func(context.Context) (nativeLegacyState, error) { return legacy, nil }, + stopLegacy: func(context.Context, *nativeLegacyState, *slog.Logger) error { + *events = append(*events, "legacy-stop") + return nil + }, + removeLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-remove") + return nil + }, + restoreLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-restore") + return nil + }, + restartLegacy: func(context.Context, nativeLegacyState) error { + *events = append(*events, "legacy-restart") + return nil + }, + verifyBroker: func(context.Context, string) error { + *events = append(*events, "verify") + return nil + }, + wait: immediateWait, + } +} + +func immediateWait(context.Context, time.Duration) error { return nil } + +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func stringPointer(value string) *string { return &value } + +func nativeRunRegistrationPointer(value string, valueType uint32) *nativeRunRegistration { + return &nativeRunRegistration{value: value, valueType: valueType} +} + +func beforeEvent(events []string, first, second string) bool { + firstIndex, secondIndex := -1, -1 + for index, event := range events { + if event == first && firstIndex < 0 { + firstIndex = index + } + if event == second && secondIndex < 0 { + secondIndex = index + } + } + return firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex +} + +func lastIndex(events []string, value string) int { + for index := len(events) - 1; index >= 0; index-- { + if events[index] == value { + return index + } + } + return -1 +} diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go index 675c2121..39846424 100644 --- a/internal/cmd/service_windows.go +++ b/internal/cmd/service_windows.go @@ -7,13 +7,13 @@ import ( "errors" "fmt" "log/slog" - "os" "path/filepath" "strings" "sync" "time" "github.com/Alia5/VIIPER/internal/log" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" ) @@ -52,12 +52,9 @@ func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error } func nativeServiceKeyFilePath() (string, error) { - programData := strings.TrimSpace(os.Getenv("ProgramData")) - if programData == "" { - return "", errors.New("ProgramData is not set; refusing to place a machine service credential in a user profile") - } - if !filepath.IsAbs(programData) { - return "", fmt.Errorf("ProgramData must be an absolute path: %s", programData) + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", fmt.Errorf("resolve ProgramData known folder: %w", err) } return filepath.Join(filepath.Clean(programData), "VIIPER", keyFileName), nil } diff --git a/internal/cmd/service_windows_test.go b/internal/cmd/service_windows_test.go index e4853f44..f8af979f 100644 --- a/internal/cmd/service_windows_test.go +++ b/internal/cmd/service_windows_test.go @@ -9,16 +9,21 @@ import ( "testing" "time" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" ) func TestNativeServiceKeyFileUsesMachineData(t *testing.T) { - t.Setenv("ProgramData", `C:\ProgramData`) + t.Setenv("ProgramData", `C:\Users\attacker\redirected`) got, err := nativeServiceKeyFilePath() if err != nil { t.Fatal(err) } - want := filepath.Join(`C:\ProgramData`, "VIIPER", keyFileName) + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(programData, "VIIPER", keyFileName) if got != want { t.Fatalf("key path=%q want=%q", got, want) } From 54e86acfafaf6f42d28bd8e8fd12a2fc6949515b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 08:20:36 -0500 Subject: [PATCH 135/240] Stress native Windows IOCP client lifecycle --- .github/workflows/native-ude.yml | 5 + internal/transport/udecx/client_windows.go | 90 ++++- .../udecx/client_windows_stress_test.go | 320 ++++++++++++++++++ 3 files changed, 398 insertions(+), 17 deletions(-) create mode 100644 internal/transport/udecx/client_windows_stress_test.go diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 449f1e28..919ca8b0 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -72,6 +72,11 @@ jobs: cache: true - name: Test complete VIIPER tree run: go test ./... + - name: Stress native Windows client cancellation, close, pump failure, and reconnect + run: >- + go test -count=10 -timeout=5m + -run=^TestWindowsClientIOCPStress$ + ./internal/transport/udecx - name: Vet complete VIIPER tree run: go vet ./... - name: Type-check cross-transport end-to-end benchmark diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index b90e87ab..5e067a01 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -60,6 +60,8 @@ type Client struct { completionPort windows.Handle pumpDone chan struct{} pumpErr error + closeDone chan struct{} + closeErr error requestPool sync.Pool completionPool sync.Pool // Windows suppresses IOCP packets only for operations that return success @@ -70,6 +72,15 @@ type Client struct { driverNonce uint64 capabilities Capabilities limits NegotiateResponse + // pendingObserver is a package-private synchronization seam for the + // Windows IOCP stress harness. Production clients leave it nil. It runs + // only after the overlapped issuer has returned ERROR_IO_PENDING, so tests can + // trigger cancellation and close without scheduler sleeps. + pendingObserver func(*ioRequest) + // overlappedIssuer lets the Windows-only stress harness substitute another + // real overlapped kernel request for DeviceIoControl. Production clients + // leave it nil and always issue the native UDE IOCTL below. + overlappedIssuer func(windows.Handle, *windows.Overlapped) (uint32, error) } type ioCompletion struct { @@ -149,28 +160,46 @@ func enableSkipCompletionPortOnSuccess(handle windows.Handle) bool { func (c *Client) Close() error { c.mu.Lock() if c.handle == 0 || c.handle == windows.InvalidHandle { + closeDone := c.closeDone + closeErr := c.closeErr c.mu.Unlock() - return nil + if closeDone == nil { + return closeErr + } + <-closeDone + c.mu.RLock() + defer c.mu.RUnlock() + return c.closeErr } handle := c.handle completionPort := c.completionPort pumpDone := c.pumpDone + closeDone := make(chan struct{}) + c.closeDone = closeDone c.handle = windows.InvalidHandle c.completionPort = windows.InvalidHandle c.mu.Unlock() _ = windows.CancelIoEx(handle, nil) c.inflight.Wait() + var closeErr error if err := windows.PostQueuedCompletionStatus( completionPort, 0, completionPortCloseKey, nil); err != nil { // Closing the port is the documented escape hatch for a waiter when a // sentinel cannot be posted. The pump records the abandoned wait. _ = windows.CloseHandle(completionPort) <-pumpDone - return errors.Join(windows.CloseHandle(handle), err) + closeErr = errors.Join(windows.CloseHandle(handle), err) + } else { + <-pumpDone + closeErr = errors.Join(windows.CloseHandle(handle), windows.CloseHandle(completionPort)) } - <-pumpDone - return errors.Join(windows.CloseHandle(handle), windows.CloseHandle(completionPort)) + + c.mu.Lock() + c.closeErr = closeErr + close(closeDone) + c.mu.Unlock() + return closeErr } func (c *Client) runCompletionPort(completionPort windows.Handle) { @@ -186,7 +215,11 @@ func (c *Client) runCompletionPort(completionPort windows.Handle) { return } c.mu.Lock() - c.pumpErr = fmt.Errorf("native UDE I/O completion pump stopped: %w", err) + if err == nil { + c.pumpErr = errors.New("native UDE I/O completion pump stopped on an unexpected packet") + } else { + c.pumpErr = fmt.Errorf("native UDE I/O completion pump stopped: %w", err) + } c.mu.Unlock() return } @@ -217,6 +250,22 @@ func completionAfterCancel(result ioCompletion, contextErr error) (uint32, error return result.transferred, errors.Join(contextErr, result.err) } +func completionAfterPumpStop(handle windows.Handle, request *ioRequest) ioCompletion { + // The pump closes pumpDone only after its last channel send. Drain that + // terminal packet first: if both channels were ready, select may have chosen + // pumpDone and leaving request.done populated would poison pooled reuse. + select { + case result := <-request.done: + return result + default: + } + + _ = windows.CancelIoEx(handle, &request.overlapped) + var transferred uint32 + err := windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) + return ioCompletion{transferred: transferred, err: err} +} + func (c *Client) Capabilities() Capabilities { c.mu.RLock() defer c.mu.RUnlock() @@ -410,6 +459,9 @@ func (c *Client) beginIO() (windows.Handle, error) { if c.handle == 0 || c.handle == windows.InvalidHandle { return windows.InvalidHandle, windows.ERROR_INVALID_HANDLE } + if c.pumpErr != nil { + return windows.InvalidHandle, c.pumpErr + } c.inflight.Add(1) return c.handle, nil } @@ -443,11 +495,15 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( outputPointer = &output[0] } var immediate uint32 - err = windows.DeviceIoControl( - handle, code, - inputPointer, uint32(len(input)), - outputPointer, uint32(len(output)), - &immediate, &request.overlapped) + if c.overlappedIssuer != nil { + immediate, err = c.overlappedIssuer(handle, &request.overlapped) + } else { + err = windows.DeviceIoControl( + handle, code, + inputPointer, uint32(len(input)), + outputPointer, uint32(len(output)), + &immediate, &request.overlapped) + } if err == nil && c.skipCompletionPortOnSuccess { // FILE_SKIP_COMPLETION_PORT_ON_SUCCESS guarantees that no completion // packet exists for this exact immediate-success operation. Returning @@ -458,6 +514,9 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { return 0, err } + if errors.Is(err, windows.ERROR_IO_PENDING) && c.pendingObserver != nil { + c.pendingObserver(request) + } select { case result := <-request.done: @@ -468,15 +527,12 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( case result := <-request.done: return completionAfterCancel(result, ctx.Err()) case <-c.pumpDone: - var transferred uint32 - _ = windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) - return 0, errors.Join(ctx.Err(), c.completionPumpError()) + result := completionAfterPumpStop(handle, request) + return completionAfterCancel(result, errors.Join(ctx.Err(), c.completionPumpError())) } case <-c.pumpDone: - _ = windows.CancelIoEx(handle, &request.overlapped) - var transferred uint32 - _ = windows.GetOverlappedResult(handle, &request.overlapped, &transferred, true) - return 0, c.completionPumpError() + result := completionAfterPumpStop(handle, request) + return completionAfterCancel(result, c.completionPumpError()) } } diff --git a/internal/transport/udecx/client_windows_stress_test.go b/internal/transport/udecx/client_windows_stress_test.go new file mode 100644 index 00000000..1dac9d52 --- /dev/null +++ b/internal/transport/udecx/client_windows_stress_test.go @@ -0,0 +1,320 @@ +//go:build windows + +package udecx + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +var pipeHarnessSequence atomic.Uint64 + +type controlledDeadline struct { + context.Context + done chan struct{} +} + +func newControlledDeadline() *controlledDeadline { + return &controlledDeadline{Context: context.Background(), done: make(chan struct{})} +} + +func (c *controlledDeadline) Done() <-chan struct{} { return c.done } + +func (c *controlledDeadline) Err() error { + select { + case <-c.done: + return context.DeadlineExceeded + default: + return nil + } +} + +func (c *controlledDeadline) expire() { close(c.done) } + +type ioctlResult struct { + written uint32 + err error +} + +type pipeIOCPHarness struct { + t *testing.T + client *Client + name string + pending chan *ioRequest +} + +// Named-pipe connection requests are genuine cancellable Windows overlapped +// operations. Substituting only the syscall issuer exercises the production +// request pool, IOCP pump, timeout, cancellation, and close state machine +// without requiring an installed UDE driver on hosted CI. +func newPipeIOCPHarness(t *testing.T, name string) *pipeIOCPHarness { + t.Helper() + if name == "" { + name = fmt.Sprintf(`\\.\pipe\viiper-udecx-iocp-%d-%d`, os.Getpid(), pipeHarnessSequence.Add(1)) + } + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateNamedPipe( + namePointer, + windows.PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED|windows.FILE_FLAG_FIRST_PIPE_INSTANCE, + windows.PIPE_TYPE_BYTE|windows.PIPE_READMODE_BYTE|windows.PIPE_WAIT|windows.PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, nil) + if err != nil { + t.Fatalf("create IOCP harness pipe: %v", err) + } + port, err := windows.CreateIoCompletionPort(handle, 0, 0, 1) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("associate IOCP harness pipe: %v", err) + } + + pending := make(chan *ioRequest, 1) + client := &Client{ + handle: handle, + completionPort: port, + pumpDone: make(chan struct{}), + overlappedIssuer: func(handle windows.Handle, overlapped *windows.Overlapped) (uint32, error) { + return 0, windows.ConnectNamedPipe(handle, overlapped) + }, + pendingObserver: func(request *ioRequest) { + pending <- request + }, + } + client.requestPool.New = func() any { + return &ioRequest{done: make(chan ioCompletion, 1)} + } + go client.runCompletionPort(port) + + harness := &pipeIOCPHarness{t: t, client: client, name: name, pending: pending} + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("close IOCP harness: %v", err) + } + }) + return harness +} + +func (h *pipeIOCPHarness) listen(ctx context.Context) <-chan ioctlResult { + h.t.Helper() + result := make(chan ioctlResult, 1) + go func() { + written, err := h.client.ioctl(ctx, 0, nil, nil) + result <- ioctlResult{written: written, err: err} + }() + return result +} + +func (h *pipeIOCPHarness) waitPending(result <-chan ioctlResult) *ioRequest { + h.t.Helper() + select { + case request := <-h.pending: + return request + case completed := <-result: + h.t.Fatalf("overlapped request completed before becoming pending: (%d, %v)", completed.written, completed.err) + return nil + case <-time.After(5 * time.Second): + h.t.Fatal("overlapped request did not become pending") + return nil + } +} + +func (h *pipeIOCPHarness) waitResult(result <-chan ioctlResult) ioctlResult { + h.t.Helper() + select { + case completed := <-result: + return completed + case <-time.After(5 * time.Second): + h.t.Fatal("overlapped request did not finish") + return ioctlResult{} + } +} + +func (h *pipeIOCPHarness) connect() windows.Handle { + h.t.Helper() + namePointer, err := windows.UTF16PtrFromString(h.name) + if err != nil { + h.t.Fatal(err) + } + handle, err := windows.CreateFile( + namePointer, + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + h.t.Fatalf("connect IOCP harness pipe: %v", err) + } + return handle +} + +func TestWindowsClientIOCPStress(t *testing.T) { + previousProcs := runtime.GOMAXPROCS(1) + t.Cleanup(func() { runtime.GOMAXPROCS(previousProcs) }) + + t.Run("deadline cancellation drains packets before request reuse", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + var priorRequest *ioRequest + reused := false + for iteration := 0; iteration < 32; iteration++ { + deadline := newControlledDeadline() + result := harness.listen(deadline) + request := harness.waitPending(result) + if request == priorRequest { + reused = true + } + deadline.expire() + completed := harness.waitResult(result) + if completed.written != 0 || !errors.Is(completed.err, context.DeadlineExceeded) { + t.Fatalf("iteration %d cancellation = (%d, %v), want deadline exceeded", iteration, completed.written, completed.err) + } + select { + case stale := <-request.done: + t.Fatalf("iteration %d left stale completion %+v", iteration, stale) + default: + } + priorRequest = request + } + if !reused { + t.Fatal("stress loop did not reuse an OVERLAPPED request") + } + + result := harness.listen(context.Background()) + harness.waitPending(result) + peer := harness.connect() + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("completion after cancellation stress = (%d, %v), want success", completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + }) + + t.Run("close drains pending IO and serializes callers", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + result := harness.listen(context.Background()) + harness.waitPending(result) + + const callers = 32 + start := make(chan struct{}) + closeResults := make(chan error, callers) + var ready sync.WaitGroup + ready.Add(callers) + for range callers { + go func() { + ready.Done() + <-start + closeResults <- harness.client.Close() + }() + } + ready.Wait() + close(start) + for range callers { + if err := <-closeResults; err != nil { + t.Fatalf("concurrent Close: %v", err) + } + } + completed := harness.waitResult(result) + if !errors.Is(completed.err, windows.ERROR_OPERATION_ABORTED) { + t.Fatalf("pending IO after Close = %v, want ERROR_OPERATION_ABORTED", completed.err) + } + select { + case <-harness.client.pumpDone: + default: + t.Fatal("Close returned before the IOCP pump stopped") + } + if _, err := harness.client.ioctl(context.Background(), 0, nil, nil); !errors.Is(err, windows.ERROR_INVALID_HANDLE) { + t.Fatalf("IO after Close error=%v want ERROR_INVALID_HANDLE", err) + } + }) + + t.Run("pump failure drains completion and closes admission", func(t *testing.T) { + buffered := &ioRequest{done: make(chan ioCompletion, 1)} + buffered.done <- ioCompletion{transferred: 547} + if completed := completionAfterPumpStop(windows.InvalidHandle, buffered); completed.err != nil || completed.transferred != 547 { + t.Fatalf("buffered completion after pump stop = %+v, want successful 547 bytes", completed) + } + select { + case stale := <-buffered.done: + t.Fatalf("pump-stop drain left stale completion %+v", stale) + default: + } + + harness := newPipeIOCPHarness(t, "") + releaseIssue := make(chan struct{}) + harness.client.pendingObserver = func(request *ioRequest) { + harness.pending <- request + <-releaseIssue + } + result := harness.listen(context.Background()) + harness.waitPending(result) + peer := harness.connect() + if err := windows.PostQueuedCompletionStatus(harness.client.completionPort, 0, 0, nil); err != nil { + t.Fatal(err) + } + select { + case <-harness.client.pumpDone: + case <-time.After(5 * time.Second): + t.Fatal("forced IOCP pump stop did not complete") + } + close(releaseIssue) + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("kernel completion racing pump stop = (%d, %v), want success", completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + + second := harness.listen(context.Background()) + completed = harness.waitResult(second) + if completed.err == nil || !strings.Contains(completed.err.Error(), "completion pump stopped") { + t.Fatalf("new IO after forced pump stop error=%v", completed.err) + } + select { + case request := <-harness.pending: + t.Fatalf("pump failure admitted a new kernel request %p", request) + default: + } + }) + + t.Run("reconnect isolates old completion ports", func(t *testing.T) { + name := fmt.Sprintf(`\\.\pipe\viiper-udecx-reconnect-%d-%d`, os.Getpid(), pipeHarnessSequence.Add(1)) + for iteration := 0; iteration < 16; iteration++ { + oldClient := newPipeIOCPHarness(t, name) + oldResult := oldClient.listen(context.Background()) + oldClient.waitPending(oldResult) + if err := oldClient.client.Close(); err != nil { + t.Fatalf("iteration %d close old connection: %v", iteration, err) + } + if completed := oldClient.waitResult(oldResult); !errors.Is(completed.err, windows.ERROR_OPERATION_ABORTED) { + t.Fatalf("iteration %d old connection result=%v", iteration, completed.err) + } + + newClient := newPipeIOCPHarness(t, name) + newResult := newClient.listen(context.Background()) + newClient.waitPending(newResult) + peer := newClient.connect() + completed := newClient.waitResult(newResult) + if completed.err != nil || completed.written != 0 { + t.Fatalf("iteration %d new connection = (%d, %v), want success", iteration, completed.written, completed.err) + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + if err := newClient.client.Close(); err != nil { + t.Fatalf("iteration %d close new connection: %v", iteration, err) + } + } + }) +} From 9053531c0ca092ac7c3465ff4a45a103080efc02 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 08:25:45 -0500 Subject: [PATCH 136/240] ci: pin native release actions --- .github/workflows/native-ude.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 919ca8b0..17176f94 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -65,8 +65,8 @@ jobs: protocol: runs-on: windows-2025 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: "1.26.5" cache: true @@ -89,8 +89,8 @@ jobs: race: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: "1.26.5" cache: true @@ -109,16 +109,16 @@ jobs: driver: runs-on: windows-2025-vs2026 steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: c-cpp build-mode: manual queries: security-extended - - uses: microsoft/setup-msbuild@v2 + - uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2 with: msbuild-architecture: x64 - - uses: NuGet/setup-nuget@v2 + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 - name: Restore WDK packages run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive - name: Validate Windows and KMDF target contract @@ -209,10 +209,10 @@ jobs: -SourceRevision $env:GITHUB_SHA ` -AcknowledgeTestingOnly - name: Analyze native driver and setup helper - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: category: /language:c-cpp - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ github.event_name != 'workflow_call' || inputs.upload_artifacts }} with: name: ViiperUde-x64-test-signed From 977468bed085b003e8c4d2360398b6a03069e750 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 08:31:20 -0500 Subject: [PATCH 137/240] Harden native UDE operation ABI validation --- .../udecx/client_layout_windows_test.go | 14 + internal/transport/udecx/client_windows.go | 5 +- internal/transport/udecx/protocol.go | 39 +- .../transport/udecx/protocol_contract_test.go | 452 ++++++++++++++++++ internal/transport/udecx/protocol_test.go | 171 ++++++- native/udecx/driver/Controller.c | 12 +- native/udecx/include/ViiperUdeProtocol.h | 13 + 7 files changed, 697 insertions(+), 9 deletions(-) create mode 100644 internal/transport/udecx/client_layout_windows_test.go create mode 100644 internal/transport/udecx/protocol_contract_test.go diff --git a/internal/transport/udecx/client_layout_windows_test.go b/internal/transport/udecx/client_layout_windows_test.go new file mode 100644 index 00000000..07a31033 --- /dev/null +++ b/internal/transport/udecx/client_layout_windows_test.go @@ -0,0 +1,14 @@ +//go:build windows + +package udecx + +import ( + "testing" + "unsafe" +) + +func TestIORequestOverlappedRemainsFirstField(t *testing.T) { + if got := unsafe.Offsetof(ioRequest{}.overlapped); got != 0 { + t.Fatalf("unsafe.Offsetof(ioRequest{}.overlapped)=%d want=0", got) + } +} diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 5e067a01..0b2869d3 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -386,10 +386,7 @@ func (c *Client) Dequeue(ctx context.Context, buffer []byte) (Operation, error) if err != nil { return Operation{}, err } - if written < OperationSize || written > uint32(len(buffer)) { - return Operation{}, ErrInvalidSize - } - return ParseOperation(buffer[:written]) + return parseDequeuedOperation(buffer, written) } func (c *Client) Complete(ctx context.Context, completion Completion) error { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 066a7ba8..da3d423f 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -313,7 +313,13 @@ func ParseOperation(src []byte) (Operation, error) { if h.Size < OperationSize || h.Size > MaxTransferBytes+OperationSize+MaxIsoPackets*IsoPacketSize { return Operation{}, ErrInvalidSize } - src = src[:h.Size] + // DeviceIoControl returns an independent byte count. Accepting an embedded + // size smaller than that count silently discards an unvalidated tail and can + // desynchronize the operation stream. A dequeued operation is one exact + // message, never a prefix of one. + if uint64(h.Size) != uint64(len(src)) { + return Operation{}, ErrInvalidSize + } packetCount := binary.LittleEndian.Uint32(src[56:60]) transferLength := binary.LittleEndian.Uint32(src[60:64]) payloadOffset := binary.LittleEndian.Uint32(src[64:68]) @@ -322,7 +328,15 @@ func ParseOperation(src []byte) (Operation, error) { if packetCount > MaxIsoPackets || transferLength > MaxTransferBytes || payloadLength > MaxTransferBytes { return Operation{}, ErrLimitExceeded } - if !validRange(payloadOffset, payloadLength, h.Size) || !validArrayRange(isoOffset, packetCount, IsoPacketSize, h.Size) { + isoBytes := packetCount * IsoPacketSize + expectedPayloadOffset := uint32(OperationSize) + isoBytes + // The kernel serializer emits a single canonical tail: ISO metadata first, + // then payload, with neither gaps nor aliases. Rejecting alternate layouts + // closes overlap/header-alias ambiguity before any slice is formed. + if isoOffset != OperationSize || payloadOffset != expectedPayloadOffset || + uint64(expectedPayloadOffset)+uint64(payloadLength) != uint64(h.Size) || + !validArrayRange(isoOffset, packetCount, IsoPacketSize, h.Size) || + !validRange(payloadOffset, payloadLength, h.Size) { return Operation{}, ErrInvalidRange } op := Operation{ @@ -349,15 +363,34 @@ func ParseOperation(src []byte) (Operation, error) { copy(op.SetupPacket[:], src[76:84]) for i := range op.IsoPackets { off := int(isoOffset) + i*IsoPacketSize - op.IsoPackets[i] = IsoPacket{ + packet := IsoPacket{ Offset: binary.LittleEndian.Uint32(src[off : off+4]), Length: binary.LittleEndian.Uint32(src[off+4 : off+8]), Status: int32(binary.LittleEndian.Uint32(src[off+8 : off+12])), } + if binary.LittleEndian.Uint32(src[off+12:off+16]) != 0 || + !validRange(packet.Offset, packet.Length, transferLength) { + return Operation{}, fmt.Errorf("%w: ISO packet %d", ErrInvalidRange, i) + } + op.IsoPackets[i] = packet } return op, nil } +// parseDequeuedOperation binds the kernel's independent bytes-returned value +// to the embedded wire size before ParseOperation sees the message. Keeping +// this validation platform-neutral makes malformed completion fixtures +// deterministic on every CI host. +func parseDequeuedOperation(buffer []byte, bytesReturned uint32) (Operation, error) { + if bytesReturned < OperationSize || uint64(bytesReturned) > uint64(len(buffer)) { + return Operation{}, ErrInvalidSize + } + if binary.LittleEndian.Uint32(buffer[8:12]) != bytesReturned { + return Operation{}, ErrInvalidSize + } + return ParseOperation(buffer[:bytesReturned]) +} + type Completion struct { Token uint64 DeviceID uint64 diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go new file mode 100644 index 00000000..f228b84b --- /dev/null +++ b/internal/transport/udecx/protocol_contract_test.go @@ -0,0 +1,452 @@ +package udecx + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "testing" +) + +// These mirrors are never serialized through unsafe. They let CI prove that +// Go's view of every packed native field size and offset still matches the C +// header that the KMDF driver compiles. +type contractHeader struct { + Magic uint32 + Major uint16 + Minor uint16 + Size uint32 + Flags uint32 +} + +type contractNegotiateRequest struct { + Header contractHeader + ClientNonce uint64 + RequestedCapabilities uint32 + Reserved uint32 +} + +type contractNegotiateResponse struct { + Header contractHeader + ClientNonce uint64 + DriverNonce uint64 + Capabilities uint32 + MaxDevices uint32 + MaxDescriptorBytes uint32 + MaxTransferBytes uint32 + MaxIsoPackets uint32 + MaxPendingOperations uint32 +} + +type contractDescriptorRecord struct { + Kind uint16 + Index uint16 + LanguageId uint16 + Reserved uint16 + Offset uint32 + Length uint32 +} + +type contractCreateDevice struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Speed uint32 + DescriptorCount uint32 + DescriptorRecordsOffset uint32 + DescriptorDataOffset uint32 + DescriptorDataLength uint32 + MaxPendingOperations uint32 + Reserved uint32 +} + +type contractDeviceIdentity struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Reserved uint32 +} + +type contractISOPacket struct { + Offset uint32 + Length uint32 + Status int32 + Reserved uint32 +} + +type contractOperation struct { + Header contractHeader + Token uint64 + DeviceId uint64 + Generation uint32 + Kind uint32 + EndpointAddress uint8 + Direction uint8 + InterfaceNumber uint8 + InterfaceSetting uint8 + UrbFunction uint32 + TransferFlags uint32 + StartFrame uint32 + IsoPacketCount uint32 + TransferLength uint32 + PayloadOffset uint32 + PayloadLength uint32 + IsoPacketsOffset uint32 + SetupPacket [8]uint8 + EndpointAttributes uint8 + EndpointInterval uint8 + EndpointMaxPacketSize uint16 + EndpointSequence uint64 + DeviceSequence uint64 +} + +type contractCompletion struct { + Header contractHeader + Token uint64 + DeviceId uint64 + Generation uint32 + Status int32 + UsbdStatus uint32 + TransferLength uint32 + IsoPacketCount uint32 + PayloadOffset uint32 + PayloadLength uint32 + IsoPacketsOffset uint32 + Reserved [2]uint32 +} + +type contractInputReport struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + EndpointAddress uint8 + Reserved1 [3]uint8 + PayloadOffset uint32 + PayloadLength uint32 + Sequence uint64 +} + +type contractStats struct { + Header contractHeader + OperationsDequeued uint64 + OperationsCompleted uint64 + OperationsCancelled uint64 + OperationsPurged uint64 + LateCompletions uint64 + InvalidMessages uint64 + QueueExhaustions uint64 + IsoPackets uint64 + BytesToDevice uint64 + BytesFromDevice uint64 + NotificationEvents uint64 + NotificationEventOverflows uint64 + ActiveDevices uint32 + PendingOperations uint32 + WaitingDequeues uint32 + CleanupRetries uint32 + InputReportsSubmitted uint64 + InputReportsCompleted uint64 +} + +func nativeContractSource(t *testing.T, name ...string) string { + t.Helper() + parts := append([]string{"..", "..", ".."}, name...) + raw, err := os.ReadFile(filepath.Join(parts...)) + if err != nil { + t.Fatalf("read native contract source: %v", err) + } + return string(raw) +} + +func cDefineNumber(t *testing.T, source, name string) uint64 { + t.Helper() + pattern := `(?m)^#define\s+` + regexp.QuoteMeta(name) + + `\s+(?:VIIPER_UDE_UINT(?:16|32)_C\()?((?:0x)?[0-9A-Fa-f]+)\)?(?:\s|$)` + match := regexp.MustCompile(pattern).FindStringSubmatch(source) + if match == nil { + t.Fatalf("C contract does not define %s", name) + } + value, err := strconv.ParseUint(match[1], 0, 64) + if err != nil { + t.Fatalf("parse C contract %s=%q: %v", name, match[1], err) + } + return value +} + +func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") + + numbers := map[string]uint64{ + "VIIPER_UDE_MAGIC": uint64(Magic), + "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), + "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), + "VIIPER_UDE_MAX_DEVICES": MaxDevices, + "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, + "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, + "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, + "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, + "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, + "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), + "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), + "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), + "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), + } + for name, want := range numbers { + if got := cDefineNumber(t, header, name); got != want { + t.Errorf("%s=%#x want Go %#x", name, got, want) + } + } + + types := map[string]reflect.Type{ + "HEADER": reflect.TypeOf(contractHeader{}), + "NEGOTIATE_REQUEST": reflect.TypeOf(contractNegotiateRequest{}), + "NEGOTIATE_RESPONSE": reflect.TypeOf(contractNegotiateResponse{}), + "DESCRIPTOR_RECORD": reflect.TypeOf(contractDescriptorRecord{}), + "CREATE_DEVICE": reflect.TypeOf(contractCreateDevice{}), + "DEVICE_IDENTITY": reflect.TypeOf(contractDeviceIdentity{}), + "ISO_PACKET": reflect.TypeOf(contractISOPacket{}), + "OPERATION": reflect.TypeOf(contractOperation{}), + "COMPLETION": reflect.TypeOf(contractCompletion{}), + "INPUT_REPORT": reflect.TypeOf(contractInputReport{}), + "STATS": reflect.TypeOf(contractStats{}), + } + wantSizes := map[string]uintptr{ + "HEADER": HeaderSize, "NEGOTIATE_REQUEST": NegotiateRequestSize, + "NEGOTIATE_RESPONSE": NegotiateResponseSize, "DESCRIPTOR_RECORD": DescriptorRecordSize, + "CREATE_DEVICE": CreateDeviceSize, "DEVICE_IDENTITY": DeviceIdentitySize, + "ISO_PACKET": IsoPacketSize, "OPERATION": OperationSize, "COMPLETION": CompletionSize, + "INPUT_REPORT": InputReportSize, "STATS": StatsSize, + } + sizePattern := regexp.MustCompile(`static_assert\(sizeof\(VIIPER_UDE_([A-Z_]+)\) == ([0-9]+),`) + seenSizes := make(map[string]bool) + for _, match := range sizePattern.FindAllStringSubmatch(header, -1) { + name := match[1] + wireType, ok := types[name] + if !ok { + t.Fatalf("C contract added unmodeled type VIIPER_UDE_%s", name) + } + declared, _ := strconv.ParseUint(match[2], 10, 64) + if got := wireType.Size(); uint64(got) != declared || got != wantSizes[name] { + t.Errorf("VIIPER_UDE_%s size: C=%d Go=%d contract=%d", name, declared, got, wantSizes[name]) + } + seenSizes[name] = true + } + if len(seenSizes) != len(types) { + t.Fatalf("C size contracts found=%d want=%d", len(seenSizes), len(types)) + } + + offsetPattern := regexp.MustCompile(`VIIPER_UDE_ASSERT_OFFSET\(VIIPER_UDE_([A-Z_]+),\s*([A-Za-z0-9_]+),\s*([0-9]+)\);`) + seenOffsets := 0 + for _, match := range offsetPattern.FindAllStringSubmatch(header, -1) { + wireType, ok := types[match[1]] + if !ok { + t.Fatalf("C contract added offsets for unmodeled type VIIPER_UDE_%s", match[1]) + } + field, ok := wireType.FieldByName(match[2]) + if !ok { + t.Fatalf("Go contract type %s has no field %s", match[1], match[2]) + } + want, _ := strconv.ParseUint(match[3], 10, 64) + if uint64(field.Offset) != want { + t.Errorf("VIIPER_UDE_%s.%s offset: C=%d Go=%d", match[1], match[2], want, field.Offset) + } + seenOffsets++ + } + if seenOffsets == 0 { + t.Fatal("C field-offset contracts were not found") + } + + enums := map[string]uint64{ + "ViiperUdeDescriptorDevice": uint64(DescriptorDevice), + "ViiperUdeDescriptorConfiguration": uint64(DescriptorConfiguration), + "ViiperUdeDescriptorBos": uint64(DescriptorBOS), + "ViiperUdeDescriptorString": uint64(DescriptorString), + "ViiperUdeOperationControl": uint64(OperationControl), + "ViiperUdeOperationTransfer": uint64(OperationTransfer), + "ViiperUdeOperationEndpointStart": uint64(OperationEndpointStart), + "ViiperUdeOperationEndpointPurge": uint64(OperationEndpointPurge), + "ViiperUdeOperationEndpointReset": uint64(OperationEndpointReset), + "ViiperUdeOperationDeviceReset": uint64(OperationDeviceReset), + "ViiperUdeOperationSetInterface": uint64(OperationSetInterface), + "ViiperUdeOperationDeviceD0Entry": uint64(OperationDeviceD0Entry), + "ViiperUdeOperationDeviceD0Exit": uint64(OperationDeviceD0Exit), + "ViiperUdeOperationCancel": uint64(OperationCancel), + "ViiperUdeOperationBrokerFault": uint64(OperationBrokerFault), + } + enumPattern := regexp.MustCompile(`(?m)^\s*(ViiperUde[A-Za-z0-9]+)\s*=\s*([0-9]+)[,\s]`) + seenEnums := make(map[string]bool) + for _, match := range enumPattern.FindAllStringSubmatch(header, -1) { + want, ok := enums[match[1]] + if !ok { + t.Fatalf("C contract added unmodeled enum %s", match[1]) + } + got, _ := strconv.ParseUint(match[2], 10, 64) + if got != want { + t.Errorf("%s=%d want Go %d", match[1], got, want) + } + seenEnums[match[1]] = true + } + if len(seenEnums) != len(enums) { + t.Fatalf("C enum contracts found=%d want=%d", len(seenEnums), len(enums)) + } + + verifyGUIDAndIOCTLContract(t, header) +} + +func evalGoInteger(expr ast.Expr, values map[string]uint64) (uint64, bool) { + switch value := expr.(type) { + case *ast.BasicLit: + parsed, err := strconv.ParseUint(value.Value, 0, 64) + return parsed, err == nil + case *ast.Ident: + parsed, ok := values[value.Name] + return parsed, ok + case *ast.ParenExpr: + return evalGoInteger(value.X, values) + case *ast.BinaryExpr: + left, leftOK := evalGoInteger(value.X, values) + right, rightOK := evalGoInteger(value.Y, values) + if !leftOK || !rightOK { + return 0, false + } + switch value.Op { + case token.ADD: + return left + right, true + case token.OR: + return left | right, true + case token.SHL: + return left << right, true + } + } + return 0, false +} + +func goWindowsContract(t *testing.T) (map[string]uint64, [11]uint64) { + t.Helper() + sourcePath := filepath.Join("client_windows.go") + file, err := parser.ParseFile(token.NewFileSet(), sourcePath, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", sourcePath, err) + } + values := make(map[string]uint64) + var guid [11]uint64 + for _, declaration := range file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { + continue + } + for _, rawSpec := range general.Specs { + spec, ok := rawSpec.(*ast.ValueSpec) + if !ok { + continue + } + for index, name := range spec.Names { + if index < len(spec.Values) { + if value, ok := evalGoInteger(spec.Values[index], values); ok { + values[name.Name] = value + } + } + if name.Name != "interfaceGUID" || len(spec.Values) == 0 { + continue + } + literal, ok := spec.Values[0].(*ast.CompositeLit) + if !ok { + t.Fatal("interfaceGUID is not a composite literal") + } + for _, rawElement := range literal.Elts { + element := rawElement.(*ast.KeyValueExpr) + key := element.Key.(*ast.Ident).Name + switch key { + case "Data1", "Data2", "Data3": + value, ok := evalGoInteger(element.Value, values) + if !ok { + t.Fatalf("evaluate interfaceGUID.%s", key) + } + position := map[string]int{"Data1": 0, "Data2": 1, "Data3": 2}[key] + guid[position] = value + case "Data4": + array := element.Value.(*ast.CompositeLit) + if len(array.Elts) != 8 { + t.Fatalf("interfaceGUID.Data4 elements=%d want=8", len(array.Elts)) + } + for byteIndex, byteExpression := range array.Elts { + value, ok := evalGoInteger(byteExpression, values) + if !ok { + t.Fatalf("evaluate interfaceGUID.Data4[%d]", byteIndex) + } + guid[3+byteIndex] = value + } + } + } + } + } + } + return values, guid +} + +func verifyGUIDAndIOCTLContract(t *testing.T, header string) { + t.Helper() + goValues, goGUID := goWindowsContract(t) + guidNames := []string{ + "VIIPER_UDE_INTERFACE_GUID_DATA1", "VIIPER_UDE_INTERFACE_GUID_DATA2", + "VIIPER_UDE_INTERFACE_GUID_DATA3", "VIIPER_UDE_INTERFACE_GUID_DATA4_0", + "VIIPER_UDE_INTERFACE_GUID_DATA4_1", "VIIPER_UDE_INTERFACE_GUID_DATA4_2", + "VIIPER_UDE_INTERFACE_GUID_DATA4_3", "VIIPER_UDE_INTERFACE_GUID_DATA4_4", + "VIIPER_UDE_INTERFACE_GUID_DATA4_5", "VIIPER_UDE_INTERFACE_GUID_DATA4_6", + "VIIPER_UDE_INTERFACE_GUID_DATA4_7", + } + for index, name := range guidNames { + if got := cDefineNumber(t, header, name); got != goGUID[index] { + t.Errorf("%s=%#x want Go interface GUID component %#x", name, got, goGUID[index]) + } + } + + type ioctlSpec struct { + goName string + offset uint64 + method string + access string + } + specs := map[string]ioctlSpec{ + "NEGOTIATE": {"ioctlNegotiate", 0, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "CREATE_DEVICE": {"ioctlCreateDevice", 1, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DESTROY_DEVICE": {"ioctlDestroyDevice", 2, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DEQUEUE_OPERATION": {"ioctlDequeueOperation", 3, "METHOD_OUT_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "COMPLETE_OPERATION": {"ioctlCompleteOperation", 4, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "QUERY_STATS": {"ioctlQueryStats", 5, "METHOD_BUFFERED", "FILE_READ_DATA"}, + "SUBMIT_INPUT_REPORT": {"ioctlSubmitInputReport", 6, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + } + pattern := regexp.MustCompile(`(?m)^#define IOCTL_VIIPER_UDE_([A-Z_]+) CTL_CODE\(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE \+ ([0-9]+), (METHOD_[A-Z_]+), ([^)]+)\)$`) + seen := make(map[string]bool) + methods := map[string]uint64{"METHOD_BUFFERED": 0, "METHOD_IN_DIRECT": 1, "METHOD_OUT_DIRECT": 2} + for _, match := range pattern.FindAllStringSubmatch(header, -1) { + spec, ok := specs[match[1]] + if !ok { + t.Fatalf("C contract added unmodeled IOCTL %s", match[1]) + } + offset, _ := strconv.ParseUint(match[2], 10, 64) + accessText := strings.Join(strings.Fields(match[4]), " ") + if offset != spec.offset || match[3] != spec.method || accessText != spec.access { + t.Errorf("IOCTL %s C definition=(%d,%s,%s) want=(%d,%s,%s)", + match[1], offset, match[3], accessText, spec.offset, spec.method, spec.access) + } + access := goValues["fileReadData"] | goValues["fileWriteData"] + if spec.access == "FILE_READ_DATA" { + access = goValues["fileReadData"] + } + computed := goValues["fileDeviceUnknown"]<<16 | (access << 14) | + (goValues["ioctlBase"]+offset)<<2 | methods[spec.method] + if got, ok := goValues[spec.goName]; !ok || got != computed { + t.Errorf("%s=%#x present=%v want C CTL_CODE %#x", spec.goName, got, ok, computed) + } + seen[match[1]] = true + } + if len(seen) != len(specs) { + t.Fatalf("C IOCTL contracts found=%d want=%d", len(seen), len(specs)) + } +} diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 4a3ea3c5..8a3430e9 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -123,6 +123,145 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { } } +func TestParseOperationRejectsMalformedCanonicalTail(t *testing.T) { + valid := dualSenseIsoOperationFixture(1, 4) + tests := []struct { + name string + edit func([]byte) []byte + want error + }{ + { + name: "bytes after embedded size", + edit: func(raw []byte) []byte { return append(raw, 0xaa) }, + want: ErrInvalidSize, + }, + { + name: "embedded size omits returned byte", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[8:12], uint32(len(raw)-1)) + return raw + }, + want: ErrInvalidSize, + }, + { + name: "ISO table aliases fixed header", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[72:76], OperationSize-4) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "payload aliases fixed header", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize-1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "payload overlaps ISO table", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "gap before payload", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[64:68], OperationSize+IsoPacketSize+1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "gap before ISO table", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[72:76], OperationSize+1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "unclaimed canonical tail byte", + edit: func(raw []byte) []byte { + raw = append(raw, 0) + binary.LittleEndian.PutUint32(raw[8:12], uint32(len(raw))) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "nonzero ISO reserved word", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize+12:OperationSize+16], 1) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "ISO packet exceeds transfer length", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 3) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], 2) + return raw + }, + want: ErrInvalidRange, + }, + { + name: "ISO packet extent overflows", + edit: func(raw []byte) []byte { + binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], ^uint32(0)) + binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], 2) + return raw + }, + want: ErrInvalidRange, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw := append([]byte(nil), valid...) + _, err := ParseOperation(test.edit(raw)) + if !errors.Is(err, test.want) { + t.Fatalf("ParseOperation error=%v want=%v", err, test.want) + } + }) + } +} + +func TestParseDequeuedOperationRequiresExactBytesReturned(t *testing.T) { + valid := dualSenseIsoOperationFixture(1, 4) + if _, err := parseDequeuedOperation(valid, uint32(len(valid))); err != nil { + t.Fatalf("valid dequeued operation: %v", err) + } + + tests := []struct { + name string + buffer []byte + written uint32 + }{ + {"short return", valid, OperationSize - 1}, + {"return exceeds buffer", valid, uint32(len(valid) + 1)}, + {"return truncates embedded size", valid, uint32(len(valid) - 1)}, + {"return includes trailing byte", append(append([]byte(nil), valid...), 0), uint32(len(valid) + 1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := parseDequeuedOperation(test.buffer, test.written); !errors.Is(err, ErrInvalidSize) { + t.Fatalf("parseDequeuedOperation error=%v want ErrInvalidSize", err) + } + }) + } + + headerMismatch := append([]byte(nil), valid...) + binary.LittleEndian.PutUint32(headerMismatch[8:12], uint32(len(headerMismatch)-1)) + if _, err := parseDequeuedOperation(headerMismatch, uint32(len(headerMismatch))); !errors.Is(err, ErrInvalidSize) { + t.Fatalf("header/bytes-returned mismatch error=%v want ErrInvalidSize", err) + } +} + func TestCompletionMarshalling(t *testing.T) { raw, err := (Completion{ Token: 3, DeviceID: 9, Generation: 4, Status: -1, USBDStatus: 0xc0000001, @@ -271,8 +410,38 @@ func FuzzParseOperation(f *testing.F) { binary.LittleEndian.PutUint32(valid[64:68], OperationSize) binary.LittleEndian.PutUint32(valid[72:76], OperationSize) f.Add(valid) + iso := dualSenseIsoOperationFixture(1, 4) + f.Add(iso) + trailing := append(append([]byte(nil), iso...), 0xaa) + f.Add(trailing) + reserved := append([]byte(nil), iso...) + binary.LittleEndian.PutUint32(reserved[OperationSize+12:OperationSize+16], 1) + f.Add(reserved) + extent := append([]byte(nil), iso...) + binary.LittleEndian.PutUint32(extent[OperationSize:OperationSize+4], 4) + binary.LittleEndian.PutUint32(extent[OperationSize+4:OperationSize+8], 1) + f.Add(extent) f.Fuzz(func(t *testing.T, raw []byte) { - _, _ = ParseOperation(raw) + op, err := ParseOperation(raw) + if err != nil { + return + } + if len(raw) != int(binary.LittleEndian.Uint32(raw[8:12])) { + t.Fatal("accepted bytes after embedded operation size") + } + packetCount := binary.LittleEndian.Uint32(raw[56:60]) + isoOffset := binary.LittleEndian.Uint32(raw[72:76]) + payloadOffset := binary.LittleEndian.Uint32(raw[64:68]) + if isoOffset != OperationSize || payloadOffset != OperationSize+packetCount*IsoPacketSize { + t.Fatal("accepted noncanonical operation tails") + } + for index, packet := range op.IsoPackets { + offset := int(isoOffset) + index*IsoPacketSize + if binary.LittleEndian.Uint32(raw[offset+12:offset+16]) != 0 || + !validRange(packet.Offset, packet.Length, op.TransferLength) { + t.Fatalf("accepted invalid ISO packet %d", index) + } + } }) } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index ec37bdc5..43439792 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -3,7 +3,17 @@ DEFINE_GUID( GUID_DEVINTERFACE_VIIPER_UDE, - 0x32d03f48, 0x725b, 0x4baa, 0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87); + VIIPER_UDE_INTERFACE_GUID_DATA1, + VIIPER_UDE_INTERFACE_GUID_DATA2, + VIIPER_UDE_INTERFACE_GUID_DATA3, + VIIPER_UDE_INTERFACE_GUID_DATA4_0, + VIIPER_UDE_INTERFACE_GUID_DATA4_1, + VIIPER_UDE_INTERFACE_GUID_DATA4_2, + VIIPER_UDE_INTERFACE_GUID_DATA4_3, + VIIPER_UDE_INTERFACE_GUID_DATA4_4, + VIIPER_UDE_INTERFACE_GUID_DATA4_5, + VIIPER_UDE_INTERFACE_GUID_DATA4_6, + VIIPER_UDE_INTERFACE_GUID_DATA4_7); #ifdef ALLOC_PRAGMA #pragma alloc_text(PAGE, ViiperEvtDeviceAdd) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 73de1e0f..295149cd 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -37,6 +37,19 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(8) +/* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ +#define VIIPER_UDE_INTERFACE_GUID_DATA1 VIIPER_UDE_UINT32_C(0x32d03f48) +#define VIIPER_UDE_INTERFACE_GUID_DATA2 VIIPER_UDE_UINT16_C(0x725b) +#define VIIPER_UDE_INTERFACE_GUID_DATA3 VIIPER_UDE_UINT16_C(0x4baa) +#define VIIPER_UDE_INTERFACE_GUID_DATA4_0 0x97 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_1 0x0f +#define VIIPER_UDE_INTERFACE_GUID_DATA4_2 0x7f +#define VIIPER_UDE_INTERFACE_GUID_DATA4_3 0x5d +#define VIIPER_UDE_INTERFACE_GUID_DATA4_4 0xe6 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_5 0xc4 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_6 0x46 +#define VIIPER_UDE_INTERFACE_GUID_DATA4_7 0x87 + #define VIIPER_UDE_MAX_DEVICES VIIPER_UDE_UINT32_C(32) #define VIIPER_UDE_MAX_DESCRIPTOR_BYTES VIIPER_UDE_UINT32_C(262144) #define VIIPER_UDE_MAX_TRANSFER_BYTES VIIPER_UDE_UINT32_C(1048576) From f84e550a4fb6a526f703b15132ee7a4cf7a0628f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 08:32:16 -0500 Subject: [PATCH 138/240] Retry native controller acquisition safely --- .../udecx/client_open_windows_test.go | 401 ++++++++++++++++++ internal/transport/udecx/client_windows.go | 233 ++++++++-- 2 files changed, 610 insertions(+), 24 deletions(-) create mode 100644 internal/transport/udecx/client_open_windows_test.go diff --git a/internal/transport/udecx/client_open_windows_test.go b/internal/transport/udecx/client_open_windows_test.go new file mode 100644 index 00000000..4f013ba2 --- /dev/null +++ b/internal/transport/udecx/client_open_windows_test.go @@ -0,0 +1,401 @@ +//go:build windows + +package udecx + +import ( + "context" + "errors" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +const testNativeInterfacePath = `\\?\VIIPER#native#broker` + +func TestNativeBrokerOpenRemainsExclusive(t *testing.T) { + if nativeBrokerShareMode != 0 { + t.Fatalf("native broker CreateFile share mode=%d, want exclusive mode 0", nativeBrokerShareMode) + } +} + +func TestNativeAcquisitionRediscoversUntilPriorOwnerCleanupCompletes(t *testing.T) { + discoverCalls := 0 + openCalls := 0 + waitCalls := 0 + closeCalls := 0 + wantHandle := windows.Handle(0x1234) + + handle, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + discoverCalls++ + if discoverCalls == 1 { + return nil, nil + } + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + if openCalls == 1 { + return windows.InvalidHandle, windows.ERROR_SHARING_VIOLATION + } + return wantHandle, nil + }, + close: func(windows.Handle) error { + closeCalls++ + return nil + }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 4, interval: time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if handle != wantHandle { + t.Fatalf("handle=%#x want=%#x", handle, wantHandle) + } + if discoverCalls != 3 || openCalls != 2 || waitCalls != 2 || closeCalls != 0 { + t.Fatalf("calls discover=%d open=%d wait=%d close=%d, want 3/2/2/0", + discoverCalls, openCalls, waitCalls, closeCalls) + } +} + +func TestNativeAcquisitionClassifiesBoundedInterfaceAbsence(t *testing.T) { + discoverCalls := 0 + waitCalls := 0 + + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + discoverCalls++ + return nil, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + t.Fatal("open called without a discovered interface") + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 4, interval: time.Millisecond}) + + var acquisitionErr *AcquisitionError + if !errors.As(err, &acquisitionErr) { + t.Fatalf("error=%v, want *AcquisitionError", err) + } + if acquisitionErr.Kind != AcquisitionInterfaceUnavailable || acquisitionErr.Attempts != 4 { + t.Fatalf("acquisition error=%+v, want unavailable after 4 attempts", acquisitionErr) + } + if !acquisitionErr.Temporary() || !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("error=%v, want temporary ERROR_FILE_NOT_FOUND", err) + } + if discoverCalls != 4 || waitCalls != 3 { + t.Fatalf("calls discover=%d wait=%d, want 4/3", discoverCalls, waitCalls) + } +} + +func TestNativeAcquisitionClassifiesBoundedOwnerCleanup(t *testing.T) { + openCalls := 0 + waitCalls := 0 + + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.InvalidHandle, windows.ERROR_SHARING_VIOLATION + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 3, interval: time.Millisecond}) + + var acquisitionErr *AcquisitionError + if !errors.As(err, &acquisitionErr) { + t.Fatalf("error=%v, want *AcquisitionError", err) + } + if acquisitionErr.Kind != AcquisitionOwnerCleanupInProgress || acquisitionErr.Attempts != 3 { + t.Fatalf("acquisition error=%+v, want owner cleanup after 3 attempts", acquisitionErr) + } + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("error=%v, want ERROR_SHARING_VIOLATION", err) + } + if openCalls != 3 || waitCalls != 2 { + t.Fatalf("calls open=%d wait=%d, want 3/2", openCalls, waitCalls) + } +} + +func TestNativeAcquisitionReturnsTerminalErrorsWithoutRetry(t *testing.T) { + tests := []struct { + name string + discover func(context.Context) ([]string, error) + open func(context.Context, string) (windows.Handle, error) + want error + wantOpens int + }{ + { + name: "discovery failure", + discover: func(context.Context) ([]string, error) { + return nil, windows.ERROR_INVALID_DATA + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, nil + }, + want: windows.ERROR_INVALID_DATA, + }, + { + name: "access denied", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_ACCESS_DENIED + }, + want: windows.ERROR_ACCESS_DENIED, + wantOpens: 1, + }, + { + name: "path not found is not interface absence", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_PATH_NOT_FOUND + }, + want: windows.ERROR_PATH_NOT_FOUND, + wantOpens: 1, + }, + { + name: "device disconnected", + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_DEVICE_NOT_CONNECTED + }, + want: windows.ERROR_DEVICE_NOT_CONNECTED, + wantOpens: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + openCalls := 0 + waitCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: test.discover, + open: func(ctx context.Context, path string) (windows.Handle, error) { + openCalls++ + return test.open(ctx, path) + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 5, interval: time.Millisecond}) + if !errors.Is(err, test.want) { + t.Fatalf("error=%v, want %v", err, test.want) + } + var acquisitionErr *AcquisitionError + if errors.As(err, &acquisitionErr) { + t.Fatalf("terminal error was classified transient: %+v", acquisitionErr) + } + if openCalls != test.wantOpens || waitCalls != 0 { + t.Fatalf("calls open=%d wait=%d, want %d/0", openCalls, waitCalls, test.wantOpens) + } + }) + } +} + +func TestNativeAcquisitionRejectsAmbiguousOwnershipWithoutRetry(t *testing.T) { + waitCalls := 0 + openCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{"first", "second"}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 5, interval: time.Millisecond}) + if err == nil || openCalls != 0 || waitCalls != 0 { + t.Fatalf("error=%v open=%d wait=%d, want terminal ambiguity", err, openCalls, waitCalls) + } +} + +func TestNativeAcquisitionCancellationInterruptsRetryWait(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + waitCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { return nil, nil }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.InvalidHandle, nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(ctx context.Context, _ time.Duration) error { + waitCalls++ + cancel() + <-ctx.Done() + return ctx.Err() + }, + }, nativeAcquisitionPolicy{attempts: 20, interval: time.Hour}) + if !errors.Is(err, context.Canceled) || waitCalls != 1 { + t.Fatalf("error=%v wait=%d, want prompt cancellation on first wait", err, waitCalls) + } +} + +func TestNativeAcquisitionProductionWaitHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan error, 1) + go func() { done <- waitForNativeAcquisition(ctx, time.Hour) }() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled native acquisition wait did not return promptly") + } +} + +func TestNativeAcquisitionCancellationAfterDiscoveryDoesNotOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + openCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + cancel() + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + return windows.Handle(0x1234), nil + }, + close: func(windows.Handle) error { return nil }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if !errors.Is(err, context.Canceled) || openCalls != 0 { + t.Fatalf("error=%v open=%d, want cancellation before open", err, openCalls) + } +} + +func TestNativeAcquisitionCancellationAfterOpenClosesHandle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + wantHandle := windows.Handle(0x4321) + closeCalls := 0 + _, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + cancel() + return wantHandle, nil + }, + close: func(handle windows.Handle) error { + closeCalls++ + if handle != wantHandle { + t.Fatalf("closed handle=%#x want=%#x", handle, wantHandle) + } + return nil + }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if !errors.Is(err, context.Canceled) || closeCalls != 1 { + t.Fatalf("error=%v closes=%d, want canceled and one close", err, closeCalls) + } +} + +func TestNativeAcquisitionClosesUnexpectedHandleReturnedWithError(t *testing.T) { + badHandle := windows.Handle(0x1001) + wantHandle := windows.Handle(0x1002) + openCalls := 0 + closed := make([]windows.Handle, 0, 1) + + handle, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + openCalls++ + if openCalls == 1 { + return badHandle, windows.ERROR_SHARING_VIOLATION + } + return wantHandle, nil + }, + close: func(handle windows.Handle) error { + closed = append(closed, handle) + return nil + }, + wait: func(context.Context, time.Duration) error { return nil }, + }, nativeAcquisitionPolicy{attempts: 2, interval: time.Millisecond}) + if err != nil || handle != wantHandle { + t.Fatalf("handle=%#x error=%v, want %#x", handle, err, wantHandle) + } + if len(closed) != 1 || closed[0] != badHandle { + t.Fatalf("closed=%v, want [%#x]", closed, badHandle) + } +} + +func TestNativeAcquisitionTreatsHandleCleanupFailureAsTerminal(t *testing.T) { + closeErr := errors.New("close failed") + waitCalls := 0 + _, err := acquireNativeController(context.Background(), nativeAcquisitionOps{ + discover: func(context.Context) ([]string, error) { + return []string{testNativeInterfacePath}, nil + }, + open: func(context.Context, string) (windows.Handle, error) { + return windows.Handle(0x1001), windows.ERROR_SHARING_VIOLATION + }, + close: func(windows.Handle) error { return closeErr }, + wait: func(context.Context, time.Duration) error { + waitCalls++ + return nil + }, + }, nativeAcquisitionPolicy{attempts: 3, interval: time.Millisecond}) + if !errors.Is(err, closeErr) || !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("error=%v, want joined open and handle-cleanup failures", err) + } + var acquisitionErr *AcquisitionError + if errors.As(err, &acquisitionErr) || waitCalls != 0 { + t.Fatalf("error=%v wait=%d, cleanup failure must be terminal", err, waitCalls) + } +} + +func TestNativeAcquisitionRetryClassificationIsExact(t *testing.T) { + tests := []struct { + err error + wantKind AcquisitionErrorKind + }{ + {windows.ERROR_FILE_NOT_FOUND, AcquisitionInterfaceUnavailable}, + {windows.ERROR_SHARING_VIOLATION, AcquisitionOwnerCleanupInProgress}, + {windows.ERROR_PATH_NOT_FOUND, 0}, + {windows.ERROR_DEVICE_NOT_CONNECTED, 0}, + {windows.ERROR_BUSY, 0}, + {windows.ERROR_ACCESS_DENIED, 0}, + } + for _, test := range tests { + got := classifyNativeAcquisitionError(test.err, 7) + if test.wantKind == 0 { + if got != nil { + t.Errorf("error %v classified as %+v, want terminal", test.err, got) + } + continue + } + if got == nil || got.Kind != test.wantKind || got.Attempts != 7 { + t.Errorf("error %v classified as %+v, want kind=%d attempts=7", test.err, got, test.wantKind) + } + } +} diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 0b2869d3..f8ccffb2 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -10,6 +10,7 @@ import ( "fmt" "runtime" "sync" + "time" "unicode/utf16" "unsafe" @@ -37,8 +38,64 @@ const ( completionPortCloseKey uintptr = ^uintptr(0) fileSkipCompletionPortOnSuccess byte = 0x1 requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports + // The kernel rechecks asynchronous UdeCx owner cleanup every 100 ms. Match + // that cadence for at most 1.9 seconds, rediscovering the interface before + // every exclusive CreateFile rather than spinning on a stale symbolic link. + nativeAcquisitionAttempts = 20 + nativeAcquisitionRetryInterval = 100 * time.Millisecond + nativeBrokerShareMode = 0 ) +// AcquisitionErrorKind identifies the only two controller-open failures that +// can resolve without repairing or reconfiguring the installed driver. Keep +// this set deliberately narrow: permission, ABI, ambiguity, and device faults +// must reach the caller immediately rather than being hidden by reconnect +// polling. +type AcquisitionErrorKind uint8 + +const ( + AcquisitionInterfaceUnavailable AcquisitionErrorKind = iota + 1 + AcquisitionOwnerCleanupInProgress +) + +// AcquisitionError reports a transient native-controller acquisition state. +// Temporary always returns true; every other Open error is terminal. +type AcquisitionError struct { + Kind AcquisitionErrorKind + Attempts int + Err error +} + +func (e *AcquisitionError) Error() string { + switch e.Kind { + case AcquisitionInterfaceUnavailable: + return fmt.Sprintf("VIIPER native UDE interface is temporarily unavailable after %d attempt(s): %v", e.Attempts, e.Err) + case AcquisitionOwnerCleanupInProgress: + return fmt.Sprintf("VIIPER native UDE controller ownership cleanup is still in progress after %d attempt(s): %v", e.Attempts, e.Err) + default: + return fmt.Sprintf("VIIPER native UDE controller acquisition failed after %d attempt(s): %v", e.Attempts, e.Err) + } +} + +func (e *AcquisitionError) Unwrap() error { return e.Err } + +func (e *AcquisitionError) Temporary() bool { + return e != nil && (e.Kind == AcquisitionInterfaceUnavailable || + e.Kind == AcquisitionOwnerCleanupInProgress) +} + +type nativeAcquisitionPolicy struct { + attempts int + interval time.Duration +} + +type nativeAcquisitionOps struct { + discover func(context.Context) ([]string, error) + open func(context.Context, string) (windows.Handle, error) + close func(windows.Handle) error + wait func(context.Context, time.Duration) error +} + var ( interfaceGUID = windows.GUID{ Data1: 0x32d03f48, @@ -97,32 +154,18 @@ type ioRequest struct { } func Open(ctx context.Context) (*Client, error) { - paths, err := discoverInterfacePaths() + handle, err := acquireNativeController(ctx, nativeAcquisitionOps{ + discover: discoverNativeInterfacePaths, + open: openNativeController, + close: windows.CloseHandle, + wait: waitForNativeAcquisition, + }, nativeAcquisitionPolicy{ + attempts: nativeAcquisitionAttempts, + interval: nativeAcquisitionRetryInterval, + }) if err != nil { return nil, err } - if len(paths) == 0 { - return nil, errors.New("VIIPER native UDE interface is not present") - } - if len(paths) != 1 { - return nil, fmt.Errorf("refusing ambiguous native UDE ownership: found %d controller interfaces", len(paths)) - } - - path, err := windows.UTF16PtrFromString(paths[0]) - if err != nil { - return nil, fmt.Errorf("encode native UDE interface path: %w", err) - } - handle, err := windows.CreateFile( - path, - windows.GENERIC_READ|windows.GENERIC_WRITE, - 0, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, - 0) - if err != nil { - return nil, fmt.Errorf("open native UDE controller: %w", err) - } completionPort, err := windows.CreateIoCompletionPort(handle, 0, 0, 0) if err != nil { @@ -151,6 +194,139 @@ func Open(ctx context.Context) (*Client, error) { return client, nil } +func acquireNativeController( + ctx context.Context, + ops nativeAcquisitionOps, + policy nativeAcquisitionPolicy, +) (windows.Handle, error) { + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + if policy.attempts <= 0 { + return windows.InvalidHandle, errors.New("native UDE acquisition policy has no attempts") + } + if policy.interval <= 0 { + return windows.InvalidHandle, errors.New("native UDE acquisition retry interval must be positive") + } + + var lastTransient *AcquisitionError + for attempt := 1; attempt <= policy.attempts; attempt++ { + paths, err := ops.discover(ctx) + if err != nil { + return windows.InvalidHandle, fmt.Errorf("discover native UDE controller: %w", err) + } + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + + if len(paths) > 1 { + return windows.InvalidHandle, fmt.Errorf( + "refusing ambiguous native UDE ownership: found %d controller interfaces", len(paths)) + } + if len(paths) == 0 { + lastTransient = &AcquisitionError{ + Kind: AcquisitionInterfaceUnavailable, + Attempts: attempt, + Err: windows.ERROR_FILE_NOT_FOUND, + } + } else { + handle, openErr := ops.open(ctx, paths[0]) + if openErr == nil && isUsableNativeHandle(handle) { + if err := ctx.Err(); err != nil { + if closeErr := ops.close(handle); closeErr != nil { + return windows.InvalidHandle, errors.Join(err, + fmt.Errorf("close canceled native UDE controller handle: %w", closeErr)) + } + return windows.InvalidHandle, err + } + return handle, nil + } + if isUsableNativeHandle(handle) { + if closeErr := ops.close(handle); closeErr != nil { + return windows.InvalidHandle, errors.Join(openErr, + fmt.Errorf("close failed native UDE controller handle: %w", closeErr)) + } + } + if openErr == nil { + openErr = windows.ERROR_INVALID_HANDLE + } + lastTransient = classifyNativeAcquisitionError(openErr, attempt) + if lastTransient == nil { + return windows.InvalidHandle, fmt.Errorf("open native UDE controller: %w", openErr) + } + } + + if attempt == policy.attempts { + return windows.InvalidHandle, lastTransient + } + if err := ops.wait(ctx, policy.interval); err != nil { + return windows.InvalidHandle, err + } + } + + panic("unreachable native UDE acquisition state") +} + +func classifyNativeAcquisitionError(err error, attempt int) *AcquisitionError { + switch { + case errors.Is(err, windows.ERROR_FILE_NOT_FOUND): + return &AcquisitionError{ + Kind: AcquisitionInterfaceUnavailable, + Attempts: attempt, + Err: err, + } + case errors.Is(err, windows.ERROR_SHARING_VIOLATION): + return &AcquisitionError{ + Kind: AcquisitionOwnerCleanupInProgress, + Attempts: attempt, + Err: err, + } + default: + return nil + } +} + +func isUsableNativeHandle(handle windows.Handle) bool { + return handle != 0 && handle != windows.InvalidHandle +} + +func discoverNativeInterfacePaths(ctx context.Context) ([]string, error) { + return discoverInterfacePaths(ctx) +} + +func openNativeController(ctx context.Context, interfacePath string) (windows.Handle, error) { + if err := ctx.Err(); err != nil { + return windows.InvalidHandle, err + } + path, err := windows.UTF16PtrFromString(interfacePath) + if err != nil { + return windows.InvalidHandle, fmt.Errorf("encode native UDE interface path: %w", err) + } + handle, err := windows.CreateFile( + path, + windows.GENERIC_READ|windows.GENERIC_WRITE, + nativeBrokerShareMode, // One broker owns the driver session; never weaken exclusive sharing. + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, + 0) + if err != nil { + return handle, err + } + return handle, nil +} + +func waitForNativeAcquisition(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func enableSkipCompletionPortOnSuccess(handle windows.Handle) bool { result, _, _ := procSetFileCompletionModes.Call( uintptr(handle), uintptr(fileSkipCompletionPortOnSuccess)) @@ -533,8 +709,11 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( } } -func discoverInterfacePaths() ([]string, error) { +func discoverInterfacePaths(ctx context.Context) ([]string, error) { for attempt := 0; attempt < 4; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } var required uint32 ret, _, _ := procCMGetDeviceInterfaceListSize.Call( uintptr(unsafe.Pointer(&required)), @@ -544,6 +723,9 @@ func discoverInterfacePaths() ([]string, error) { if uint32(ret) != crSuccess { return nil, fmt.Errorf("CM_Get_Device_Interface_List_SizeW returned CONFIGRET %#x", uint32(ret)) } + if err := ctx.Err(); err != nil { + return nil, err + } if required <= 1 { return nil, nil } @@ -560,6 +742,9 @@ func discoverInterfacePaths() ([]string, error) { if uint32(ret) != crSuccess { return nil, fmt.Errorf("CM_Get_Device_Interface_ListW returned CONFIGRET %#x", uint32(ret)) } + if err := ctx.Err(); err != nil { + return nil, err + } return parseMultiSZ(buffer), nil } return nil, errors.New("native UDE interface list changed repeatedly during discovery") From 5928e13383291e935d5e85ae8aa251b31e0ce98c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 11:57:55 -0500 Subject: [PATCH 139/240] Accept canonical Microsoft OS 1.0 descriptor --- internal/transport/udecx/descriptors.go | 4 +- internal/transport/udecx/descriptors_test.go | 8 +- internal/transport/udecx/protocol.go | 4 + .../transport/udecx/protocol_contract_test.go | 76 +++++++++++++++---- native/udecx/driver/Device.c | 64 ++++++++++++---- native/udecx/include/ViiperUdeProtocol.h | 5 ++ 6 files changed, 131 insertions(+), 30 deletions(-) diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go index ea7113be..8e26cc13 100644 --- a/internal/transport/udecx/descriptors.go +++ b/internal/transport/udecx/descriptors.go @@ -51,7 +51,7 @@ func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateD // The Microsoft OS 1.0 descriptor owns the reserved 0xEE string // exactly as it does on the USB/IP control path. Never publish a // conflicting ordinary string at that index. - if index == 0xEE && desc.MicrosoftOS10 != nil { + if uint16(index) == MicrosoftOS10StringIndex && desc.MicrosoftOS10 != nil { continue } languageID := uint16(0x0409) @@ -65,7 +65,7 @@ func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateD if desc.MicrosoftOS10 != nil { appendDescriptor( DescriptorString, - 0xEE, + MicrosoftOS10StringIndex, 0, desc.MicrosoftOS10.StringDescriptor()) } diff --git a/internal/transport/udecx/descriptors_test.go b/internal/transport/udecx/descriptors_test.go index d1f0188a..f554d9a4 100644 --- a/internal/transport/udecx/descriptors_test.go +++ b/internal/transport/udecx/descriptors_test.go @@ -77,7 +77,7 @@ func TestSnapshotDevicePublishesMicrosoftOS10ReservedString(t *testing.T) { } var matches []DescriptorRecord for _, record := range snapshot.Descriptors { - if record.Kind == DescriptorString && record.Index == 0xEE { + if record.Kind == DescriptorString && record.Index == MicrosoftOS10StringIndex { matches = append(matches, record) } } @@ -90,6 +90,12 @@ func TestSnapshotDevicePublishesMicrosoftOS10ReservedString(t *testing.T) { } got := snapshot.DescriptorData[record.Offset : record.Offset+record.Length] want := msOS.StringDescriptor() + if len(got) != MicrosoftOS10StringLength || MicrosoftOS10VendorCodeOffset != len(got)-2 { + t.Fatalf("Microsoft OS string layout length=%d vendor-offset=%d", len(got), MicrosoftOS10VendorCodeOffset) + } + if got[MicrosoftOS10VendorCodeOffset] != msOS.EffectiveVendorCode() || got[len(got)-1] != 0 { + t.Fatalf("Microsoft OS string vendor/pad=%#x/%#x", got[MicrosoftOS10VendorCodeOffset], got[len(got)-1]) + } if string(got) != string(want) { t.Fatalf("Microsoft OS string=%x want=%x", got, want) } diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index da3d423f..e28921b8 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -38,6 +38,10 @@ const ( MaxIsoPackets = 1024 MaxInputReportBytes = 4096 MaxPendingOperations = 4096 + + MicrosoftOS10StringIndex = 0x00EE + MicrosoftOS10StringLength = 18 + MicrosoftOS10VendorCodeOffset = 16 ) var ( diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index f228b84b..c1e2989f 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -1,6 +1,7 @@ package udecx import ( + "bytes" "go/ast" "go/parser" "go/token" @@ -11,6 +12,8 @@ import ( "strconv" "strings" "testing" + + "github.com/Alia5/VIIPER/usb" ) // These mirrors are never serialized through unsafe. They let CI prove that @@ -182,19 +185,22 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") numbers := map[string]uint64{ - "VIIPER_UDE_MAGIC": uint64(Magic), - "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), - "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), - "VIIPER_UDE_MAX_DEVICES": MaxDevices, - "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, - "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, - "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, - "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, - "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, - "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), - "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), - "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), - "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), + "VIIPER_UDE_MAGIC": uint64(Magic), + "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), + "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), + "VIIPER_UDE_MAX_DEVICES": MaxDevices, + "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, + "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, + "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, + "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, + "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, + "VIIPER_UDE_MS_OS_10_STRING_INDEX": uint64(MicrosoftOS10StringIndex), + "VIIPER_UDE_MS_OS_10_STRING_LENGTH": MicrosoftOS10StringLength, + "VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET": MicrosoftOS10VendorCodeOffset, + "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), + "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), + "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), + "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), } for name, want := range numbers { if got := cDefineNumber(t, header, name); got != want { @@ -298,6 +304,50 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { verifyGUIDAndIOCTLContract(t, header) } +func TestKernelMicrosoftOS10StringExceptionMatchesGoContract(t *testing.T) { + driver := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + prefixMatch := regexp.MustCompile( + `(?s)static const UCHAR microsoftOS10StringPrefix\[\]\s*=\s*\{([^}]*)\};`, + ).FindStringSubmatch(driver) + if prefixMatch == nil { + t.Fatal("kernel Microsoft OS 1.0 string prefix is missing") + } + var prefix []byte + for _, token := range regexp.MustCompile(`0x([0-9A-Fa-f]{2})`).FindAllStringSubmatch(prefixMatch[1], -1) { + value, err := strconv.ParseUint(token[1], 16, 8) + if err != nil { + t.Fatalf("parse kernel Microsoft OS 1.0 prefix byte %q: %v", token[1], err) + } + prefix = append(prefix, byte(value)) + } + want := (usb.MicrosoftOS10Descriptor{VendorCode: 0x20}).StringDescriptor() + if len(want) != MicrosoftOS10StringLength || + MicrosoftOS10VendorCodeOffset != len(want)-2 || + !bytes.Equal(prefix, want[:MicrosoftOS10VendorCodeOffset]) { + t.Fatalf("kernel Microsoft OS 1.0 prefix=%x want=%x", prefix, want[:MicrosoftOS10VendorCodeOffset]) + } + + // Keep the exception exact: only the reserved index, LANGID zero, the + // canonical MSFT100 descriptor, a usable vendor code, and a zero pad pass. + // The final check also proves all other nonzero-index/LANGID-zero strings + // still take the original rejection path. + normalized := strings.Join(strings.Fields(driver), " ") + checks := []string{ + "Record->Index == VIIPER_UDE_MS_OS_10_STRING_INDEX", + "Record->LanguageId == 0", + "Record->Length == VIIPER_UDE_MS_OS_10_STRING_LENGTH", + "Descriptor[VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET] != 0", + "Descriptor[VIIPER_UDE_MS_OS_10_STRING_LENGTH - 1] == 0", + "record->Index != 0 && record->LanguageId == 0 && !isMicrosoftOS10String", + "if (foundMicrosoftOS10String) { return FALSE; }", + } + for _, check := range checks { + if !strings.Contains(normalized, check) { + t.Errorf("kernel Microsoft OS 1.0 validation lost contract %q", check) + } + } +} + func evalGoInteger(expr ast.Expr, values map[string]uint64) (uint64, bool) { switch value := expr.(type) { case *ast.BasicLit: diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 1e30b297..78211ea6 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -58,6 +58,31 @@ ViiperValidateDescriptorChain( return offset == Length; } +static const UCHAR microsoftOS10StringPrefix[] = { + 0x12, 0x03, + 0x4d, 0x00, 0x53, 0x00, 0x46, 0x00, 0x54, 0x00, + 0x31, 0x00, 0x30, 0x00, 0x30, 0x00 +}; + +static +BOOLEAN +ViiperIsMicrosoftOS10StringDescriptor( + _In_ const VIIPER_UDE_DESCRIPTOR_RECORD *Record, + _In_reads_bytes_(Record->Length) const UCHAR *Descriptor + ) +{ + return Record->Index == VIIPER_UDE_MS_OS_10_STRING_INDEX && + Record->LanguageId == 0 && + Record->Length == VIIPER_UDE_MS_OS_10_STRING_LENGTH && + sizeof(microsoftOS10StringPrefix) == VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET && + RtlCompareMemory( + Descriptor, + microsoftOS10StringPrefix, + sizeof(microsoftOS10StringPrefix)) == sizeof(microsoftOS10StringPrefix) && + Descriptor[VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET] != 0 && + Descriptor[VIIPER_UDE_MS_OS_10_STRING_LENGTH - 1] == 0; +} + static BOOLEAN ViiperValidateCreateDevice( @@ -73,6 +98,7 @@ ViiperValidateCreateDevice( BOOLEAN foundBos = FALSE; BOOLEAN foundLanguageTable = FALSE; BOOLEAN foundLocalizedString = FALSE; + BOOLEAN foundMicrosoftOS10String = FALSE; if (InputLength < sizeof(*Input) || InputLength > (size_t)VIIPER_UDE_MAX_DESCRIPTOR_BYTES * 2 + sizeof(*Input) || @@ -152,23 +178,33 @@ ViiperValidateCreateDevice( foundBos = TRUE; break; case ViiperUdeDescriptorString: - if (record->Index > MAXUCHAR || record->Length > MAXUCHAR || - descriptor[0] != record->Length || descriptor[1] != USB_STRING_DESCRIPTOR_TYPE || - (record->Length & 1) != 0 || - (record->Index == 0 && record->LanguageId != 0) || - (record->Index == 0 && record->Length < 4) || - (record->Index != 0 && record->LanguageId == 0)) { - return FALSE; - } - if (record->Index == 0) { - if (foundLanguageTable) { + { + BOOLEAN isMicrosoftOS10String = + ViiperIsMicrosoftOS10StringDescriptor(record, descriptor); + if (record->Index > MAXUCHAR || record->Length > MAXUCHAR || + descriptor[0] != record->Length || descriptor[1] != USB_STRING_DESCRIPTOR_TYPE || + (record->Length & 1) != 0 || + (record->Index == 0 && record->LanguageId != 0) || + (record->Index == 0 && record->Length < 4) || + (record->Index != 0 && record->LanguageId == 0 && + !isMicrosoftOS10String)) { return FALSE; } - foundLanguageTable = TRUE; - } else { - foundLocalizedString = TRUE; + if (record->Index == 0) { + if (foundLanguageTable) { + return FALSE; + } + foundLanguageTable = TRUE; + } else if (isMicrosoftOS10String) { + if (foundMicrosoftOS10String) { + return FALSE; + } + foundMicrosoftOS10String = TRUE; + } else { + foundLocalizedString = TRUE; + } + break; } - break; default: return FALSE; } diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 295149cd..7c965c60 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -57,6 +57,11 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAX_INPUT_REPORT_BYTES VIIPER_UDE_UINT32_C(4096) #define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) +/* Microsoft OS 1.0 defines this reserved string outside normal LANGID rules. */ +#define VIIPER_UDE_MS_OS_10_STRING_INDEX VIIPER_UDE_UINT16_C(0x00ee) +#define VIIPER_UDE_MS_OS_10_STRING_LENGTH VIIPER_UDE_UINT32_C(18) +#define VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET VIIPER_UDE_UINT32_C(16) + #define VIIPER_UDE_CAP_ISOCHRONOUS VIIPER_UDE_UINT32_C(0x00000001) #define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) #define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) From 5ee3b8b193567475f60e240b6c1a2b05f6df5b4e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 12:01:41 -0500 Subject: [PATCH 140/240] Observe stalled native I/O cancellation --- internal/transport/udecx/client_windows.go | 85 +++++++++++++++++-- .../udecx/client_windows_stress_test.go | 68 +++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index f8ccffb2..ae4d5b51 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -8,8 +8,10 @@ import ( "encoding/binary" "errors" "fmt" + "log/slog" "runtime" "sync" + "sync/atomic" "time" "unicode/utf16" "unsafe" @@ -44,6 +46,10 @@ const ( nativeAcquisitionAttempts = 20 nativeAcquisitionRetryInterval = 100 * time.Millisecond nativeBrokerShareMode = 0 + // Context expiry requests cancellation; it cannot safely bound completion. + // Surface a stuck driver promptly without releasing memory still owned by + // the Windows I/O manager. + cancellationWatchdogInterval = 5 * time.Second ) // AcquisitionErrorKind identifies the only two controller-open failures that @@ -121,6 +127,7 @@ type Client struct { closeErr error requestPool sync.Pool completionPool sync.Pool + slowCancels atomic.Uint64 // Windows suppresses IOCP packets only for operations that return success // inline. Pending operations still use the shared completion pump. This // removes a scheduler/channel round trip from direct input without changing @@ -138,6 +145,12 @@ type Client struct { // real overlapped kernel request for DeviceIoControl. Production clients // leave it nil and always issue the native UDE IOCTL below. overlappedIssuer func(windows.Handle, *windows.Overlapped) (uint32, error) + // These seams let the Windows IOCP harness deterministically model a driver + // that accepts cancellation but delays completion. Production clients use + // CancelIoEx and a real timer and leave the observer nil. + cancelIssuer func(windows.Handle, *windows.Overlapped) error + cancellationWatchdog func() (<-chan time.Time, func()) + slowCancellationObserver func(code uint32, elapsed time.Duration, count uint64) } type ioCompletion struct { @@ -145,6 +158,13 @@ type ioCompletion struct { err error } +// CancellationTelemetry reports client-side cancellation acknowledgements +// that exceeded the watchdog interval. It is deliberately outside the ABI +// 1.8 Stats message, so observing a sick driver does not alter the wire format. +type CancellationTelemetry struct { + SlowAcknowledgements uint64 +} + // overlapped must remain the first field. Windows returns the exact pointer // submitted to DeviceIoControl through the completion port, allowing the // single completion pump to recover the owning request without a map or lock. @@ -442,6 +462,39 @@ func completionAfterPumpStop(handle windows.Handle, request *ioRequest) ioComple return ioCompletion{transferred: transferred, err: err} } +func (c *Client) cancelOverlapped(handle windows.Handle, request *ioRequest) error { + if c.cancelIssuer != nil { + return c.cancelIssuer(handle, &request.overlapped) + } + return windows.CancelIoEx(handle, &request.overlapped) +} + +func (c *Client) startCancellationWatchdog() (<-chan time.Time, func()) { + if c.cancellationWatchdog != nil { + return c.cancellationWatchdog() + } + timer := time.NewTimer(cancellationWatchdogInterval) + return timer.C, func() { timer.Stop() } +} + +func (c *Client) recordSlowCancellation(code uint32, elapsed time.Duration) { + count := c.slowCancels.Add(1) + if c.slowCancellationObserver != nil { + c.slowCancellationObserver(code, elapsed, count) + return + } + slog.Warn( + "native UDE driver has not acknowledged overlapped I/O cancellation", + "ioctl", fmt.Sprintf("%#x", code), + "elapsed", elapsed.Round(time.Millisecond), + "slow_cancellations", count, + ) +} + +func (c *Client) CancellationTelemetry() CancellationTelemetry { + return CancellationTelemetry{SlowAcknowledgements: c.slowCancels.Load()} +} + func (c *Client) Capabilities() Capabilities { c.mu.RLock() defer c.mu.RUnlock() @@ -695,13 +748,31 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( case result := <-request.done: return result.transferred, result.err case <-ctx.Done(): - _ = windows.CancelIoEx(handle, &request.overlapped) - select { - case result := <-request.done: - return completionAfterCancel(result, ctx.Err()) - case <-c.pumpDone: - result := completionAfterPumpStop(handle, request) - return completionAfterCancel(result, errors.Join(ctx.Err(), c.completionPumpError())) + contextErr := ctx.Err() + cancelStarted := time.Now() + _ = c.cancelOverlapped(handle, request) + watchdog, stopWatchdog := c.startCancellationWatchdog() + defer stopWatchdog() + + // CancelIoEx only marks the operation for cancellation and explicitly + // forbids freeing or reusing OVERLAPPED until the final completion: + // https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-cancelioex + // DeviceIoControl also still owns input/output buffers. A hard return here + // would therefore permit both use-after-return and pooled OVERLAPPED reuse. + // Lifecycle mutations may also complete successfully after cancellation, + // so wait for and preserve the authoritative kernel outcome. The watchdog + // makes a broken cancellation path observable without violating lifetime. + for { + select { + case result := <-request.done: + return completionAfterCancel(result, contextErr) + case <-c.pumpDone: + result := completionAfterPumpStop(handle, request) + return completionAfterCancel(result, errors.Join(contextErr, c.completionPumpError())) + case <-watchdog: + c.recordSlowCancellation(code, time.Since(cancelStarted)) + watchdog = nil + } } case <-c.pumpDone: result := completionAfterPumpStop(handle, request) diff --git a/internal/transport/udecx/client_windows_stress_test.go b/internal/transport/udecx/client_windows_stress_test.go index 1dac9d52..c67cc304 100644 --- a/internal/transport/udecx/client_windows_stress_test.go +++ b/internal/transport/udecx/client_windows_stress_test.go @@ -187,6 +187,9 @@ func TestWindowsClientIOCPStress(t *testing.T) { if !reused { t.Fatal("stress loop did not reuse an OVERLAPPED request") } + if got := harness.client.CancellationTelemetry().SlowAcknowledgements; got != 0 { + t.Fatalf("prompt cancellation emitted %d slow acknowledgements", got) + } result := harness.listen(context.Background()) harness.waitPending(result) @@ -200,6 +203,71 @@ func TestWindowsClientIOCPStress(t *testing.T) { } }) + t.Run("stalled cancellation keeps request live and emits watchdog telemetry", func(t *testing.T) { + harness := newPipeIOCPHarness(t, "") + cancelAttempted := make(chan struct{}, 1) + watchdog := make(chan time.Time, 1) + type observation struct { + code uint32 + elapsed time.Duration + count uint64 + } + observed := make(chan observation, 1) + harness.client.cancelIssuer = func(windows.Handle, *windows.Overlapped) error { + cancelAttempted <- struct{}{} + // Model a driver that accepts the request but has not completed the + // IRP yet. The named-pipe OVERLAPPED remains genuinely pending. + return nil + } + harness.client.cancellationWatchdog = func() (<-chan time.Time, func()) { + return watchdog, func() {} + } + harness.client.slowCancellationObserver = func(code uint32, elapsed time.Duration, count uint64) { + observed <- observation{code: code, elapsed: elapsed, count: count} + } + + deadline := newControlledDeadline() + result := harness.listen(deadline) + request := harness.waitPending(result) + deadline.expire() + select { + case <-cancelAttempted: + case <-time.After(5 * time.Second): + t.Fatal("client did not attempt targeted cancellation") + } + watchdog <- time.Now() + select { + case event := <-observed: + if event.code != 0 || event.elapsed < 0 || event.count != 1 { + t.Fatalf("slow-cancellation observation=%+v", event) + } + case <-time.After(5 * time.Second): + t.Fatal("stalled cancellation did not emit watchdog telemetry") + } + if got := harness.client.CancellationTelemetry().SlowAcknowledgements; got != 1 { + t.Fatalf("slow cancellation telemetry=%d want=1", got) + } + select { + case completed := <-result: + t.Fatalf("deadline returned before OVERLAPPED completion: (%d, %v)", completed.written, completed.err) + default: + } + + peer := harness.connect() + completed := harness.waitResult(result) + if completed.err != nil || completed.written != 0 { + t.Fatalf("completion after delayed cancellation = (%d, %v), want kernel success", completed.written, completed.err) + } + select { + case stale := <-request.done: + t.Fatalf("delayed cancellation left stale completion %+v", stale) + default: + } + if err := windows.CloseHandle(peer); err != nil { + t.Fatal(err) + } + }) + t.Run("close drains pending IO and serializes callers", func(t *testing.T) { harness := newPipeIOCPHarness(t, "") result := harness.listen(context.Background()) From 0410d9fff901da199ce4240e9cc66490aeb25986 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 12:08:35 -0500 Subject: [PATCH 141/240] Replay native input at USB service cadence --- internal/transport/udecx/host.go | 67 +++++++- internal/transport/udecx/host_test.go | 214 ++++++++++++++++++++++++++ usb/device.go | 7 +- 3 files changed, 279 insertions(+), 9 deletions(-) diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 6edb8e61..0369f8a1 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -64,7 +64,7 @@ type registeredDevice struct { cancel context.CancelFunc stopping bool publisherStopping bool - fastInput map[uint8]int + fastInput map[uint8]fastInputEndpoint publishers map[uint8]*inputPublisher activeInput map[uint8]bool resettingInput map[uint8]bool @@ -77,11 +77,17 @@ type registeredDevice struct { type inputPublisher struct { endpoint uint8 reportSize int + interval time.Duration sequence *atomic.Uint64 cancel context.CancelFunc done chan struct{} } +type fastInputEndpoint struct { + reportSize int + interval time.Duration +} + type laneKey struct { deviceID uint64 generation uint32 @@ -137,6 +143,10 @@ type Host struct { operationMu sync.Mutex operations map[uint64]*operationState completed []uint64 + + // inputAttemptContext is a deterministic deadline seam for host tests. + // Production hosts leave it nil and use context.WithTimeout. + inputAttemptContext func(context.Context, time.Duration) (context.Context, context.CancelFunc) } func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, error) { @@ -185,8 +195,25 @@ func (h *Host) lockDeviceLifecycle(deviceID uint64) func() { } } -func fastInputEndpoints(dev usb.Device) map[uint8]int { - result := make(map[uint8]int) +func interruptInputServiceInterval(speed uint32, bInterval uint8) time.Duration { + // Match the proven USB/IP interrupt scheduler in + // internal/server/usb.usbServiceInterval. + if bInterval == 0 { + return 0 + } + if speed >= uint32(DeviceSpeedHigh) { + // USB 2.x/3.x encode interrupt service periods as a power of two + // microframes and reserve values above 16. + if bInterval > 16 { + return 0 + } + return time.Duration(uint64(1)<<(bInterval-1)) * 125 * time.Microsecond + } + return time.Duration(bInterval) * time.Millisecond +} + +func fastInputEndpoints(dev usb.Device) map[uint8]fastInputEndpoint { + result := make(map[uint8]fastInputEndpoint) if dev == nil || dev.GetDescriptor() == nil { return result } @@ -201,7 +228,11 @@ func fastInputEndpoints(dev usb.Device) map[uint8]int { transactions := 1 + int((endpoint.WMaxPacketSize>>11)&0x03) reportSize := packetBytes * transactions if reportSize > 0 && reportSize <= MaxInputReportBytes { - result[endpoint.BEndpointAddress] = reportSize + result[endpoint.BEndpointAddress] = fastInputEndpoint{ + reportSize: reportSize, + interval: interruptInputServiceInterval( + dev.GetDescriptor().Device.Speed, endpoint.BInterval), + } } } } @@ -390,7 +421,7 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { h.mu.Unlock() return } - reportSize, fast := entry.fastInput[endpoint] + endpointContract, fast := entry.fastInput[endpoint] if !fast || entry.publishers[endpoint] != nil { h.mu.Unlock() return @@ -402,7 +433,8 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { } ctx, cancel := context.WithCancel(entry.ctx) publisher := &inputPublisher{ - endpoint: endpoint, reportSize: reportSize, sequence: sequence, + endpoint: endpoint, reportSize: endpointContract.reportSize, + interval: endpointContract.interval, sequence: sequence, cancel: cancel, done: make(chan struct{}), } entry.publishers[endpoint] = publisher @@ -451,6 +483,15 @@ func (h *Host) activeInputEndpoints(entry *registeredDevice) []uint8 { return endpoints } +func (h *Host) withInputAttemptDeadline( + ctx context.Context, interval time.Duration, +) (context.Context, context.CancelFunc) { + if h.inputAttemptContext != nil { + return h.inputAttemptContext(ctx, interval) + } + return context.WithTimeout(ctx, interval) +} + func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { defer close(publisher.done) reader, direct := entry.device.(usb.InterruptInputDevice) @@ -461,12 +502,24 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p for { var payload []byte if direct { + attemptCtx := ctx + attemptCancel := context.CancelFunc(func() {}) + if publisher.interval > 0 { + attemptCtx, attemptCancel = h.withInputAttemptDeadline(ctx, publisher.interval) + } written, err := reader.ReadInterruptInput( - ctx, uint32(publisher.endpoint&0x0f), reportBuffer) + attemptCtx, uint32(publisher.endpoint&0x0f), reportBuffer) + attemptCancel() if err != nil { if ctx.Err() != nil { return } + // Event-only devices may decline to synthesize an idle report. + // Cached-state controller implementations return success on the + // same deadline and are submitted below at the endpoint cadence. + if errors.Is(err, context.DeadlineExceeded) { + continue + } h.reportFatal(fmt.Errorf( "encode native UDE input report for device %d endpoint 0x%02x: %w", entry.identity.DeviceID, publisher.endpoint, err)) diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index fed29148..d727e98b 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -415,6 +415,28 @@ func hostTestDevice() usb.Device { }} } +func TestInterruptInputServiceIntervalMatchesUSBContract(t *testing.T) { + tests := []struct { + name string + speed uint32 + bInterval uint8 + want time.Duration + }{ + {name: "full-speed frames", speed: uint32(DeviceSpeedFull), bInterval: 5, want: 5 * time.Millisecond}, + {name: "high-speed microframes", speed: uint32(DeviceSpeedHigh), bInterval: 4, want: time.Millisecond}, + {name: "maximum high-speed exponent", speed: uint32(DeviceSpeedSuper), bInterval: 16, want: 4096 * time.Millisecond}, + {name: "zero is unscheduled", speed: uint32(DeviceSpeedHigh), bInterval: 0, want: 0}, + {name: "reserved high-speed exponent", speed: uint32(DeviceSpeedHigh), bInterval: 17, want: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := interruptInputServiceInterval(test.speed, test.bInterval); got != test.want { + t.Fatalf("service interval=%v want=%v", got, test.want) + } + }) + } +} + func TestHostDoesNotSerializeIndependentControllerRegistration(t *testing.T) { driver := &independentlyBlockingCreateDriver{ fakeHostDriver: newFakeHostDriver(), blockedDevice: 81, @@ -639,6 +661,52 @@ type directInputPublisherTestDevice struct { buffers chan *byte } +type cachedDeadlineInputPublisherTestDevice struct { + *inputPublisherTestDevice + cached []byte +} + +type controlledInputAttempt struct { + context.Context + deadline time.Time + done chan struct{} + once sync.Once + mu sync.Mutex + err error + stopParent func() bool +} + +func newControlledInputAttempt(parent context.Context, interval time.Duration) *controlledInputAttempt { + attempt := &controlledInputAttempt{ + Context: parent, deadline: time.Now().Add(interval), done: make(chan struct{}), + } + attempt.stopParent = context.AfterFunc(parent, func() { attempt.finish(parent.Err()) }) + return attempt +} + +func (c *controlledInputAttempt) Deadline() (time.Time, bool) { return c.deadline, true } +func (c *controlledInputAttempt) Done() <-chan struct{} { return c.done } +func (c *controlledInputAttempt) Err() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.err +} +func (c *controlledInputAttempt) finish(err error) { + c.once.Do(func() { + c.mu.Lock() + c.err = err + c.mu.Unlock() + close(c.done) + }) +} +func (c *controlledInputAttempt) expire() { c.finish(context.DeadlineExceeded) } +func (c *controlledInputAttempt) cancel() { + if c.stopParent != nil { + c.stopParent() + } + c.finish(context.Canceled) +} + func newInputPublisherTestDevice() *inputPublisherTestDevice { base := hostTestDevice().GetDescriptor() return &inputPublisherTestDevice{descriptor: *base, reports: make(chan []byte, 4)} @@ -651,6 +719,13 @@ func newDirectInputPublisherTestDevice() *directInputPublisherTestDevice { } } +func newCachedDeadlineInputPublisherTestDevice(report []byte) *cachedDeadlineInputPublisherTestDevice { + return &cachedDeadlineInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + cached: append([]byte(nil), report...), + } +} + func (d *directInputPublisherTestDevice) ReadInterruptInput( ctx context.Context, _ uint32, dst []byte, ) (int, error) { @@ -670,6 +745,20 @@ func (d *directInputPublisherTestDevice) ReadInterruptInput( } } +func (d *cachedDeadlineInputPublisherTestDevice) ReadInterruptInput( + ctx context.Context, _ uint32, dst []byte, +) (int, error) { + <-ctx.Done() + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + if len(d.cached) > len(dst) { + return 0, errors.New("native input buffer is too short for cached report") + } + copy(dst, d.cached) + return len(d.cached), nil +} + func (d *inputPublisherTestDevice) HandleTransfer( ctx context.Context, _ uint32, _ uint32, _ []byte, ) []byte { @@ -1423,6 +1512,131 @@ func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t } } +func TestHostReplaysCachedInputAtServiceDeadlineAcrossPurgeStart(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 8)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 4), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + type attempt struct { + context *controlledInputAttempt + interval time.Duration + } + attempts := make(chan attempt, 8) + host.inputAttemptContext = func(parent context.Context, interval time.Duration) (context.Context, context.CancelFunc) { + controlled := newControlledInputAttempt(parent, interval) + attempts <- attempt{context: controlled, interval: interval} + return controlled, controlled.cancel + } + device := newCachedDeadlineInputPublisherTestDevice([]byte{0x11, 0x22, 0x33}) + identity, err := host.Register(context.Background(), 471, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + var firstAttempt attempt + select { + case firstAttempt = <-attempts: + case <-time.After(time.Second): + t.Fatal("publisher did not arm its first service deadline") + } + if firstAttempt.interval != time.Millisecond { + t.Fatalf("high-speed bInterval=4 deadline=%v want=1ms", firstAttempt.interval) + } + firstAttempt.context.expire() + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{0x11, 0x22, 0x33}) { + t.Fatalf("first cached input report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("idle controller did not publish cached state at its service deadline") + } + + // Join the next blocked read through purge. Once lifecycle processing + // returns, the old publisher cannot submit a late report. + select { + case <-attempts: + case <-time.After(time.Second): + t.Fatal("publisher did not arm its next service deadline") + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint purge was not processed") + } + select { + case report := <-driver.reports: + t.Fatalf("cached report crossed completed purge: %+v", report) + default: + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart was not processed") + } + var restartedAttempt attempt + select { + case restartedAttempt = <-attempts: + case <-time.After(time.Second): + t.Fatal("restarted publisher did not arm a service deadline") + } + restartedAttempt.context.expire() + select { + case report := <-driver.reports: + if report.Sequence != 2 || string(report.Payload) != string([]byte{0x11, 0x22, 0x33}) { + t.Fatalf("cached report after endpoint restart=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("restarted publisher did not replay cached controller state") + } + + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 4, Kind: OperationEndpointPurge, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("final endpoint purge was not processed") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func trackAndDispatch(host *Host, op Operation) error { if err := host.trackOperation(op); err != nil { return fmt.Errorf("track token %d: %w", op.Token, err) diff --git a/usb/device.go b/usb/device.go index 767c1e68..624b8a07 100644 --- a/usb/device.go +++ b/usb/device.go @@ -19,8 +19,11 @@ type Device interface { // encode directly into it instead of allocating a new report for every input // sample. Implementations must block until input is available or ctx is // cancelled, must not retain dst, and must be safe when different endpoints -// are read concurrently. A successful call returns the number of bytes written -// to dst; zero-length successful reports are invalid. +// are read concurrently. Native transports impose the endpoint's USB service +// interval as a deadline. Stateful controllers may encode their cached state +// when that deadline expires; event-only devices may return DeadlineExceeded +// and keep waiting. A successful call returns the number of bytes written to +// dst; zero-length successful reports are invalid. // // HandleTransfer remains the compatibility contract for USB/IP and for devices // which do not implement this interface. From 0ccbb174924ed3f33cc6effb814ba7a1917c8707 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 12:11:24 -0500 Subject: [PATCH 142/240] Honor native ISO endpoint schedules --- internal/server/usb/native.go | 282 +++++++++++++---- internal/server/usb/native_frame_other.go | 15 + internal/server/usb/native_frame_windows.go | 22 ++ internal/server/usb/native_production_test.go | 6 +- internal/server/usb/native_test.go | 294 +++++++++++++++++- internal/transport/udecx/protocol.go | 6 + native/udecx/driver/Broker.c | 2 +- 7 files changed, 551 insertions(+), 76 deletions(-) create mode 100644 internal/server/usb/native_frame_other.go create mode 100644 internal/server/usb/native_frame_windows.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 496474bb..cbf884ab 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -16,6 +16,9 @@ type nativeLaneKey struct { deviceID uint64 generation uint32 endpoint uint8 + attributes uint8 + interval uint8 + maxPacket uint16 } type nativeSessionKey struct { @@ -35,6 +38,23 @@ type nativeSessionState struct { active map[nativeEndpointSignature]struct{} } +type nativeClockSample struct { + now time.Time + frame uint32 +} + +type nativeIsoEndpoint struct { + number uint32 + direction uint32 + interval time.Duration + key nativeLaneKey +} + +const ( + // USBD_ISO_START_FRAME_RANGE from usb.h. + usbdIsoStartFrameRange = int64(1024) +) + // NativeProcessor adapts the native UdeCx broker to the same control and // transfer engine used by USB/IP. Transport-specific clocks live here; device // state, feedback, HID, audio, and descriptor behavior remain in usb.Device. @@ -44,6 +64,8 @@ type NativeProcessor struct { next map[nativeLaneKey]time.Time lastIn map[nativeLaneKey][]byte sessions map[nativeSessionKey]*nativeSessionState + clock func() nativeClockSample + wait func(context.Context, time.Time) bool } func NewNativeProcessor(server *Server) (*NativeProcessor, error) { @@ -55,6 +77,8 @@ func NewNativeProcessor(server *Server) (*NativeProcessor, error) { next: make(map[nativeLaneKey]time.Time), lastIn: make(map[nativeLaneKey][]byte), sessions: make(map[nativeSessionKey]*nativeSessionState), + clock: nativeClockSnapshot, + wait: waitUntilContext, }, nil } @@ -108,6 +132,11 @@ func (p *NativeProcessor) clearDeviceTransportLocked(identity udecx.DeviceIdenti delete(p.lastIn, key) } } + for key := range p.lastIn { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.lastIn, key) + } + } p.mu.Unlock() clear(session.active) } @@ -119,20 +148,22 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op defer session.mu.Unlock() key := nativeLaneKey{ deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, + attributes: op.EndpointAttributes, interval: op.EndpointInterval, + maxPacket: op.EndpointMaxPacketSize, } switch op.Kind { case udecx.OperationEndpointStart: - p.clearLane(key) + p.clearEndpointLanes(key) p.activateEndpointLocked(dev, op, session) case udecx.OperationEndpointPurge: - p.clearLane(key) + p.clearEndpointLanes(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } p.deactivateEndpointLocked(dev, op, session) case udecx.OperationEndpointReset: - p.clearLane(key) + p.clearEndpointLanes(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } @@ -287,16 +318,109 @@ func (p *NativeProcessor) clearDeviceLanes(identity udecx.DeviceIdentity) { delete(p.lastIn, key) } } + for key := range p.lastIn { + if key.deviceID == identity.DeviceID && key.generation == identity.Generation { + delete(p.lastIn, key) + } + } p.mu.Unlock() } -func (p *NativeProcessor) clearLane(key nativeLaneKey) { +func (p *NativeProcessor) clearEndpointLanes(endpoint nativeLaneKey) { p.mu.Lock() - delete(p.next, key) - delete(p.lastIn, key) + for key := range p.next { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint { + delete(p.lastIn, key) + } + } p.mu.Unlock() } +func nativeLaneKeyFromOperation(op udecx.Operation) nativeLaneKey { + return nativeLaneKey{ + deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, + attributes: op.EndpointAttributes, interval: op.EndpointInterval, + maxPacket: op.EndpointMaxPacketSize, + } +} + +func descriptorHasEndpointSignature(desc *usbdevice.Descriptor, signature nativeEndpointSignature) bool { + if desc == nil { + return false + } + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if signatureFromDescriptor(endpoint) == signature { + return true + } + } + } + return false +} + +func nativeIsoServiceInterval(speed uint32, bInterval uint8) (time.Duration, error) { + if bInterval == 0 { + return 0, errors.New("native ISO endpoint has zero bInterval") + } + if speed == uint32(udecx.DeviceSpeedLow) { + return 0, errors.New("low-speed USB does not support isochronous endpoints") + } + if speed >= uint32(udecx.DeviceSpeedHigh) { + if bInterval > 16 { + return 0, fmt.Errorf("native high-speed ISO bInterval %d exceeds 16", bInterval) + } + return time.Duration(1<<(bInterval-1)) * 125 * time.Microsecond, nil + } + return time.Duration(bInterval) * time.Millisecond, nil +} + +func resolveNativeIsoEndpoint(dev usbdevice.Device, op udecx.Operation) (nativeIsoEndpoint, error) { + desc := dev.GetDescriptor() + if desc == nil { + return nativeIsoEndpoint{}, errors.New("native ISO operation has no device descriptor") + } + signature := signatureFromOperation(op) + if signature.address == 0 || signature.attributes&0x03 != 0x01 { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO operation has invalid endpoint signature %+v", signature) + } + direction := uint8(0) + usbDirection := uint32(usbip.DirOut) + if signature.address&0x80 != 0 { + direction = 1 + usbDirection = usbip.DirIn + } + flagDirection := uint8(0) + if op.TransferFlags&udecx.TransferFlagDirectionIn != 0 { + flagDirection = 1 + } + if op.Direction != direction || flagDirection != direction { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO endpoint 0x%02x direction %d disagrees with operation %d/flags %d", + signature.address, direction, op.Direction, flagDirection) + } + if !descriptorHasEndpointSignature(desc, signature) { + return nativeIsoEndpoint{}, fmt.Errorf( + "native ISO endpoint signature %+v is not present in the device descriptor", signature) + } + interval, err := nativeIsoServiceInterval(desc.Device.Speed, signature.interval) + if err != nil { + return nativeIsoEndpoint{}, err + } + return nativeIsoEndpoint{ + number: uint32(signature.address & 0x0f), direction: usbDirection, interval: interval, + key: nativeLaneKeyFromOperation(op), + }, nil +} + func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op udecx.Operation) (udecx.Completion, error) { if dev == nil { return udecx.Completion{}, errors.New("native UDE operation has no device") @@ -304,26 +428,37 @@ func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op if op.TransferLength > udecx.MaxTransferBytes || len(op.Payload) > udecx.MaxTransferBytes { return udecx.Completion{}, udecx.ErrLimitExceeded } + if len(op.IsoPackets) != 0 { + if op.Kind != udecx.OperationTransfer { + return udecx.Completion{}, fmt.Errorf( + "native operation kind %d carries ISO packets", op.Kind) + } + for index, packet := range op.IsoPackets { + if packet.Offset > op.TransferLength || + packet.Length > op.TransferLength-packet.Offset { + return udecx.Completion{}, fmt.Errorf( + "native ISO packet %d is outside transfer buffer", index) + } + } + endpoint, err := resolveNativeIsoEndpoint(dev, op) + if err != nil { + return udecx.Completion{}, err + } + if endpoint.direction == usbip.DirIn { + return p.processIsoIn(ctx, dev, op, endpoint) + } + return p.processIsoOut(ctx, dev, op, endpoint) + } ep := uint32(op.EndpointAddress & 0x0f) dir := uint32(usbip.DirOut) if op.Direction != 0 { dir = usbip.DirIn } - key := nativeLaneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} - if len(op.IsoPackets) != 0 { - // The first ISO URB is itself authoritative proof that Windows activated - // this endpoint. This also closes the scheduling race where a transfer is - // dequeued before the endpoint-start notification reaches another worker. - p.activateEndpoint(dev, op) - } + key := nativeLaneKeyFromOperation(op) switch { case op.Kind == udecx.OperationControl: return p.processControl(ctx, dev, op, ep, dir) - case len(op.IsoPackets) != 0 && dir == usbip.DirIn: - return p.processIsoIn(ctx, dev, op, ep, dir, key) - case len(op.IsoPackets) != 0: - return p.processIsoOut(ctx, dev, op, ep, dir, key) case dir == usbip.DirIn: return p.processInterruptIn(ctx, dev, op, ep, dir, key) default: @@ -405,11 +540,22 @@ func (p *NativeProcessor) processInterruptIn(ctx context.Context, dev usbdevice. } func (p *NativeProcessor) processIsoOut(ctx context.Context, dev usbdevice.Device, - op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { - duration := isoCompletionDelay(dev.GetDescriptor(), ep, len(op.IsoPackets)) - deadline := p.reserveCompletionDeadline(key, duration) - p.server.processSubmit(ctx, dev, ep, dir, nil, op.Payload) - if !waitUntilContext(ctx, deadline) { + op udecx.Operation, endpoint nativeIsoEndpoint) (udecx.Completion, error) { + duration := time.Duration(len(op.IsoPackets)) * endpoint.interval + serviceStart, serviceEnd, err := p.reserveIsoServiceWindow( + endpoint.key, op.StartFrame, op.TransferFlags, duration) + if err != nil { + return udecx.Completion{}, err + } + // The operation's full endpoint signature is the authoritative active + // UdeCx identity. Applying it only after frame validation avoids mutating + // alternate-setting state for a rejected explicit schedule. + p.activateEndpoint(dev, op) + if !p.wait(ctx, serviceStart) { + return udecx.Completion{}, ctx.Err() + } + p.server.processSubmit(ctx, dev, endpoint.number, endpoint.direction, nil, op.Payload) + if !p.wait(ctx, serviceEnd) { return udecx.Completion{}, ctx.Err() } packets := make([]udecx.IsoPacket, len(op.IsoPackets)) @@ -420,30 +566,28 @@ func (p *NativeProcessor) processIsoOut(ctx context.Context, dev usbdevice.Devic } func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device, - op udecx.Operation, ep, dir uint32, key nativeLaneKey) (udecx.Completion, error) { - interval := isoPacketInterval(dev.GetDescriptor(), ep) - if interval <= 0 { - interval = time.Millisecond + op udecx.Operation, endpoint nativeIsoEndpoint) (udecx.Completion, error) { + duration := time.Duration(len(op.IsoPackets)) * endpoint.interval + serviceStart, _, err := p.reserveIsoServiceWindow( + endpoint.key, op.StartFrame, op.TransferFlags, duration) + if err != nil { + return udecx.Completion{}, err } - duration := time.Duration(len(op.IsoPackets)) * interval - serviceStart := p.reserveServiceWindow(key, duration, interval) + p.activateEndpoint(dev, op) payload := make([]byte, op.TransferLength) packets := make([]udecx.IsoPacket, len(op.IsoPackets)) actualTotal := uint32(0) serviceTime := serviceStart reader, direct := dev.(usbdevice.IsochronousInputDevice) for i, packet := range op.IsoPackets { - if packet.Offset > op.TransferLength || packet.Length > op.TransferLength-packet.Offset { - return udecx.Completion{}, fmt.Errorf("native ISO packet %d is outside transfer buffer", i) - } - if !waitUntilContext(ctx, serviceTime) { + if !p.wait(ctx, serviceTime) { return udecx.Completion{}, ctx.Err() } - serviceTime = serviceTime.Add(interval) + serviceTime = serviceTime.Add(endpoint.interval) var packetData []byte if direct { packetRegion := payload[packet.Offset : packet.Offset+packet.Length] - written, readErr := reader.ReadIsochronousInput(ctx, ep, packetRegion) + written, readErr := reader.ReadIsochronousInput(ctx, endpoint.number, packetRegion) if readErr != nil { return udecx.Completion{}, readErr } @@ -454,8 +598,9 @@ func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device } packetData = packetRegion[:written] } else { - attemptCtx, cancel := context.WithTimeout(ctx, interval) - packetData = p.server.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + attemptCtx, cancel := context.WithTimeout(ctx, endpoint.interval) + packetData = p.server.processSubmit( + attemptCtx, dev, endpoint.number, endpoint.direction, nil, nil) cancel() } if ctx.Err() != nil { @@ -474,10 +619,21 @@ func (p *NativeProcessor) processIsoIn(ctx context.Context, dev usbdevice.Device } packets[i] = udecx.IsoPacket{Offset: packet.Offset, Length: actual} actualTotal += actual + serviceTime = reanchorMissedIsoPacketSlot( + serviceTime, endpoint.interval, p.clock().now) } + p.extendIsoServiceWindow(endpoint.key, serviceTime) return successCompletion(op, actualTotal, payload, packets), nil } +func (p *NativeProcessor) extendIsoServiceWindow(key nativeLaneKey, serviceEnd time.Time) { + p.mu.Lock() + if serviceEnd.After(p.next[key]) { + p.next[key] = serviceEnd + } + p.mu.Unlock() +} + func (p *NativeProcessor) reserveServiceTime(key nativeLaneKey, interval time.Duration) time.Time { p.mu.Lock() defer p.mu.Unlock() @@ -490,33 +646,41 @@ func (p *NativeProcessor) reserveServiceTime(key nativeLaneKey, interval time.Du return serviceTime } -func (p *NativeProcessor) reserveCompletionDeadline(key nativeLaneKey, duration time.Duration) time.Time { - if duration <= 0 { - return time.Now() - } - p.mu.Lock() - defer p.mu.Unlock() - now := time.Now() - deadline := p.next[key] - if deadline.IsZero() || now.Sub(deadline) >= duration { - deadline = now.Add(duration) - } else { - deadline = deadline.Add(duration) - } - p.next[key] = deadline - return deadline -} +func (p *NativeProcessor) reserveIsoServiceWindow( + key nativeLaneKey, startFrame, transferFlags uint32, duration time.Duration, +) (time.Time, time.Time, error) { + sample := p.clock() + delta := int64(int32(startFrame - sample.frame)) + explicit := transferFlags&udecx.TransferFlagStartIsoASAP == 0 + if explicit && (delta <= 0 || delta >= usbdIsoStartFrameRange) { + return time.Time{}, time.Time{}, fmt.Errorf( + "native explicit ISO start frame %d is outside the future frame range from %d", + startFrame, sample.frame) + } + plannedStart := sample.now.Add(time.Duration(delta) * time.Millisecond) + if plannedStart.Before(sample.now) { + // A delayed ASAP dequeue must not replay elapsed USB frames in a burst. + // Explicit frames take the range-error path above instead. + plannedStart = sample.now + } + // For ASAP URBs the kernel has already discarded the caller's input value + // and replaced StartFrame with its ordered output reservation. This mapping + // gates host-side service only; controller media-clock correction remains in + // the device engine. -func (p *NativeProcessor) reserveServiceWindow(key nativeLaneKey, duration, interval time.Duration) time.Time { p.mu.Lock() defer p.mu.Unlock() - now := time.Now() - start := p.next[key] - if start.IsZero() || (interval > 0 && now.Sub(start) >= interval) { - start = now + if previousEnd := p.next[key]; previousEnd.After(plannedStart) { + if explicit { + return time.Time{}, time.Time{}, fmt.Errorf( + "native explicit ISO start frame %d overlaps the previous endpoint window", + startFrame) + } + plannedStart = previousEnd } - p.next[key] = start.Add(duration) - return start + serviceEnd := plannedStart.Add(duration) + p.next[key] = serviceEnd + return plannedStart, serviceEnd, nil } func successCompletion(op udecx.Operation, transferLength uint32, payload []byte, diff --git a/internal/server/usb/native_frame_other.go b/internal/server/usb/native_frame_other.go new file mode 100644 index 00000000..71a5fead --- /dev/null +++ b/internal/server/usb/native_frame_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package usb + +import "time" + +var nativeProcessClockStart = time.Now() + +func nativeClockSnapshot() nativeClockSample { + now := time.Now() + return nativeClockSample{ + now: now, + frame: uint32(now.Sub(nativeProcessClockStart) / time.Millisecond), + } +} diff --git a/internal/server/usb/native_frame_windows.go b/internal/server/usb/native_frame_windows.go new file mode 100644 index 00000000..903c4d1a --- /dev/null +++ b/internal/server/usb/native_frame_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package usb + +import ( + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var queryInterruptTimePrecise = windows.NewLazySystemDLL("api-ms-win-core-realtime-l1-1-1.dll"). + NewProc("QueryInterruptTimePrecise") + +func nativeClockSnapshot() nativeClockSample { + var interruptTime100ns uint64 + queryInterruptTimePrecise.Call(uintptr(unsafe.Pointer(&interruptTime100ns))) + return nativeClockSample{ + now: time.Now(), + frame: uint32(interruptTime100ns / 10_000), + } +} diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go index 55e1ad5a..0a2924be 100644 --- a/internal/server/usb/native_production_test.go +++ b/internal/server/usb/native_production_test.go @@ -447,10 +447,11 @@ func productionIsoOperation(token uint64, endpoint uint8, input bool, payload [] op := udecx.Operation{ Token: token, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, EndpointAddress: endpoint, TransferLength: uint32(packetCount) * packetLength, - IsoPackets: packets, Payload: payload, + TransferFlags: udecx.TransferFlagStartIsoASAP, IsoPackets: packets, Payload: payload, } if input { op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn } return op } @@ -518,7 +519,7 @@ func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, op := udecx.Operation{ Token: 100, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, EndpointAddress: endpoint, TransferLength: transferLength, - IsoPackets: packets, Payload: payload, + TransferFlags: udecx.TransferFlagStartIsoASAP, IsoPackets: packets, Payload: payload, } for _, iface := range dev.GetDescriptor().Interfaces { for _, descEndpoint := range iface.Endpoints { @@ -531,6 +532,7 @@ func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, } if input { op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn } completion, err := processor.Process(context.Background(), dev, op) if err != nil { diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 17faa8fd..3fe79bb8 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -136,6 +136,7 @@ func TestNativeProcessorFirstISOTransferClosesEndpointStartRace(t *testing.T) { Token: 1, DeviceID: 3, Generation: 7, Kind: udecx.OperationTransfer, EndpointAddress: 0x02, EndpointAttributes: 0x05, EndpointInterval: 4, EndpointMaxPacketSize: 196, + TransferFlags: udecx.TransferFlagStartIsoASAP, TransferLength: 4, Payload: []byte{1, 2, 3, 4}, IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 4}}, }) @@ -205,12 +206,13 @@ func TestNativeProcessorSetConfigurationRetiresGenerationTransportState(t *testi if err := processor.Lifecycle(context.Background(), dev, endpoint); err != nil { t.Fatal(err) } - key := nativeLaneKey{ - deviceID: identity.DeviceID, generation: identity.Generation, endpoint: endpoint.EndpointAddress, - } + key := nativeLaneKeyFromOperation(endpoint) + cacheOnlyKey := key + cacheOnlyKey.endpoint = 0x83 processor.mu.Lock() processor.next[key] = time.Now() processor.lastIn[key] = []byte{1, 2, 3} + processor.lastIn[cacheOnlyKey] = []byte{4, 5, 6} processor.mu.Unlock() _, err := processor.Process(context.Background(), dev, udecx.Operation{ @@ -227,12 +229,14 @@ func TestNativeProcessorSetConfigurationRetiresGenerationTransportState(t *testi processor.mu.Lock() _, hasClock := processor.next[key] _, hasCachedInput := processor.lastIn[key] + _, hasCacheOnlyInput := processor.lastIn[cacheOnlyKey] session := processor.sessions[nativeSessionKey{ deviceID: identity.DeviceID, generation: identity.Generation, }] processor.mu.Unlock() - if hasClock || hasCachedInput { - t.Fatalf("SET_CONFIGURATION retained clock=%v cachedInput=%v", hasClock, hasCachedInput) + if hasClock || hasCachedInput || hasCacheOnlyInput { + t.Fatalf("SET_CONFIGURATION retained clock=%v cachedInput=%v cacheOnlyInput=%v", + hasClock, hasCachedInput, hasCacheOnlyInput) } if session == nil { t.Fatal("SET_CONFIGURATION lost the registered generation session") @@ -257,8 +261,11 @@ func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { }} op := udecx.Operation{ Token: 2, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, - EndpointAddress: 0x82, Direction: 1, TransferLength: 48, - IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 32, Length: 16}}, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x01, + EndpointInterval: 1, EndpointMaxPacketSize: 32, + TransferFlags: udecx.TransferFlagStartIsoASAP | udecx.TransferFlagDirectionIn, + TransferLength: 48, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 32, Length: 16}}, } completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) if err != nil { @@ -275,14 +282,17 @@ func TestNativeProcessorPreservesSparseIsoInLayout(t *testing.T) { } type isoOutRecordingDevice struct { - desc *usbdevice.Descriptor - payload []byte + desc *usbdevice.Descriptor + payload []byte + endpoint uint32 + direction uint32 } type directIsoInTestDevice struct { desc *usbdevice.Descriptor calls int fallbackCalls int + endpoint uint32 } func (d *directIsoInTestDevice) HandleTransfer( @@ -293,9 +303,10 @@ func (d *directIsoInTestDevice) HandleTransfer( } func (d *directIsoInTestDevice) ReadIsochronousInput( - _ context.Context, _ uint32, dst []byte, + _ context.Context, endpoint uint32, dst []byte, ) (int, error) { d.calls++ + d.endpoint = endpoint actual := len(dst) - d.calls for index := 0; index < actual; index++ { dst[index] = byte(0x20*d.calls + index) @@ -316,8 +327,11 @@ func TestNativeProcessorWritesIsoInDirectlyIntoURBPacketRegions(t *testing.T) { dev := &directIsoInTestDevice{desc: desc} op := udecx.Operation{ Token: 8, DeviceID: 4, Generation: 2, Kind: udecx.OperationTransfer, - EndpointAddress: 0x82, Direction: 1, TransferLength: 24, - IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 16, Length: 8}}, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x05, + EndpointInterval: 1, EndpointMaxPacketSize: 8, + TransferFlags: udecx.TransferFlagStartIsoASAP | udecx.TransferFlagDirectionIn, + TransferLength: 24, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 16, Length: 8}}, } completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) if err != nil { @@ -339,7 +353,11 @@ func TestNativeProcessorWritesIsoInDirectlyIntoURBPacketRegions(t *testing.T) { } } -func (d *isoOutRecordingDevice) HandleTransfer(_ context.Context, _ uint32, _ uint32, out []byte) []byte { +func (d *isoOutRecordingDevice) HandleTransfer( + _ context.Context, endpoint, direction uint32, out []byte, +) []byte { + d.endpoint = endpoint + d.direction = direction d.payload = append(d.payload[:0], out...) return nil } @@ -357,7 +375,9 @@ func TestNativeProcessorCompletesIsoOutWithoutEchoPayload(t *testing.T) { payload := bytes.Repeat([]byte{0x5a}, 32) op := udecx.Operation{ Token: 3, DeviceID: 1, Generation: 1, Kind: udecx.OperationTransfer, - EndpointAddress: 0x02, TransferLength: 32, Payload: payload, + EndpointAddress: 0x02, EndpointAttributes: 0x01, + EndpointInterval: 1, EndpointMaxPacketSize: 32, + TransferFlags: udecx.TransferFlagStartIsoASAP, TransferLength: 32, Payload: payload, IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 16}, {Offset: 16, Length: 16}}, } completion, err := nativeProcessorForTest(t).Process(context.Background(), dev, op) @@ -370,6 +390,251 @@ func TestNativeProcessorCompletesIsoOutWithoutEchoPayload(t *testing.T) { } } +func TestNativeProcessorSchedulesIsoOutFromExactOperationFrame(t *testing.T) { + base := time.Unix(500, 0) + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, + WMaxPacketSize: 32, BInterval: 1, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 2, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x09, + WMaxPacketSize: 64, BInterval: 4, + }}}, + }, + } + dev := &isoOutRecordingDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 100} + } + var waits []time.Time + processor.wait = func(_ context.Context, deadline time.Time) bool { + waits = append(waits, deadline) + return true + } + payload := []byte{1, 2, 3, 4} + completion, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 20, DeviceID: 4, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x02, Direction: 0, EndpointAttributes: 0x09, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + StartFrame: 103, TransferLength: uint32(len(payload)), Payload: payload, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 2}, {Offset: 2, Length: 2}}, + }) + if err != nil { + t.Fatal(err) + } + wantWaits := []time.Time{base.Add(3 * time.Millisecond), base.Add(5 * time.Millisecond)} + if len(waits) != len(wantWaits) || !waits[0].Equal(wantWaits[0]) || !waits[1].Equal(wantWaits[1]) { + t.Fatalf("ISO OUT waits=%v want %v", waits, wantWaits) + } + if dev.endpoint != 2 || dev.direction != 0 || !bytes.Equal(dev.payload, payload) { + t.Fatalf("ISO OUT routed endpoint=%d direction=%d payload=%x", dev.endpoint, dev.direction, dev.payload) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.IsoPackets) != 2 { + t.Fatalf("unexpected ISO OUT completion: %+v", completion) + } +} + +func TestNativeProcessorSchedulesIsoInPacketsFromExactOperationFrame(t *testing.T) { + base := time.Unix(600, 0) + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 8, BInterval: 4, + }}}}, + } + dev := &directIsoInTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 700} + } + var waits []time.Time + processor.wait = func(_ context.Context, deadline time.Time) bool { + waits = append(waits, deadline) + return true + } + completion, err := processor.Process(context.Background(), dev, udecx.Operation{ + Token: 21, DeviceID: 4, Generation: 7, Kind: udecx.OperationTransfer, + EndpointAddress: 0x82, Direction: 1, EndpointAttributes: 0x05, + EndpointInterval: 4, EndpointMaxPacketSize: 8, + TransferFlags: udecx.TransferFlagDirectionIn, StartFrame: 702, TransferLength: 16, + IsoPackets: []udecx.IsoPacket{{Offset: 0, Length: 8}, {Offset: 8, Length: 8}}, + }) + if err != nil { + t.Fatal(err) + } + wantWaits := []time.Time{base.Add(2 * time.Millisecond), base.Add(3 * time.Millisecond)} + if len(waits) != len(wantWaits) || !waits[0].Equal(wantWaits[0]) || !waits[1].Equal(wantWaits[1]) { + t.Fatalf("ISO IN waits=%v want %v", waits, wantWaits) + } + if dev.endpoint != 2 || dev.calls != 2 || completion.TransferLength != 13 { + t.Fatalf("ISO IN endpoint=%d calls=%d completion=%+v", dev.endpoint, dev.calls, completion) + } +} + +func TestResolveNativeIsoEndpointUsesDirectionAndAlternateSignature(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x01, + WMaxPacketSize: 32, BInterval: 1, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 2}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x02, BMAttributes: 0x09, + WMaxPacketSize: 64, BInterval: 4, + }}}, + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x05, + WMaxPacketSize: 96, BInterval: 2, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + out, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x02, Direction: 0, + EndpointAttributes: 0x09, EndpointInterval: 4, EndpointMaxPacketSize: 64, + }) + if err != nil { + t.Fatal(err) + } + in, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x82, Direction: 1, + EndpointAttributes: 0x05, EndpointInterval: 2, EndpointMaxPacketSize: 96, + TransferFlags: udecx.TransferFlagDirectionIn, + }) + if err != nil { + t.Fatal(err) + } + if out.interval != time.Millisecond || out.direction != 0 || out.key == in.key { + t.Fatalf("exact OUT endpoint=%+v IN endpoint=%+v", out, in) + } + if in.interval != 250*time.Microsecond || in.direction != 1 { + t.Fatalf("exact IN endpoint=%+v", in) + } + _, err = resolveNativeIsoEndpoint(dev, udecx.Operation{ + EndpointAddress: 0x82, Direction: 0, EndpointAttributes: 0x05, + EndpointInterval: 2, EndpointMaxPacketSize: 96, + TransferFlags: udecx.TransferFlagDirectionIn, + }) + if err == nil { + t.Fatal("direction mismatch was accepted") + } +} + +func TestNativeIsoExplicitFrameRangeHandlesWrap(t *testing.T) { + base := time.Unix(700, 0) + processor := nativeProcessorForTest(t) + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 0xfffffffe} + } + key := nativeLaneKey{deviceID: 1, generation: 1, endpoint: 0x02, interval: 4} + start, end, err := processor.reserveIsoServiceWindow(key, 1, 0, 2*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(3*time.Millisecond)) || !end.Equal(base.Add(5*time.Millisecond)) { + t.Fatalf("wrapped explicit frame start=%s end=%s", start, end) + } + + processor.clock = func() nativeClockSample { + return nativeClockSample{now: base, frame: 100} + } + if _, _, err := processor.reserveIsoServiceWindow( + key, 100+uint32(usbdIsoStartFrameRange), 0, time.Millisecond); err == nil { + t.Fatal("out-of-range explicit frame was accepted") + } + if _, _, err := processor.reserveIsoServiceWindow(key, 99, 0, time.Millisecond); err == nil { + t.Fatal("past explicit frame was accepted") + } +} + +func TestNativeIsoASAPContinuityReanchorsAfterDrift(t *testing.T) { + base := time.Unix(800, 0) + processor := nativeProcessorForTest(t) + sample := nativeClockSample{now: base, frame: 100} + processor.clock = func() nativeClockSample { return sample } + key := nativeLaneKey{deviceID: 1, generation: 1, endpoint: 0x82, interval: 4} + start, end, err := processor.reserveIsoServiceWindow( + key, 101, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(time.Millisecond)) || !end.Equal(base.Add(5*time.Millisecond)) { + t.Fatalf("first ASAP window start=%s end=%s", start, end) + } + + start, end, err = processor.reserveIsoServiceWindow( + key, 102, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(base.Add(5*time.Millisecond)) || !end.Equal(base.Add(9*time.Millisecond)) { + t.Fatalf("ordered ASAP window start=%s end=%s", start, end) + } + + sample = nativeClockSample{now: base.Add(20 * time.Millisecond), frame: 120} + start, end, err = processor.reserveIsoServiceWindow( + key, 103, udecx.TransferFlagStartIsoASAP, 4*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if !start.Equal(sample.now) || !end.Equal(sample.now.Add(4*time.Millisecond)) { + t.Fatalf("late ASAP drift replayed stale slots: start=%s end=%s", start, end) + } +} + +func TestNativeEndpointResetClearsAlternateReuseClocks(t *testing.T) { + processor := nativeProcessorForTest(t) + base := udecx.Operation{ + DeviceID: 8, Generation: 3, EndpointAddress: 0x02, + EndpointAttributes: 0x01, EndpointInterval: 1, EndpointMaxPacketSize: 32, + } + first := nativeLaneKeyFromOperation(base) + base.EndpointAttributes = 0x09 + base.EndpointInterval = 4 + base.EndpointMaxPacketSize = 64 + second := nativeLaneKeyFromOperation(base) + other := second + other.endpoint = 0x82 + processor.next[first] = time.Now() + processor.next[second] = time.Now() + processor.next[other] = time.Now() + processor.lastIn[first] = []byte{1} + processor.lastIn[second] = []byte{2} + + if err := processor.Lifecycle(context.Background(), &altSettingTestDevice{}, udecx.Operation{ + DeviceID: 8, Generation: 3, Kind: udecx.OperationEndpointReset, + EndpointAddress: 0x02, EndpointAttributes: 0x09, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + }); err != nil { + t.Fatal(err) + } + if _, ok := processor.next[first]; ok { + t.Fatal("endpoint reset retained the old alternate clock") + } + if _, ok := processor.next[second]; ok { + t.Fatal("endpoint reset retained the current alternate clock") + } + if _, ok := processor.lastIn[first]; ok { + t.Fatal("endpoint reset retained the old alternate cache") + } + if _, ok := processor.next[other]; !ok { + t.Fatal("endpoint reset cleared an independent direction") + } +} + type concurrentNativeTestDevice struct { desc *usbdevice.Descriptor mu sync.Mutex @@ -552,6 +817,7 @@ func TestNativeProcessorConcurrentMediaAndLifecycleSoak(t *testing.T) { DeviceID: identity.DeviceID, Generation: identity.Generation, EndpointAddress: 0x02, EndpointAttributes: 0x05, EndpointInterval: 1, EndpointMaxPacketSize: 4, + TransferFlags: udecx.TransferFlagStartIsoASAP, } var wg sync.WaitGroup diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index e28921b8..911f92af 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -38,6 +38,12 @@ const ( MaxIsoPackets = 1024 MaxInputReportBytes = 4096 MaxPendingOperations = 4096 + // TransferFlagDirectionIn is the wire value of + // USBD_TRANSFER_DIRECTION_IN from usb.h. + TransferFlagDirectionIn uint32 = 0x00000001 + // TransferFlagStartIsoASAP is the wire value of + // USBD_START_ISO_TRANSFER_ASAP from usb.h. + TransferFlagStartIsoASAP uint32 = 0x00000004 MicrosoftOS10StringIndex = 0x00EE MicrosoftOS10StringLength = 18 diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index c8c18091..e36e9912 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1033,7 +1033,7 @@ ViiperReserveIsoStartFrame( return RequestedStartFrame; } - currentFrame = (ULONG)(KeQueryInterruptTime() / 10000ULL); + currentFrame = (ULONG)(KeQueryInterruptTimePrecise(NULL) / 10000ULL); for (;;) { observed = InterlockedCompareExchange64( &EndpointContext->NextIsoStartFrame, 0, 0); From 66fe039b09b8a8cd54cddb44b9d09655df37e9c6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 12:15:28 -0500 Subject: [PATCH 143/240] Install native UDE package transactionally Bind the Microsoft-returned driver package, source manifest, broker, helper, and one-time nested commit token before mutation. Stage the broker under protected Program Files ACLs, replace weak exact-owned services by delete/recreate, and preserve usbip until authenticated native ABI 1.8 health commits. Make the machine lock a protected private-namespace mutex with real ownership and abandoned-state recovery. Bound ABI negotiation with overlapped cancellation/drain and the nested broker with an explicit rollback ceiling. Reconcile rollback by exact captured root devnode identity and signed package hash instead of generated/all-device replacement. Add deterministic failpoint/source-contract gates, a pinned Windows CI job, and reference-backed package transaction documentation. --- .../workflows/native-package-transaction.yml | 53 + .../native-udecx-package-install.md | 142 ++ docs/architecture/native-udecx.md | 3 + internal/cmd/install_windows.go | 14 + internal/cmd/native_package.go | 233 ++++ internal/cmd/native_package_contract_test.go | 121 ++ internal/cmd/native_package_other.go | 17 + internal/cmd/native_package_test.go | 246 ++++ internal/cmd/native_package_windows.go | 1186 +++++++++++++++++ internal/cmd/native_package_windows_test.go | 64 + .../cmd/native_service_install_windows.go | 42 + internal/config/config.go | 8 +- native/udecx/README.md | 17 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 65 +- native/udecx/tools/ViiperUdeCtl.cpp | 1180 ++++++++++++++-- 15 files changed, 3260 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/native-package-transaction.yml create mode 100644 docs/architecture/native-udecx-package-install.md create mode 100644 internal/cmd/native_package.go create mode 100644 internal/cmd/native_package_contract_test.go create mode 100644 internal/cmd/native_package_other.go create mode 100644 internal/cmd/native_package_test.go create mode 100644 internal/cmd/native_package_windows.go create mode 100644 internal/cmd/native_package_windows_test.go diff --git a/.github/workflows/native-package-transaction.yml b/.github/workflows/native-package-transaction.yml new file mode 100644 index 00000000..f2b4d5be --- /dev/null +++ b/.github/workflows/native-package-transaction.yml @@ -0,0 +1,53 @@ +name: Native package transaction + +on: + push: + paths: + - "internal/cmd/native_package*" + - "internal/cmd/native_service_install_windows*" + - "native/udecx/tools/ViiperUdeCtl.cpp" + - "native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1" + - ".github/workflows/native-package-transaction.yml" + pull_request: + paths: + - "internal/cmd/native_package*" + - "internal/cmd/native_service_install_windows*" + - "native/udecx/tools/ViiperUdeCtl.cpp" + - "native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1" + - ".github/workflows/native-package-transaction.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fail-closed-simulation: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.26.5" + cache: true + - name: Run deterministic package transaction simulations + shell: pwsh + run: go test -count=1 -run '^TestNativePackage' ./internal/cmd + - name: Enforce package helper source contract + shell: pwsh + run: ./native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 + - name: Compile and self-test package helper + shell: pwsh + run: | + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw "Visual C++ toolchain was not found" } + $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" + $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path + $output = Join-Path $env:RUNNER_TEMP "ViiperUdeCtl.exe" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib Crypt32.lib Wintrust.lib" + cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output)) { + throw "ViiperUdeCtl build failed" + } + & $output self-test + if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl self-test failed" } diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md new file mode 100644 index 00000000..ca724f7a --- /dev/null +++ b/docs/architecture/native-udecx-package-install.md @@ -0,0 +1,142 @@ +# Native UDE package installation transaction + +The native UDE release is installed through one fail-closed composition +transaction. `viiper native-package-install` is a hidden bootstrapper boundary; +users enter it only through a signed DS4Windows/VIIPER installer that embeds the +reviewed SHA-256 values. It cannot turn a CI test-signed package into production +media. The driver must first satisfy the production HLK/WHCP contract in +[`native-udecx-signing.md`](native-udecx-signing.md). + +## Trust inputs + +The signed bootstrapper supplies all of the following as immutable build data: + +- the exact VIIPER broker, `ViiperUdeCtl.exe`, and reviewed production-manifest + SHA-256 values; +- the reviewed 40-64 hexadecimal source revision; +- the four-file Microsoft-returned driver directory and source-bound HLK/WHCP + manifest; and +- the target interactive-user SID whose legacy startup ownership may be + migrated. + +Before its first mutation, the command holds non-write-shared and +non-delete-shared handles to the broker, helper, manifest, INF, SYS, PDB, and +CAT, plus every local directory ancestor used to reopen those paths. It rejects +reparse points, hard links, ancestor replacement, extra package files, hash changes, +noncanonical INF contracts, non-production manifests, and packages that do not +pass the helper's read-only signature/catalog verification. The helper proves +that the exact adjacent `ViiperUde.inf` and `ViiperUde.sys` are both members of +the exact adjacent Microsoft catalog under Windows driver policy before any +SetupAPI mutation. +Production checks +the actual catalog signer certificate for Windows Hardware Driver Verification +EKU and explicitly rejects the attestation EKU; the publisher display name is +not sufficient. The helper repeats +the installer-embedded manifest SHA-256 check before both verification and +installation. Paths are passed as arguments, never through a command shell. + +## Commit order + +1. Acquire the administrator-only machine package mutex and validate every + immutable input. +2. Inspect `VIIPERNativeBroker` without changing it. A protected canonical + service and executable become an exact rollback source. An exact service + name whose executable is restricted to one of VIIPER's managed Program + Files layouts, but whose service/image ACL is weak or stale, is stopped and + deleted; its unsafe ACL is never repaired in place or restored. +3. Create `%ProgramFiles%\VIIPER` with the canonical protected ACL, or require + an existing directory to already have that exact ACL. Write a random sibling + staging file with the exact executable ACL, flush it, verify its SHA-256 and + single-link identity, retain the previous broker under a random protected + rollback name, and publish with `MoveFileExW(..., WRITE_THROUGH)`. The outer + transaction also creates and holds a random one-time token with an + administrator/SYSTEM-only DACL and passes its installer-bound SHA-256 to the + helper. +4. `ViiperUdeCtl verify` repeats source/package verification without mutation. + `ViiperUdeCtl install` then retains its in-memory pre-install DriverStore and + devnode snapshot while launching the staged broker's hidden + `native-package-broker-commit` command. That command reopens the immutable + token, requires its exact DACL/hash/path, and proves the package mutex is + still owned by the separate outer process before it may enter the normal + broker service transaction. +5. The broker transaction creates or updates the LocalSystem service, rotates + its protected credential, starts it, and requires authenticated `ping` + identity, `Ready=true`, ABI 1.8, and the exact negotiated capability mask. + Only then does it disable legacy Run/task/process ownership, and it + authenticates again before returning success. +6. The helper commits the driver only after that broker proof. A broker failure + first rolls back SCM, credential, and legacy state inside the broker, then + restores the prior driver packages/devnode inside the still-running helper. + The outer command finally restores the old broker image and prior service + run-state. It never removes USB/IP directly. + +The mutating broker process is never hard-terminated. The outer absolute +four-minute deadline is passed through the helper into the nested broker, so it +does not receive a fresh budget after driver mutation. The broker owns a +separately bounded rollback and unwinds cooperatively; the helper retains the +driver snapshot and polls only through that explicit rollback ceiling. If the +child violates both bounds, the helper reports an indeterminate rollback and +does not race the still-owning child with a second driver rollback. The outer rollback +uses its own non-canceled two-minute context. Synchronous SetupAPI work is +checked immediately before and after each mutating boundary; no new phase may +start after expiry, and no process is killed mid-rollback. + +## Reference-backed Windows invariants + +- The machine transaction lock uses a private namespace bounded to the local + Administrators SID and a protected SYSTEM/Administrators DACL. It then waits + on the mutex and owns it until `ReleaseMutex`; object existence is not lock + ownership, and `WAIT_ABANDONED` triggers a fresh inventory before mutation. + This follows Microsoft's [private namespace](https://learn.microsoft.com/windows/win32/sync/object-namespaces) + and [mutex wait](https://learn.microsoft.com/windows/win32/sync/using-mutex-objects) + contracts and prevents an unelevated process from pre-creating the machine + lock name. +- Native ABI health opens the UDE control interface for overlapped I/O. A + pending `DeviceIoControl` is waited only until the absolute transaction + deadline; timeout calls `CancelIoEx` and drains the exact `OVERLAPPED` before + rollback continues. This is the documented [overlapped DeviceIoControl](https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol) + and [CancelIoEx](https://learn.microsoft.com/windows/win32/fileio/cancelioex-func) + lifetime rule. +- SetupAPI rollback preserves the captured root device instance ID. Per + [`SetupDiCreateDeviceInfoW`](https://learn.microsoft.com/windows/win32/api/setupapi/nf-setupapi-setupdicreatedeviceinfow), + omitting `DICD_GENERATE_ID` makes `DeviceName` the complete instance ID; + generated IDs are used only for a first-time forward install. Rollback + reconciles and verifies the captured identity, topology, and signed package + hash rather than deleting every matching devnode and manufacturing a + replacement. +- ViGEmBus's root-enumerated bus architecture is used only as the lifecycle + reference: the bus owns its exact child identities and separates user-mode + submission from PnP mutation. usbip-win2 remains an untouched legacy + fallback until authenticated native health succeeds; package rollback never + treats its service or driver store as installer-owned. + +Every public native install, repair, or uninstall takes locks in the same +machine-wide order: package mutex, then broker-service mutex. The nested helper +callback does not reacquire the package mutex (which would deadlock); the +protected one-time token and zero-time ownership check authorize that one +service transaction. The token is removed on commit or rollback and is inert +without a live outer mutex owner. Because Win32 mutexes are thread-owned, each +Go acquisition pins its goroutine to that OS thread until the matching release; +scheduler migration cannot strand either global lock. + +## Restart boundary + +If Windows reports that driver activation requires a restart, the helper does +not start the broker or remove legacy ownership. It rolls the attempted driver +transaction back, the outer transaction restores the prior executable/service, +and preserves Windows `ERROR_SUCCESS_REBOOT_REQUIRED` (3010) through the Go +bootstrapper for the signed installer. After restart, the installer +retries the complete preflight and transaction from the beginning. No +cross-reboot journal is trusted as executable authority. + +## Deterministic gates + +The normal Go suite runs a failpoint matrix for every transaction phase, +including partial preparation, authenticated-health failure, commit failure, +caller cancellation, rollback failure, and close failure. A source-contract +test requires the immutable-input locks, read-only helper verification, +protected ACLs, weak-service delete/recreate path, atomic publication, inner +driver/broker rollback, protected nested-commit token, global lock ordering, +and authenticated proof. It also rejects hard process +termination, context-killed helper processes, recursive deletion, or direct +legacy/USB-IP removal in the outer layer. diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index d99c2d9a..142a9391 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -543,6 +543,9 @@ stall an independent pad's registration or removal. The exact attestation/HLK boundary, CAB construction, and Microsoft-signature validation contract is documented in [`native-udecx-signing.md`](native-udecx-signing.md). +The protected broker staging, cross-component driver/service rollback, and +authenticated commit order are documented in +[`native-udecx-package-install.md`](native-udecx-package-install.md). ## Primary documentation diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 1cae04ad..5cb80d30 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -30,6 +30,13 @@ const ( func install(logger *slog.Logger, transport, targetUserSID string) error { if transport == "native-ude" { + release, err := acquireNamedNativePackageMutex( + nativePackageMutexName, nativePackageTransactionTimeout, + ) + if err != nil { + return err + } + defer release() return installNativeBroker(logger, targetUserSID) } if targetUserSID != "" { @@ -128,6 +135,13 @@ func requireNativeUDEBroker() error { } func uninstall(logger *slog.Logger, targetUserSID string) error { + release, err := acquireNamedNativePackageMutex( + nativePackageMutexName, nativePackageTransactionTimeout, + ) + if err != nil { + return err + } + defer release() return uninstallNativeBroker(logger, targetUserSID) } diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go new file mode 100644 index 00000000..3e67effe --- /dev/null +++ b/internal/cmd/native_package.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "regexp" + "strings" + "time" +) + +var nativePackageHexRevision = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`) +var nativePackageSHA256 = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +const ( + nativePackageTransactionTimeout = 4 * time.Minute + nativePackageRollbackTimeout = 2 * time.Minute + nativePackageRebootRequiredCode = 3010 +) + +type nativePackageRebootRequiredError struct { + cause error +} + +func (e *nativePackageRebootRequiredError) Error() string { + return "native package activation requires a restart after safe rollback: " + e.cause.Error() +} + +func (e *nativePackageRebootRequiredError) Unwrap() error { return e.cause } + +// ExitCode lets Kong preserve Windows' ERROR_SUCCESS_REBOOT_REQUIRED contract +// for the signed installer instead of flattening the reconciled state to 1. +func (e *nativePackageRebootRequiredError) ExitCode() int { + return nativePackageRebootRequiredCode +} + +// NativePackageInstall is the narrow bootstrapper boundary for the production +// native UDE package. It is hidden because normal users enter through the +// signed DS4Windows installer, which embeds the reviewed hashes passed here. +type NativePackageInstall struct { + PackageDirectory string `help:"Directory containing the four Microsoft-returned VIIPER UDE files." required:""` + SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` + SourceRevision string `help:"Reviewed 40-64 character source revision." required:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` + ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` + ExpectedManifestSHA256 string `help:"Installer-embedded SHA-256 of the reviewed HLK/WHCP manifest." required:""` + TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` +} + +// NativePackageBrokerCommit is invoked only by ViiperUdeCtl while the signed +// outer package transaction holds its machine mutex and protected token. +type NativePackageBrokerCommit struct { + TokenFile string `help:"Protected package-transaction token path." required:""` + ExpectedTokenSHA256 string `help:"SHA-256 of the protected transaction token." required:""` + TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` + TransactionDeadlineUnixMS string `help:"Outer package transaction deadline as Unix milliseconds." required:""` +} + +func (c *NativePackageBrokerCommit) Run(logger *slog.Logger) error { + if !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedTokenSHA256)) { + return errors.New("native package transaction token SHA-256 must contain exactly 64 hexadecimal characters") + } + return commitNativePackageBroker(logger, strings.TrimSpace(c.TokenFile), + strings.ToLower(strings.TrimSpace(c.ExpectedTokenSHA256)), strings.TrimSpace(c.TargetUserSID), + strings.TrimSpace(c.TransactionDeadlineUnixMS)) +} + +func (c *NativePackageInstall) Run(logger *slog.Logger) error { + executable, err := currentExecutable() + if err != nil { + return err + } + if strings.Contains(executable, "go-build") { + return errors.New("cannot provision the native package from 'go run'") + } + request := nativePackageRequest{ + brokerSource: executable, + packageDirectory: strings.TrimSpace(c.PackageDirectory), + submissionManifest: strings.TrimSpace(c.SubmissionManifest), + sourceRevision: strings.ToLower(strings.TrimSpace(c.SourceRevision)), + driverHelper: strings.TrimSpace(c.DriverHelper), + expectedBrokerSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), + expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + expectedManifestSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedManifestSHA256)), + targetUserSID: strings.TrimSpace(c.TargetUserSID), + } + if err := request.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), nativePackageTransactionTimeout) + defer cancel() + return installNativePackage(ctx, logger, request) +} + +type nativePackageRequest struct { + brokerSource string + packageDirectory string + submissionManifest string + sourceRevision string + driverHelper string + expectedBrokerSHA256 string + expectedHelperSHA256 string + expectedManifestSHA256 string + targetUserSID string +} + +func (r nativePackageRequest) validate() error { + for name, value := range map[string]string{ + "broker source": r.brokerSource, "driver package": r.packageDirectory, + "submission manifest": r.submissionManifest, "driver helper": r.driverHelper, + "target user SID": r.targetUserSID, + } { + if value == "" { + return fmt.Errorf("native package %s is empty", name) + } + if strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("native package %s contains NUL", name) + } + } + if !nativePackageHexRevision.MatchString(r.sourceRevision) { + return errors.New("native package source revision must contain 40-64 hexadecimal characters") + } + if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || + !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || + !nativePackageSHA256.MatchString(r.expectedManifestSHA256) { + return errors.New("native package broker, helper, and manifest SHA-256 values must contain exactly 64 hexadecimal characters") + } + for name, path := range map[string]string{ + "broker source": r.brokerSource, "driver package": r.packageDirectory, + "submission manifest": r.submissionManifest, "driver helper": r.driverHelper, + } { + if !filepath.IsAbs(path) { + return fmt.Errorf("native package %s must be an absolute path: %s", name, path) + } + } + return nil +} + +type nativePackageServiceDisposition uint8 + +const ( + nativePackageServiceAbsent nativePackageServiceDisposition = iota + nativePackageServiceTrusted + nativePackageServiceWeakExactOwned +) + +type nativePackageServiceSnapshot struct { + disposition nativePackageServiceDisposition + wasRunning bool + opaque any +} + +type nativePackageTransaction interface { + Preflight(context.Context) error + InspectService(context.Context) (nativePackageServiceSnapshot, error) + Prepare(context.Context, nativePackageServiceSnapshot) error + InstallDriverAndBroker(context.Context) error + VerifyAuthenticatedHealth(context.Context) error + Commit(context.Context) error + Rollback(context.Context) error + Close() error +} + +func runNativePackageTransaction( + ctx context.Context, + logger *slog.Logger, + transaction nativePackageTransaction, +) (resultErr error) { + if transaction == nil { + return errors.New("native package transaction is nil") + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close native package transaction: %w", closeErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before preflight: %w", err) + } + if err := transaction.Preflight(ctx); err != nil { + return fmt.Errorf("native package preflight rejected before mutation: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before service inspection: %w", err) + } + service, err := transaction.InspectService(ctx) + if err != nil { + return fmt.Errorf("inspect native broker service before mutation: %w", err) + } + prepared := false + committed := false + defer func() { + if !prepared || committed { + return + } + rollbackCtx, cancelRollback := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + defer cancelRollback() + if rollbackErr := transaction.Rollback(rollbackCtx); rollbackErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("roll back native package transaction: %w", rollbackErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before preparation: %w", err) + } + // Preparation can fail after stopping a prior service or publishing one of + // the staged paths. Arm rollback before entering the mutating method. + prepared = true + if err := transaction.Prepare(ctx, service); err != nil { + return fmt.Errorf("prepare protected native package staging: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package transaction canceled before driver installation: %w", err) + } + if err := transaction.InstallDriverAndBroker(ctx); err != nil { + return fmt.Errorf("install native driver and broker transaction: %w", err) + } + if err := transaction.VerifyAuthenticatedHealth(ctx); err != nil { + return fmt.Errorf("verify native package authenticated health: %w", err) + } + if err := transaction.Commit(ctx); err != nil { + return fmt.Errorf("commit native package transaction: %w", err) + } + committed = true + logger.Info("VIIPER native UDE package transaction committed", + "transport", "native-ude", "sourceRevision", "verified") + return nil +} diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go new file mode 100644 index 00000000..16457150 --- /dev/null +++ b/internal/cmd/native_package_contract_test.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestNativePackageProductionSourceContract(t *testing.T) { + t.Parallel() + _, current, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")) + windowsSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_windows.go")) + helperSource := readNativePackageContractFile(t, + filepath.Join(root, "native", "udecx", "tools", "ViiperUdeCtl.cpp")) + transactionSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package.go")) + serviceSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_service_install_windows.go")) + + requiredWindows := []string{ + `runDriverHelper(ctx, "verify", false)`, + "expectedManifestSHA256", + "--manifest-sha256", + "nativeBrokerDirectorySDDL", + "nativeBrokerExecutableSDDL", + "nativePackageServiceWeakExactOwned", + "isCanonicalNativePackageService", + "nativeServiceConfigsEqual", + "slices.Equal(recovery, nativeServiceRecoveryActions)", + "service.Delete()", + "lockNativeServiceExecutableReadOnly", + `runDriverHelper(ctx, "install", true)`, + "MOVEFILE_WRITE_THROUGH", + "VerifyAuthenticatedHealth", + "nativePackageTokenSDDL", + "nativePackageMutexHeldByAnotherOwner", + "runtime.LockOSThread()", + "lockNativePackageDirectoryChain", + "--broker-token-sha256", + "nativePackageRebootRequiredError", + } + for _, fragment := range requiredWindows { + if !strings.Contains(windowsSource, fragment) { + t.Errorf("Windows package orchestrator lost %q", fragment) + } + } + requiredHelper := []string{ + "Outcome Verify(", "ValidateCandidateInputs(", "RunBrokerInstall(", + "--manifest-sha256", "manifest-installer-hash", "--broker-sha256", + "--broker-token-sha256", "native-package-broker-commit", + "RollbackInstall(prior", "broker-reboot-boundary", + "--transaction-deadline-unix-ms", "kBrokerRollbackCeilingMs", + "CreatePrivateNamespaceW", "WAIT_ABANDONED", "ReleaseMutex", + "FILE_FLAG_OVERLAPPED", "CancelIoEx", "kCancelledIoDrainMs", + "RegisterRootDeviceExact", "rollback-identity-verification", + "CertGetEnhancedKeyUsage", "1.3.6.1.4.1.311.10.3.5.1", + "CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG", + "VerifyDriverCatalogMember", "WinVerifyTrust", + "VerifyDriverCatalogMember(catalogPath, infPath", + "LoadLibraryExW", "LOAD_LIBRARY_SEARCH_SYSTEM32", "GetProcAddress", + "ValidateExactPackageDirectory", "Sha256Handle(manifest.get()", + } + for _, fragment := range requiredHelper { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper lost %q", fragment) + } + } + requiredTransaction := []string{ + "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", + "prepared = true", "transaction.Prepare(ctx, service)", + "transaction.InstallDriverAndBroker(ctx)", + "transaction.VerifyAuthenticatedHealth(ctx)", "transaction.Commit(ctx)", + "nativePackageRollbackTimeout", "context.WithoutCancel(ctx)", + } + for _, fragment := range requiredTransaction { + if !strings.Contains(transactionSource, fragment) { + t.Errorf("package transaction lost %q", fragment) + } + } + for _, fragment := range []string{ + "func acquireNativeInstallMutex(", "runtime.LockOSThread()", "runtime.UnlockOSThread()", + } { + if !strings.Contains(serviceSource, fragment) { + t.Errorf("native broker service mutex lost %q", fragment) + } + } + for name, source := range map[string]string{ + "Windows package orchestrator": windowsSource, + "driver helper": helperSource, + } { + for _, forbidden := range []string{"TerminateProcess(", "exec.CommandContext(", "os.RemoveAll("} { + if strings.Contains(source, forbidden) { + t.Errorf("%s contains unsafe %q", name, forbidden) + } + } + } + if strings.Contains(helperSource, "WaitForSingleObject(processHandle.get(), INFINITE)") { + t.Error("driver helper retained an unbounded nested broker wait") + } + for _, forbidden := range []string{"removeLegacy", "usbip"} { + if strings.Contains(windowsSource, forbidden) { + t.Errorf("outer package transaction must leave legacy ownership to the authenticated broker commit; found %q", forbidden) + } + } +} + +func readNativePackageContractFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.ReplaceAll(string(content), "\r\n", "\n") +} diff --git a/internal/cmd/native_package_other.go b/internal/cmd/native_package_other.go new file mode 100644 index 00000000..263bb4e1 --- /dev/null +++ b/internal/cmd/native_package_other.go @@ -0,0 +1,17 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "log/slog" +) + +func installNativePackage(context.Context, *slog.Logger, nativePackageRequest) error { + return errors.New("native UDE package installation is supported only on Windows") +} + +func commitNativePackageBroker(*slog.Logger, string, string, string, string) error { + return errors.New("native UDE package installation is supported only on Windows") +} diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go new file mode 100644 index 00000000..359abfbe --- /dev/null +++ b/internal/cmd/native_package_test.go @@ -0,0 +1,246 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "reflect" + "strings" + "testing" +) + +type fakeNativePackageTransaction struct { + events []string + fail string + closeErr error + rollbackErr error + snapshot nativePackageServiceSnapshot + installStarted chan struct{} + rollbackHadDeadline bool +} + +func (f *fakeNativePackageTransaction) event(name string) error { + f.events = append(f.events, name) + if f.fail == name { + return errors.New(name + " failure") + } + return nil +} + +func (f *fakeNativePackageTransaction) Preflight(context.Context) error { + return f.event("preflight") +} + +func (f *fakeNativePackageTransaction) InspectService(context.Context) (nativePackageServiceSnapshot, error) { + return f.snapshot, f.event("inspect") +} + +func (f *fakeNativePackageTransaction) Prepare( + _ context.Context, snapshot nativePackageServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + return f.event("prepare") +} + +func (f *fakeNativePackageTransaction) InstallDriverAndBroker(ctx context.Context) error { + if err := f.event("install"); err != nil { + return err + } + if f.installStarted != nil { + close(f.installStarted) + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func (f *fakeNativePackageTransaction) VerifyAuthenticatedHealth(context.Context) error { + return f.event("verify") +} + +func (f *fakeNativePackageTransaction) Commit(context.Context) error { + return f.event("commit") +} + +func (f *fakeNativePackageTransaction) Rollback(ctx context.Context) error { + f.events = append(f.events, "rollback") + if ctx.Err() != nil { + return errors.New("rollback inherited canceled context") + } + _, f.rollbackHadDeadline = ctx.Deadline() + return f.rollbackErr +} + +func (f *fakeNativePackageTransaction) Close() error { + f.events = append(f.events, "close") + return f.closeErr +} + +func nativePackageTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestNativePackageTransactionCommitsOnlyAfterAuthenticatedHealth(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{snapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, wasRunning: true, + }} + if err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake); err != nil { + t.Fatalf("run transaction: %v", err) + } + want := []string{"preflight", "inspect", "prepare", "install", "verify", "commit", "close"} + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageTransactionFailureMatrix(t *testing.T) { + t.Parallel() + for _, fail := range []string{"preflight", "inspect", "prepare", "install", "verify", "commit"} { + fail := fail + t.Run(fail, func(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{fail: fail} + err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), fail+" failure") { + t.Fatalf("error=%v", err) + } + rollbackExpected := fail == "prepare" || fail == "install" || fail == "verify" || fail == "commit" + rollbackSeen := false + for _, event := range fake.events { + rollbackSeen = rollbackSeen || event == "rollback" + } + if rollbackSeen != rollbackExpected { + t.Fatalf("events=%v rollbackExpected=%v", fake.events, rollbackExpected) + } + if fake.events[len(fake.events)-1] != "close" { + t.Fatalf("transaction did not close: %v", fake.events) + } + }) + } +} + +func TestNativePackageTransactionRejectsCancellationBeforeMutation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageTransaction{fail: "install"} + cancel() + err := runNativePackageTransaction(ctx, nativePackageTestLogger(), fake) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !reflect.DeepEqual(fake.events, []string{"close"}) { + t.Fatalf("events=%v", fake.events) + } +} + +func TestNativePackageTransactionCancellationReconcilesWithBoundedRollback(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageTransaction{installStarted: make(chan struct{})} + result := make(chan error, 1) + go func() { + result <- runNativePackageTransaction(ctx, nativePackageTestLogger(), fake) + }() + <-fake.installStarted + cancel() + err := <-result + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !fake.rollbackHadDeadline { + t.Fatal("rollback did not receive its own bounded deadline") + } + want := []string{"preflight", "inspect", "prepare", "install", "rollback", "close"} + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageBrokerCommitRejectsUnboundTokenBeforePlatformCall(t *testing.T) { + t.Parallel() + command := NativePackageBrokerCommit{ + TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + ExpectedTokenSHA256: "not-a-hash", + TargetUserSID: "S-1-5-21-1-2-3-1001", + } + err := command.Run(nativePackageTestLogger()) + if err == nil || !strings.Contains(err.Error(), "64 hexadecimal") { + t.Fatalf("error=%v", err) + } +} + +func TestNativePackageBrokerCommitRejectsInvalidDeadlineBeforePlatformCall(t *testing.T) { + t.Parallel() + command := NativePackageBrokerCommit{ + TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + ExpectedTokenSHA256: strings.Repeat("a", 64), + TargetUserSID: "S-1-5-21-1-2-3-1001", + TransactionDeadlineUnixMS: "not-a-deadline", + } + err := command.Run(nativePackageTestLogger()) + if err == nil || !strings.Contains(err.Error(), "deadline") { + t.Fatalf("error=%v", err) + } +} + +func TestNativePackageTransactionReportsRollbackAndCloseFailures(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageTransaction{ + fail: "verify", rollbackErr: errors.New("rollback failed"), closeErr: errors.New("close failed"), + } + err := runNativePackageTransaction(context.Background(), nativePackageTestLogger(), fake) + for _, fragment := range []string{"verify failure", "rollback failed", "close failed"} { + if err == nil || !strings.Contains(err.Error(), fragment) { + t.Fatalf("error=%v missing %q", err, fragment) + } + } +} + +func TestNativePackageRebootRequiredPreservesInstallerExitCode(t *testing.T) { + t.Parallel() + cause := errors.New("helper safely rolled back") + err := fmt.Errorf("install native package: %w", &nativePackageRebootRequiredError{cause: cause}) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + if !errors.Is(err, cause) { + t.Fatalf("reboot-required error lost cause: %v", err) + } +} + +func TestNativePackageRequestFailsClosed(t *testing.T) { + t.Parallel() + base := nativePackageRequest{ + brokerSource: `C:\bundle\viiper.exe`, packageDirectory: `C:\bundle\driver`, + submissionManifest: `C:\bundle\submission.json`, sourceRevision: strings.Repeat("a", 40), + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, expectedBrokerSHA256: strings.Repeat("b", 64), + expectedHelperSHA256: strings.Repeat("c", 64), targetUserSID: "S-1-5-21-1-2-3-1001", + expectedManifestSHA256: strings.Repeat("d", 64), + } + if err := base.validate(); err != nil { + t.Fatalf("valid request: %v", err) + } + cases := map[string]func(*nativePackageRequest){ + "relative package": func(r *nativePackageRequest) { r.packageDirectory = `driver` }, + "short revision": func(r *nativePackageRequest) { r.sourceRevision = "abc" }, + "bad broker hash": func(r *nativePackageRequest) { r.expectedBrokerSHA256 = strings.Repeat("z", 64) }, + "embedded NUL": func(r *nativePackageRequest) { r.submissionManifest += "\x00evil" }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + request := base + mutate(&request) + if err := request.validate(); err == nil { + t.Fatal("invalid request accepted") + } + }) + } +} diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go new file mode 100644 index 00000000..58a28cd0 --- /dev/null +++ b/internal/cmd/native_package_windows.go @@ -0,0 +1,1186 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const nativePackageMutexName = `Global\VIIPER.NativePackage.Install.v1` +const nativePackageTokenSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" + +var nativePackageDriverFiles = []string{ + "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.pdb", "ViiperUde.cat", +} + +type windowsNativePackageTransaction struct { + logger *slog.Logger + request nativePackageRequest + + releaseMutex func() + inputHandles []windows.Handle + sourceHandle windows.Handle + helperHandle windows.Handle + + programFiles string + destination string + parent string + parentHandle windows.Handle + parentMade bool + + manager nativeSCM + service nativeManagedService + serviceSnapshot nativePackageServiceSnapshot + priorServiceExecutable string + priorExecutableRelease func() + stoppedTrustedService bool + + temporaryPath string + backupPath string + destinationPublished bool + destinationRelease func() + tokenPath string + tokenSHA256 string + tokenHandle windows.Handle + installProof bool + closed bool +} + +func installNativePackage( + ctx context.Context, + logger *slog.Logger, + request nativePackageRequest, +) error { + transaction := &windowsNativePackageTransaction{logger: logger, request: request} + return runNativePackageTransaction(ctx, logger, transaction) +} + +func commitNativePackageBroker( + logger *slog.Logger, + tokenPath, expectedTokenSHA256, targetUserSID, deadlineUnixMS string, +) error { + deadlineMilliseconds, err := strconv.ParseInt(deadlineUnixMS, 10, 64) + if err != nil || deadlineMilliseconds <= 0 { + return errors.New("native package transaction deadline must be positive Unix milliseconds") + } + deadline := time.UnixMilli(deadlineMilliseconds) + if !deadline.After(time.Now()) || deadline.After(time.Now().Add(nativePackageTransactionTimeout)) { + return errors.New("native package transaction deadline is expired or outside the package budget") + } + if !filepath.IsAbs(tokenPath) || strings.IndexByte(tokenPath, 0) >= 0 { + return errors.New("native package transaction token path must be absolute and contain no NUL") + } + if _, err := validateNativeInstallingUserSID(targetUserSID); err != nil { + return fmt.Errorf("validate package transaction target SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files: %w", err) + } + expectedParent := filepath.Join(filepath.Clean(programFiles), "VIIPER") + base := filepath.Base(tokenPath) + if !strings.EqualFold(filepath.Dir(filepath.Clean(tokenPath)), expectedParent) || + !strings.HasPrefix(strings.ToLower(base), ".viiper.transaction.") || + !strings.HasSuffix(strings.ToLower(base), ".token") { + return fmt.Errorf("package transaction token escaped the managed VIIPER directory: %s", tokenPath) + } + handle, err := lockNativePackageInput(tokenPath) + if err != nil { + return fmt.Errorf("lock package transaction token: %w", err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return fmt.Errorf("validate package transaction token ACL: %w", err) + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return fmt.Errorf("hash package transaction token: %w", err) + } + if !strings.EqualFold(hash, expectedTokenSHA256) { + return errors.New("package transaction token SHA-256 does not match the active installer") + } + held, err := nativePackageMutexHeldByAnotherOwner(nativePackageMutexName) + if err != nil { + return fmt.Errorf("verify outer package transaction mutex: %w", err) + } + if !held { + return errors.New("outer native package transaction mutex is not held") + } + return installNativeBrokerUntil(logger, targetUserSID, deadline) +} + +func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + mutexBudget := nativePackageTransactionTimeout + if deadline, ok := ctx.Deadline(); ok { + mutexBudget = time.Until(deadline) + if mutexBudget <= 0 { + return context.DeadlineExceeded + } + } + release, err := acquireNamedNativePackageMutex(nativePackageMutexName, mutexBudget) + if err != nil { + return err + } + t.releaseMutex = release + if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { + return fmt.Errorf("validate target user SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files known folder: %w", err) + } + t.programFiles = filepath.Clean(programFiles) + t.parent = filepath.Join(t.programFiles, "VIIPER") + t.destination = filepath.Join(t.parent, "viiper.exe") + if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + return err + } + programFilesHandle, err := openNativePathWithoutReparse( + t.programFiles, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return fmt.Errorf("lock Program Files root: %w", err) + } + t.inputHandles = append(t.inputHandles, programFilesHandle) + for _, input := range []struct { + name string + directory string + }{ + {name: "broker source", directory: filepath.Dir(t.request.brokerSource)}, + {name: "driver helper", directory: filepath.Dir(t.request.driverHelper)}, + {name: "submission manifest", directory: filepath.Dir(t.request.submissionManifest)}, + {name: "signed driver package", directory: t.request.packageDirectory}, + } { + handles, lockErr := lockNativePackageDirectoryChain(input.directory) + if lockErr != nil { + return fmt.Errorf("lock %s directory chain: %w", input.name, lockErr) + } + t.inputHandles = append(t.inputHandles, handles...) + } + + t.sourceHandle, err = t.lockAndVerifyInput( + t.request.brokerSource, t.request.expectedBrokerSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound VIIPER broker: %w", err) + } + t.helperHandle, err = t.lockAndVerifyInput( + t.request.driverHelper, t.request.expectedHelperSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver helper: %w", err) + } + entries, err := os.ReadDir(t.request.packageDirectory) + if err != nil { + return fmt.Errorf("enumerate signed driver package: %w", err) + } + if len(entries) != len(nativePackageDriverFiles) { + return fmt.Errorf("signed driver package must contain exactly four files, found %d", len(entries)) + } + for _, expected := range nativePackageDriverFiles { + matches := 0 + for _, entry := range entries { + if entry.Name() == expected && entry.Type().IsRegular() { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("signed driver package must contain one case-exact regular %s", expected) + } + handle, lockErr := lockNativePackageInput(filepath.Join(t.request.packageDirectory, expected)) + if lockErr != nil { + return fmt.Errorf("lock signed driver file %s: %w", expected, lockErr) + } + t.inputHandles = append(t.inputHandles, handle) + } + manifestHandle, err := t.lockAndVerifyInput( + t.request.submissionManifest, t.request.expectedManifestSHA256, false, + ) + if err != nil { + return fmt.Errorf("verify installer-bound driver manifest: %w", err) + } + _ = manifestHandle + + if err := t.runDriverHelper(ctx, "verify", false); err != nil { + return fmt.Errorf("source-bound Microsoft driver verification: %w", err) + } + if attributes, attrErr := nativePathAttributes(t.parent); attrErr == nil { + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return errors.New("managed VIIPER directory is not a regular non-reparse directory") + } + parent, openErr := openNativePathWithoutReparse( + t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if openErr != nil { + return fmt.Errorf("open managed VIIPER directory: %w", openErr) + } + defer windows.CloseHandle(parent) //nolint:errcheck + if validateErr := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); validateErr != nil { + return fmt.Errorf("managed VIIPER directory is not installer-owned: %w", validateErr) + } + } else if !errors.Is(attrErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(attrErr, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect managed VIIPER directory: %w", attrErr) + } + return nil +} + +func (t *windowsNativePackageTransaction) InspectService( + ctx context.Context, +) (nativePackageServiceSnapshot, error) { + manager, err := mgr.Connect() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + service, err := t.manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + t.serviceSnapshot = nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent} + return t.serviceSnapshot, nil + } + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("open %s: %w", NativeBrokerServiceName, err) + } + t.service = service + config, err := service.Config() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s config: %w", NativeBrokerServiceName, err) + } + priorExecutable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("parse %s executable: %w", NativeBrokerServiceName, err) + } + if _, err := nativeServiceExecutableParent(t.programFiles, priorExecutable); err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf( + "refusing to delete or adopt non-owned %s: %w", NativeBrokerServiceName, err, + ) + } + t.priorServiceExecutable = priorExecutable + status, err := service.Query() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s state: %w", NativeBrokerServiceName, err) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, waitContext) + if err != nil { + return nativePackageServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s DACL: %w", NativeBrokerServiceName, err) + } + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("resolve native broker credential: %w", err) + } + expectedConfig, _, err := nativeBrokerServiceConfiguration(priorExecutable, keyPath) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("construct canonical native broker service: %w", err) + } + recovery, err := service.RecoveryActions() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery actions: %w", + NativeBrokerServiceName, err) + } + reset, err := service.ResetPeriod() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery reset: %w", + NativeBrokerServiceName, err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("query %s recovery mode: %w", + NativeBrokerServiceName, err) + } + canonical := isCanonicalNativePackageService( + config, expectedConfig, securityDescriptor, recovery, reset, nonCrash, + ) + disposition := nativePackageServiceWeakExactOwned + if canonical { + releaseExecutable, lockErr := lockNativePriorServiceExecutable(priorExecutable) + if lockErr == nil { + disposition = nativePackageServiceTrusted + t.priorExecutableRelease = releaseExecutable + } else { + // An exact service name/path with weak image ACLs is stale package + // ownership, not a trustworthy rollback source. It is removed and + // recreated; never "repair" its ACL while old handles may exist. + t.logger.Warn("Replacing weak exact-owned native broker service image", + "path", priorExecutable, "error", lockErr) + } + } + t.serviceSnapshot = nativePackageServiceSnapshot{ + disposition: disposition, + wasRunning: status.State == svc.Running, + } + return t.serviceSnapshot, nil +} + +func isCanonicalNativePackageService( + actual, expected mgr.Config, + securityDescriptor string, + recovery []mgr.RecoveryAction, + reset uint32, + nonCrash bool, +) bool { + return compareNativeSecurityDescriptorStrings( + securityDescriptor, nativeBrokerServiceSDDL, + ) == nil && nativeServiceConfigsEqual(actual, expected) && + slices.Equal(recovery, nativeServiceRecoveryActions) && + reset == nativeServiceRecoveryResetSecond && nonCrash +} + +func (t *windowsNativePackageTransaction) Prepare( + ctx context.Context, + snapshot nativePackageServiceSnapshot, +) error { + if snapshot.disposition != t.serviceSnapshot.disposition || + snapshot.wasRunning != t.serviceSnapshot.wasRunning { + return errors.New("native service snapshot changed before preparation") + } + if t.service != nil && snapshot.disposition == nativePackageServiceWeakExactOwned { + if snapshot.wasRunning { + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("stop weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + } + if err := t.service.Delete(); err != nil && + !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return fmt.Errorf("delete weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + t.service.Close() //nolint:errcheck + t.service = nil + if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + return err + } + } + if t.service != nil && snapshot.disposition == nativePackageServiceTrusted && + strings.EqualFold(t.priorServiceExecutable, t.destination) { + if snapshot.wasRunning { + // STOP is itself the mutation. Arm reconciliation before sending it so + // a timeout while StopPending cannot strand a formerly-running service. + t.stoppedTrustedService = true + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("quiesce trusted %s for atomic image replacement: %w", + NativeBrokerServiceName, err) + } + } + // The read-only preflight lock deliberately denies rename/delete. Once + // the exact trusted service is quiescent, release that lock so the + // protected image can move to the rollback name in the same directory. + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + } + return t.stageBrokerExecutable() +} + +func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if err := t.runDriverHelper(ctx, "install", true); err != nil { + return err + } + // A deadline that expires after the synchronous mutating helper starts must + // not turn its authenticated success into a contradictory outer rollback. + // The helper owns the driver snapshot and the nested broker owns its bounded + // SCM rollback; wait for that authoritative result, then commit its proof. + t.installProof = true + return nil +} + +func (t *windowsNativePackageTransaction) VerifyAuthenticatedHealth(ctx context.Context) error { + // ViiperUdeCtl does not return success until the staged broker's native + // service transaction has performed authenticated ABI/capability health, + // removed legacy ownership, and authenticated a second time. Preserve that + // proof rather than adding a racy third ping after the inner commit. + if !t.installProof { + return errors.New("driver helper returned no authenticated broker health proof") + } + // The nested broker accepts this proof only when its authenticated health + // commit completed under the exact outer deadline. A scheduler delay between + // child exit and this check must not trigger a contradictory driver rollback. + if err := ctx.Err(); err != nil { + t.logger.Warn("Native package proof completed at the transaction deadline; finishing outer cleanup", + "deadline", err) + } + return nil +} + +func (t *windowsNativePackageTransaction) Commit(context.Context) error { + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + if err := t.releaseCoordinationToken(); err != nil { + // The nested broker transaction has already authenticated the native + // service and removed legacy ownership. A stale token is inert without + // the outer package mutex, so retain it for repair instead of turning a + // committed installation into an unsafe rollback. + t.logger.Warn("Could not remove protected package transaction token after commit", + "path", t.tokenPath, "error", err) + } + if t.backupPath != "" { + if err := deleteNativePackageFile(t.backupPath); err != nil { + // Cleanup cannot invalidate an already-authenticated inner transaction. + // Keep the administrator-only backup for the next repair instead. + t.logger.Warn("Could not remove protected prior broker backup after commit", + "path", t.backupPath, "error", err) + } else { + t.backupPath = "" + } + } + return nil +} + +func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) error { + var rollbackErrors []error + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + if err := t.releaseCoordinationToken(); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("remove package transaction token: %w", err)) + } + restored := true + if err := t.restoreBrokerExecutable(); err != nil { + restored = false + rollbackErrors = append(rollbackErrors, err) + } + if t.stoppedTrustedService && t.service != nil && t.serviceSnapshot.wasRunning { + if !restored { + rollbackErrors = append(rollbackErrors, + errors.New("refusing to restart prior native broker because its image was not restored")) + return errors.Join(rollbackErrors...) + } + release, err := lockNativePriorServiceExecutable(t.priorServiceExecutable) + if err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("revalidate restored native broker before restart: %w", err)) + return errors.Join(rollbackErrors...) + } + defer release() + if err := reconcileNativePackageServiceRunning(ctx, t.service); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore prior trusted %s run state: %w", NativeBrokerServiceName, err)) + } + } + return errors.Join(rollbackErrors...) +} + +func (t *windowsNativePackageTransaction) Close() error { + if t.closed { + return nil + } + t.closed = true + if t.destinationRelease != nil { + t.destinationRelease() + t.destinationRelease = nil + } + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + if t.tokenHandle != 0 { + windows.CloseHandle(t.tokenHandle) //nolint:errcheck + t.tokenHandle = 0 + } + if t.service != nil { + t.service.Close() //nolint:errcheck + } + if t.manager != nil { + t.manager.Close() //nolint:errcheck + } + if t.parentHandle != 0 { + windows.CloseHandle(t.parentHandle) //nolint:errcheck + } + for index := len(t.inputHandles) - 1; index >= 0; index-- { + windows.CloseHandle(t.inputHandles[index]) //nolint:errcheck + } + if t.releaseMutex != nil { + t.releaseMutex() + } + return nil +} + +func (t *windowsNativePackageTransaction) releaseCoordinationToken() error { + if t.tokenHandle != 0 { + if err := windows.CloseHandle(t.tokenHandle); err != nil { + return fmt.Errorf("close protected package transaction token: %w", err) + } + t.tokenHandle = 0 + } + if t.tokenPath == "" { + return nil + } + path := t.tokenPath + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return err + } + t.tokenPath = "" + t.tokenSHA256 = "" + return nil +} + +func (t *windowsNativePackageTransaction) lockAndVerifyInput( + path, expectedHash string, + requirePE bool, +) (windows.Handle, error) { + handle, err := lockNativePackageInput(path) + if err != nil { + return 0, err + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if !strings.EqualFold(hash, expectedHash) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, fmt.Errorf("SHA-256=%s expected=%s", hash, expectedHash) + } + if requirePE { + if err := requireNativePackagePE(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + } + t.inputHandles = append(t.inputHandles, handle) + return handle, nil +} + +func (t *windowsNativePackageTransaction) runDriverHelper( + ctx context.Context, operation string, broker bool, +) error { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return context.DeadlineExceeded + } + arguments := []string{ + operation, filepath.Join(t.request.packageDirectory, "ViiperUde.inf"), + "--manifest", t.request.submissionManifest, + "--manifest-sha256", t.request.expectedManifestSHA256, + "--source-revision", t.request.sourceRevision, + "--validation-mode", "production", + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + } + if broker { + arguments = append(arguments, + "--broker-executable", t.destination, + "--broker-sha256", t.request.expectedBrokerSHA256, + "--broker-token", t.tokenPath, + "--broker-token-sha256", t.tokenSHA256, + "--target-user-sid", t.request.targetUserSID, + ) + } + // Do not use CommandContext: killing ViiperUdeCtl could interrupt its in-memory + // DriverStore rollback or the broker's deferred SCM/credential rollback. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + return err + } + var err error + if operation == "verify" { + // This process is strictly read-only. It is safe to stop if signature or + // manifest inspection wedges; the mutating install path is never killed. + done := make(chan error, 1) + go func() { done <- command.Wait() }() + select { + case err = <-done: + case <-ctx.Done(): + _ = command.Process.Kill() + <-done + return ctx.Err() + } + } else { + // The helper owns the driver snapshot and nested broker rollback. Its + // propagated absolute deadline is cooperative; never terminate it here. + err = command.Wait() + } + text := strings.TrimSpace(output.String()) + expected := "result=success operation=" + operation + var exitError *exec.ExitError + if operation == "install" && errors.As(err, &exitError) && + exitError.ExitCode() == nativePackageRebootRequiredCode { + return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} + } + if err != nil || !strings.Contains(text, expected) { + if err == nil { + err = errors.New("driver helper did not emit its structured success proof") + } + return fmt.Errorf("%w: %s", err, text) + } + return nil +} + +func reconcileNativePackageServiceRunning(ctx context.Context, service nativeManagedService) error { + for { + status, err := service.Query() + if err != nil { + return err + } + switch status.State { + case svc.Running: + return nil + case svc.Stopped: + if err := service.Start(); err != nil { + return err + } + case svc.StartPending, svc.StopPending: + // Reconcile the partial forward STOP before deciding whether START is + // required. Both paths remain bounded by the rollback-only context. + default: + return fmt.Errorf("unexpected service state %d during rollback", status.State) + } + if err := waitContext(ctx, nativeServiceStatePoll); err != nil { + return err + } + } +} + +func (t *windowsNativePackageTransaction) stageCoordinationToken() error { + path, err := t.uniqueManagedPath("transaction") + if err != nil { + return err + } + path = strings.TrimSuffix(path, ".tmp") + ".token" + content := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, content); err != nil { + return fmt.Errorf("generate package transaction token: %w", err) + } + security, err := nativeSecurityAttributes(nativePackageTokenSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_HIDDEN|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, 0) + if err != nil { + return fmt.Errorf("create protected package transaction token: %w", err) + } + fail := func(failErr error) error { + windows.CloseHandle(handle) //nolint:errcheck + _ = deleteNativePackageFile(path) + return failErr + } + var written uint32 + if err := windows.WriteFile(handle, content, &written, nil); err != nil { + return fail(err) + } + if written != uint32(len(content)) { + return fail(io.ErrShortWrite) + } + if err := windows.FlushFileBuffers(handle); err != nil { + return fail(err) + } + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return fail(err) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(err) + } + sum := sha256.Sum256(content) + t.tokenPath = path + t.tokenSHA256 = hex.EncodeToString(sum[:]) + t.tokenHandle = handle + return nil +} + +func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { + if attributes, err := nativePathAttributes(t.parent); err != nil { + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + security, securityErr := nativeSecurityAttributes(nativeBrokerDirectorySDDL) + if securityErr != nil { + return securityErr + } + parentPointer, pointerErr := windows.UTF16PtrFromString(t.parent) + if pointerErr != nil { + return pointerErr + } + if createErr := windows.CreateDirectory(parentPointer, security); createErr != nil { + return fmt.Errorf("atomically create protected VIIPER directory: %w", createErr) + } + t.parentMade = true + } else if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return errors.New("managed VIIPER path is not a regular directory") + } + parent, err := openNativePathWithoutReparse( + t.parent, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return fmt.Errorf("lock protected VIIPER directory: %w", err) + } + t.parentHandle = parent + if err := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); err != nil { + return fmt.Errorf("validate protected VIIPER directory: %w", err) + } + if err := t.stageCoordinationToken(); err != nil { + return err + } + + if existing, openErr := openNativePathWithoutReparse( + t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, + ); openErr == nil { + if err := requireSingleNativeFileLink(existing); err != nil { + windows.CloseHandle(existing) //nolint:errcheck + return err + } + if err := validateNativeSecurityDescriptor(existing, nativeBrokerExecutableSDDL); err != nil { + windows.CloseHandle(existing) //nolint:errcheck + return fmt.Errorf("existing broker is not installer-owned: %w", err) + } + existingHash, hashErr := hashNativePackageHandle(existing) + windows.CloseHandle(existing) //nolint:errcheck + if hashErr != nil { + return hashErr + } + if strings.EqualFold(existingHash, t.request.expectedBrokerSHA256) { + release, err := lockNativeServiceExecutableReadOnly(t.destination) + if err != nil { + return err + } + t.destinationRelease = release + return nil + } + t.backupPath, err = t.uniqueManagedPath("rollback") + if err != nil { + return err + } + if err := moveNativePackageFile(t.destination, t.backupPath, false); err != nil { + return fmt.Errorf("retain prior broker for rollback: %w", err) + } + } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("inspect existing broker: %w", openErr) + } + + t.temporaryPath, err = t.uniqueManagedPath("staging") + if err != nil { + return err + } + if err := copyNativePackageHandleAtomically( + t.sourceHandle, t.temporaryPath, t.request.expectedBrokerSHA256, + ); err != nil { + return err + } + if err := moveNativePackageFile(t.temporaryPath, t.destination, false); err != nil { + return fmt.Errorf("publish staged broker: %w", err) + } + t.temporaryPath = "" + t.destinationPublished = true + release, err := lockNativeServiceExecutableReadOnly(t.destination) + if err != nil { + return fmt.Errorf("verify published protected broker: %w", err) + } + t.destinationRelease = release + return nil +} + +func (t *windowsNativePackageTransaction) restoreBrokerExecutable() error { + var restoreErrors []error + if t.temporaryPath != "" { + if err := deleteNativePackageFile(t.temporaryPath); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + restoreErrors = append(restoreErrors, fmt.Errorf("remove staged broker: %w", err)) + } + t.temporaryPath = "" + } + if t.destinationPublished { + handle, err := openNativePathWithoutReparse(t.destination, windows.GENERIC_READ, false) + if err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("lock rejected broker for rollback: %w", err)) + } else { + hash, hashErr := hashNativePackageHandle(handle) + windows.CloseHandle(handle) //nolint:errcheck + if hashErr != nil || !strings.EqualFold(hash, t.request.expectedBrokerSHA256) { + restoreErrors = append(restoreErrors, + errors.New("refusing to remove broker that changed after protected staging")) + } else if deleteErr := deleteNativePackageFile(t.destination); deleteErr != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("remove rejected broker: %w", deleteErr)) + } + } + t.destinationPublished = false + } + if t.backupPath != "" { + if err := moveNativePackageFile(t.backupPath, t.destination, false); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore prior broker: %w", err)) + } else { + t.backupPath = "" + } + } + if t.parentMade { + if t.parentHandle != 0 { + windows.CloseHandle(t.parentHandle) //nolint:errcheck + t.parentHandle = 0 + } + pointer, err := windows.UTF16PtrFromString(t.parent) + if err == nil { + err = windows.RemoveDirectory(pointer) + } + if err != nil && !errors.Is(err, windows.ERROR_DIR_NOT_EMPTY) { + restoreErrors = append(restoreErrors, fmt.Errorf("remove created VIIPER directory: %w", err)) + } + t.parentMade = false + } + return errors.Join(restoreErrors...) +} + +func (t *windowsNativePackageTransaction) uniqueManagedPath(label string) (string, error) { + var suffix [12]byte + if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil { + return "", err + } + return filepath.Join(t.parent, ".viiper."+label+"."+hex.EncodeToString(suffix[:])+".tmp"), nil +} + +func acquireNamedNativePackageMutex(name string, timeout time.Duration) (func(), error) { + // Win32 mutex ownership belongs to an OS thread, not a Go goroutine. Pin + // the caller until the release closure runs or ReleaseMutex can execute on + // a different thread and silently strand/abandon the package lock. + runtime.LockOSThread() + pointer, err := windows.UTF16PtrFromString(name) + if err != nil { + runtime.UnlockOSThread() + return nil, err + } + descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)") + if err != nil { + runtime.UnlockOSThread() + return nil, err + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, + } + handle, err := windows.CreateMutex(&attributes, false, pointer) + if err != nil { + runtime.UnlockOSThread() + return nil, err + } + status, err := windows.WaitForSingleObject(handle, uint32(timeout/time.Millisecond)) + if err != nil || (status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED) { + windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() + if err != nil { + return nil, err + } + return nil, errors.New("another VIIPER native package transaction is still running") + } + return func() { + windows.ReleaseMutex(handle) //nolint:errcheck + windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() + }, nil +} + +// nativePackageMutexHeldByAnotherOwner proves that this short-lived broker +// commit is nested inside the signed outer package transaction. The helper is +// a separate process/thread, so acquiring the mutex here would deadlock; a +// zero-time wait must instead report WAIT_TIMEOUT. If the mutex is absent, +// abandoned, or acquirable, no authorized outer transaction exists. +func nativePackageMutexHeldByAnotherOwner(name string) (bool, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + pointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return false, err + } + handle, err := windows.OpenMutex(windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, + false, pointer) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return false, nil + } + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return false, err + } + switch status { + case uint32(windows.WAIT_TIMEOUT): + return true, nil + case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): + windows.ReleaseMutex(handle) //nolint:errcheck + return false, nil + default: + return false, fmt.Errorf("unexpected package mutex wait status: 0x%08x", status) + } +} + +func lockNativePackageInput(path string) (windows.Handle, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return 0, err + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx(handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + windows.CloseHandle(handle) //nolint:errcheck + return 0, errors.New("input is not a regular non-reparse file") + } + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, err + } + return handle, nil +} + +// lockNativePackageDirectoryChain prevents path redirection after hashing. +// Holding only the final file does not stop an ancestor directory from being +// renamed and replaced before CreateProcess/SetupAPI reopens the same string. +func lockNativePackageDirectoryChain(directory string) ([]windows.Handle, error) { + directory = filepath.Clean(directory) + if !filepath.IsAbs(directory) || strings.IndexByte(directory, 0) >= 0 { + return nil, fmt.Errorf("package input directory must be absolute and contain no NUL: %s", directory) + } + volume := filepath.VolumeName(directory) + if len(volume) != 2 || volume[1] != ':' { + return nil, fmt.Errorf("package input directory must use a local drive path: %s", directory) + } + root := volume + string(filepath.Separator) + relative, err := filepath.Rel(root, directory) + if err != nil || filepath.IsAbs(relative) || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("package input directory escaped its volume root: %s", directory) + } + paths := []string{root} + current := root + if relative != "." { + for _, component := range strings.Split(relative, string(filepath.Separator)) { + if component == "" || component == "." || component == ".." { + return nil, fmt.Errorf("package input directory has an unsafe component: %s", directory) + } + current = filepath.Join(current, component) + paths = append(paths, current) + } + } + handles := make([]windows.Handle, 0, len(paths)) + for _, path := range paths { + handle, openErr := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES, true, + ) + if openErr != nil { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } + return nil, fmt.Errorf("open non-reparse ancestor %s: %w", path, openErr) + } + handles = append(handles, handle) + } + return handles, nil +} + +func hashNativePackageHandle(handle windows.Handle) (string, error) { + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return "", err + } + hash := sha256.New() + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(handle, buffer, &read, nil); err != nil { + return "", err + } + if read == 0 { + break + } + _, _ = hash.Write(buffer[:read]) + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func requireNativePackagePE(handle windows.Handle) error { + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + header := make([]byte, 2) + var read uint32 + if err := windows.ReadFile(handle, header, &read, nil); err != nil { + return err + } + if _, err := windows.SetFilePointer(handle, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + if read != 2 || header[0] != 'M' || header[1] != 'Z' { + return errors.New("file is not a Windows PE image") + } + return nil +} + +func nativeSecurityAttributes(sddl string) (*windows.SecurityAttributes, error) { + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return nil, err + } + return &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, + }, nil +} + +func copyNativePackageHandleAtomically( + source windows.Handle, + destination, expectedHash string, +) (resultErr error) { + security, err := nativeSecurityAttributes(nativeBrokerExecutableSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + target, err := windows.CreateFile(pointer, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_WRITE_THROUGH, 0) + if err != nil { + return fmt.Errorf("create protected staged broker: %w", err) + } + defer func() { + windows.CloseHandle(target) //nolint:errcheck + if resultErr != nil { + _ = deleteNativePackageFile(destination) + } + }() + if _, err := windows.SetFilePointer(source, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(source, buffer, &read, nil); err != nil { + return err + } + if read == 0 { + break + } + var written uint32 + if err := windows.WriteFile(target, buffer[:read], &written, nil); err != nil { + return err + } + if written != read { + return io.ErrShortWrite + } + } + if err := windows.FlushFileBuffers(target); err != nil { + return err + } + if err := validateNativeSecurityDescriptor(target, nativeBrokerExecutableSDDL); err != nil { + return err + } + if err := requireSingleNativeFileLink(target); err != nil { + return err + } + hash, err := hashNativePackageHandle(target) + if err != nil { + return err + } + if !strings.EqualFold(hash, expectedHash) { + return fmt.Errorf("staged broker SHA-256=%s expected=%s", hash, expectedHash) + } + return nil +} + +func moveNativePackageFile(source, destination string, replace bool) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + flags := uint32(windows.MOVEFILE_WRITE_THROUGH) + if replace { + flags |= windows.MOVEFILE_REPLACE_EXISTING + } + return windows.MoveFileEx(from, to, flags) +} + +func deleteNativePackageFile(path string) error { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + return windows.DeleteFile(pointer) +} + +func nativePathAttributes(path string) (uint32, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.GetFileAttributes(pointer) +} + +func waitForNativePackageServiceDeletion(ctx context.Context, manager nativeSCM) error { + for { + service, err := manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil + } + if err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return err + } + if service != nil { + service.Close() //nolint:errcheck + } + if err := waitContext(ctx, nativeServiceStatePoll); err != nil { + return fmt.Errorf("wait for weak %s deletion: %w", NativeBrokerServiceName, err) + } + } +} diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go new file mode 100644 index 00000000..9eb2075d --- /dev/null +++ b/internal/cmd/native_package_windows_test.go @@ -0,0 +1,64 @@ +//go:build windows + +package cmd + +import ( + "context" + "testing" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestNativePackageServiceTrustRequiresExactOwnedState(t *testing.T) { + t.Parallel() + expected := mgr.Config{ + ServiceType: 0x10, StartType: mgr.StartAutomatic, ErrorControl: mgr.ErrorNormal, + BinaryPathName: `"C:\Program Files\VIIPER\viiper.exe" service --transport native-ude`, + ServiceStartName: nativeServiceAccount, DisplayName: nativeBrokerDisplayName, + Description: nativeBrokerDescription, SidType: 1, + } + actions := append([]mgr.RecoveryAction(nil), nativeServiceRecoveryActions...) + canonical := func(actual mgr.Config, dacl string, recovery []mgr.RecoveryAction, + reset uint32, nonCrash bool) bool { + return isCanonicalNativePackageService( + actual, expected, dacl, recovery, reset, nonCrash, + ) + } + if !canonical(expected, nativeBrokerServiceSDDL, actions, + nativeServiceRecoveryResetSecond, true) { + t.Fatal("exact protected service was not trusted") + } + + staleConfig := expected + staleConfig.StartType = mgr.StartManual + staleRecovery := append([]mgr.RecoveryAction(nil), actions...) + staleRecovery[0].Delay = time.Millisecond + cases := map[string]bool{ + "stale config": canonical(staleConfig, nativeBrokerServiceSDDL, actions, nativeServiceRecoveryResetSecond, true), + "weak DACL": canonical(expected, "D:(A;;GA;;;WD)", actions, nativeServiceRecoveryResetSecond, true), + "stale recovery": canonical(expected, nativeBrokerServiceSDDL, staleRecovery, nativeServiceRecoveryResetSecond, true), + "stale reset": canonical(expected, nativeBrokerServiceSDDL, actions, 1, true), + "stale mode": canonical(expected, nativeBrokerServiceSDDL, actions, nativeServiceRecoveryResetSecond, false), + } + for name, trusted := range cases { + if trusted { + t.Errorf("%s was trusted instead of delete/recreate", name) + } + } +} + +func TestNativePackageRollbackReconcilesStoppedPriorService(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := reconcileNativePackageServiceRunning(ctx, service); err != nil { + t.Fatalf("reconcile prior service: %v", err) + } + if service.startCalls != 1 || service.status.State != svc.Running { + t.Fatalf("startCalls=%d state=%d events=%v", service.startCalls, service.status.State, events) + } +} diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 8ba58688..16f280db 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "slices" "sort" "strconv" @@ -409,6 +410,37 @@ func installNativeBroker(logger *slog.Logger, explicitUserSID string) error { return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) } +// installNativeBrokerUntil is reserved for the nested native-package commit. +// It shares the outer transaction's absolute deadline rather than granting a +// fresh service-install budget after the driver has already been mutated. +func installNativeBrokerUntil( + logger *slog.Logger, explicitUserSID string, deadline time.Time, +) error { + remaining := time.Until(deadline) + if remaining <= 0 { + return context.DeadlineExceeded + } + if remaining > nativeServiceInstallTimeout { + remaining = nativeServiceInstallTimeout + } + release, err := acquireNativeInstallMutex(remaining) + if err != nil { + return err + } + defer release() + userSID, err := resolveNativeInstallingUserSID(explicitUserSID) + if err != nil { + return err + } + executable, err := currentExecutable() + if err != nil { + return err + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) +} + func uninstallNativeBroker(logger *slog.Logger, explicitUserSID string) error { release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) if err != nil { @@ -1254,12 +1286,18 @@ func waitContext(ctx context.Context, delay time.Duration) error { } func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { + // Win32 mutexes are owned by OS threads. Keep this goroutine pinned through + // its deferred release so the Go scheduler cannot move ReleaseMutex to a + // non-owner thread and leave service installation permanently serialized. + runtime.LockOSThread() name, err := windows.UTF16PtrFromString(nativeInstallMutexName) if err != nil { + runtime.UnlockOSThread() return nil, err } descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)") if err != nil { + runtime.UnlockOSThread() return nil, fmt.Errorf("create native install mutex security descriptor: %w", err) } attributes := windows.SecurityAttributes{ @@ -1268,20 +1306,24 @@ func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { } handle, err := windows.CreateMutex(&attributes, false, name) if err != nil { + runtime.UnlockOSThread() return nil, fmt.Errorf("create native install mutex: %w", err) } status, err := windows.WaitForSingleObject(handle, uint32(timeout/time.Millisecond)) if err != nil { windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() return nil, fmt.Errorf("wait for native install mutex: %w", err) } if status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED { windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() return nil, errors.New("another VIIPER native install, update, or uninstall is still running") } return func() { windows.ReleaseMutex(handle) //nolint:errcheck windows.CloseHandle(handle) //nolint:errcheck + runtime.UnlockOSThread() }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 31cd047b..5c06ae53 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,9 @@ type CLI struct { Service cmd.ServiceCommand `cmd:"" help:"Run the managed Windows native UDE broker service" hidden:""` Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` - Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` - Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` - Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` + Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` + Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` + Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` + NativePackageInstall cmd.NativePackageInstall `cmd:"" name:"native-package-install" help:"Install a verified native UDE package and broker transactionally" hidden:""` + NativePackageBrokerCommit cmd.NativePackageBrokerCommit `cmd:"" name:"native-package-broker-commit" help:"Commit the broker inside an active native package transaction" hidden:""` } diff --git a/native/udecx/README.md b/native/udecx/README.md index ea2e796c..eb83d509 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -94,15 +94,20 @@ The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in `docs/architecture/native-udecx-signing.md`. -Install a validated package only after stopping the broker that owns the native -interface. Production mode accepts only a release-eligible `HLK/WHCP` manifest; -controlled-test attestation must be named explicitly: +Production installation is intentionally available only through the signed +package orchestrator, which binds the broker/helper/manifest hashes and keeps +the driver rollback snapshot alive through authenticated broker health. An +operator can run the same read-only production preflight without mutation: ```powershell -.\ViiperUdeCtl.exe install C:\ViiperUde\Signed\ViiperUde.inf ` - --manifest C:\ViiperUde\ViiperUde.cab.sha256.json ` +$manifest = 'C:\ViiperUde\ViiperUde.cab.sha256.json' +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds() +.\ViiperUdeCtl.exe verify C:\ViiperUde\Signed\ViiperUde.inf ` + --manifest $manifest ` + --manifest-sha256 (Get-FileHash -Algorithm SHA256 -LiteralPath $manifest).Hash ` --source-revision 0123456789abcdef0123456789abcdef01234567 ` - --validation-mode production + --validation-mode production ` + --transaction-deadline-unix-ms $deadline ``` The only forced selection available to an operator is an intentional downgrade diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index ddde71a8..ae495db5 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -14,7 +14,18 @@ if ([string]::IsNullOrWhiteSpace($SourcePath)) { $source = Get-Content -LiteralPath $SourcePath -Raw $requiredContracts = [ordered]@{ 'source-manifest preflight' = 'ValidateManifest\(' + 'installer manifest hash binding' = '--manifest-sha256' + 'read-only package verification' = 'Outcome Verify\(' 'catalog signature preflight' = 'SetupVerifyInfFileW\(' + 'Microsoft hardware publisher gate' = 'VerifyMicrosoftHardwareInfSigner\(' + 'exact SYS catalog membership' = 'VerifyDriverCatalogMember\(' + 'exact INF catalog membership' = 'VerifyDriverCatalogMember\(catalogPath, infPath' + 'Windows driver catalog policy' = 'WinVerifyTrust\(' + 'System32-only catalog API loading' = 'LoadLibraryExW\([\s\S]*LOAD_LIBRARY_SEARCH_SYSTEM32' + 'documented dynamic catalog API contract' = 'GetProcAddress\(' + 'production hardware verification EKU' = '1\.3\.6\.1\.4\.1\.311\.10\.3\.5' + 'production attestation rejection' = '1\.3\.6\.1\.4\.1\.311\.10\.3\.5\.1' + 'signed-certificate EKU extension only' = 'CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG' 'published INF capture' = 'SetupGetInfPublishedNameW\(' 'driver-store source capture' = 'SetupGetInfDriverStoreLocationW\(' 'installed INF ownership' = 'DEVPKEY_Device_DriverInfPath' @@ -23,8 +34,28 @@ $requiredContracts = [ordered]@{ 'documented package removal' = 'DiUninstallDriverW\(' 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' 'install rollback' = 'RollbackInstall\(' + 'broker health transaction' = 'RunBrokerInstall\(' + 'production broker requirement' = 'broker-required' + 'staged broker hash binding' = '--broker-sha256' + 'protected package token binding' = '--broker-token-sha256' + 'nested package broker commit' = 'native-package-broker-commit' + 'cooperative package deadline' = '--transaction-deadline-unix-ms' + 'same-handle manifest binding' = 'Sha256Handle\(manifest\.get\(\)' + 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' + 'reboot boundary rollback' = 'broker-reboot-boundary' 'remove rollback backup' = 'BackupPackages\(' 'transaction mutex' = 'VIIPER_UDE_DRIVER_TRANSACTION_V1' + 'protected private transaction namespace' = 'CreatePrivateNamespaceW\(' + 'protected transaction object DACL' = 'D:P\(A;;GA;;;SY\)\(A;;GA;;;BA\)' + 'acquired transaction mutex ownership' = 'WaitForSingleObject\(mutex_\.get\(\), 0\)' + 'abandoned transaction recovery' = 'WAIT_ABANDONED' + 'transaction mutex release' = 'ReleaseMutex\(' + 'overlapped ABI negotiation' = 'FILE_FLAG_OVERLAPPED' + 'deadline cancellation' = 'CancelIoEx\(' + 'cancelled IO drain ceiling' = 'kCancelledIoDrainMs' + 'finite broker rollback ceiling' = 'kBrokerRollbackCeilingMs' + 'exact rollback devnode identity' = 'RegisterRootDeviceExact\(' + 'rollback identity verification' = 'rollback-identity-verification' 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' 'guarded downgrade' = '--allow-controlled-downgrade' } @@ -39,6 +70,36 @@ if ($source -match 'SUOI_FORCEDELETE') { throw 'ViiperUdeCtl must never force-delete a published INF.' } +if ($source -match 'TerminateProcess\(') { + throw 'ViiperUdeCtl must never hard-terminate the mutating broker transaction.' +} + +foreach ($runtimeExport in @( + 'CryptCATAdminAcquireContext2', + 'CryptCATAdminCalcHashFromFileHandle2', + 'CryptCATAdminReleaseContext' +)) { + if ($source -match ("\b" + [regex]::Escape($runtimeExport) + "\s*\(")) { + throw "$runtimeExport must be loaded from the protected System32 Wintrust runtime, not statically imported." + } +} + +if ($source -match 'WaitForSingleObject\(processHandle\.get\(\),\s*INFINITE\)') { + throw 'The nested broker wait must use the cooperative package deadline contract.' +} + +if ([regex]::Matches($source, ',\s*DICD_GENERATE_ID\s*,').Count -ne 1) { + throw 'Generated root identities are allowed only for first-time forward creation, never rollback.' +} + +if ([regex]::Matches($source, '\bRemoveAllExactDevices\(').Count -ne 2) { + throw 'All-device removal is allowed only for explicit forward uninstall, never rollback.' +} + +if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -ne 2) { + throw 'Production validation must bind both the exact INF and SYS to the exact adjacent catalog.' +} + $forceInfUses = [regex]::Matches($source, '\bDIIRFLAG_FORCE_INF\b').Count if ($forceInfUses -ne 1 -or $source -notmatch 'const DWORD installFlags = downgrade \? DIIRFLAG_FORCE_INF : 0;') { @@ -46,8 +107,8 @@ if ($forceInfUses -ne 1 -or } $forceBindUses = [regex]::Matches($source, '\bINSTALLFLAG_FORCE\b').Count -if ($forceBindUses -ne 3) { - throw "Expected force binding only in controlled downgrade and the two rollback paths; found $forceBindUses uses." +if ($forceBindUses -ne 2) { + throw "Expected force binding only in controlled downgrade and the shared exact-identity rollback path; found $forceBindUses uses." } if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index fcd6ec8c..d80f6ce3 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -10,24 +10,34 @@ #define WIN32_LEAN_AND_MEAN #define NOMINMAX +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif #include #include #include #include #include #include -#include #include +#include +#include +#include +#include +#include #include "../include/ViiperUdeProtocol.h" #include #include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -56,6 +66,8 @@ BOOL WINAPI DiUninstallDriverW(HWND, LPCWSTR, DWORD, PBOOL); #pragma comment(lib, "Newdev.lib") #pragma comment(lib, "Setupapi.lib") #pragma comment(lib, "Advapi32.lib") +#pragma comment(lib, "Crypt32.lib") +#pragma comment(lib, "Wintrust.lib") namespace { @@ -67,8 +79,19 @@ constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; -constexpr wchar_t kTransactionMutex[] = L"Global\\VIIPER_UDE_DRIVER_TRANSACTION_V1"; +constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; +constexpr wchar_t kTransactionBoundary[] = L"VIIPER_UDE_DRIVER_TRANSACTION_BOUNDARY_V1"; +constexpr wchar_t kTransactionMutex[] = L"VIIPER_UDE_DRIVER_TRANSACTION_V1"; +constexpr wchar_t kTransactionObjectSecurity[] = + L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"; constexpr size_t kMaximumManifestBytes = 1024U * 1024U; +constexpr uint64_t kMaximumTransactionDurationMs = 4ULL * 60ULL * 1000ULL; +constexpr uint64_t kBrokerRollbackCeilingMs = 60ULL * 1000ULL; +constexpr DWORD kCancelledIoDrainMs = 5000; +constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; +constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; + +uint64_t CurrentUnixMilliseconds(); constexpr GUID kViiperInterfaceGuid = { 0x32d03f48, 0x725b, 0x4baa, {0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}}; @@ -228,21 +251,85 @@ class InfHandle final { class TransactionMutex final { public: + ~TransactionMutex() { + if (owned_ && mutex_) { + ReleaseMutex(mutex_.get()); + } + mutex_.reset(); + if (namespace_ != nullptr) { + ClosePrivateNamespace(namespace_, 0); + } + if (boundary_ != nullptr) { + DeleteBoundaryDescriptor(boundary_); + } + } + bool Acquire(Error* error) { - WinHandle handle(CreateMutexW(nullptr, FALSE, kTransactionMutex)); - if (!handle) { + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize)) { + return SetLastErrorDetail(error, L"transaction-boundary-sid"); + } + boundary_ = CreateBoundaryDescriptorW(kTransactionBoundary, 0); + if (boundary_ == nullptr || + !AddSIDToBoundaryDescriptor(&boundary_, administratorsBuffer)) { + return SetLastErrorDetail(error, L"transaction-boundary"); + } + + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + kTransactionObjectSecurity, SDDL_REVISION_1, &descriptor, nullptr)) { + return SetLastErrorDetail(error, L"transaction-security"); + } + SECURITY_ATTRIBUTES security{}; + security.nLength = sizeof(security); + security.lpSecurityDescriptor = descriptor; + security.bInheritHandle = FALSE; + + namespace_ = CreatePrivateNamespaceW(&security, boundary_, kTransactionNamespace); + DWORD namespaceError = GetLastError(); + if (namespace_ == nullptr && namespaceError == ERROR_ALREADY_EXISTS) { + namespace_ = OpenPrivateNamespaceW(boundary_, kTransactionNamespace); + namespaceError = GetLastError(); + } + if (namespace_ == nullptr) { + LocalFree(descriptor); + SetLastError(namespaceError); + return SetLastErrorDetail(error, L"transaction-namespace"); + } + + const std::wstring mutexName = + std::wstring(kTransactionNamespace) + L"\\" + kTransactionMutex; + mutex_.reset(CreateMutexExW(&security, mutexName.c_str(), 0, MUTEX_ALL_ACCESS)); + const DWORD mutexError = GetLastError(); + LocalFree(descriptor); + if (!mutex_) { + SetLastError(mutexError); return SetLastErrorDetail(error, L"transaction-mutex"); } - if (GetLastError() == ERROR_ALREADY_EXISTS) { + const DWORD wait = WaitForSingleObject(mutex_.get(), 0); + if (wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED) { + // WAIT_ABANDONED is safe here: all mutable state is inventoried + // again before the first SetupAPI operation, so no prior process + // state is trusted merely because the lock changed owners. + owned_ = true; + abandoned_ = wait == WAIT_ABANDONED; + return true; + } + if (wait == WAIT_TIMEOUT) { return SetError(error, L"transaction-mutex", ERROR_INSTALL_ALREADY_RUNNING, L"another VIIPER native driver transaction is active"); } - handle_ = std::move(handle); - return true; + return SetLastErrorDetail(error, L"transaction-mutex-wait"); } private: - WinHandle handle_; + HANDLE namespace_ = nullptr; + HANDLE boundary_ = nullptr; + WinHandle mutex_; + bool owned_ = false; + bool abandoned_ = false; }; bool IsElevated() { @@ -607,14 +694,13 @@ const JsonValue* ObjectField(const JsonValue::Object& object, const char* name) return iterator == object.end() ? nullptr : &iterator->second; } -bool ReadSmallFile(const std::filesystem::path& path, std::string* contents, Error* error) { - WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); - if (!file) { - return SetLastErrorDetail(error, L"manifest-open"); +bool ReadSmallHandle(HANDLE file, std::string* contents, Error* error) { + LARGE_INTEGER beginning{}; + if (!SetFilePointerEx(file, beginning, nullptr, FILE_BEGIN)) { + return SetLastErrorDetail(error, L"manifest-seek"); } LARGE_INTEGER size{}; - if (!GetFileSizeEx(file.get(), &size)) { + if (!GetFileSizeEx(file, &size)) { return SetLastErrorDetail(error, L"manifest-size"); } if (size.QuadPart <= 0 || static_cast(size.QuadPart) > kMaximumManifestBytes) { @@ -623,7 +709,7 @@ bool ReadSmallFile(const std::filesystem::path& path, std::string* contents, Err } contents->assign(static_cast(size.QuadPart), '\0'); DWORD read = 0; - if (!ReadFile(file.get(), contents->data(), static_cast(contents->size()), &read, nullptr) || + if (!ReadFile(file, contents->data(), static_cast(contents->size()), &read, nullptr) || static_cast(read) != contents->size()) { return SetLastErrorDetail(error, L"manifest-read"); } @@ -636,7 +722,7 @@ bool ReadSmallFile(const std::filesystem::path& path, std::string* contents, Err return true; } -bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* error) { +bool Sha256Handle(HANDLE file, std::string* digest, Error* error) { HCRYPTPROV provider = 0; HCRYPTHASH hash = 0; if (!CryptAcquireContextW(&provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { @@ -648,18 +734,17 @@ bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* e releaseProvider(); return SetError(error, L"sha256-create", code); } - WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); - if (!file) { + LARGE_INTEGER beginning{}; + if (!SetFilePointerEx(file, beginning, nullptr, FILE_BEGIN)) { const DWORD code = GetLastError(); CryptDestroyHash(hash); releaseProvider(); - return SetError(error, L"sha256-open", code); + return SetError(error, L"sha256-seek", code); } std::array buffer{}; for (;;) { DWORD read = 0; - if (!ReadFile(file.get(), buffer.data(), static_cast(buffer.size()), &read, nullptr)) { + if (!ReadFile(file, buffer.data(), static_cast(buffer.size()), &read, nullptr)) { const DWORD code = GetLastError(); CryptDestroyHash(hash); releaseProvider(); @@ -695,6 +780,15 @@ bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* e return true; } +bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* error) { + WinHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"sha256-open"); + } + return Sha256Handle(file.get(), digest, error); +} + bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* error) { std::error_code fileError; const uintmax_t size = std::filesystem::file_size(path, fileError); @@ -706,14 +800,16 @@ bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* erro } bool ValidateManifest( - const std::filesystem::path& manifestPath, + const std::string& rawManifest, const std::string& expectedRevision, bool production, const std::filesystem::path& packageDirectory, Error* error) { - std::string raw; - if (!ReadSmallFile(manifestPath, &raw, error)) { - return false; + std::string raw = rawManifest; + if (raw.size() >= 3 && static_cast(raw[0]) == 0xefU && + static_cast(raw[1]) == 0xbbU && + static_cast(raw[2]) == 0xbfU) { + raw.erase(0, 3); } JsonValue root; std::string parseMessage; @@ -928,6 +1024,231 @@ bool VerifyInfSignature( return true; } +bool IsProductionHardwareVerificationUsage(const std::vector& usages) { + const bool hardware = std::find(usages.begin(), usages.end(), + kHardwareVerificationOid) != usages.end(); + const bool attestation = std::find(usages.begin(), usages.end(), + kAttestationVerificationOid) != usages.end(); + return hardware && !attestation; +} + +template +bool LoadWinTrustFunction( + HMODULE module, + const char* name, + Function* function, + Error* error) { + const FARPROC address = GetProcAddress(module, name); + if (address == nullptr) { + return SetLastErrorDetail(error, L"catalog-policy-api", + L"required Windows catalog policy API is unavailable"); + } + static_assert(sizeof(address) == sizeof(*function)); + std::memcpy(function, &address, sizeof(address)); + return true; +} + +bool VerifyDriverCatalogMember( + const std::filesystem::path& catalogPath, + const std::filesystem::path& memberPath, + Error* error) { + WinHandle member(CreateFileW(memberPath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!member) { + return SetLastErrorDetail(error, L"catalog-member-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(member.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"catalog-member-open", ERROR_REPARSE_TAG_MISMATCH, + L"catalog member must be a regular non-reparse file"); + } + using AcquireContext2 = BOOL (WINAPI*)( + HCATADMIN*, const GUID*, PCWSTR, PCCERT_STRONG_SIGN_PARA, DWORD); + using CalculateHash2 = BOOL (WINAPI*)(HCATADMIN, HANDLE, DWORD*, BYTE*, DWORD); + using ReleaseContext = BOOL (WINAPI*)(HCATADMIN, DWORD); + HMODULE winTrust = LoadLibraryExW( + L"wintrust.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (winTrust == nullptr) { + return SetLastErrorDetail(error, L"catalog-policy-library"); + } + AcquireContext2 acquireContext = nullptr; + CalculateHash2 calculateHash = nullptr; + ReleaseContext releaseContext = nullptr; + if (!LoadWinTrustFunction(winTrust, "CryptCATAdminAcquireContext2", + &acquireContext, error) || + !LoadWinTrustFunction(winTrust, "CryptCATAdminCalcHashFromFileHandle2", + &calculateHash, error) || + !LoadWinTrustFunction(winTrust, "CryptCATAdminReleaseContext", + &releaseContext, error)) { + FreeLibrary(winTrust); + return false; + } + HCATADMIN administrator = nullptr; + // Let Windows select the catalog's approved hash algorithm. Microsoft + // explicitly recommends this over hard-coding an algorithm that policy may + // retire; the returned context is also supplied to WinVerifyTrust below. + if (!acquireContext(&administrator, nullptr, nullptr, nullptr, 0)) { + const DWORD code = GetLastError(); + FreeLibrary(winTrust); + return SetError(error, L"catalog-admin", code); + } + const auto releasePolicy = [&]() { + releaseContext(administrator, 0); + FreeLibrary(winTrust); + }; + DWORD hashSize = 0; + if (!calculateHash( + administrator, member.get(), &hashSize, nullptr, 0) || hashSize == 0) { + const DWORD code = GetLastError(); + releasePolicy(); + return SetError(error, L"catalog-member-hash", code); + } + std::vector hash(hashSize); + if (!calculateHash( + administrator, member.get(), &hashSize, hash.data(), 0)) { + const DWORD code = GetLastError(); + releasePolicy(); + return SetError(error, L"catalog-member-hash", code); + } + hash.resize(hashSize); + static constexpr wchar_t digits[] = L"0123456789ABCDEF"; + std::wstring memberTag; + memberTag.reserve(hash.size() * 2); + for (BYTE value : hash) { + memberTag.push_back(digits[value >> 4U]); + memberTag.push_back(digits[value & 0x0fU]); + } + WINTRUST_CATALOG_INFO catalog{}; + catalog.cbStruct = sizeof(catalog); + catalog.pcwszCatalogFilePath = catalogPath.c_str(); + catalog.pcwszMemberTag = memberTag.c_str(); + catalog.pcwszMemberFilePath = memberPath.c_str(); + catalog.hMemberFile = member.get(); + catalog.pbCalculatedFileHash = hash.data(); + catalog.cbCalculatedFileHash = static_cast(hash.size()); + catalog.hCatAdmin = administrator; + WINTRUST_DATA trust{}; + trust.cbStruct = sizeof(trust); + trust.dwUIChoice = WTD_UI_NONE; + trust.fdwRevocationChecks = WTD_REVOKE_NONE; + trust.dwUnionChoice = WTD_CHOICE_CATALOG; + trust.pCatalog = &catalog; + trust.dwStateAction = WTD_STATEACTION_VERIFY; + trust.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL; + GUID action = DRIVER_ACTION_VERIFY; + const LONG status = WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); + trust.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); + releasePolicy(); + if (status != ERROR_SUCCESS) { + return SetError(error, L"catalog-member-policy", static_cast(status), + L"package file is not a valid member of the exact Microsoft driver catalog"); + } + return true; +} + +bool VerifyMicrosoftHardwareInfSigner( + const std::filesystem::path& infPath, + Error* error) { + SP_INF_SIGNER_INFO_W signer{}; + signer.cbSize = sizeof(signer); + if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { + return SetLastErrorDetail(error, L"inf-microsoft-signature"); + } + if (_wcsicmp(signer.DigitalSigner, + L"Microsoft Windows Hardware Compatibility Publisher") != 0) { + return SetError(error, L"inf-microsoft-signature", ERROR_INVALID_DATA, + L"driver catalog signer is not Microsoft Windows Hardware Compatibility Publisher"); + } + + DWORD encoding = 0; + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + const std::filesystem::path catalogPath = infPath.parent_path() / kCatalogName; + if (!VerifyDriverCatalogMember(catalogPath, infPath, error) || + !VerifyDriverCatalogMember(catalogPath, + infPath.parent_path() / kDriverFileName, error)) { + return false; + } + if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED | CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, nullptr, nullptr, + &store, &message, nullptr)) { + return SetLastErrorDetail(error, L"catalog-signature-open"); + } + const auto closeCatalog = [&]() { + if (message != nullptr) { + CryptMsgClose(message); + } + if (store != nullptr) { + CertCloseStore(store, 0); + } + }; + DWORD signerSize = 0; + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &signerSize) || + signerSize < sizeof(CMSG_SIGNER_INFO)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-info", code); + } + std::vector signerBytes(signerSize); + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, + signerBytes.data(), &signerSize)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-info", code); + } + const auto* signerInfo = reinterpret_cast(signerBytes.data()); + CERT_INFO certificateIdentity{}; + certificateIdentity.Issuer = signerInfo->Issuer; + certificateIdentity.SerialNumber = signerInfo->SerialNumber; + PCCERT_CONTEXT certificate = CertFindCertificateInStore(store, encoding, 0, + CERT_FIND_SUBJECT_CERT, &certificateIdentity, nullptr); + if (certificate == nullptr) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"catalog-signer-certificate", code); + } + DWORD usageSize = 0; + if (!CertGetEnhancedKeyUsage(certificate, CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG, + nullptr, &usageSize) || + usageSize < sizeof(CERT_ENHKEY_USAGE)) { + const DWORD code = GetLastError(); + CertFreeCertificateContext(certificate); + closeCatalog(); + return SetError(error, L"catalog-signer-eku", code, + L"production catalog signer must declare Windows Hardware Driver Verification EKU"); + } + std::vector usageBytes(usageSize); + auto* usage = reinterpret_cast(usageBytes.data()); + if (!CertGetEnhancedKeyUsage(certificate, CERT_FIND_EXT_ONLY_ENHKEY_USAGE_FLAG, + usage, &usageSize)) { + const DWORD code = GetLastError(); + CertFreeCertificateContext(certificate); + closeCatalog(); + return SetError(error, L"catalog-signer-eku", code); + } + std::vector usages; + usages.reserve(usage->cUsageIdentifier); + for (DWORD index = 0; index < usage->cUsageIdentifier; ++index) { + const char* oid = usage->rgpszUsageIdentifier[index]; + if (oid != nullptr) { + usages.emplace_back(oid); + } + } + const bool productionUsage = IsProductionHardwareVerificationUsage(usages); + CertFreeCertificateContext(certificate); + closeCatalog(); + if (!productionUsage) { + return SetError(error, L"catalog-signer-eku", ERROR_INVALID_DATA, + L"production requires HLK/WHCP hardware verification and rejects attestation EKU"); + } + return true; +} + bool LoadOwnedPackage( const std::filesystem::path& rawPath, bool requireOwned, @@ -1410,7 +1731,45 @@ bool RegisterRootDevice( return true; } -bool VerifyAbiHealth(Error* error) { +bool RegisterRootDeviceExact( + const GUID& classGuid, + const std::wstring& instanceId, + DeviceInfoSet* set, + SP_DEVINFO_DATA* data, + Error* error) { + const std::wstring expectedPrefix = std::wstring(kHardwareId) + L"\\"; + if (instanceId.size() <= expectedPrefix.size() || + _wcsnicmp(instanceId.c_str(), expectedPrefix.c_str(), expectedPrefix.size()) != 0) { + return SetError(error, L"rollback-instance-id", ERROR_INVALID_DATA, + L"captured root devnode identity is outside the exact VIIPER hardware namespace"); + } + *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); + if (!*set) { + return SetLastErrorDetail(error, L"rollback-create-device-info-list"); + } + *data = SP_DEVINFO_DATA{}; + data->cbSize = sizeof(*data); + // With DICD_GENERATE_ID absent, SetupAPI treats DeviceName as the complete + // device instance ID. Rollback must never substitute a fresh ROOT instance. + if (!SetupDiCreateDeviceInfoW(set->get(), instanceId.c_str(), &classGuid, + nullptr, nullptr, 0, data)) { + return SetLastErrorDetail(error, L"rollback-create-exact-root-devnode"); + } + const size_t idCharacters = std::size(kHardwareId) + 1; + std::vector identifiers(idCharacters, L'\0'); + std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); + if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, + reinterpret_cast(identifiers.data()), + static_cast(identifiers.size() * sizeof(wchar_t)))) { + return SetLastErrorDetail(error, L"rollback-set-root-hardware-id"); + } + if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { + return SetLastErrorDetail(error, L"rollback-register-exact-root-devnode"); + } + return true; +} + +bool VerifyAbiHealth(uint64_t deadlineUnixMs, Error* error) { DeviceInfoSet set(SetupDiGetClassDevsW( &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); if (!set) { @@ -1460,7 +1819,7 @@ bool VerifyAbiHealth(Error* error) { exactCount == 0 ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); } WinHandle device(CreateFileW(interfacePath.c_str(), GENERIC_READ | GENERIC_WRITE, - 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr)); if (!device) { return SetLastErrorDetail(error, L"abi-interface-open", L"native broker interface is unavailable or still owned by another process"); @@ -1479,10 +1838,72 @@ bool VerifyAbiHealth(Error* error) { VIIPER_UDE_CAP_INPUT_REPORTS; VIIPER_UDE_NEGOTIATE_RESPONSE response{}; DWORD returned = 0; - if (!DeviceIoControl(device.get(), IOCTL_VIIPER_UDE_NEGOTIATE, - &request, sizeof(request), &response, sizeof(response), &returned, nullptr)) { + WinHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event) { + return SetLastErrorDetail(error, L"abi-negotiate-event"); + } + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + const BOOL completed = DeviceIoControl(device.get(), IOCTL_VIIPER_UDE_NEGOTIATE, + &request, sizeof(request), &response, sizeof(response), &returned, &overlapped); + if (!completed && GetLastError() != ERROR_IO_PENDING) { return SetLastErrorDetail(error, L"abi-negotiate"); } + if (!completed) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now) { + const BOOL cancelled = CancelIoEx(device.get(), &overlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if ((!cancelled && cancelError != ERROR_NOT_FOUND) || drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", + !cancelled && cancelError != ERROR_NOT_FOUND + ? cancelError : ERROR_OPERATION_ABORTED, + L"expired native ABI negotiation could not be cancelled and drained safely"); + } + DWORD ignored = 0; + GetOverlappedResult(device.get(), &overlapped, &ignored, FALSE); + return SetError(error, L"abi-negotiate-timeout", ERROR_TIMEOUT, + L"native ABI negotiation exceeded the package transaction deadline"); + } + const uint64_t remaining = deadlineUnixMs - now; + const DWORD waitMilliseconds = static_cast( + std::min(remaining, static_cast(MAXDWORD - 1))); + const DWORD wait = WaitForSingleObject(event.get(), waitMilliseconds); + if (wait == WAIT_TIMEOUT) { + const BOOL cancelled = CancelIoEx(device.get(), &overlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if (drain == WAIT_OBJECT_0) { + DWORD ignored = 0; + GetOverlappedResult(device.get(), &overlapped, &ignored, FALSE); + } + if (!cancelled && cancelError != ERROR_NOT_FOUND) { + return SetError(error, L"abi-negotiate-cancel", cancelError, + L"timed-out native ABI negotiation could not be cancelled"); + } + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", ERROR_OPERATION_ABORTED, + L"timed-out native ABI negotiation did not complete cancellation within the rollback ceiling"); + } + return SetError(error, L"abi-negotiate-timeout", ERROR_TIMEOUT, + L"native ABI negotiation exceeded the package transaction deadline"); + } + if (wait != WAIT_OBJECT_0) { + const DWORD waitError = GetLastError(); + CancelIoEx(device.get(), &overlapped); + const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"abi-negotiate-drain", ERROR_OPERATION_ABORTED, + L"failed native ABI wait could not be drained safely"); + } + SetLastError(waitError); + return SetLastErrorDetail(error, L"abi-negotiate-wait"); + } + if (!GetOverlappedResult(device.get(), &overlapped, &returned, FALSE)) { + return SetLastErrorDetail(error, L"abi-negotiate-result"); + } + } const VIIPER_UDE_UINT32 requiredCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | VIIPER_UDE_CAP_INPUT_REPORTS; if (returned != sizeof(response) || response.Header.Magic != VIIPER_UDE_MAGIC || @@ -1506,6 +1927,7 @@ bool VerifyInstalled( const PackageInfo& candidate, const std::wstring& publishedName, bool allowStopped, + uint64_t healthDeadlineUnixMs, Error* error) { Snapshot snapshot; if (!CaptureSnapshot(&snapshot, error)) { @@ -1522,7 +1944,7 @@ bool VerifyInstalled( return SetError(error, L"install-start", ERROR_DEVICE_NOT_AVAILABLE, L"installed driver did not start; problem=" + std::to_wstring(snapshot.devices[0].problem)); } - return allowStopped || VerifyAbiHealth(error); + return allowStopped || VerifyAbiHealth(healthDeadlineUnixMs, error); } bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* error) { @@ -1564,25 +1986,71 @@ std::vector NewPackageIndices( } bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* error) { - bool ignored = false; - if (!RemoveAllExactDevices(&ignored, error)) { + if (prior.devices.size() > 1) { + return SetError(error, L"rollback-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"rollback refuses an unsupported multi-devnode native topology"); + } + + Snapshot current; + if (!CaptureSnapshot(¤t, error) || current.devices.size() > 1) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"rollback observed an unexpected multi-devnode native topology"); + } return false; } - *rebootRequired = *rebootRequired || ignored; + const auto sameIdentity = [](const std::wstring& left, const std::wstring& right) { + return _wcsicmp(left.c_str(), right.c_str()) == 0; + }; + const bool keepCurrent = !prior.devices.empty() && !current.devices.empty() && + sameIdentity(prior.devices[0].instanceId, current.devices[0].instanceId); + + if (!current.devices.empty() && !keepCurrent) { + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"rollback-open-root-devices"); + } + std::vector> matches; + if (!FindExactDevices(set.get(), &matches, error) || matches.size() != 1) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-topology", ERROR_REVISION_MISMATCH); + } + return false; + } + bool removalReboot = false; + if (!RemoveDevice(set.get(), matches[0].first, &removalReboot, error)) { + return false; + } + *rebootRequired = *rebootRequired || removalReboot; + } if (prior.devices.empty()) { + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || !restored.devices.empty()) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-identity-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the captured empty devnode topology"); + } + return false; + } return true; } - const PackageInfo& package = prior.devices[0].package; - GUID classGuid{}; - wchar_t className[MAX_CLASS_NAME_LEN]{}; - if (!SetupDiGetINFClassW(package.infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { - return SetLastErrorDetail(error, L"rollback-inf-class"); - } - DeviceInfoSet created; - SP_DEVINFO_DATA createdData{}; - createdData.cbSize = sizeof(createdData); - if (!RegisterRootDevice(classGuid, className, &created, &createdData, error)) { - return false; + + const DeviceState& expected = prior.devices[0]; + const PackageInfo& package = expected.package; + if (!keepCurrent) { + GUID classGuid{}; + wchar_t className[MAX_CLASS_NAME_LEN]{}; + if (!SetupDiGetINFClassW(package.infPath.c_str(), &classGuid, className, + MAX_CLASS_NAME_LEN, nullptr)) { + return SetLastErrorDetail(error, L"rollback-inf-class"); + } + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); + if (!RegisterRootDeviceExact(classGuid, expected.instanceId, + &created, &createdData, error)) { + return false; + } } BOOL reboot = FALSE; if (!UpdateDriverForPlugAndPlayDevicesW( @@ -1590,6 +2058,17 @@ bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* err return SetLastErrorDetail(error, L"rollback-bind-prior"); } *rebootRequired = *rebootRequired || reboot != FALSE; + + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || restored.devices.size() != 1 || + !sameIdentity(restored.devices[0].instanceId, expected.instanceId) || + restored.devices[0].package.infSha256 != expected.package.infSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-identity-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the exact captured devnode identity and package binding"); + } + return false; + } return true; } @@ -1608,7 +2087,8 @@ bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) } if (!prior.devices.empty() && !*rebootRequired) { return VerifyInstalled( - prior.devices[0].package, prior.devices[0].publishedInf, false, error); + prior.devices[0].package, prior.devices[0].publishedInf, false, + CurrentUnixMilliseconds() + 15000, error); } return true; } @@ -1620,24 +2100,304 @@ bool LockPackageFiles( locks->clear(); for (const wchar_t* name : {L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.pdb", L"ViiperUde.cat"}) { WinHandle file(CreateFileW((directory / name).c_str(), GENERIC_READ, FILE_SHARE_READ, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); if (!file) { return SetLastErrorDetail(error, L"package-lock", L"all four package files must exist and remain immutable during installation"); } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"package-lock", ERROR_REPARSE_TAG_MISMATCH, + L"package inputs must be regular non-reparse files"); + } locks->push_back(std::move(file)); } return true; } +bool ValidateExactPackageDirectory(const std::filesystem::path& directory, Error* error) { + static const std::set expected = { + L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.pdb", L"ViiperUde.cat"}; + const DWORD attributes = GetFileAttributesW(directory.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"package-directory", ERROR_REPARSE_TAG_MISMATCH, + L"signed package path must be a regular non-reparse directory"); + } + std::set seen; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator(directory, enumerationError), end; + !enumerationError && iterator != end; iterator.increment(enumerationError)) { + std::error_code typeError; + if (!iterator->is_regular_file(typeError) || typeError || + !expected.contains(iterator->path().filename().wstring()) || + !seen.insert(iterator->path().filename().wstring()).second) { + return SetError(error, L"package-directory", ERROR_INVALID_DATA, + L"signed package directory must contain only the four exact regular VIIPER files"); + } + } + if (enumerationError || seen != expected) { + return SetError(error, L"package-directory", ERROR_INVALID_DATA, + L"signed package directory changed or is incomplete"); + } + return true; +} + struct InstallOptions { std::filesystem::path infPath; std::filesystem::path manifestPath; + std::string manifestSha256; std::string sourceRevision; bool production = true; std::optional expectedDowngradeFrom; + std::filesystem::path brokerExecutable; + std::string brokerSha256; + std::filesystem::path brokerToken; + std::string brokerTokenSha256; + std::wstring targetUserSid; + uint64_t transactionDeadlineUnixMs = 0; }; +uint64_t CurrentUnixMilliseconds() { + FILETIME now{}; + GetSystemTimeAsFileTime(&now); + ULARGE_INTEGER ticks{}; + ticks.LowPart = now.dwLowDateTime; + ticks.HighPart = now.dwHighDateTime; + constexpr uint64_t windowsToUnixEpochTicks = 116444736000000000ULL; + return ticks.QuadPart <= windowsToUnixEpochTicks + ? 0 : (ticks.QuadPart - windowsToUnixEpochTicks) / 10000ULL; +} + +bool CheckTransactionDeadline(const InstallOptions& options, const wchar_t* phase, Error* error) { + if (options.transactionDeadlineUnixMs == 0 || + CurrentUnixMilliseconds() >= options.transactionDeadlineUnixMs) { + return SetError(error, phase, ERROR_TIMEOUT, + L"native package transaction deadline expired before the next mutation"); + } + return true; +} + +bool ValidateTransactionDeadlineBudget(const InstallOptions& options, Error* error) { + const uint64_t now = CurrentUnixMilliseconds(); + if (options.transactionDeadlineUnixMs <= now || + options.transactionDeadlineUnixMs - now > kMaximumTransactionDurationMs) { + return SetError(error, L"transaction-deadline", ERROR_INVALID_PARAMETER, + L"transaction deadline is expired or exceeds the four-minute package budget"); + } + return true; +} + +bool ValidateCandidateInputs( + const InstallOptions& options, + std::filesystem::path* packageDirectory, + std::vector* packageLocks, + PackageInfo* candidate, + Error* error) { + if (!ValidateTransactionDeadlineBudget(options, error)) { + return false; + } + std::error_code candidatePathError; + const std::filesystem::path lockedInfPath = + std::filesystem::canonical(options.infPath, candidatePathError); + if (candidatePathError || lockedInfPath.filename().wstring() != L"ViiperUde.inf") { + return SetError(error, L"package-path", ERROR_FILE_NOT_FOUND); + } + *packageDirectory = lockedInfPath.parent_path(); + if (!ValidateExactPackageDirectory(*packageDirectory, error) || + !LockPackageFiles(*packageDirectory, packageLocks, error)) { + return false; + } + bool owned = false; + if (!LoadOwnedPackage(lockedInfPath, true, candidate, &owned, error) || !owned || + (options.production && !VerifyMicrosoftHardwareInfSigner(lockedInfPath, error))) { + return false; + } + WinHandle manifest(CreateFileW(options.manifestPath.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!manifest) { + error->phase = L"manifest-installer-open"; + return SetLastErrorDetail(error, L"manifest-installer-open"); + } + FILE_ATTRIBUTE_TAG_INFO manifestAttributes{}; + if (!GetFileInformationByHandleEx(manifest.get(), FileAttributeTagInfo, + &manifestAttributes, sizeof(manifestAttributes)) || + (manifestAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"manifest-installer-open", ERROR_REPARSE_TAG_MISMATCH, + L"source-bound manifest must be a regular non-reparse file"); + } + std::string actualManifestSha256; + if (!Sha256Handle(manifest.get(), &actualManifestSha256, error)) { + error->phase = L"manifest-installer-hash"; + return false; + } + if (_stricmp(actualManifestSha256.c_str(), options.manifestSha256.c_str()) != 0) { + return SetError(error, L"manifest-installer-hash", ERROR_CRC, + L"source-bound manifest does not match the installer-embedded SHA-256"); + } + std::string manifestContents; + if (!ReadSmallHandle(manifest.get(), &manifestContents, error)) { + return false; + } + return ValidateManifest(manifestContents, options.sourceRevision, options.production, + *packageDirectory, error) && + CheckTransactionDeadline(options, L"transaction-deadline-preflight", error); +} + +Outcome Verify(const InstallOptions& options) { + Outcome outcome; + std::filesystem::path packageDirectory; + std::vector packageLocks; + PackageInfo candidate; + if (!ValidateCandidateInputs( + options, &packageDirectory, &packageLocks, &candidate, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +bool IsSafeTargetUserSid(const std::wstring& sid) { + return sid.size() >= 5 && sid.size() <= 184 && + (sid.starts_with(L"S-") || sid.starts_with(L"s-")) && + std::all_of(sid.begin() + 2, sid.end(), [](wchar_t value) { + return (value >= L'0' && value <= L'9') || value == L'-'; + }); +} + +std::wstring QuoteWindowsArgument(const std::wstring& value) { + std::wstring quoted(1, L'"'); + size_t backslashes = 0; + for (const wchar_t character : value) { + if (character == L'\\') { + ++backslashes; + continue; + } + if (character == L'"') { + quoted.append(backslashes * 2 + 1, L'\\'); + quoted.push_back(L'"'); + backslashes = 0; + continue; + } + quoted.append(backslashes, L'\\'); + backslashes = 0; + quoted.push_back(character); + } + quoted.append(backslashes * 2, L'\\'); + quoted.push_back(L'"'); + return quoted; +} + +bool RunBrokerInstall(const InstallOptions& options, bool* transactionSettled, Error* error) { + *transactionSettled = false; + if (options.brokerExecutable.empty() || !options.brokerExecutable.is_absolute() || + options.brokerExecutable.filename().wstring() != L"viiper.exe" || + options.brokerToken.empty() || !options.brokerToken.is_absolute() || + options.brokerToken.extension().wstring() != L".token" || + options.brokerTokenSha256.size() != 64 || + !std::all_of(options.brokerTokenSha256.begin(), options.brokerTokenSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; }) || + !IsSafeTargetUserSid(options.targetUserSid)) { + return SetError(error, L"broker-arguments", ERROR_INVALID_PARAMETER, + L"broker executable, protected transaction token, and target SID do not match the native package contract"); + } + WinHandle broker(CreateFileW(options.brokerExecutable.c_str(), + GENERIC_READ | FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!broker) { + return SetLastErrorDetail(error, L"broker-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(broker.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + return SetError(error, L"broker-path", ERROR_REPARSE_TAG_MISMATCH, + L"broker executable must be a regular non-reparse file"); + } + std::array header{}; + DWORD read = 0; + if (!ReadFile(broker.get(), header.data(), static_cast(header.size()), &read, nullptr) || + read != static_cast(header.size()) || header[0] != 'M' || header[1] != 'Z') { + return SetError(error, L"broker-image", ERROR_BAD_EXE_FORMAT, + L"broker executable is not a Windows PE image"); + } + std::string actualBrokerSha256; + if (!Sha256Handle(broker.get(), &actualBrokerSha256, error)) { + error->phase = L"broker-hash"; + return false; + } + if (_stricmp(actualBrokerSha256.c_str(), options.brokerSha256.c_str()) != 0) { + return SetError(error, L"broker-hash", ERROR_CRC, + L"staged native broker does not match the installer-bound SHA-256"); + } + + std::wstring commandLine = QuoteWindowsArgument(options.brokerExecutable.wstring()) + + L" native-package-broker-commit --token-file " + + QuoteWindowsArgument(options.brokerToken.wstring()) + + L" --expected-token-sha256 " + + QuoteWindowsArgument(std::wstring( + options.brokerTokenSha256.begin(), options.brokerTokenSha256.end())) + + L" --target-user-sid " + + QuoteWindowsArgument(options.targetUserSid) + + L" --transaction-deadline-unix-ms " + + QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)); + std::vector mutableCommand(commandLine.begin(), commandLine.end()); + mutableCommand.push_back(L'\0'); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!CreateProcessW(options.brokerExecutable.c_str(), mutableCommand.data(), nullptr, nullptr, + FALSE, CREATE_NO_WINDOW, nullptr, options.brokerExecutable.parent_path().c_str(), + &startup, &process)) { + return SetLastErrorDetail(error, L"broker-start"); + } + WinHandle processHandle(process.hProcess); + WinHandle threadHandle(process.hThread); + // The child shares the exact outer deadline and owns a separately bounded + // rollback. Poll rather than hard-terminate: cancellation is cooperative, + // and the helper retains its driver snapshot until that rollback settles. + const uint64_t brokerCeiling = + options.transactionDeadlineUnixMs + kBrokerRollbackCeilingMs; + for (;;) { + const uint64_t now = CurrentUnixMilliseconds(); + if (now >= brokerCeiling) { + // Never terminate a mutating installer child. Its independently + // bounded rollback owns reconciliation; the parent reports an + // indeterminate transaction and deliberately does not race it by + // attempting driver rollback in parallel. + return SetError(error, L"broker-wait-ceiling", ERROR_TIMEOUT, + L"native broker exceeded its transaction deadline and rollback ceiling; external reconciliation is required"); + } + const DWORD waitSlice = static_cast( + std::min(250, brokerCeiling - now)); + const DWORD wait = WaitForSingleObject(processHandle.get(), waitSlice); + if (wait == WAIT_OBJECT_0) { + *transactionSettled = true; + break; + } + if (wait != WAIT_TIMEOUT) { + return SetLastErrorDetail(error, L"broker-wait"); + } + } + DWORD exitCode = ERROR_GEN_FAILURE; + if (!GetExitCodeProcess(processHandle.get(), &exitCode)) { + return SetLastErrorDetail(error, L"broker-exit"); + } + if (exitCode != ERROR_SUCCESS) { + return SetError(error, L"broker-health", exitCode, + L"native broker transaction did not reach authenticated healthy state"); + } + return true; +} + Outcome Install(const InstallOptions& options) { Outcome outcome; if (!IsElevated()) { @@ -1650,25 +2410,16 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - std::error_code candidatePathError; - const std::filesystem::path lockedInfPath = - std::filesystem::canonical(options.infPath, candidatePathError); - if (candidatePathError || lockedInfPath.filename().wstring() != L"ViiperUde.inf") { - SetError(&outcome.error, L"package-path", ERROR_FILE_NOT_FOUND); - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; - } - const std::filesystem::path packageDirectory = lockedInfPath.parent_path(); + std::filesystem::path packageDirectory; std::vector packageLocks; - if (!LockPackageFiles(packageDirectory, &packageLocks, &outcome.error)) { + PackageInfo candidate; + if (!ValidateCandidateInputs( + options, &packageDirectory, &packageLocks, &candidate, &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - PackageInfo candidate; - bool owned = false; - if (!LoadOwnedPackage(lockedInfPath, true, &candidate, &owned, &outcome.error) || !owned || - !ValidateManifest(options.manifestPath, options.sourceRevision, options.production, - packageDirectory, &outcome.error)) { + if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || + !CheckTransactionDeadline(options, L"transaction-deadline-before-driver", &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } @@ -1723,6 +2474,13 @@ Outcome Install(const InstallOptions& options) { return outcome; } + // Re-enumerate at the last possible point before SetupAPI reopens the + // package paths. The four leaf handles already deny write/delete sharing. + if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || + !CheckTransactionDeadline(options, L"transaction-deadline-before-driver", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } outcome.changed = true; BOOL installReboot = FALSE; const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; @@ -1775,8 +2533,12 @@ Outcome Install(const InstallOptions& options) { !FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { // Candidate inventory recorded the exact failure. } + if (outcome.error.code == ERROR_SUCCESS) { + CheckTransactionDeadline(options, L"transaction-deadline-before-verify", &outcome.error); + } if (outcome.error.code == ERROR_SUCCESS && - !VerifyInstalled(candidate, publishedCandidate.publishedName, outcome.rebootRequired, &outcome.error)) { + !VerifyInstalled(candidate, publishedCandidate.publishedName, outcome.rebootRequired, + options.transactionDeadlineUnixMs, &outcome.error)) { // Verification recorded the exact failure. } if (outcome.error.code != ERROR_SUCCESS) { @@ -1806,6 +2568,54 @@ Outcome Install(const InstallOptions& options) { return outcome; } + if (!options.brokerExecutable.empty()) { + Error brokerError; + bool brokerTransactionSettled = true; + if (outcome.rebootRequired) { + SetError(&brokerError, L"broker-reboot-boundary", ERROR_SUCCESS_REBOOT_REQUIRED, + L"driver activation requires a restart; legacy ownership remains active and broker migration was not attempted"); + } else if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker", &brokerError) || + !RunBrokerInstall(options, &brokerTransactionSettled, &brokerError)) { + // The broker command includes authenticated health verification and + // rolls back its own SCM/credential/legacy transaction. Keep the + // driver snapshot alive in this process until that proof succeeds. + } + if (brokerError.code != ERROR_SUCCESS) { + if (!brokerTransactionSettled) { + outcome.rollback = L"failed"; + outcome.error = std::move(brokerError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + Error rollbackError; + bool rollbackReboot = outcome.rebootRequired; + if (createdHere) { + Error cleanupError; + if (!RemoveDevice(created.get(), createdData, &rollbackReboot, &cleanupError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(cleanupError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + } + if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(brokerError); + outcome.exitCode = outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; + return outcome; + } + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + } + outcome.success = true; outcome.rollback = L"not-needed"; outcome.exitCode = outcome.rebootRequired ? ExitCode::RebootRequired : ExitCode::Success; @@ -1941,62 +2751,53 @@ bool RollbackRemove( } *rebootRequired = *rebootRequired || reboot != FALSE; } - bool removalReboot = false; - if (!RemoveAllExactDevices(&removalReboot, error)) { + Snapshot restorablePrior = prior; + std::vector reinstalledPackages; + if (!EnumerateOwnedPackages(&reinstalledPackages, error)) { return false; } - *rebootRequired = *rebootRequired || removalReboot; - if (!prior.devices.empty()) { - const auto iterator = std::find_if(backups.begin(), backups.end(), [&](const PackageBackup& backup) { - return _wcsicmp(backup.original.publishedName.c_str(), - prior.devices[0].publishedInf.c_str()) == 0; - }); - if (iterator == backups.end()) { - return SetError(error, L"remove-rollback-binding", ERROR_NOT_FOUND); - } - GUID classGuid{}; - wchar_t className[MAX_CLASS_NAME_LEN]{}; - if (!SetupDiGetINFClassW(iterator->infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { - return SetLastErrorDetail(error, L"remove-rollback-inf-class"); - } - DeviceInfoSet created; - SP_DEVINFO_DATA createdData{}; - createdData.cbSize = sizeof(createdData); - if (!RegisterRootDevice(classGuid, className, &created, &createdData, error)) { - return false; - } - BOOL reboot = FALSE; - if (!UpdateDriverForPlugAndPlayDevicesW( - nullptr, kHardwareId, iterator->infPath.c_str(), INSTALLFLAG_FORCE, &reboot)) { - return SetLastErrorDetail(error, L"remove-rollback-binding"); + for (DeviceState& device : restorablePrior.devices) { + const auto package = std::find_if(reinstalledPackages.begin(), reinstalledPackages.end(), + [&](const PackageInfo& candidate) { + return candidate.infSha256 == device.package.infSha256 && + candidate.version == device.package.version; + }); + if (package == reinstalledPackages.end()) { + return SetError(error, L"remove-rollback-package-identity", ERROR_NOT_FOUND, + L"the exact captured package was not republished for devnode restoration"); } - *rebootRequired = *rebootRequired || reboot != FALSE; + device.package = *package; + device.publishedInf = package->publishedName; } - if (!*rebootRequired) { - Snapshot restored; - if (!CaptureSnapshot(&restored, error)) { - return false; - } - std::multiset> expectedPackages; - std::multiset> actualPackages; - for (const PackageInfo& package : prior.packages) { - expectedPackages.emplace(package.version, package.infSha256); - } - for (const PackageInfo& package : restored.packages) { - actualPackages.emplace(package.version, package.infSha256); - } - if (expectedPackages != actualPackages || restored.devices.size() != prior.devices.size()) { + if (!RestorePriorBinding(restorablePrior, rebootRequired, error)) { + return false; + } + + Snapshot restored; + if (!CaptureSnapshot(&restored, error)) { + return false; + } + std::multiset> expectedPackages; + std::multiset> actualPackages; + for (const PackageInfo& package : prior.packages) { + expectedPackages.emplace(package.version, package.infSha256); + } + for (const PackageInfo& package : restored.packages) { + actualPackages.emplace(package.version, package.infSha256); + } + if (expectedPackages != actualPackages || restored.devices.size() != prior.devices.size()) { + return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, + L"rollback did not restore the exact prior package and devnode topology"); + } + if (!prior.devices.empty()) { + if (_wcsicmp(restored.devices[0].instanceId.c_str(), prior.devices[0].instanceId.c_str()) != 0 || + restored.devices[0].package.infSha256 != prior.devices[0].package.infSha256) { return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, - L"rollback did not restore the exact prior package and devnode set"); + L"rollback restored a different devnode identity or active package"); } - if (!prior.devices.empty()) { - if (restored.devices[0].package.infSha256 != prior.devices[0].package.infSha256) { - return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, - L"rollback restored a different active package"); - } - if (!VerifyAbiHealth(error)) { - return false; - } + if (!*rebootRequired && prior.devices[0].started && + !VerifyAbiHealth(CurrentUnixMilliseconds() + 15000, error)) { + return false; } } return true; @@ -2019,6 +2820,13 @@ Outcome Remove() { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + if (prior.devices.size() > 1 || + (!prior.devices.empty() && !prior.devices[0].present)) { + SetError(&outcome.error, L"remove-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"removal requires zero devices or one present exact owned root devnode"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } if (prior.devices.empty() && prior.packages.empty()) { outcome.success = true; outcome.exitCode = ExitCode::Success; @@ -2135,6 +2943,22 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-rollback-cleanup", ERROR_INVALID_DATA); return outcome; } + if (!IsSafeTargetUserSid(L"S-1-5-21-1-2-3-1001") || + IsSafeTargetUserSid(L"S-1-5-21-bad") || + QuoteWindowsArgument(LR"(C:\Program Files\VIIPER\viiper.exe)") != + LR"("C:\Program Files\VIIPER\viiper.exe")" || + QuoteWindowsArgument(LR"(value\"quoted)") != LR"("value\\\"quoted")") { + SetError(&outcome.error, L"self-test-broker-command", ERROR_INVALID_DATA); + return outcome; + } + if (!IsProductionHardwareVerificationUsage({kHardwareVerificationOid}) || + IsProductionHardwareVerificationUsage( + {kHardwareVerificationOid, kAttestationVerificationOid}) || + IsProductionHardwareVerificationUsage({kAttestationVerificationOid}) || + IsProductionHardwareVerificationUsage({})) { + SetError(&outcome.error, L"self-test-production-eku", ERROR_INVALID_DATA); + return outcome; + } outcome.success = true; outcome.exitCode = ExitCode::Success; return outcome; @@ -2146,13 +2970,39 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro } options->infPath = argv[2]; bool manifestSeen = false; + bool manifestHashSeen = false; bool revisionSeen = false; bool modeSeen = false; + bool brokerSeen = false; + bool brokerHashSeen = false; + bool brokerTokenSeen = false; + bool brokerTokenHashSeen = false; + bool targetUserSeen = false; + bool transactionDeadlineSeen = false; for (int index = 3; index < argc; ++index) { const std::wstring argument = argv[index]; if (_wcsicmp(argument.c_str(), L"--manifest") == 0 && index + 1 < argc && !manifestSeen) { options->manifestPath = argv[++index]; manifestSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--manifest-sha256") == 0 && + index + 1 < argc && !manifestHashSeen) { + const std::wstring wide = argv[++index]; + options->manifestSha256.clear(); + options->manifestSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest SHA-256 must contain ASCII hexadecimal characters"); + } + options->manifestSha256.push_back(static_cast(value)); + } + if (options->manifestSha256.size() != 64 || + !std::all_of(options->manifestSha256.begin(), options->manifestSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"manifest SHA-256 must contain exactly 64 hexadecimal characters"); + } + manifestHashSeen = true; } else if (_wcsicmp(argument.c_str(), L"--source-revision") == 0 && index + 1 < argc && !revisionSeen) { const std::wstring wide = argv[++index]; @@ -2190,14 +3040,87 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro L"controlled downgrade requires the exact installed four-part version"); } options->expectedDowngradeFrom = expected; + } else if (_wcsicmp(argument.c_str(), L"--broker-executable") == 0 && + index + 1 < argc && !brokerSeen) { + options->brokerExecutable = argv[++index]; + brokerSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-sha256") == 0 && + index + 1 < argc && !brokerHashSeen) { + const std::wstring wide = argv[++index]; + options->brokerSha256.clear(); + options->brokerSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker SHA-256 must contain ASCII hexadecimal characters"); + } + options->brokerSha256.push_back(static_cast(value)); + } + if (options->brokerSha256.size() != 64 || + !std::all_of(options->brokerSha256.begin(), options->brokerSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker SHA-256 must contain exactly 64 hexadecimal characters"); + } + brokerHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--target-user-sid") == 0 && + index + 1 < argc && !targetUserSeen) { + options->targetUserSid = argv[++index]; + targetUserSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-token") == 0 && + index + 1 < argc && !brokerTokenSeen) { + options->brokerToken = argv[++index]; + brokerTokenSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-token-sha256") == 0 && + index + 1 < argc && !brokerTokenHashSeen) { + const std::wstring wide = argv[++index]; + options->brokerTokenSha256.clear(); + options->brokerTokenSha256.reserve(wide.size()); + for (const wchar_t value : wide) { + if (value > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker token SHA-256 must contain ASCII hexadecimal characters"); + } + options->brokerTokenSha256.push_back(static_cast(value)); + } + if (options->brokerTokenSha256.size() != 64 || + !std::all_of(options->brokerTokenSha256.begin(), options->brokerTokenSha256.end(), + [](unsigned char value) { return std::isxdigit(value) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"broker token SHA-256 must contain exactly 64 hexadecimal characters"); + } + brokerTokenHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--transaction-deadline-unix-ms") == 0 && + index + 1 < argc && !transactionDeadlineSeen) { + const std::wstring value = argv[++index]; + if (value.empty() || value.size() > 20 || + !std::all_of(value.begin(), value.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = value.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + value.size() || parsed == 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + options->transactionDeadlineUnixMs = static_cast(parsed); + transactionDeadlineSeen = true; } else { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, L"unknown, duplicate, or incomplete install option"); } } - if (!manifestSeen || !revisionSeen || !modeSeen) { + if (!manifestSeen || !manifestHashSeen || !revisionSeen || !modeSeen || + !transactionDeadlineSeen || + brokerSeen != targetUserSeen || brokerSeen != brokerHashSeen || + brokerSeen != brokerTokenSeen || brokerSeen != brokerTokenHashSeen) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, - L"manifest, source revision, and validation mode are all required"); + L"manifest, its installer hash, source revision, and validation mode are required; broker executable, hashes, protected token, and target SID must be supplied together"); } return true; } @@ -2205,9 +3128,16 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro void Usage() { std::wcerr << L"usage:\n" - << L" ViiperUdeCtl.exe install --manifest " + << L" ViiperUdeCtl.exe install --manifest --manifest-sha256 <64 hex> " L"--source-revision <40-64 hex> --validation-mode " - L"[--allow-controlled-downgrade ]\n" + L"--transaction-deadline-unix-ms " + L"[--allow-controlled-downgrade ] " + L"--broker-executable --broker-sha256 <64 hex> " + L"--broker-token --broker-token-sha256 <64 hex> " + L"--target-user-sid \n" + << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " + L"--source-revision <40-64 hex> --validation-mode " + L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove\n" << L" ViiperUdeCtl.exe status\n" << L" ViiperUdeCtl.exe self-test\n"; @@ -2216,7 +3146,8 @@ void Usage() { } // namespace int wmain(int argc, wchar_t** argv) { - if (argc >= 3 && _wcsicmp(argv[1], L"install") == 0) { + if (argc >= 3 && + (_wcsicmp(argv[1], L"install") == 0 || _wcsicmp(argv[1], L"verify") == 0)) { InstallOptions options; Error argumentError; if (!ParseInstallOptions(argc, argv, &options, &argumentError)) { @@ -2224,11 +3155,20 @@ int wmain(int argc, wchar_t** argv) { Outcome outcome; outcome.error = std::move(argumentError); outcome.exitCode = ExitCode::Usage; - EmitOutcome(L"install", outcome); + EmitOutcome(argv[1], outcome); + return static_cast(outcome.exitCode); + } + if (_wcsicmp(argv[1], L"install") == 0 && options.production && + options.brokerExecutable.empty()) { + Outcome outcome; + SetError(&outcome.error, L"broker-required", ERROR_INVALID_PARAMETER, + L"production driver installation requires the authenticated broker transaction"); + outcome.exitCode = ExitCode::PreflightRejected; + EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } - Outcome outcome = Install(options); - EmitOutcome(L"install", outcome); + Outcome outcome = _wcsicmp(argv[1], L"verify") == 0 ? Verify(options) : Install(options); + EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } if (argc == 2 && _wcsicmp(argv[1], L"remove") == 0) { From c593545eab89b1ca852f121eecc5b1f0699b177d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 15:16:53 -0500 Subject: [PATCH 144/240] Decouple native owner from child cleanup --- docs/architecture/native-udecx.md | 22 +- .../udecx/driver_lifecycle_contract_test.go | 216 ++++++++++++++++++ native/udecx/driver/Controller.c | 88 ++----- native/udecx/driver/Device.c | 104 ++++----- native/udecx/driver/ViiperUde.h | 4 +- 5 files changed, 296 insertions(+), 138 deletions(-) create mode 100644 internal/transport/udecx/driver_lifecycle_contract_test.go diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 142a9391..7d129821 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -342,16 +342,20 @@ a wedged provider cannot retain the installer mutex indefinitely. child cleanup from the PnP cleanup callback. Embedding the mutex in the controller context keeps endpoint/device cleanup independent of sibling WDF child deletion order. -- Removal atomically revokes the UDE handle from the device table before - `UdecxUsbDevicePlugOutAndDelete`; that slot remains reserved until the - asynchronous object cleanup runs. Once that API returns, success or failure, - no path dereferences or restores the invalidated UDE handle and the broker - owner cannot be released while its reserved removal slot remains. +- Removal atomically revokes the UDE handle from the device table and retires + its logical active count before `UdecxUsbDevicePlugOutAndDelete`. `Devices[]` + is the sole slot-ownership table, so a slot can be reused after the consuming + UdeCx call returns even when KMDF defers the old child's object cleanup. No + path dereferences or restores that invalidated UDE handle. +- File cleanup first closes create/destroy admission, then joins only those + finite UdeCx API calls before removing the owner's remaining logical devices + and releasing the exclusive controller owner. Each retired child keeps its + own reference on the old file object until `EvtCleanupCallback`; that late + physical rundown cannot block a successor owner or clear a reused slot. - A post-transfer UdeCx removal failure is terminal for the controller, not - retryable for the child. The kernel accepts the broker's removal request, - requests a PnP controller restart, and keeps owner cleanup closed until - object teardown completes. User mode can retry only failures returned before - ownership reached UdeCx. + retryable for the child. The kernel accepts the broker's removal request and + requests a PnP controller restart; user mode can retry only failures returned + before ownership reached UdeCx. - Each device has a short-held state lock and independent endpoint queues. - User mode serializes controller-engine lifecycle mutations as one complete transaction. Endpoint reset cannot overlap an endpoint start/purge, device diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go new file mode 100644 index 00000000..be766622 --- /dev/null +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -0,0 +1,216 @@ +package udecx + +import ( + "strings" + "testing" +) + +func nativeCFunction(t *testing.T, source, name string) string { + t.Helper() + start := strings.Index(source, "\n"+name+"(") + if start < 0 { + t.Fatalf("native function %s is missing", name) + } + start++ + openOffset := strings.IndexByte(source[start:], '{') + if openOffset < 0 { + t.Fatalf("native function %s has no body", name) + } + open := start + openOffset + depth := 0 + for index := open; index < len(source); index++ { + switch source[index] { + case '/': + if index+1 >= len(source) { + continue + } + switch source[index+1] { + case '/': + newline := strings.IndexByte(source[index+2:], '\n') + if newline < 0 { + t.Fatalf("native function %s has an unterminated line comment", name) + } + index += newline + 2 + case '*': + closeComment := strings.Index(source[index+2:], "*/") + if closeComment < 0 { + t.Fatalf("native function %s has an unterminated block comment", name) + } + index += closeComment + 3 + } + case '\'', '"': + quote := source[index] + for index++; index < len(source); index++ { + if source[index] == '\\' { + index++ + continue + } + if source[index] == quote { + break + } + } + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return source[start : index+1] + } + } + } + t.Fatalf("native function %s has an unterminated body", name) + return "" +} + +func normalizedContract(source string) string { + return strings.Join(strings.Fields(source), " ") +} + +func requireContractOrder(t *testing.T, source string, fragments ...string) { + t.Helper() + cursor := 0 + for _, fragment := range fragments { + offset := strings.Index(source[cursor:], fragment) + if offset < 0 { + t.Fatalf("native contract lost ordered fragment %q in:\n%s", fragment, source) + } + cursor += offset + len(fragment) + } +} + +func TestKernelOwnerCleanupJoinsFiniteMutationRundown(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + all := controller + device + header + for _, obsolete := range []string{"OwnerCleanupTimer", "ViiperEvtOwnerCleanupRetry"} { + if strings.Contains(all, obsolete) { + t.Fatalf("owner cleanup still depends on unbounded retry state %q", obsolete) + } + } + if !strings.Contains(header, "KEVENT OwnerAdmissionsDrained;") { + t.Fatal("controller lost the finite owner-admission rundown event") + } + if !strings.Contains(controller, + "KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE);") { + t.Fatal("owner-admission rundown must start signaled") + } + + begin := normalizedContract(nativeCFunction(t, device, "ViiperBeginOwnerAdmission")) + requireContractOrder(t, begin, + "WdfWaitLockAcquire(controllerContext->OwnerLock, NULL);", + "InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions) == 1", + "KeClearEvent(&controllerContext->OwnerAdmissionsDrained);", + "WdfWaitLockRelease(controllerContext->OwnerLock);") + end := normalizedContract(nativeCFunction(t, device, "ViiperEndOwnerAdmission")) + requireContractOrder(t, end, + "InterlockedDecrement(&controllerContext->ActiveOwnerAdmissions);", + "if (remaining == 0)", + "KeSetEvent(&controllerContext->OwnerAdmissionsDrained, IO_NO_INCREMENT, FALSE);", + "WdfWaitLockRelease(controllerContext->OwnerLock);", + "WdfObjectDereference(OwnerFile);") + + finish := normalizedContract(nativeCFunction(t, controller, "ViiperFinishOwnerCleanup")) + requireContractOrder(t, finish, + "WdfWaitLockRelease(context->OwnerLock);", + "KeWaitForSingleObject( &context->OwnerAdmissionsDrained", + "ViiperDestroyOwnedDevices(Device, OwnerFile)", + "context->OwnerFile = WDF_NO_HANDLE;", + "WdfObjectDereference(OwnerFile);") + + for _, name := range []string{"ViiperCreateVirtualDevice", "ViiperDestroyVirtualDevice"} { + mutation := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, mutation, + "ViiperBeginOwnerAdmission(controller, Request, &ownerFile)", + "ViiperEndOwnerAdmission(controller, ownerFile);") + } +} + +func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + if strings.Contains(device+header, "RemovingSlots") { + t.Fatal("logical slot reuse still depends on asynchronous child cleanup") + } + + claim := normalizedContract(nativeCFunction(t, device, "ViiperClaimDeviceSlot")) + requireContractOrder(t, claim, + "if (current == WDF_NO_HANDLE)", + "freeSlot = index;", + "ControllerContext->Devices[freeSlot] = Device;") + release := normalizedContract(nativeCFunction(t, device, "ViiperReleaseDeviceSlot")) + requireContractOrder(t, release, + "if (ControllerContext->Devices[Slot] == Device)", + "ControllerContext->Devices[Slot] = WDF_NO_HANDLE;") + + remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) + requireContractOrder(t, remove, + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "ControllerContext->Devices[index] = WDF_NO_HANDLE;", + "ViiperRetireActiveDevice(ControllerContext, deviceContext);", + "*Device = current;") + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, cleanup, + "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);", + "ViiperRetireActiveDevice(controllerContext, deviceContext);", + "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", + "WdfObjectDereference(deviceContext->OwnerFile);") + + destroyOwned := nativeCFunction(t, device, "ViiperDestroyOwnedDevices") + for _, forbidden := range []string{"EvtVirtualDeviceCleanup", "ActiveDevices", "CleanupRetries"} { + if strings.Contains(destroyOwned, forbidden) { + t.Fatalf("logical owner release still waits on physical cleanup state %q", forbidden) + } + } +} + +func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "UdecxUsbDevicePlugOutAndDelete(device);", + "ViiperEndOwnerAdmission(controller, ownerFile);") + if suffix := destroy[strings.Index(destroy, "UdecxUsbDevicePlugOutAndDelete(device);")+len("UdecxUsbDevicePlugOutAndDelete(device);"):]; strings.Contains(suffix, "ViiperGetDeviceContext(device)") || + strings.Contains(suffix, "WdfObjectDelete(device)") { + t.Fatalf("destroy path uses consumed UDE handle after PlugOutAndDelete: %s", suffix) + } + + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "deviceContext = ViiperGetDeviceContext(device);", + "if (deviceContext->Plugged)", + "UdecxUsbDevicePlugOutAndDelete(device)", + "return FALSE;") + assertNoConsumedHandleUse(t, destroyOwned, "UdecxUsbDevicePlugOutAndDelete(device)", "} else {") + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, shutdown, + "VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]);", + "if (deviceContext->Plugged)", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + assertNoConsumedHandleUse(t, shutdown, + "UdecxUsbDevicePlugOutAndDelete(devices[index]);", "} else {") +} + +func assertNoConsumedHandleUse(t *testing.T, source, call, branchEnd string) { + t.Helper() + callAt := strings.Index(source, call) + if callAt < 0 { + t.Fatalf("native contract lost consuming call %q", call) + } + afterCall := source[callAt+len(call):] + endAt := strings.Index(afterCall, branchEnd) + if endAt < 0 { + t.Fatalf("native contract lost branch end %q after %q", branchEnd, call) + } + for _, forbidden := range []string{ + "ViiperGetDeviceContext", "WdfObjectDelete", "WdfObjectReference", + "WdfObjectDereference", + } { + if strings.Contains(afterCall[:endAt], forbidden) { + t.Fatalf("consumed UDE handle branch calls %s after %s: %s", + forbidden, call, afterCall[:endAt]) + } + } +} diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 43439792..d64fd84e 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -21,12 +21,9 @@ DEFINE_GUID( #pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) #pragma alloc_text(PAGE, ViiperEvtFileCleanup) -#pragma alloc_text(PAGE, ViiperEvtOwnerCleanupRetry) #pragma alloc_text(PAGE, ViiperCreateQueues) #endif -#define VIIPER_OWNER_CLEANUP_RETRY_MS 100 - static BOOLEAN ViiperFinishOwnerCleanup( @@ -46,11 +43,22 @@ ViiperFinishOwnerCleanup( WdfWaitLockRelease(context->OwnerLock); return TRUE; } - if (InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) != 0) { - WdfWaitLockRelease(context->OwnerLock); + WdfWaitLockRelease(context->OwnerLock); + + // EvtFileCleanup can run while a create/destroy IOCTL is still dispatched. + // Closing and CleanupInProgress prevent a successor from entering; join + // only those finite UdeCx API calls here. Child EvtCleanup is deliberately + // not part of this rundown because PlugOutAndDelete consumes its handle + // before KMDF necessarily destroys the object. + (VOID)KeWaitForSingleObject( + &context->OwnerAdmissionsDrained, + Executive, + KernelMode, + FALSE, + NULL); + if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { return FALSE; } - WdfWaitLockRelease(context->OwnerLock); if (!ViiperDestroyOwnedDevices(Device, OwnerFile)) { return FALSE; @@ -69,46 +77,6 @@ ViiperFinishOwnerCleanup( return TRUE; } -VOID -ViiperEvtOwnerCleanupRetry( - _In_ WDFTIMER Timer - ) -{ - WDFDEVICE device = (WDFDEVICE)WdfTimerGetParentObject(Timer); - VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(device); - WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; - - PAGED_CODE(); - if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { - return; - } - WdfWaitLockAcquire(context->OwnerLock, NULL); - if (context->CleanupInProgress && context->OwnerFile != WDF_NO_HANDLE) { - ownerFile = context->OwnerFile; - // Pin the file object across the unlocked cleanup attempt. Another - // cleanup path can finish device removal and release the controller's - // long-lived owner reference immediately after OwnerLock is dropped. - // Without this temporary reference the timer could dereference a - // deleted WDFFILEOBJECT while retrying process-death cleanup. - WdfObjectReference(ownerFile); - } - WdfWaitLockRelease(context->OwnerLock); - if (ownerFile == WDF_NO_HANDLE) { - return; - } - - if (ViiperFinishOwnerCleanup(device, ownerFile)) { - WdfObjectDereference(ownerFile); - return; - } - - if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0) { - InterlockedIncrement(&context->CleanupRetries); - (VOID)WdfTimerStart(Timer, WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); - } - WdfObjectDereference(ownerFile); -} - NTSTATUS ViiperEvtQueryUsbCapability( _In_ WDFDEVICE UdecxWdfDevice, @@ -151,7 +119,6 @@ ViiperEvtDeviceAdd( WDF_OBJECT_ATTRIBUTES fileAttributes; WDF_OBJECT_ATTRIBUTES requestAttributes; WDF_FILEOBJECT_CONFIG fileConfig; - WDF_TIMER_CONFIG timerConfig; UDECX_WDF_DEVICE_CONFIG udeConfig; WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; VIIPER_UDE_CONTROLLER_CONTEXT *context; @@ -200,6 +167,7 @@ ViiperEvtDeviceAdd( RtlZeroMemory(context, sizeof(*context)); ExInitializeFastMutex(&context->DeviceLock); KeInitializeEvent(&context->BrokerOperationsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -213,16 +181,6 @@ ViiperEvtDeviceAdd( return status; } - WDF_TIMER_CONFIG_INIT(&timerConfig, ViiperEvtOwnerCleanupRetry); - timerConfig.AutomaticSerialization = FALSE; - WDF_OBJECT_ATTRIBUTES_INIT(&attributes); - attributes.ParentObject = device; - attributes.ExecutionLevel = WdfExecutionLevelPassive; - status = WdfTimerCreate(&timerConfig, &attributes, &context->OwnerCleanupTimer); - if (!NT_SUCCESS(status)) { - return status; - } - RtlInitUnicodeString(&brokerReference, VIIPER_UDE_BROKER_REFERENCE_STRING); status = WdfDeviceCreateDeviceInterface( device, &GUID_DEVINTERFACE_VIIPER_UDE, &brokerReference); @@ -257,7 +215,7 @@ ViiperEvtControllerCleanup( context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); // Every active operation belongs in SelfManagedIoCleanup, while the - // controller's child queues, locks, memory objects, timer, and work item are + // controller's child queues, locks, memory objects, and work item are // still callable. WDF invokes child cleanup before parent cleanup, so this // callback is deliberately limited to invariant checks over context data. NT_ASSERT(InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0); @@ -305,10 +263,6 @@ ViiperEvtDeviceSelfManagedIoCleanup( WdfWaitLockRelease(context->OwnerLock); } - if (context->OwnerCleanupTimer != WDF_NO_HANDLE) { - WdfTimerStop(context->OwnerCleanupTimer, TRUE); - } - // A file cleanup that crossed OwnerLock before ShuttingDown may still be // using the controller's queue and lock children. The gate prevents any // successor, so this event is a finite rundown join before those objects @@ -336,7 +290,7 @@ ViiperEvtDeviceSelfManagedIoCleanup( WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); } - // Create-device owner admissions execute on ControlQueue and therefore + // Create/destroy owner admissions execute on ControlQueue and therefore // must have returned before its synchronous purge completes. NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); @@ -492,13 +446,7 @@ ViiperEvtFileCleanup( WdfSpinLockRelease(context->BrokerLock); } if (ownsController) { - if (!ViiperFinishOwnerCleanup(device, FileObject) && - InterlockedCompareExchange(&context->ShuttingDown, 0, 0) == 0) { - InterlockedIncrement(&context->CleanupRetries); - (VOID)WdfTimerStart( - context->OwnerCleanupTimer, - WDF_REL_TIMEOUT_IN_MS(VIIPER_OWNER_CLEANUP_RETRY_MS)); - } + (VOID)ViiperFinishOwnerCleanup(device, FileObject); } if (cleanupAdmitted) { WdfWaitLockAcquire(context->OwnerLock, NULL); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 78211ea6..e64936e9 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -263,37 +263,6 @@ ViiperAddDeviceDescriptors( return STATUS_SUCCESS; } -static -NTSTATUS -ViiperValidateOwner( - _In_ WDFDEVICE Controller, - _In_ WDFREQUEST Request, - _Out_ WDFFILEOBJECT *OwnerFile - ) -{ - VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); - VIIPER_UDE_FILE_CONTEXT *fileContext; - WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); - NTSTATUS status = STATUS_SUCCESS; - - if (fileObject == WDF_NO_HANDLE) { - return STATUS_INVALID_HANDLE; - } - fileContext = ViiperGetFileContext(fileObject); - WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); - if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || - controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || - InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || - InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { - status = STATUS_INVALID_DEVICE_STATE; - } - WdfWaitLockRelease(controllerContext->OwnerLock); - if (NT_SUCCESS(status)) { - *OwnerFile = fileObject; - } - return status; -} - static NTSTATUS ViiperBeginOwnerAdmission( @@ -319,10 +288,12 @@ ViiperBeginOwnerAdmission( status = STATUS_INVALID_DEVICE_STATE; } else { // Keep both the owner object and cleanup boundary alive while a child - // is being built. UdeCx creation and PlugIn may invoke asynchronous - // callbacks, so do not hold OwnerLock across those calls. + // is created or destroyed. UdeCx lifecycle calls may invoke callbacks, + // so do not hold OwnerLock across them. WdfObjectReference(fileObject); - InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions); + if (InterlockedIncrement(&controllerContext->ActiveOwnerAdmissions) == 1) { + KeClearEvent(&controllerContext->OwnerAdmissionsDrained); + } *OwnerFile = fileObject; } WdfWaitLockRelease(controllerContext->OwnerLock); @@ -342,6 +313,9 @@ ViiperEndOwnerAdmission( WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); remaining = InterlockedDecrement(&controllerContext->ActiveOwnerAdmissions); NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&controllerContext->OwnerAdmissionsDrained, IO_NO_INCREMENT, FALSE); + } WdfWaitLockRelease(controllerContext->OwnerLock); WdfObjectDereference(OwnerFile); } @@ -387,8 +361,7 @@ ViiperClaimDeviceSlot( for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; if (current == WDF_NO_HANDLE) { - if (!ControllerContext->RemovingSlots[index] && - freeSlot == VIIPER_UDE_MAX_DEVICES) { + if (freeSlot == VIIPER_UDE_MAX_DEVICES) { freeSlot = index; } continue; @@ -424,13 +397,26 @@ ViiperReleaseDeviceSlot( if (ControllerContext->Devices[Slot] == Device) { ControllerContext->Devices[Slot] = WDF_NO_HANDLE; } - if (ControllerContext->Devices[Slot] == WDF_NO_HANDLE) { - ControllerContext->RemovingSlots[Slot] = FALSE; - } } ExReleaseFastMutex(&ControllerContext->DeviceLock); } +static +VOID +ViiperRetireActiveDevice( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext + ) +{ + LONG remaining; + + if (InterlockedExchange(&DeviceContext->ActiveCounted, 0) == 0) { + return; + } + remaining = InterlockedDecrement(&ControllerContext->ActiveDevices); + NT_ASSERT(remaining >= 0); +} + NTSTATUS ViiperCreateVirtualDevice( _In_ WDFQUEUE Queue, @@ -591,7 +577,10 @@ ViiperBeginRemoveDevice( InterlockedExchange(&deviceContext->Purging, TRUE); WdfSpinLockRelease(ControllerContext->BrokerLock); ControllerContext->Devices[index] = WDF_NO_HANDLE; - ControllerContext->RemovingSlots[index] = TRUE; + // Devices[] is the logical ownership table. Retire the slot and its + // active count while the UDE handle is still valid; KMDF may defer the + // object's cleanup long after PlugOutAndDelete consumes this handle. + ViiperRetireActiveDevice(ControllerContext, deviceContext); *Device = current; status = STATUS_SUCCESS; break; @@ -615,13 +604,13 @@ ViiperDestroyVirtualDevice( UDECXUSBDEVICE device; PAGED_CODE(); - status = ViiperValidateOwner(controller, Request, &ownerFile); + status = ViiperBeginOwnerAdmission(controller, Request, &ownerFile); if (!NT_SUCCESS(status)) { return status; } status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); if (!NT_SUCCESS(status)) { - return status; + goto ExitAdmission; } if (inputLength != sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || input->Header.Major != VIIPER_UDE_ABI_MAJOR || @@ -630,13 +619,14 @@ ViiperDestroyVirtualDevice( input->Header.Size != sizeof(*input) || input->DeviceId == 0 || input->Generation == 0 || input->Reserved != 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); - return STATUS_INVALID_PARAMETER; + status = STATUS_INVALID_PARAMETER; + goto ExitAdmission; } status = ViiperBeginRemoveDevice( controllerContext, ownerFile, input->DeviceId, input->Generation, TRUE, &device); if (!NT_SUCCESS(status)) { - return status; + goto ExitAdmission; } status = UdecxUsbDevicePlugOutAndDelete(device); if (!NT_SUCCESS(status)) { @@ -645,9 +635,12 @@ ViiperDestroyVirtualDevice( // attempting to restore or retry this handle would be a use-after- // invalidation. Restart the controller so PnP owns final recovery. WdfDeviceSetFailed(controller, WdfDeviceFailedAttemptRestart); - return STATUS_SUCCESS; + status = STATUS_SUCCESS; } - return STATUS_SUCCESS; + +ExitAdmission: + ViiperEndOwnerAdmission(controller, ownerFile); + return status; } BOOLEAN @@ -663,7 +656,6 @@ ViiperDestroyOwnedDevices( UDECXUSBDEVICE device; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; ULONGLONG deviceId = 0; - BOOLEAN removalPending = FALSE; ULONG index; ExAcquireFastMutex(&controllerContext->DeviceLock); @@ -676,18 +668,18 @@ ViiperDestroyOwnedDevices( deviceId = ViiperGetDeviceContext(device)->DeviceId; break; } - if (controllerContext->RemovingSlots[index]) { - removalPending = TRUE; - } } ExReleaseFastMutex(&controllerContext->DeviceLock); if (deviceId == 0) { - return !removalPending; + return TRUE; } if (!NT_SUCCESS(ViiperBeginRemoveDevice( controllerContext, OwnerFile, deviceId, 0, FALSE, &device))) { - return FALSE; + // The logical table is authoritative. A framework-owned deletion + // can revoke the snapshot before this claim; rescan instead of + // pinning the exclusive owner to an object that is already gone. + continue; } deviceContext = ViiperGetDeviceContext(device); if (deviceContext->Plugged) { @@ -728,7 +720,7 @@ ViiperBeginControllerShutdown( InterlockedExchange(&deviceContext->Purging, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); controllerContext->Devices[index] = WDF_NO_HANDLE; - controllerContext->RemovingSlots[index] = TRUE; + ViiperRetireActiveDevice(controllerContext, deviceContext); devices[deviceCount++] = device; } ExReleaseFastMutex(&controllerContext->DeviceLock); @@ -761,9 +753,9 @@ ViiperEvtVirtualDeviceCleanup( } controllerContext = ViiperGetControllerContext(deviceContext->Controller); ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot); - if (InterlockedExchange(&deviceContext->ActiveCounted, 0) != 0) { - InterlockedDecrement(&controllerContext->ActiveDevices); - } + // Normal removal retired the logical count before PlugOutAndDelete. This + // is only the fallback for an unexpected framework-owned deletion. + ViiperRetireActiveDevice(controllerContext, deviceContext); if (InterlockedExchange(&deviceContext->OwnerReferenced, 0) != 0) { WdfObjectDereference(deviceContext->OwnerFile); } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 93857619..3fa57dda 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -112,8 +112,8 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFQUEUE ControlQueue; WDFQUEUE InputQueue; WDFQUEUE WaitingDequeues; - WDFTIMER OwnerCleanupTimer; KEVENT BrokerOperationsDrained; + KEVENT OwnerAdmissionsDrained; KEVENT FileCleanupsDrained; BOOLEAN CleanupInProgress; volatile LONG ShuttingDown; @@ -140,7 +140,6 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; - BOOLEAN RemovingSlots[VIIPER_UDE_MAX_DEVICES]; } VIIPER_UDE_CONTROLLER_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) @@ -213,7 +212,6 @@ EVT_WDF_DEVICE_SELF_MANAGED_IO_INIT ViiperEvtDeviceSelfManagedIoInit; EVT_WDF_DEVICE_SELF_MANAGED_IO_CLEANUP ViiperEvtDeviceSelfManagedIoCleanup; EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; -EVT_WDF_TIMER ViiperEvtOwnerCleanupRetry; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControlRoute; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtInputIoDeviceControl; From 78a8ef45f263688b7b3c39f66cbb5ad3774c3c95 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 15:28:53 -0500 Subject: [PATCH 145/240] Harden native release provenance gates Require release tags at the current main tip, make native package transaction checks a mandatory reusable release dependency, and prevent reusable tag runs from uploading test-signed driver output implicitly. Pin all external workflow actions and release toolchains, derive Go from the exact go.mod directive, attest an allowlisted release asset set, and add a policy self-test. Add a manual production-driver intake that downloads one immutable artifact by run and artifact ID, verifies its digest/source binding, and accepts it only after the existing Microsoft HLK/WHCP Production validator rejects test or attestation-signed packages. --- .github/scripts/Test-WorkflowSecurity.ps1 | 99 +++++++++ .github/workflows/build_base.yml | 56 +++-- .github/workflows/clients_ci.yml | 64 +++--- .github/workflows/docs-deploy.yml | 4 +- .github/workflows/generate-changelog.yml | 2 +- .../workflows/native-package-transaction.yml | 14 +- .../workflows/native-production-package.yml | 203 ++++++++++++++++++ .github/workflows/native-ude.yml | 26 ++- .github/workflows/release.yml | 149 ++++++++++--- .github/workflows/snapshots.yml | 8 +- 10 files changed, 523 insertions(+), 102 deletions(-) create mode 100644 .github/scripts/Test-WorkflowSecurity.ps1 create mode 100644 .github/workflows/native-production-package.yml diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 new file mode 100644 index 00000000..7cf2f6f2 --- /dev/null +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$workflowDirectory = Join-Path $repositoryRoot '.github\workflows' +$workflowFiles = @(Get-ChildItem -LiteralPath $workflowDirectory -File -Filter '*.yml') + +foreach ($workflow in $workflowFiles) { + $source = Get-Content -LiteralPath $workflow.FullName -Raw + foreach ($match in [regex]::Matches($source, '(?m)^\s*uses:\s*(?[^\s#]+)')) { + $reference = $match.Groups['reference'].Value + if ($reference.StartsWith('./', [StringComparison]::Ordinal)) { + continue + } + if ($reference -notmatch '^[^@\s]+@[0-9a-f]{40}$') { + throw "$($workflow.Name) uses mutable or malformed action reference '$reference'. External actions must use a full commit SHA." + } + } + if ($source -match '(?m)^\s*go-version\s*:') { + throw "$($workflow.Name) selects a floating Go toolchain. Use the exact version declared by go.mod." + } +} + +$releaseSource = Get-Content -LiteralPath (Join-Path $workflowDirectory 'release.yml') -Raw +foreach ($required in @( + 'native-validation', + 'native-package-transaction', + 'Test-WorkflowSecurity.ps1', + 'actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a')) { + if (-not $releaseSource.Contains($required)) { + throw "The release workflow is missing required gate '$required'." + } +} +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-validation[^\]]*native-package-transaction[^\]]*\]') { + throw 'create-release must depend on both native validation and package-transaction gates.' +} +if ($releaseSource -notmatch '(?ms)^\s{4}release-policy:\s.*?current origin/main tip') { + throw 'Release tags must be constrained to the workflow-protected current main tip.' +} +if ($releaseSource.Contains('ViiperUde-x64-test-signed')) { + throw 'The production release workflow must never consume the native test-signed artifact.' +} +if ([regex]::Matches($releaseSource, 'pattern:\s*"\*-Release"').Count -ne 2) { + throw 'Release artifact downloads must use the explicit *-Release artifact allowlist.' +} + +$nativeWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-ude.yml') -Raw +if ($nativeWorkflow -notmatch '(?m)^\s*if:\s*\$\{\{\s*inputs\.upload_artifacts\s*==\s*true\s*\}\}\s*$') { + throw 'Native test-signed artifacts may upload only through the explicit Boolean test-artifact input.' +} + +$productionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-production-package.yml') -Raw +foreach ($required in @( + 'Test-ViiperUdeSignedPackage.ps1', + '-ValidationMode Production', + 'Microsoft-signed', + 'signingRoute')) { + if (-not $productionWorkflow.Contains($required)) { + throw "The production-native workflow is missing required validation contract '$required'." + } +} +if ($productionWorkflow -match '(?m)^\s{2}(?:push|pull_request):') { + throw 'Production Microsoft-signed package acceptance must remain an explicit manual intake path.' +} + +$goDirective = Get-Content -LiteralPath (Join-Path $repositoryRoot 'go.mod') -TotalCount 3 | + Where-Object { $_ -match '^go\s+' } | + Select-Object -First 1 +if ($goDirective -notmatch '^go\s+\d+\.\d+\.\d+$') { + throw "go.mod must pin a complete Go toolchain version; found '$goDirective'." +} + +$packagesPath = Join-Path $repositoryRoot 'native\udecx\driver\packages.config' +[xml]$packages = Get-Content -LiteralPath $packagesPath -Raw +$expectedWdkVersion = '10.0.28000.1839' +$expectedPackages = @( + 'Microsoft.Windows.SDK.CPP', + 'Microsoft.Windows.SDK.CPP.x64', + 'Microsoft.Windows.WDK.x64' +) +foreach ($packageId in $expectedPackages) { + $matches = @($packages.packages.package | Where-Object { $_.id -ceq $packageId }) + if ($matches.Count -ne 1 -or $matches[0].version -cne $expectedWdkVersion) { + throw "Native package '$packageId' must be pinned exactly to $expectedWdkVersion." + } +} + +$projectSource = Get-Content -LiteralPath (Join-Path $repositoryRoot 'native\udecx\driver\ViiperUde.vcxproj') -Raw +foreach ($packageId in $expectedPackages) { + $escapedPath = [regex]::Escape("$packageId.$expectedWdkVersion") + if ($projectSource -notmatch $escapedPath) { + throw "The native project does not import exact package '$packageId.$expectedWdkVersion'." + } +} + +Write-Host 'Workflow action pins, release gates, provenance, and native toolchain contracts are deterministic.' diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index ba9a92e7..d7132140 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -14,32 +14,30 @@ on: default: false description: "Whether to upload build artifacts" +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + jobs: test: name: Test runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: stable + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 - - - name: Install goversioninfo (Windows) - if: ${{ matrix.target.goos == 'windows' }} - shell: pwsh - run: | - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 - name: Show Go version run: go version @@ -75,16 +73,16 @@ jobs: } - name: Lint - uses: golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: latest + version: v2.12.2 install-mode: goinstall - name: Run tests run: just test-coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 with: token: ${{ secrets.CODECOV_TOKEN }} directory: . @@ -104,7 +102,7 @@ jobs: - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-latest } steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -114,15 +112,15 @@ jobs: run: ./scripts/test-install-first-run.ps1 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 - name: Install goversioninfo shell: pwsh @@ -162,7 +160,7 @@ jobs: - name: Upload artifact (Linux) if: ${{ inputs.upload_artifacts && matrix.target.goos == 'linux' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.tar.gz @@ -170,7 +168,7 @@ jobs: - name: Upload artifact (Windows) if: ${{ inputs.upload_artifacts && matrix.target.goos == 'windows' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.zip @@ -182,20 +180,20 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 - name: Build run: | @@ -206,7 +204,7 @@ jobs: - name: Upload artifact if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: libVIIPER-linux-amd64${{ inputs.artifact_suffix }} path: dist/libVIIPER/libVIIPER-linux-amd64.zip @@ -218,20 +216,20 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.26 + go-version-file: go.mod cache: true cache-dependency-path: | go.sum - name: Setup just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 - name: Install build tools shell: pwsh @@ -255,7 +253,7 @@ jobs: - name: Upload artifact if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: libVIIPER-windows-amd64${{ inputs.artifact_suffix }} path: dist/libVIIPER/libVIIPER-windows-amd64.zip diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index b216424b..d296af16 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -21,8 +21,12 @@ on: default: "" description: "Override version injected via ldflags (e.g. tag v1.2.3)" -permissions: - contents: read +permissions: + contents: read + +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local jobs: codegen: @@ -30,12 +34,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: stable + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod cache: true cache-dependency-path: go.sum @@ -52,7 +56,7 @@ jobs: fi - name: Upload generated clients - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: generated-clients path: clients/ @@ -64,18 +68,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24.11.1" cache: "npm" cache-dependency-path: | clients/typescript/package-lock.json @@ -106,7 +110,7 @@ jobs: - name: Upload TypeScript Client Library tarball if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: typescript-client-library${{ inputs.artifact_suffix }} path: clients/typescript/viiperclient-typescript-client-library${{ inputs.artifact_suffix }}.tgz @@ -118,18 +122,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: "8.0.x" + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: "8.0.419" - name: Pack C# Client Library run: dotnet pack clients/csharp/Viiper.Client/Viiper.Client.csproj -c Release -o artifacts/nuget @@ -142,7 +146,7 @@ jobs: - name: Upload C# Client Library nupkg if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: csharp-client-library-nupkg${{ inputs.artifact_suffix }} path: artifacts/nuget/*.nupkg @@ -154,18 +158,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2.2.0 - with: - cmake-version: "3.26.x" + uses: jwlawson/actions-setup-cmake@0d6a7d60b009d01c9e7523be22153ff8f19460d3 # v2.2.0 + with: + cmake-version: "3.26.6" - name: Install OpenSSL (libssl-dev) run: | @@ -185,7 +189,7 @@ jobs: - name: Upload C++ Client Library headers if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cpp-client-library-headers${{ inputs.artifact_suffix }} path: cpp-client-library-headers${{ inputs.artifact_suffix }}.zip @@ -197,16 +201,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download generated clients - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: generated-clients path: clients/ - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: "1.97.1" - name: Build Rust Client Library working-directory: clients/rust @@ -234,7 +240,7 @@ jobs: - name: Upload Rust Client Library crate if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: rust-client-library${{ inputs.artifact_suffix }} path: clients/rust/target/package/viiper-client-rust-client-library${{ inputs.artifact_suffix }}.crate diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c5f539cd..e8e53f16 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -33,7 +33,7 @@ jobs: git config user.email github-actions[bot]@users.noreply.github.com - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 64884d7e..28e7ce33 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -23,7 +23,7 @@ jobs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/native-package-transaction.yml b/.github/workflows/native-package-transaction.yml index f2b4d5be..22b83616 100644 --- a/.github/workflows/native-package-transaction.yml +++ b/.github/workflows/native-package-transaction.yml @@ -15,11 +15,16 @@ on: - "native/udecx/tools/ViiperUdeCtl.cpp" - "native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1" - ".github/workflows/native-package-transaction.yml" + workflow_call: workflow_dispatch: permissions: contents: read +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + jobs: fail-closed-simulation: runs-on: windows-latest @@ -28,8 +33,15 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: "1.26.5" + go-version-file: go.mod cache: true + - name: Verify exact Go dependency graph + shell: pwsh + run: | + $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] + $actual = (go env GOVERSION).TrimStart('g', 'o') + if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } + go mod verify - name: Run deterministic package transaction simulations shell: pwsh run: go test -count=1 -run '^TestNativePackage' ./internal/cmd diff --git a/.github/workflows/native-production-package.yml b/.github/workflows/native-production-package.yml new file mode 100644 index 00000000..3069ff74 --- /dev/null +++ b/.github/workflows/native-production-package.yml @@ -0,0 +1,203 @@ +name: Validate production native package + +on: + workflow_dispatch: + inputs: + source_revision: + description: Full reviewed source commit represented by the Microsoft-signed package. + required: true + type: string + artifact_run_id: + description: Workflow run containing the immutable Microsoft-returned artifact. + required: true + type: string + artifact_id: + description: Immutable GitHub artifact ID; names and broad downloads are not accepted. + required: true + type: string + artifact_digest: + description: GitHub artifact SHA-256 digest, without the sha256 prefix. + required: true + type: string + package_directory: + description: Relative path to the four-file driver package inside the artifact. + required: true + type: string + submission_manifest_path: + description: Relative path to the source-bound submission manifest inside the artifact. + required: true + type: string + +permissions: + actions: read + attestations: write + contents: read + id-token: write + +jobs: + validate-production-package: + name: Accept Microsoft HLK/WHCP-signed native package + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + steps: + - name: Checkout trusted validation policy + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + path: gate-policy + persist-credentials: false + + - name: Validate explicit artifact provenance + shell: pwsh + env: + ARTIFACT_DIGEST: ${{ inputs.artifact_digest }} + ARTIFACT_ID: ${{ inputs.artifact_id }} + ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + SOURCE_REVISION: ${{ inputs.source_revision }} + GH_TOKEN: ${{ github.token }} + run: | + if ($env:SOURCE_REVISION -cnotmatch '^[0-9a-f]{40}$') { + throw 'source_revision must be a lowercase, full 40-character Git commit.' + } + if ($env:ARTIFACT_ID -notmatch '^\d+$' -or $env:ARTIFACT_RUN_ID -notmatch '^\d+$') { + throw 'artifact_id and artifact_run_id must be numeric GitHub identifiers.' + } + if ($env:ARTIFACT_DIGEST -cnotmatch '^[0-9a-f]{64}$') { + throw 'artifact_digest must be a lowercase SHA-256 digest.' + } + $metadata = gh api "/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:ARTIFACT_ID" | ConvertFrom-Json + if ([long]$metadata.id -ne [long]$env:ARTIFACT_ID -or [bool]$metadata.expired) { + throw 'The selected artifact is missing, expired, or does not match artifact_id.' + } + if ([long]$metadata.workflow_run.id -ne [long]$env:ARTIFACT_RUN_ID -or + [string]$metadata.workflow_run.head_sha -cne $env:SOURCE_REVISION) { + throw 'The selected artifact run is not bound to source_revision.' + } + if ([string]$metadata.digest -cne "sha256:$env:ARTIFACT_DIGEST") { + throw 'The selected artifact digest does not match the explicit SHA-256 input.' + } + + - name: Checkout exact reviewed source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.source_revision }} + fetch-depth: 0 + path: reviewed-source + persist-credentials: false + + - name: Require reviewed main history + shell: pwsh + env: + SOURCE_REVISION: ${{ inputs.source_revision }} + working-directory: reviewed-source + run: | + git fetch --no-tags origin main + git merge-base --is-ancestor $env:SOURCE_REVISION origin/main + if ($LASTEXITCODE -ne 0) { + throw 'Production native packages must represent reviewed origin/main history.' + } + + - name: Download only the explicit immutable artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ inputs.artifact_id }} + run-id: ${{ inputs.artifact_run_id }} + github-token: ${{ github.token }} + path: signed-input + + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" + - name: Restore exact source-bound WDK tools + run: >- + nuget restore reviewed-source/native/udecx/ViiperUde.sln + -PackagesDirectory reviewed-source/native/udecx/packages + -NonInteractive + - name: Expose source-bound WDK validation tools + shell: pwsh + run: | + $tools = Get-ChildItem reviewed-source/native/udecx/packages -Recurse -File -Filter *.exe + foreach ($required in @('signtool.exe', 'infverif.exe')) { + if (-not ($tools | Where-Object Name -ieq $required | Select-Object -First 1)) { + throw "Restored WDK packages did not contain $required." + } + } + $tools.DirectoryName | Sort-Object -Unique | Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Validate Microsoft-signed production package + id: validate + shell: pwsh + env: + PACKAGE_DIRECTORY: ${{ inputs.package_directory }} + SOURCE_REVISION: ${{ inputs.source_revision }} + SUBMISSION_MANIFEST: ${{ inputs.submission_manifest_path }} + run: | + $artifactRoot = (Resolve-Path -LiteralPath signed-input).Path + function Resolve-ContainedPath([string]$relativePath, [bool]$requireDirectory) { + if ([string]::IsNullOrWhiteSpace($relativePath) -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'Production package inputs must be non-empty relative paths.' + } + $resolved = (Resolve-Path -LiteralPath (Join-Path $artifactRoot $relativePath)).Path + $prefix = $artifactRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Input path '$relativePath' escapes the downloaded artifact." + } + if ((Get-Item -LiteralPath $resolved).PSIsContainer -ne $requireDirectory) { + throw "Input path '$relativePath' has the wrong file type." + } + return $resolved + } + $packagePath = Resolve-ContainedPath $env:PACKAGE_DIRECTORY $true + $manifestPath = Resolve-ContainedPath $env:SUBMISSION_MANIFEST $false + # Production is literal: the validator requires releaseEligible=true, + # signingRoute=HLK/WHCP, Microsoft kernel policy, and rejects attestation EKU. + & ./gate-policy/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 ` + -PackageDirectory $packagePath ` + -SubmissionManifestPath $manifestPath ` + -ExpectedSourceRevision $env:SOURCE_REVISION ` + -ValidationMode Production + "package_path=$packagePath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "manifest_path=$manifestPath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Package only validated production bytes + id: package + shell: pwsh + env: + PACKAGE_PATH: ${{ steps.validate.outputs.package_path }} + MANIFEST_PATH: ${{ steps.validate.outputs.manifest_path }} + SOURCE_REVISION: ${{ inputs.source_revision }} + run: | + $staging = Join-Path $env:RUNNER_TEMP 'viiper-production-package' + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $staging | Out-Null + Copy-Item -LiteralPath $env:PACKAGE_PATH -Destination (Join-Path $staging 'ViiperUde') -Recurse + Copy-Item -LiteralPath $env:MANIFEST_PATH -Destination (Join-Path $staging 'submission-manifest.json') + $archive = Join-Path $env:RUNNER_TEMP "ViiperUde-x64-production-$env:SOURCE_REVISION.zip" + Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $archive -CompressionLevel Optimal + "archive=$archive" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest validated production package provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: ${{ steps.package.outputs.archive }} + + - name: Upload validated production package + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUde-x64-production-microsoft-signed-${{ inputs.source_revision }} + path: ${{ steps.package.outputs.archive }} + if-no-files-found: error + retention-days: 30 + + - name: Record accepted artifact identity + shell: pwsh + run: | + @" + ### Microsoft-signed native production package accepted + + - Source: `${{ inputs.source_revision }}` + - Artifact ID: `${{ steps.upload.outputs.artifact-id }}` + - Artifact SHA-256: `${{ steps.upload.outputs.artifact-digest }}` + - Validation: literal `Production` (HLK/WHCP; attestation rejected) + "@ | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 17176f94..0764934b 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -19,7 +19,10 @@ on: - "_testing/e2e/**" - "docs/testing/e2e_latency.md" - ".github/workflows/build_base.yml" + - ".github/workflows/**" + - ".github/scripts/Test-WorkflowSecurity.ps1" - ".github/workflows/native-ude.yml" + - ".github/workflows/native-package-transaction.yml" - ".github/workflows/release.yml" pull_request: paths: @@ -38,7 +41,10 @@ on: - "_testing/e2e/**" - "docs/testing/e2e_latency.md" - ".github/workflows/build_base.yml" + - ".github/workflows/**" + - ".github/scripts/Test-WorkflowSecurity.ps1" - ".github/workflows/native-ude.yml" + - ".github/workflows/native-package-transaction.yml" - ".github/workflows/release.yml" workflow_call: inputs: @@ -54,6 +60,10 @@ permissions: contents: read security-events: write +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + # A driver artifact is meaningful only for the exact current branch head. # Cancel superseded WDK/CodeQL work instead of letting several incompatible # ABI revisions finish and present equally downloadable test packages. @@ -68,8 +78,16 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: "1.26.5" + go-version-file: go.mod cache: true + - name: Verify workflow and native toolchain policy + shell: pwsh + run: | + ./.github/scripts/Test-WorkflowSecurity.ps1 + $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] + $actual = (go env GOVERSION).TrimStart('g', 'o') + if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } + go mod verify - name: Test complete VIIPER tree run: go test ./... - name: Stress native Windows client cancellation, close, pump failure, and reconnect @@ -92,7 +110,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: "1.26.5" + go-version-file: go.mod cache: true - name: Race-test native host, USB processor, and realtime controller encoders run: >- @@ -119,6 +137,8 @@ jobs: with: msbuild-architecture: x64 - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" - name: Restore WDK packages run: nuget restore native/udecx/ViiperUde.sln -PackagesDirectory native/udecx/packages -NonInteractive - name: Validate Windows and KMDF target contract @@ -213,7 +233,7 @@ jobs: with: category: /language:c-cpp - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: ${{ github.event_name != 'workflow_call' || inputs.upload_artifacts }} + if: ${{ inputs.upload_artifacts == true }} with: name: ViiperUde-x64-test-signed path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f689549f..12a62a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,19 +7,61 @@ on: permissions: actions: read - contents: write - id-token: write - security-events: write + contents: read jobs: + release-policy: + name: Validate release source and workflow policy + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout exact release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + + - name: Require an exact SemVer tag on reviewed main history + shell: bash + env: + TAG_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release tag must be exact vMAJOR.MINOR.PATCH SemVer." + exit 1 + fi + test "$(git rev-parse "refs/tags/${TAG_NAME}^{commit}")" = "$GITHUB_SHA" + git fetch --no-tags origin main + if [[ "$(git rev-parse origin/main)" != "$GITHUB_SHA" ]]; then + echo "::error::Release tags must point to the current origin/main tip." + exit 1 + fi + + - name: Enforce immutable workflow dependency policy + shell: pwsh + run: ./.github/scripts/Test-WorkflowSecurity.ps1 + native-validation: name: Native UdeCx release gate + needs: release-policy + permissions: + contents: read + security-events: write uses: ./.github/workflows/native-ude.yml with: upload_artifacts: false + native-package-transaction: + name: Native package transaction release gate + needs: release-policy + permissions: + contents: read + uses: ./.github/workflows/native-package-transaction.yml + build: - uses: ./.github/workflows/build_base.yml + needs: release-policy + uses: ./.github/workflows/build_base.yml secrets: inherit with: artifact_suffix: "-Release" @@ -33,9 +75,10 @@ jobs: mode: release tag_name: ${{ github.ref_name }} - client-libraries: - name: Client library smoke builds and pack - uses: ./.github/workflows/clients_ci.yml + client-libraries: + name: Client library smoke builds and pack + needs: release-policy + uses: ./.github/workflows/clients_ci.yml with: artifact_suffix: "-Release" upload_artifacts: true @@ -43,20 +86,26 @@ jobs: create-release: name: Create Release - needs: [native-validation, build, generate-changelog, client-libraries] - runs-on: ubuntu-latest + needs: [release-policy, native-validation, native-package-transaction, build, generate-changelog, client-libraries] + permissions: + actions: read + attestations: write + contents: write + id-token: write + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: artifacts - - - name: Organize and rename artifacts + - name: Download all artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + pattern: "*-Release" + + - name: Organize and rename artifacts shell: bash run: | set -euo pipefail @@ -96,17 +145,47 @@ jobs: echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT TAG_NAME=${GITHUB_REF#refs/tags/} echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") - if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - COMMIT_COUNT=$(git rev-list --count HEAD) - GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" - fi - echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT - echo "Version from git: $GIT_VERSION" + GIT_VERSION="$TAG_NAME" + echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT + echo "Version from git: $GIT_VERSION" + + - name: Create deterministic release checksums + shell: bash + run: | + set -euo pipefail + expected=( + viiper-cpp-client-library-headers.zip + viiper-csharp-client-library-nupkg.nupkg + viiper-libVIIPER-linux-amd64.zip + viiper-libVIIPER-windows-amd64.zip + viiper-linux-amd64.tar.gz + viiper-linux-arm64.tar.gz + viiper-rust-client-library.crate + viiper-typescript-client-library.tgz + viiper-windows-amd64.zip + viiper-windows-arm64.zip + ) + mapfile -t actual < <(find release_files -maxdepth 1 -type f -printf '%f\n' | sort) + if ! diff -u <(printf '%s\n' "${expected[@]}") <(printf '%s\n' "${actual[@]}"); then + echo "::error::Release staging contained a missing or unexpected artifact." + exit 1 + fi + ( + cd release_files + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | + sort -z | + xargs -0 sha256sum | + sed 's# \./# #' + ) > release_files/SHA256SUMS + + - name: Attest release artifact provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: release_files/* - name: Create Release id: create_release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: ${{ steps.build_info.outputs.tag_name }} name: "VIIPER ${{ steps.build_info.outputs.version }}" @@ -143,16 +222,18 @@ jobs: publish-client-registries: name: Publish client libraries (best effort) needs: create-release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 continue-on-error: true permissions: + actions: read contents: read id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts + pattern: "*-Release" - name: Organize client library artifacts shell: bash @@ -186,9 +267,9 @@ jobs: - name: Set up Node.js (for npm publish) id: setup_node continue-on-error: true - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: "24" + node-version: "24.11.1" registry-url: "https://registry.npmjs.org/" - name: Publish TypeScript client library to npm @@ -239,15 +320,15 @@ jobs: id: setup_dotnet if: ${{ steps.nuget_config.outputs.enabled == 'true' }} continue-on-error: true - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: - dotnet-version: "8.0.x" + dotnet-version: "8.0.419" - name: NuGet login (OIDC to temporary API key) id: nuget_login if: ${{ steps.nuget_config.outputs.enabled == 'true' && steps.setup_dotnet.outcome == 'success' }} continue-on-error: true - uses: NuGet/login@v1.2.0 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: user: ${{ secrets.NUGET_USER }} @@ -269,13 +350,15 @@ jobs: - name: Set up Rust (for crates.io publish) id: setup_rust continue-on-error: true - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: "1.97.1" - name: Authenticate with crates.io (OIDC) id: crates_io_auth if: ${{ steps.setup_rust.outcome == 'success' }} continue-on-error: true - uses: rust-lang/crates-io-auth-action@v1.0.4 + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 - name: Publish Rust client library to crates.io id: publish_crates diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 0869c765..0392903a 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -15,7 +15,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -62,12 +62,12 @@ jobs: if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts @@ -104,7 +104,7 @@ jobs: echo "Version from git: $GIT_VERSION" - name: Update Dev Snapshot Release - uses: andelf/nightly-release@v1 + uses: andelf/nightly-release@c5ed4bdb7c1da04a4fa1e40bc5e67306f682563b # v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 79498dd9465727771054c6a2a8eff6767741afb8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 20:34:02 -0500 Subject: [PATCH 146/240] Separate certification symbols from runtime package --- .../native-udecx-package-install.md | 14 ++++++++++---- internal/cmd/native_package_contract_test.go | 16 ++++++++++++++++ internal/cmd/native_package_windows.go | 4 ++-- internal/cmd/native_package_windows_test.go | 11 +++++++++++ native/udecx/README.md | 4 +++- native/udecx/tools/ViiperUdeCtl.cpp | 17 +++++++++++------ 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index ca724f7a..a09be56f 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -14,14 +14,14 @@ The signed bootstrapper supplies all of the following as immutable build data: - the exact VIIPER broker, `ViiperUdeCtl.exe`, and reviewed production-manifest SHA-256 values; - the reviewed 40-64 hexadecimal source revision; -- the four-file Microsoft-returned driver directory and source-bound HLK/WHCP - manifest; and +- the runtime driver directory containing only the Microsoft-returned INF, + SYS, and CAT, plus the source-bound HLK/WHCP manifest; and - the target interactive-user SID whose legacy startup ownership may be migrated. Before its first mutation, the command holds non-write-shared and -non-delete-shared handles to the broker, helper, manifest, INF, SYS, PDB, and -CAT, plus every local directory ancestor used to reopen those paths. It rejects +non-delete-shared handles to the broker, helper, manifest, INF, SYS, and CAT, +plus every local directory ancestor used to reopen those paths. It rejects reparse points, hard links, ancestor replacement, extra package files, hash changes, noncanonical INF contracts, non-production manifests, and packages that do not pass the helper's read-only signature/catalog verification. The helper proves @@ -35,6 +35,12 @@ not sufficient. The helper repeats the installer-embedded manifest SHA-256 check before both verification and installation. Paths are passed as arguments, never through a command shell. +The certification/intake artifact still contains the PDB, and the intake gate +binds that PDB to the same manifest. The public runtime bundle omits it: the +installer pins the validated manifest hash and rechecks the unchanged INF, +while Windows consumes only INF/SYS/CAT. Debug symbols therefore remain part +of the source-provenance evidence without becoming a user-machine dependency. + ## Commit order 1. Acquire the administrator-only machine package mutex and validate every diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 16457150..902f9f31 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -104,6 +104,22 @@ func TestNativePackageProductionSourceContract(t *testing.T) { if strings.Contains(helperSource, "WaitForSingleObject(processHandle.get(), INFINITE)") { t.Error("driver helper retained an unbounded nested broker wait") } + runtimeStart := strings.Index(helperSource, "bool LockPackageFiles(") + runtimeEnd := strings.Index(helperSource, "struct InstallOptions") + if runtimeStart < 0 || runtimeEnd <= runtimeStart { + t.Fatal("could not isolate the helper runtime-package contract") + } + runtimeContract := helperSource[runtimeStart:runtimeEnd] + if strings.Contains(runtimeContract, "ViiperUde.pdb") { + t.Error("driver helper retained a certification-PDB runtime dependency") + } + if !strings.Contains(runtimeContract, + `L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"`) { + t.Error("driver helper lost the exact INF/SYS/CAT runtime package contract") + } + if !strings.Contains(helperSource[:runtimeStart], `"ViiperUde.pdb"`) { + t.Error("driver helper stopped binding the certification PDB in the source manifest") + } for _, forbidden := range []string{"removeLegacy", "usbip"} { if strings.Contains(windowsSource, forbidden) { t.Errorf("outer package transaction must leave legacy ownership to the authenticated broker commit; found %q", forbidden) diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index 58a28cd0..64e98442 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -31,7 +31,7 @@ const nativePackageMutexName = `Global\VIIPER.NativePackage.Install.v1` const nativePackageTokenSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" var nativePackageDriverFiles = []string{ - "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.pdb", "ViiperUde.cat", + "ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat", } type windowsNativePackageTransaction struct { @@ -199,7 +199,7 @@ func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { return fmt.Errorf("enumerate signed driver package: %w", err) } if len(entries) != len(nativePackageDriverFiles) { - return fmt.Errorf("signed driver package must contain exactly four files, found %d", len(entries)) + return fmt.Errorf("signed runtime driver package must contain exactly INF, SYS, and CAT, found %d files", len(entries)) } for _, expected := range nativePackageDriverFiles { matches := 0 diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index 9eb2075d..991b45d1 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "slices" "testing" "time" @@ -11,6 +12,16 @@ import ( "golang.org/x/sys/windows/svc/mgr" ) +func TestNativePackageRuntimePayloadExcludesCertificationPDB(t *testing.T) { + want := []string{"ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat"} + if !slices.Equal(nativePackageDriverFiles, want) { + t.Fatalf("runtime driver payload = %v, want %v", nativePackageDriverFiles, want) + } + if slices.Contains(nativePackageDriverFiles, "ViiperUde.pdb") { + t.Fatal("certification PDB became a runtime installation dependency") + } +} + func TestNativePackageServiceTrustRequiresExactOwnedState(t *testing.T) { t.Parallel() expected := mgr.Config{ diff --git a/native/udecx/README.md b/native/udecx/README.md index eb83d509..3a30fb44 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -29,7 +29,9 @@ Directory contract: driver and catalog against kernel signing policy, proves that INF and SYS are members of that exact catalog, distinguishes testing-only attestation from production HLK/WHCP signatures, and binds the returned INF/PDB to the - reviewed source-revision manifest. + reviewed source-revision manifest. The PDB stays in that certification + evidence artifact; the installable runtime bundle contains only the + validated INF/SYS/CAT plus the pinned manifest. - `tools/Invoke-ViiperUdeLiveValidation.ps1` hash-binds that verified package to the installed service image and root devnode, then exercises every production controller through the real UdeCx host, direct interrupt-input diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index d80f6ce3..b76abc38 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -869,7 +869,12 @@ bool ValidateManifest( return SetError(error, L"manifest-files", ERROR_INVALID_DATA, L"manifest has an unexpected, duplicate, or malformed file entry"); } - if (*name == "ViiperUde.inf" || *name == "ViiperUde.pdb") { + // Production intake binds both INF and PDB to this manifest. The + // installer pins that validated manifest hash, but the public runtime + // package deliberately omits the PDB because Windows needs only + // INF/SYS/CAT. Recheck the unchanged INF here; retaining the PDB entry + // proves this is the exact intake manifest, not a weaker replacement. + if (*name == "ViiperUde.inf") { const std::filesystem::path filePath = packageDirectory / std::wstring(name->begin(), name->end()); uint64_t actualLength = 0; std::string actualHash; @@ -879,7 +884,7 @@ bool ValidateManifest( if (actualLength != static_cast(*length) || LowerAscii(actualHash) != LowerAscii(*hash)) { return SetError(error, L"manifest-hash", ERROR_CRC, - L"INF or PDB does not match the source-bound submission manifest"); + L"INF does not match the source-bound submission manifest"); } } } @@ -2098,12 +2103,12 @@ bool LockPackageFiles( std::vector* locks, Error* error) { locks->clear(); - for (const wchar_t* name : {L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.pdb", L"ViiperUde.cat"}) { + for (const wchar_t* name : {L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"}) { WinHandle file(CreateFileW((directory / name).c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); if (!file) { return SetLastErrorDetail(error, L"package-lock", - L"all four package files must exist and remain immutable during installation"); + L"INF, SYS, and CAT must exist and remain immutable during installation"); } FILE_ATTRIBUTE_TAG_INFO attributes{}; if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, @@ -2120,7 +2125,7 @@ bool LockPackageFiles( bool ValidateExactPackageDirectory(const std::filesystem::path& directory, Error* error) { static const std::set expected = { - L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.pdb", L"ViiperUde.cat"}; + L"ViiperUde.inf", L"ViiperUde.sys", L"ViiperUde.cat"}; const DWORD attributes = GetFileAttributesW(directory.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || @@ -2137,7 +2142,7 @@ bool ValidateExactPackageDirectory(const std::filesystem::path& directory, Error !expected.contains(iterator->path().filename().wstring()) || !seen.insert(iterator->path().filename().wstring()).second) { return SetError(error, L"package-directory", ERROR_INVALID_DATA, - L"signed package directory must contain only the four exact regular VIIPER files"); + L"signed runtime package directory must contain only INF, SYS, and CAT"); } } if (enumerationError || seen != expected) { From 0e27580eb7d1fce0ddbaf96ed6ec4c302dbd73b8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 20:38:30 -0500 Subject: [PATCH 147/240] Make native protocol contract tests line-ending neutral --- .../transport/udecx/protocol_contract_test.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index c1e2989f..474e93b0 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -163,7 +163,21 @@ func nativeContractSource(t *testing.T, name ...string) string { if err != nil { t.Fatalf("read native contract source: %v", err) } - return string(raw) + return normalizeNativeContractSource(string(raw)) +} + +func normalizeNativeContractSource(source string) string { + return strings.ReplaceAll(source, "\r\n", "\n") +} + +func TestNormalizeNativeContractSource(t *testing.T) { + t.Parallel() + + const windowsHeader = "#define FIRST 1\r\n#define SECOND 2\r\n" + const normalizedHeader = "#define FIRST 1\n#define SECOND 2\n" + if got := normalizeNativeContractSource(windowsHeader); got != normalizedHeader { + t.Fatalf("normalized native source = %q, want %q", got, normalizedHeader) + } } func cDefineNumber(t *testing.T, source, name string) uint64 { From 6294cdf6a4a54889f503e76c0e1d18e3f6122f9e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 20:54:12 -0500 Subject: [PATCH 148/240] Fix UdeCx URB completion execution contract --- docs/architecture/native-udecx.md | 97 ++++- native/udecx/driver/Broker.c | 368 +++++++++++++++--- native/udecx/driver/Controller.c | 16 +- native/udecx/driver/Device.c | 55 ++- native/udecx/driver/ViiperUde.h | 36 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 137 +++++-- 6 files changed, 555 insertions(+), 154 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 7d129821..65dcce36 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -17,6 +17,11 @@ after transfer ordering, cancellation, teardown, and recovery are proven. cancelled before `UdecxUsbEndpointPurgeComplete` is called. - The local usbip-win2 0.9.7.8 reference proves that UdeCx can expose VIIPER's bidirectional isochronous PlayStation audio topology on Windows. +- Its WHLK-released UDE lineage invokes normal-response URB completion at + `DISPATCH_LEVEL`; current upstream moved every terminal path to a real WDF + DPC. + ViGEmBus is not a UdeCx driver, so its mixed request-completion contexts are + evidence for manual-queue/cache ownership only, not for UDE completion IRQL. - Its controller contract also reports chained-MDL, high-speed, and SuperSpeed compatibility for a root controller with USB 2 and USB 3 ports. VIIPER mirrors that capability set and explicitly forwards post-enumeration child @@ -116,16 +121,38 @@ cannot replay itself into multiple successor polls. Live validation requires both forward publication and a completed Windows poll without inventing a strict one-to-one relationship. -Both `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` require -`PASSIVE_LEVEL`. VIIPER therefore uses one preallocated controller work item to -finish bounded broker slots, including cancellations that can originate at -dispatch level. The worker drains every ready slot per invocation, so the -PASSIVE transition neither allocates per request nor creates one work item per -packet. Direct producer input already runs on an explicitly passive queue and -can complete there. A late cached poll first crosses the preallocated endpoint -work-item boundary because `WdfIoQueueReadyNotify` is permitted to run inline on -the UdeCx submitter thread; the boundary avoids recursive successor-poll -completion while preserving the documented passive completion contract. +Microsoft's UDE programming guide and host-controller I/O guide require URB +completion at `DISPATCH_LEVEL` for USB-client compatibility. They additionally +require synchronously handled URBs, `EvtIoCanceledOnQueue`, and request-cancel +paths to complete from a separate DPC. The individual +`UdecxUrbComplete`/`UdecxUrbCompleteWithNtStatus` API pages conflict with that +guidance by listing `PASSIVE_LEVEL`; the current WDK declarations carry no IRQL +SAL annotation that resolves the conflict (verified against the project's +pinned WDK 10.0.28000.1839). VIIPER follows the UDE-specific +compatibility rule because it explicitly covers terminal and cancellation +behavior and agrees with usbip-win2's WHLK-released DISPATCH behavior. Current +usbip-win2 upstream uses a WDF DPC; VIIPER does not copy the older reference's +synthetic IRQL raise. + +One preallocated controller WDF DPC is the only function that calls either +UdeCx URB completion API. A request-context intrusive queue holds request and +endpoint references without per-transfer allocation. Broker replies, +cancellation, admission rejection, and direct interrupt-IN all claim exactly +one queue entry. Every endpoint queue registers `EvtIoCanceledOnQueue`, +overriding KMDF's synchronous default so a URB canceled before dispatch follows +the same path. The DPC runs at asserted `DISPATCH_LEVEL`, makes the terminal +call, then retires the broker slot or direct endpoint operation and signals the +drain event. Teardown closes admission, waits the tracked broker count, joins +the completion count, and uses `WdfDpcCancel(..., TRUE)` only after the list is +empty; a canceled pre-dispatch invocation is re-armed rather than abandoned. + +Buffer lookup, validation, copies, and user-mode publication remain on the +explicitly passive queues and work items. A late cached poll still crosses the +preallocated endpoint work-item boundary because `WdfIoQueueReadyNotify` can +run inline on the UdeCx submitter thread; that work item consumes one cache +token and prepares the buffer, then transfers terminal ownership to the shared +DPC. No payload, endpoint ordering, or isochronous scheduling behavior changes +at this execution-level boundary. Input publishers start and stop from UdeCx endpoint lifecycle notifications, retain their sequence across a purge/start cycle, and are cancelled before @@ -329,11 +356,11 @@ a wedged provider cannot retain the installer mutex indefinitely. contract. - Controller removal closes a single `ShuttingDown` admission gate in `EvtDeviceSelfManagedIoCleanup`, while the controller's queues, timer, - passive completion worker, locks, and broker storage are still valid. + completion DPC, locks, and broker storage are still valid. Cleanup first joins any file cleanup that crossed the owner lock before the gate, then purges user-mode queues, aborts every admitted broker operation, - waits for the tracked operation count to reach zero, and flushes the worker - before revoking device-table handles. The final controller + waits for tracked and untracked completion counts to reach zero, and joins + the DPC before revoking device-table handles. The final controller `EvtCleanupCallback` performs only invariant checks because KMDF has already cleaned up child objects by then. - UdeCx USB-device deletion remains asynchronous. Shutdown snapshots and @@ -442,16 +469,12 @@ a wedged provider cannot retain the installer mutex indefinitely. threads. - Every mark-cancelable transition revalidates its prior state under the broker lock. If KMDF invokes cancellation before that lock is reacquired, the cancel - callback's passive-completion ownership is final and cannot be overwritten + callback's DPC-completion ownership is final and cannot be overwritten by admission or publication. - Broker dequeue validation, wait-count admission, and transfer into the manual inverted-call queue share the owner lock with file cleanup. No close can finish purging that queue and then have an already-validated request appear behind the purge boundary. -- The process-death cleanup timer takes its own temporary reference to the - owner file object before dropping the owner lock. Concurrent cleanup can - release the controller's long-lived reference without leaving the retry path - with a stale WDF handle. - Child creation is protected by an owner-admission barrier. Cleanup closes admission under the owner lock and waits for every admitted UdeCx create and PlugIn transaction before enumerating owned children. UdeCx calls run without @@ -503,6 +526,27 @@ stall an independent pad's registration or removal. ## Release gates - No verifier findings under KMDF/USB/UdeCx stress. +- The completion-execution contract has a two-machine signed-live gate. On a + clean disposable Windows 10 1809 x64 machine, stage + `Enable-ViiperUdeVerifierForNextBoot.ps1` (which selects only + `ViiperUde.sys`, Microsoft's `/standard` checks, and `oneboot`), restart, and + run `Invoke-ViiperUdeLiveValidation.ps1` in `Production` mode with + `-RequireDriverVerifier`, at least three iterations, both media/input probes, + and at least 180 seconds of media. On the current Windows 11 x64 HLK target, + run the same command with `-RestartRootDevice -ReleaseGate`. Both runs must + exercise normal broker replies, mark-cancel races, owner-process death, + endpoint reset/purge, root removal, and concurrent control/interrupt/ISO + traffic; `verifier /query` must show the reviewed image and there may be no + verifier violation, bugcheck, stuck request, nonzero terminal pending count, + or late duplicate completion. +- The HLK gate is the complete Studio-generated applicable playlist, without + manually suppressing tests, for the VIIPER root controller and every + enumerated USB/HID/audio child on Windows 10 1809 x64 and the current Windows + 11 x64 certification target. This includes every applicable Device + Fundamentals I/O, PnP, power, reliability and security test plus USB, HID, + and Audio tests. Every result must pass, or carry a Microsoft-approved + erratum recorded in the source-bound HLKX evidence; a locally filtered or + waived cancellation/IRQL failure is not a pass. - Repeated create/remove, service kill, process crash, sleep/resume, and device reconnect leave zero stale children and zero stuck requests. - Descriptor and protocol fuzzing rejects malformed inputs without a bugcheck. @@ -555,13 +599,26 @@ authenticated commit order are documented in - Microsoft, *Write a UDE client driver* +- Microsoft, *Handling I/O Requests in a USB Host Controller Driver* + - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` - Microsoft, `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` + - Microsoft, `EvtDeviceSelfManagedIoCleanup` -- Microsoft, `WdfWorkItemEnqueue` and `WdfWorkItemFlush` - +- Microsoft, `EVT_WDF_DPC`, `WdfDpcEnqueue`, and `WdfDpcCancel` + + + +- usbip-win2, separate UDE completion-DPC change + +- usbip-win2 `v.0.9.7.8` source at `74f5a7f` (WHLK-released DISPATCH + behavior) + +- ViGEmBus source archive at `d986e1d` (manual-queue and target-lifecycle + reference; not UDE) + - Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index e36e9912..79c8e401 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -21,12 +21,25 @@ ViiperCompleteUnownedUrb( _In_ NTSTATUS Status ) { - UNREFERENCED_PARAMETER(Controller); - // This request never entered the broker's tracked slot set. Completing it - // directly is safe because every endpoint queue explicitly runs passive. - // UdeCx's completion APIs require PASSIVE_LEVEL; never raise to dispatch. - NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); - UdecxUrbCompleteWithNtStatus(Request, Status); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN queued; + + // ViiperQueueUrb starts endpoint rundown before it can reject admission, + // so even an untracked failure remains owned until the DPC completes it. + NT_ASSERT(requestContext->Controller == Controller); + NT_ASSERT(requestContext->Endpoint != WDF_NO_HANDLE); + queued = ViiperQueueUrbCompletion( + Controller, + requestContext->Endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + Status, + USBD_STATUS_INTERNAL_HC_ERROR, + TRUE); + if (!queued) { + NT_ASSERT(FALSE); + } } static @@ -319,6 +332,7 @@ ViiperClearSlotLocked( } } +_IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationStarted( _In_ UDECXUSBENDPOINT Endpoint @@ -332,6 +346,7 @@ ViiperEndpointOperationStarted( (VOID)InterlockedIncrement(&endpointContext->ActiveOperations); } +_IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationCompleted( _In_ UDECXUSBENDPOINT Endpoint @@ -390,7 +405,7 @@ ViiperInitializeBroker( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Device); WDF_OBJECT_ATTRIBUTES attributes; - WDF_WORKITEM_CONFIG workItemConfig; + WDF_DPC_CONFIG dpcConfig; NTSTATUS status; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -455,58 +470,124 @@ ViiperInitializeBroker( controllerContext->ManagementSlots, sizeof(VIIPER_UDE_MANAGEMENT_SLOT) * VIIPER_UDE_MAX_PENDING_MANAGEMENT); - WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtCompletionWorkItem); + WDF_DPC_CONFIG_INIT(&dpcConfig, ViiperEvtCompletionDpc); + dpcConfig.AutomaticSerialization = WdfFalse; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = Device; - return WdfWorkItemCreate( - &workItemConfig, &attributes, &controllerContext->CompletionWorkItem); + return WdfDpcCreate( + &dpcConfig, &attributes, &controllerContext->CompletionDpc); +} + +_IRQL_requires_max_(DISPATCH_LEVEL) +BOOLEAN +ViiperQueueUrbCompletion( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ ULONG PendingSlot, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN enqueueDpc = FALSE; + + NT_ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (requestContext->CompletionQueued) { + WdfSpinLockRelease(controllerContext->BrokerLock); + NT_ASSERT(FALSE); + return FALSE; + } + + WdfObjectReference(Request); + WdfObjectReference(Endpoint); + requestContext->CompletionRequest = Request; + requestContext->Controller = Controller; + requestContext->Endpoint = Endpoint; + requestContext->PendingSlot = PendingSlot; + requestContext->Token = Token; + requestContext->CompletionStatus = Status; + requestContext->CompletionUsbdStatus = UsbdStatus; + requestContext->CompleteWithNtStatus = CompleteWithNtStatus; + requestContext->CompletionQueued = TRUE; + if (InterlockedCompareExchange(&controllerContext->PendingCompletions, 0, 0) == 0) { + KeClearEvent(&controllerContext->CompletionOperationsDrained); + } + (VOID)InterlockedIncrement(&controllerContext->PendingCompletions); + InsertTailList(&controllerContext->CompletionQueue, &requestContext->CompletionEntry); + if (!controllerContext->CompletionDpcActive) { + controllerContext->CompletionDpcActive = TRUE; + enqueueDpc = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + + if (enqueueDpc) { + // A running KDPC has already left the system queue, so it can be + // requeued during its final empty-queue handoff. A FALSE result only + // means another invocation is already queued. + (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); + } + return TRUE; } VOID -ViiperEvtCompletionWorkItem( - _In_ WDFWORKITEM WorkItem +ViiperEvtCompletionDpc( + _In_ WDFDPC Dpc ) { - WDFDEVICE controller = (WDFDEVICE)WdfWorkItemGetParentObject(WorkItem); + WDFDEVICE controller = (WDFDEVICE)WdfDpcGetParentObject(Dpc); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); - PAGED_CODE(); - NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL); for (;;) { WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; ULONGLONG token = 0; ULONG slot = VIIPER_UDE_MAX_PENDING_OPERATIONS; NTSTATUS completionStatus = STATUS_SUCCESS; USBD_STATUS usbdStatus = USBD_STATUS_SUCCESS; BOOLEAN completeWithNtStatus = FALSE; - ULONG index; + BOOLEAN ownershipReleased = FALSE; + PLIST_ENTRY entry; + VIIPER_UDE_REQUEST_CONTEXT *requestContext; + LONG remaining; WdfSpinLockAcquire(controllerContext->BrokerLock); - for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { - ULONG candidate = (controllerContext->NextCompletionSlot + index) % - VIIPER_UDE_MAX_PENDING_OPERATIONS; - VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; - if (pending->State != ViiperUdePendingPassiveCompletion) { - continue; - } - request = pending->Request; - token = pending->Token; - slot = candidate; - completionStatus = pending->CompletionStatus; - usbdStatus = pending->CompletionUsbdStatus; - completeWithNtStatus = pending->CompleteWithNtStatus; - pending->State = ViiperUdePendingCompleting; - controllerContext->NextCompletionSlot = (candidate + 1) % - VIIPER_UDE_MAX_PENDING_OPERATIONS; - WdfObjectReference(request); + if (IsListEmpty(&controllerContext->CompletionQueue)) { + controllerContext->CompletionDpcActive = FALSE; + WdfSpinLockRelease(controllerContext->BrokerLock); break; } - WdfSpinLockRelease(controllerContext->BrokerLock); - - if (request == WDF_NO_HANDLE) { - break; + entry = RemoveHeadList(&controllerContext->CompletionQueue); + requestContext = CONTAINING_RECORD( + entry, VIIPER_UDE_REQUEST_CONTEXT, CompletionEntry); + request = requestContext->CompletionRequest; + endpoint = requestContext->Endpoint; + token = requestContext->Token; + slot = requestContext->PendingSlot; + completionStatus = requestContext->CompletionStatus; + usbdStatus = requestContext->CompletionUsbdStatus; + completeWithNtStatus = requestContext->CompleteWithNtStatus; + requestContext->CompletionRequest = WDF_NO_HANDLE; + requestContext->CompletionQueued = FALSE; + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { + BOOLEAN slotOwned = + ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && + controllerContext->PendingSlots[slot].State == + ViiperUdePendingDpcCompletion; + NT_ASSERT(slotOwned); + if (slotOwned) { + controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; + } + } else { + NT_ASSERT(token == 0); } + WdfSpinLockRelease(controllerContext->BrokerLock); if (completeWithNtStatus) { UdecxUrbCompleteWithNtStatus(request, completionStatus); @@ -515,15 +596,69 @@ ViiperEvtCompletionWorkItem( } WdfSpinLockAcquire(controllerContext->BrokerLock); - if (ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && + ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && controllerContext->PendingSlots[slot].State == ViiperUdePendingCompleting) { ViiperClearSlotLocked(controllerContext, slot); + ownershipReleased = TRUE; + } else if (slot >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + ViiperEndpointOperationCompleted(endpoint); + ownershipReleased = TRUE; + } + if (!ownershipReleased) { + NT_ASSERT(FALSE); + } + remaining = InterlockedDecrement(&controllerContext->PendingCompletions); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent( + &controllerContext->CompletionOperationsDrained, + IO_NO_INCREMENT, + FALSE); } WdfSpinLockRelease(controllerContext->BrokerLock); + WdfObjectDereference(endpoint); WdfObjectDereference(request); } } +_IRQL_requires_(PASSIVE_LEVEL) +VOID +ViiperDrainUrbCompletions( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + + PAGED_CODE(); + for (;;) { + BOOLEAN drained; + + (VOID)KeWaitForSingleObject( + &controllerContext->CompletionOperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + (VOID)WdfDpcCancel(controllerContext->CompletionDpc, TRUE); + + // Closing the device's I/O queues precedes this join. If cancellation + // won the narrow interval before a queued DPC began, re-arm that + // already-owned list instead of abandoning its request references. + WdfSpinLockAcquire(controllerContext->BrokerLock); + drained = IsListEmpty(&controllerContext->CompletionQueue) && + InterlockedCompareExchange(&controllerContext->PendingCompletions, 0, 0) == 0; + if (!drained) { + controllerContext->CompletionDpcActive = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (drained) { + break; + } + (VOID)WdfDpcEnqueue(controllerContext->CompletionDpc); + } +} + static BOOLEAN ViiperQueueLifecycleEventLocked( @@ -836,7 +971,6 @@ ViiperAllocatePendingSlot( pending->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; pending->AbortStatus = STATUS_SUCCESS; ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; - ViiperEndpointOperationStarted(Endpoint); ViiperPendingOperationStartedLocked(ControllerContext); InterlockedIncrement(&deviceContext->PendingOperations); *Slot = index; @@ -872,7 +1006,7 @@ ViiperHasEarlierUnpublishedAdmissionLocked( if (other->State == ViiperUdePendingEmpty || other->PublishedToOwner || other->AbortPending || other->State == ViiperUdePendingCompleting || - other->State == ViiperUdePendingPassiveCompletion || + other->State == ViiperUdePendingDpcCompletion || other->DeviceId != candidate->DeviceId || other->DeviceGeneration != candidate->DeviceGeneration || other->EndpointAddress != candidate->EndpointAddress || @@ -885,27 +1019,70 @@ ViiperHasEarlierUnpublishedAdmissionLocked( return FALSE; } +VOID +ViiperEvtUrbCanceledOnQueue( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + WDFDEVICE controller = WdfIoQueueGetDevice(Queue); + UDECXUSBENDPOINT endpoint = *ViiperGetQueueEndpoint(Queue); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(controller); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + BOOLEAN queued; + + // KMDF has removed this request from the endpoint queue and transferred + // ownership to this callback. Count that ownership before deferring the + // terminal call so endpoint purge cannot pass the queued DPC. + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = controller; + requestContext->Endpoint = endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationStarted(endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); + + queued = ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE); + if (!queued) { + NT_ASSERT(FALSE); + } + InterlockedIncrement64(&controllerContext->OperationsCancelled); +} + VOID ViiperEvtUrbCancel( _In_ WDFREQUEST Request ) { VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; + ULONG slot = requestContext->PendingSlot; + ULONGLONG token = requestContext->Token; VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = - ViiperGetControllerContext(requestContext->Controller); + ViiperGetControllerContext(controller); BOOLEAN ownsRequest = FALSE; BOOLEAN notifyOwner = FALSE; WdfSpinLockAcquire(controllerContext->BrokerLock); - if (requestContext->PendingSlot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { + if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { VIIPER_UDE_PENDING_SLOT *pending = - &controllerContext->PendingSlots[requestContext->PendingSlot]; - if (ViiperSlotMatches(pending, Request, requestContext->Token)) { + &controllerContext->PendingSlots[slot]; + if (ViiperSlotMatches(pending, Request, token)) { notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); pending->CompletionStatus = STATUS_CANCELLED; pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; pending->CompleteWithNtStatus = TRUE; - pending->State = ViiperUdePendingPassiveCompletion; + pending->State = ViiperUdePendingDpcCompletion; ownsRequest = TRUE; } } @@ -913,9 +1090,17 @@ ViiperEvtUrbCancel( if (ownsRequest) { InterlockedIncrement64(&controllerContext->OperationsCancelled); - WdfWorkItemEnqueue(controllerContext->CompletionWorkItem); + (VOID)ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + slot, + token, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE); if (notifyOwner) { - ViiperDispatchNotificationEvents(requestContext->Controller); + ViiperDispatchNotificationEvents(controller); } } } @@ -1320,6 +1505,12 @@ ViiperQueueOwnedCompletion( ) { BOOLEAN queued = FALSE; + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; + NTSTATUS completionStatus = Status; + USBD_STATUS completionUsbdStatus = UsbdStatus; + BOOLEAN completionWithNtStatus = CompleteWithNtStatus; WdfSpinLockAcquire(ControllerContext->BrokerLock); if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && @@ -1335,12 +1526,23 @@ ViiperQueueOwnedCompletion( pending->CompletionUsbdStatus = UsbdStatus; pending->CompleteWithNtStatus = CompleteWithNtStatus; } - pending->State = ViiperUdePendingPassiveCompletion; + completionStatus = pending->CompletionStatus; + completionUsbdStatus = pending->CompletionUsbdStatus; + completionWithNtStatus = pending->CompleteWithNtStatus; + pending->State = ViiperUdePendingDpcCompletion; queued = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (queued) { - WdfWorkItemEnqueue(ControllerContext->CompletionWorkItem); + queued = ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + Slot, + Token, + completionStatus, + completionUsbdStatus, + completionWithNtStatus); } return queued; } @@ -1356,7 +1558,7 @@ ViiperExpectedLateAbortLocked( if (Pending->Token != Token || (Pending->State != ViiperUdePendingCompleting && - Pending->State != ViiperUdePendingPassiveCompletion) || + Pending->State != ViiperUdePendingDpcCompletion) || (!Pending->AbortPending && !Pending->CompleteWithNtStatus)) { return FALSE; } @@ -1383,6 +1585,9 @@ ViiperRemovePublishingRequest( { BOOLEAN ownsRequest = FALSE; BOOLEAN notifyOwner = FALSE; + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); + WDFDEVICE controller = requestContext->Controller; + UDECXUSBENDPOINT endpoint = requestContext->Endpoint; WdfSpinLockAcquire(ControllerContext->BrokerLock); if (Slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && @@ -1394,14 +1599,22 @@ ViiperRemovePublishingRequest( ControllerContext->PendingSlots[Slot].CompletionStatus = Status; ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = USBD_STATUS_CANCELED; ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = TRUE; - ControllerContext->PendingSlots[Slot].State = ViiperUdePendingPassiveCompletion; + ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; ownsRequest = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (ownsRequest) { - WdfWorkItemEnqueue(ControllerContext->CompletionWorkItem); + (VOID)ViiperQueueUrbCompletion( + controller, + endpoint, + Request, + Slot, + Token, + Status, + USBD_STATUS_CANCELED, + TRUE); if (notifyOwner) { - ViiperDispatchNotificationEvents(ViiperGetRequestContext(Request)->Controller); + ViiperDispatchNotificationEvents(controller); } } } @@ -1630,18 +1843,26 @@ ViiperQueueUrb( NTSTATUS status; BOOLEAN abortPending = FALSE; BOOLEAN cancelClaimed = FALSE; + BOOLEAN queueCancelledCompletion = FALSE; NTSTATUS abortStatus = STATUS_CANCELLED; + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = deviceContext->Controller; + requestContext->Endpoint = endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + // Endpoint purge closes admission under BrokerLock. Enter rundown before + // any admission check so an untracked rejection cannot be completed after + // PurgeComplete has observed a stale zero count. + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationStarted(endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { return STATUS_DEVICE_NOT_READY; } - RtlZeroMemory(requestContext, sizeof(*requestContext)); - requestContext->Controller = deviceContext->Controller; - requestContext->Endpoint = endpoint; - requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; status = ViiperAllocatePendingSlot( controllerContext, Request, endpoint, &slot, &token); if (!NT_SUCCESS(status)) { @@ -1658,7 +1879,7 @@ ViiperQueueUrb( VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; if (pending->State != ViiperUdePendingPreparing) { // An immediate cancel callback already moved this slot to its - // passive completion state and owns the request. + // DPC completion state and owns the request. cancelClaimed = TRUE; } else { abortPending = pending->AbortPending; @@ -1668,14 +1889,35 @@ ViiperQueueUrb( : ViiperUdePendingQueued; } } else { - ViiperClearSlotLocked(controllerContext, slot); + VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; + pending->CompletionStatus = STATUS_CANCELLED; + pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; + pending->CompleteWithNtStatus = TRUE; + pending->State = ViiperUdePendingDpcCompletion; + queueCancelledCompletion = TRUE; } - } else if (NT_SUCCESS(status)) { + } else { + // Only the cancel callback/DPC can retire this just-allocated identity + // before the mark handoff reacquires BrokerLock. cancelClaimed = TRUE; } WdfSpinLockRelease(controllerContext->BrokerLock); if (!NT_SUCCESS(status)) { - return STATUS_CANCELLED; + if (queueCancelledCompletion) { + (VOID)ViiperQueueUrbCompletion( + deviceContext->Controller, + endpoint, + Request, + slot, + token, + STATUS_CANCELLED, + USBD_STATUS_CANCELED, + TRUE); + InterlockedIncrement64(&controllerContext->OperationsCancelled); + } else if (!cancelClaimed) { + NT_ASSERT(FALSE); + } + return STATUS_PENDING; } if (cancelClaimed) { return STATUS_PENDING; @@ -2052,8 +2294,8 @@ ViiperAbortMatchingOperations( if (pending->State == ViiperUdePendingPublishing) { pending->AbortPending = TRUE; pending->AbortStatus = Status; - } else if (pending->State == ViiperUdePendingPassiveCompletion) { - /* The request is already owned by the passive completion worker. */ + } else if (pending->State == ViiperUdePendingDpcCompletion) { + /* The request is already owned by the completion DPC. */ } else if (pending->State != ViiperUdePendingPreparing && pending->State != ViiperUdePendingCompleting) { request = pending->Request; diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index d64fd84e..5d474ba7 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -166,7 +166,9 @@ ViiperEvtDeviceAdd( context = ViiperGetControllerContext(device); RtlZeroMemory(context, sizeof(*context)); ExInitializeFastMutex(&context->DeviceLock); + InitializeListHead(&context->CompletionQueue); KeInitializeEvent(&context->BrokerOperationsDrained, NotificationEvent, TRUE); + KeInitializeEvent(&context->CompletionOperationsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); @@ -219,6 +221,9 @@ ViiperEvtControllerCleanup( // still callable. WDF invokes child cleanup before parent cleanup, so this // callback is deliberately limited to invariant checks over context data. NT_ASSERT(InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->PendingCompletions, 0, 0) == 0); + NT_ASSERT(IsListEmpty(&context->CompletionQueue)); + NT_ASSERT(!context->CompletionDpcActive); NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->ActiveDevices, 0, 0) == 0); @@ -295,9 +300,8 @@ ViiperEvtDeviceSelfManagedIoCleanup( NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); - if (context->CompletionWorkItem != WDF_NO_HANDLE) { + if (context->CompletionDpc != WDF_NO_HANDLE) { if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { - WdfWorkItemEnqueue(context->CompletionWorkItem); (VOID)KeWaitForSingleObject( &context->BrokerOperationsDrained, Executive, @@ -305,10 +309,10 @@ ViiperEvtDeviceSelfManagedIoCleanup( FALSE, NULL); } - // BrokerOperationsDrained closes the admission race; Flush then joins - // the passive callback after its final request dereference. UdeCx URB - // completion is PASSIVE-only, so no DPC may own this work. - WdfWorkItemFlush(context->CompletionWorkItem); + // BrokerOperationsDrained covers tracked slots. The second join also + // covers rejected and fast-input URBs, then cancels/joins the reusable + // DPC only after its intrusive request list is empty. + ViiperDrainUrbCompletions(Device); } if (context->BrokerLock != WDF_NO_HANDLE) { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index e64936e9..97bdb001 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -969,6 +969,10 @@ ViiperCreateEndpointQueue( WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, DispatchType); queueConfig.PowerManaged = WdfFalse; + // KMDF's default queued-cancellation path completes synchronously. UDE + // requires an explicit callback so even a never-dispatched URB can cross + // the shared completion DPC. + queueConfig.EvtIoCanceledOnQueue = ViiperEvtUrbCanceledOnQueue; if (DispatchType != WdfIoQueueDispatchManual) { queueConfig.EvtIoInternalDeviceControl = ViiperEvtEndpointIoInternalControl; } @@ -1169,18 +1173,28 @@ ViiperEvtDefaultEndpointAdd( static VOID ViiperCompleteRetrievedInputUrb( + _In_ UDECXUSBENDPOINT Endpoint, _In_ WDFREQUEST Request, _In_ NTSTATUS Status ) { - // The URB was parked in a manual queue and is therefore completed from a - // passive endpoint work item. UdeCx requires PASSIVE_LEVEL for both - // completion APIs, so completing from a DPC or raising IRQL is invalid. - NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); - if (NT_SUCCESS(Status)) { - UdecxUrbComplete(Request, USBD_STATUS_SUCCESS); - } else { - UdecxUrbCompleteWithNtStatus(Request, Status); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(ViiperGetEndpointContext(Endpoint)->Device); + BOOLEAN queued; + + // The passive caller owns buffer validation/copying. Terminal completion + // and the endpoint rundown release are transferred together to the DPC. + queued = ViiperQueueUrbCompletion( + deviceContext->Controller, + Endpoint, + Request, + VIIPER_UDE_MAX_PENDING_OPERATIONS, + 0, + Status, + NT_SUCCESS(Status) ? USBD_STATUS_SUCCESS : USBD_STATUS_INTERNAL_HC_ERROR, + !NT_SUCCESS(Status)); + if (!queued) { + NT_ASSERT(FALSE); } } @@ -1203,12 +1217,12 @@ ViiperCompleteCachedInputUrb( (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { - ViiperCompleteRetrievedInputUrb(Request, STATUS_INVALID_DEVICE_REQUEST); + ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_INVALID_DEVICE_REQUEST); return STATUS_INVALID_DEVICE_REQUEST; } transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; if (endpointContext->InputReportLength > transferLength) { - ViiperCompleteRetrievedInputUrb(Request, STATUS_BUFFER_TOO_SMALL); + ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_BUFFER_TOO_SMALL); return STATUS_BUFFER_TOO_SMALL; } status = ViiperCopyTransferBuffer( @@ -1218,7 +1232,7 @@ ViiperCompleteCachedInputUrb( endpointContext->InputReportLength, TRUE); if (!NT_SUCCESS(status)) { - ViiperCompleteRetrievedInputUrb(Request, status); + ViiperCompleteRetrievedInputUrb(Endpoint, Request, status); return status; } @@ -1226,7 +1240,7 @@ ViiperCompleteCachedInputUrb( UdecxUrbSetBytesCompleted(Request, endpointContext->InputReportLength); InterlockedAdd64(&controllerContext->BytesFromDevice, endpointContext->InputReportLength); InterlockedIncrement64(&controllerContext->InputReportsCompleted); - ViiperCompleteRetrievedInputUrb(Request, STATUS_SUCCESS); + ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_SUCCESS); return STATUS_SUCCESS; } @@ -1262,6 +1276,7 @@ ViiperEvtFastInputWorkItem( ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request = WDF_NO_HANDLE; BOOLEAN admitted = FALSE; + BOOLEAN completionQueued = FALSE; PAGED_CODE(); WdfWaitLockAcquire(endpointContext->InputLock, NULL); @@ -1291,10 +1306,15 @@ ViiperEvtFastInputWorkItem( // complete it on the allocation-free direct path. if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &request))) { InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); + ViiperInvalidateInputIfLifecycleClosed(endpoint); (VOID)ViiperCompleteCachedInputUrb(endpoint, request); + completionQueued = TRUE; + } else { + ViiperInvalidateInputIfLifecycleClosed(endpoint); + } + if (!completionQueued) { + ViiperEndpointOperationCompleted(endpoint); } - ViiperInvalidateInputIfLifecycleClosed(endpoint); - ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); } @@ -1458,13 +1478,12 @@ ViiperSubmitInputReport( return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; } InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); - status = ViiperCompleteCachedInputUrb(endpoint, urbRequest); // Lifecycle admission can close after this operation was admitted. The // pre-boundary poll may finish, but its cached state must never survive the // reset/purge/D0 boundary. Revalidate under the same admission lock so // either this path or the lifecycle callback performs the final clear. ViiperInvalidateInputIfLifecycleClosed(endpoint); - ViiperEndpointOperationCompleted(endpoint); + status = ViiperCompleteCachedInputUrb(endpoint, urbRequest); WdfWaitLockRelease(endpointContext->InputLock); WdfObjectDereference(endpoint); return status; @@ -1555,8 +1574,8 @@ ViiperEvtEndpointPurgeWorkItem( PAGED_CODE(); // UdeCx requires every request forwarded out of the endpoint queue to be - // completed before PurgeComplete. The broker completion worker and direct - // input path signal this event only after their last owned URB completes. + // completed before PurgeComplete. The shared completion DPC releases both + // broker and direct-input ownership only after the terminal UdeCx call. (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, Executive, diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 3fa57dda..2a3ba651 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -26,7 +26,7 @@ typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingPublishing, ViiperUdePendingInFlight, ViiperUdePendingCompleting, - ViiperUdePendingPassiveCompletion + ViiperUdePendingDpcCompletion } VIIPER_UDE_PENDING_STATE; typedef struct VIIPER_UDE_PENDING_SLOT { @@ -82,6 +82,14 @@ typedef struct VIIPER_UDE_REQUEST_CONTEXT { ULONG IsoPacketCount; ULONG IsoStartFrame; BOOLEAN DirectionIn; + // Protected by the controller BrokerLock. The DPC removes and snapshots + // these fields before UdeCx may recycle this request context. + LIST_ENTRY CompletionEntry; + WDFREQUEST CompletionRequest; + NTSTATUS CompletionStatus; + USBD_STATUS CompletionUsbdStatus; + BOOLEAN CompleteWithNtStatus; + BOOLEAN CompletionQueued; } VIIPER_UDE_REQUEST_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestContext) @@ -97,13 +105,16 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; - ULONG NextCompletionSlot; ULONG NextManagementSlot; WDFMEMORY NotificationStorage; VIIPER_UDE_NOTIFICATION *Notifications; WDFMEMORY ManagementStorage; VIIPER_UDE_MANAGEMENT_SLOT *ManagementSlots; - WDFWORKITEM CompletionWorkItem; + // One nonpageable FIFO owns every terminal UdeCx URB completion. Request + // contexts provide its entries, so the completion boundary never allocates. + WDFDPC CompletionDpc; + LIST_ENTRY CompletionQueue; + BOOLEAN CompletionDpcActive; ULONG NotificationHead; ULONG NotificationTail; ULONG NotificationCount; @@ -113,6 +124,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFQUEUE InputQueue; WDFQUEUE WaitingDequeues; KEVENT BrokerOperationsDrained; + KEVENT CompletionOperationsDrained; KEVENT OwnerAdmissionsDrained; KEVENT FileCleanupsDrained; BOOLEAN CleanupInProgress; @@ -124,6 +136,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG CleanupRetries; volatile LONG ActiveDevices; volatile LONG PendingOperations; + volatile LONG PendingCompletions; volatile LONG WaitingDequeueCount; volatile LONG64 OperationsDequeued; volatile LONG64 OperationsCompleted; @@ -227,11 +240,12 @@ EVT_UDECX_USB_ENDPOINT_RESET ViiperEvtEndpointReset; EVT_UDECX_USB_ENDPOINT_PURGE ViiperEvtEndpointPurge; EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; +EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; EVT_WDF_WORKITEM ViiperEvtFastInputWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; -EVT_WDF_WORKITEM ViiperEvtCompletionWorkItem; +EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtEndpointCleanup; @@ -248,6 +262,18 @@ VOID ViiperCompleteUnownedUrb( _In_ WDFDEVICE Controller, _In_ WDFREQUEST Request, _In_ NTSTATUS Status); +_IRQL_requires_max_(DISPATCH_LEVEL) +BOOLEAN ViiperQueueUrbCompletion( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBENDPOINT Endpoint, + _In_ WDFREQUEST Request, + _In_ ULONG PendingSlot, + _In_ ULONGLONG Token, + _In_ NTSTATUS Status, + _In_ USBD_STATUS UsbdStatus, + _In_ BOOLEAN CompleteWithNtStatus); +_IRQL_requires_(PASSIVE_LEVEL) +VOID ViiperDrainUrbCompletions(_In_ WDFDEVICE Controller); NTSTATUS ViiperSubmitInputReport(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperValidateBrokerOwner(_In_ WDFDEVICE Controller, _In_ WDFREQUEST Request); PURB ViiperGetUrb(_In_ WDFREQUEST Request); @@ -258,7 +284,9 @@ NTSTATUS ViiperCopyTransferBuffer( _In_ ULONG Length, _In_ BOOLEAN ToUrb); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); +_IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); +_IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationCompleted(_In_ UDECXUSBENDPOINT Endpoint); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); NTSTATUS ViiperQueueEndpointLifecycleEvent( diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index a066b987..c81aeeaf 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -112,7 +112,11 @@ $allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter ' foreach ($requiredHeaderContract in @( 'FAST_MUTEX DeviceLock;', 'KEVENT BrokerOperationsDrained;', + 'KEVENT CompletionOperationsDrained;', 'KEVENT FileCleanupsDrained;', + 'WDFDPC CompletionDpc;', + 'LIST_ENTRY CompletionQueue;', + 'volatile LONG PendingCompletions;', 'volatile LONG ShuttingDown;')) { if (-not $header.Contains($requiredHeaderContract)) { throw "Missing native teardown contract in ViiperUde.h: $requiredHeaderContract" @@ -142,67 +146,114 @@ if ($deviceSource -notmatch } if ($deviceSource -notmatch 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*UDECXUSBENDPOINT\);[\s\S]{0,300}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?WdfIoQueueCreate') { - throw 'Every endpoint queue must explicitly run at PASSIVE_LEVEL for direct UdeCx completion.' + throw 'Every endpoint queue must explicitly run at PASSIVE_LEVEL for buffer preparation and broker admission.' } if (($controllerSource + $deviceSource + $brokerSource) -match 'WdfWaitLock(?:Acquire|Release)\s*\([^;\r\n]*DeviceLock') { throw 'DeviceLock must remain embedded; a sibling WDF lock is unsafe during UdeCx child cleanup.' } -if ($header -notmatch 'WDFWORKITEM\s+CompletionWorkItem\s*;' -or - $brokerSource -notmatch - 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtCompletionWorkItem\s*\)') { - throw 'UdeCx broker completion must use the preallocated passive completion work item.' -} -if ($allDriverCSource -match - 'CompletionDpc|ViiperEvtCompletionDpc|WdfDpc(?:Create|Enqueue|Cancel)|KeRaiseIrql') { - throw 'UdeCx completion must never execute through a DPC or synthetic DISPATCH_LEVEL transition.' -} -$dpcCallbackNames = [regex]::Matches($header, 'EVT_WDF_DPC\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*;') -foreach ($dpcCallbackName in $dpcCallbackNames) { - $callbackName = [regex]::Escape($dpcCallbackName.Groups['name'].Value) - $dpcBody = [regex]::Match( - $allDriverCSource, - "(?ms)^VOID\s+$callbackName\s*\([^)]*\)\s*\{(?.*?)^\}") - if ($dpcBody.Success -and $dpcBody.Groups['body'].Value -match 'UdecxUrbComplete') { - throw "WDF DPC callback '$($dpcCallbackName.Groups['name'].Value)' must not complete a UdeCx URB." - } +if ($brokerSource -notmatch + 'WDF_DPC_CONFIG_INIT\s*\(\s*&dpcConfig\s*,\s*ViiperEvtCompletionDpc\s*\)[\s\S]{0,200}?dpcConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;[\s\S]{0,300}?WdfDpcCreate') { + throw 'UdeCx completion must use one preallocated, nonserialized controller DPC.' +} +if ($controllerSource -notmatch + 'InitializeListHead\s*\(\s*&context->CompletionQueue\s*\)[\s\S]{0,300}?KeInitializeEvent\s*\(\s*&context->CompletionOperationsDrained') { + throw 'The controller must initialize the intrusive completion queue and its drain event before broker creation.' +} +if ($allDriverCSource -match 'Ke(?:Raise|Lower)Irql') { + throw 'The UdeCx completion boundary must be a real WDF DPC, never a synthetic IRQL transition.' } -$completionWorkerMatch = [regex]::Match( +$completionDpcMatch = [regex]::Match( $brokerSource, - '(?ms)^VOID\s+ViiperEvtCompletionWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $completionWorkerMatch.Success -or - $completionWorkerMatch.Groups['body'].Value -notmatch 'UdecxUrbComplete' -or - $completionWorkerMatch.Groups['body'].Value -notmatch - 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { - throw 'Could not verify that broker UdeCx completion is owned by the passive work item.' + '(?ms)^VOID\s+ViiperEvtCompletionDpc\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionDpcMatch.Success -or + $completionDpcMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*DISPATCH_LEVEL' -or + $completionDpcMatch.Groups['body'].Value -match 'PAGED_CODE' -or + $completionDpcMatch.Groups['body'].Value -notmatch + 'RemoveHeadList[\s\S]*UdecxUrbComplete[\s\S]*ViiperClearSlotLocked[\s\S]*ViiperEndpointOperationCompleted[\s\S]*InterlockedDecrement\s*\(\s*&controllerContext->PendingCompletions[\s\S]*KeSetEvent\s*\(\s*&controllerContext->CompletionOperationsDrained') { + throw 'The completion DPC must run at exact DISPATCH_LEVEL, complete once, release ownership afterward, and signal final drain.' +} +$completionQueueMatch = [regex]::Match( + $brokerSource, + '(?ms)^BOOLEAN\s+ViiperQueueUrbCompletion\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionQueueMatch.Success -or + $completionQueueMatch.Groups['body'].Value -notmatch 'requestContext->CompletionQueued' -or + $completionQueueMatch.Groups['body'].Value -notmatch + 'WdfObjectReference\s*\(\s*Request\s*\)[\s\S]*WdfObjectReference\s*\(\s*Endpoint\s*\)' -or + $completionQueueMatch.Groups['body'].Value -notmatch + 'KeClearEvent\s*\(\s*&controllerContext->CompletionOperationsDrained\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&controllerContext->PendingCompletions\s*\)[\s\S]*InsertTailList\s*\(\s*&controllerContext->CompletionQueue[\s\S]*WdfDpcEnqueue') { + throw 'Completion admission must reject duplicate ownership, reference both WDF objects, account drain, and enqueue the DPC.' } $unownedCompletionMatch = [regex]::Match( $brokerSource, '(?ms)^VOID\s+ViiperCompleteUnownedUrb\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $unownedCompletionMatch.Success -or - $unownedCompletionMatch.Groups['body'].Value -notmatch 'UdecxUrbComplete' -or - $unownedCompletionMatch.Groups['body'].Value -notmatch - 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { - throw 'Unowned endpoint URBs must complete directly under an asserted PASSIVE_LEVEL contract.' + $unownedCompletionMatch.Groups['body'].Value -notmatch 'ViiperQueueUrbCompletion' -or + $unownedCompletionMatch.Groups['body'].Value -match 'UdecxUrbComplete') { + throw 'Rejected endpoint URBs must transfer terminal ownership to the shared completion DPC.' } $retrievedInputCompletionMatch = [regex]::Match( $deviceSource, '(?ms)^static\s+VOID\s+ViiperCompleteRetrievedInputUrb\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $retrievedInputCompletionMatch.Success -or - $retrievedInputCompletionMatch.Groups['body'].Value -match 'KeRaiseIrql' -or - $retrievedInputCompletionMatch.Groups['body'].Value -notmatch - 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL') { - throw 'Retrieved input URBs must complete directly under an asserted PASSIVE_LEVEL contract.' + $retrievedInputCompletionMatch.Groups['body'].Value -notmatch 'ViiperQueueUrbCompletion' -or + $retrievedInputCompletionMatch.Groups['body'].Value -match 'UdecxUrbComplete') { + throw 'Retrieved fast-input URBs must transfer terminal ownership to the shared completion DPC.' } $allUdeCxCompletionCalls = [regex]::Matches( $allDriverCSource, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count -$approvedUdeCxCompletionCalls = - [regex]::Matches($completionWorkerMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count + - [regex]::Matches($unownedCompletionMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count + - [regex]::Matches($retrievedInputCompletionMatch.Groups['body'].Value, 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count -if ($allUdeCxCompletionCalls -ne $approvedUdeCxCompletionCalls) { - throw 'Every UdeCx completion call must remain inside an approved PASSIVE_LEVEL completion surface.' +$dpcUdeCxCompletionCalls = [regex]::Matches( + $completionDpcMatch.Groups['body'].Value, + 'UdecxUrbComplete(?:WithNtStatus)?\s*\(').Count +if ($dpcUdeCxCompletionCalls -ne 2 -or + $allUdeCxCompletionCalls -ne $dpcUdeCxCompletionCalls) { + throw 'Every UdeCx URB terminal call must remain exclusively inside the DISPATCH_LEVEL completion DPC.' +} +$cancelMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtUrbCancel\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $cancelMatch.Success -or + $cancelMatch.Groups['body'].Value -notmatch + 'pending->State\s*=\s*ViiperUdePendingDpcCompletion[\s\S]*ViiperQueueUrbCompletion') { + throw 'WDF cancellation must claim the slot exactly once before queueing its DISPATCH_LEVEL completion.' +} +$canceledOnQueueMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEvtUrbCanceledOnQueue\s*\([^)]*\)\s*\{(?.*?)^\}') +if ($deviceSource -notmatch + 'queueConfig\.EvtIoCanceledOnQueue\s*=\s*ViiperEvtUrbCanceledOnQueue\s*;' -or + -not $canceledOnQueueMatch.Success -or + $canceledOnQueueMatch.Groups['body'].Value -notmatch + 'ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*ViiperQueueUrbCompletion') { + throw 'Every endpoint queue must override synchronous queued cancellation and transfer it through endpoint rundown to the DPC.' +} +$queueUrbMatch = [regex]::Match( + $brokerSource, + '(?ms)^NTSTATUS\s+ViiperQueueUrb\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $queueUrbMatch.Success -or + $queueUrbMatch.Groups['body'].Value -notmatch + 'ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*ViiperAllocatePendingSlot' -or + $queueUrbMatch.Groups['body'].Value -notmatch + 'queueCancelledCompletion[\s\S]*ViiperUdePendingDpcCompletion[\s\S]*ViiperQueueUrbCompletion') { + throw 'URB admission must enter endpoint rundown before rejection and route mark-cancel races through the DPC.' +} +$purgeWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointPurgeWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $purgeWorkItemMatch.Success -or + $purgeWorkItemMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*UdecxUsbEndpointPurgeComplete') { + throw 'Endpoint purge-complete must remain behind the forwarded-URB completion drain.' +} +$completionDrainMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperDrainUrbCompletions\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $completionDrainMatch.Success -or + $completionDrainMatch.Groups['body'].Value -notmatch + 'CompletionOperationsDrained[\s\S]*WdfDpcCancel\s*\(\s*controllerContext->CompletionDpc\s*,\s*TRUE\s*\)[\s\S]*IsListEmpty\s*\(\s*&controllerContext->CompletionQueue\s*\)[\s\S]*WdfDpcEnqueue') { + throw 'Terminal DPC rundown must wait, join, verify the list, and re-arm work canceled before dispatch.' } $controllerCleanupMatch = [regex]::Match( $controllerSource, @@ -231,8 +282,8 @@ $selfManagedCleanupMatch = [regex]::Match( '(?ms)^VOID\s+ViiperEvtDeviceSelfManagedIoCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $selfManagedCleanupMatch.Success -or $selfManagedCleanupMatch.Groups['body'].Value -notmatch - 'ViiperPurgeOwnerOperations[\s\S]*BrokerOperationsDrained[\s\S]*WdfWorkItemFlush[\s\S]*ViiperBeginControllerShutdown') { - throw 'Terminal rundown must drain and flush broker completion before asynchronous child teardown.' + 'ViiperPurgeOwnerOperations[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*ViiperBeginControllerShutdown') { + throw 'Terminal rundown must drain and join all completion-DPC ownership before asynchronous child teardown.' } $controllerShutdownMatch = [regex]::Match( $deviceSource, @@ -243,4 +294,4 @@ if (-not $controllerShutdownMatch.Success -or } $stampState = if ($RequireStampedInf) { 'stamped output' } else { 'source template' } -Write-Host "VIIPER UDE target and teardown contracts are aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." +Write-Host "VIIPER UDE target, DISPATCH completion, and teardown contracts are aligned: Windows 10 1809, KMDF 1.27, deterministic DriverVer ($stampState)." From 685b2920cf273efe010780abd4c540a0a18e08a5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 20:57:16 -0500 Subject: [PATCH 149/240] Harden native release package gates --- .github/scripts/Test-WorkflowSecurity.ps1 | 125 +++++- .github/workflows/build_base.yml | 22 +- .github/workflows/clients_ci.yml | 10 +- .github/workflows/docs-deploy.yml | 4 +- .github/workflows/generate-changelog.yml | 2 +- .../workflows/native-package-transaction.yml | 18 +- .../workflows/native-production-package.yml | 26 +- .github/workflows/native-ude.yml | 41 +- .github/workflows/release.yml | 410 +++++++++++++++++- .github/workflows/snapshots.yml | 4 +- docs/architecture/native-udecx-signing.md | 50 ++- internal/transport/udecx/protocol.go | 2 +- justfile | 12 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Protect-ViiperWindowsReleaseBinaries.ps1 | 172 ++++++++ .../tools/Test-ViiperUdeReleaseBundle.ps1 | 175 ++++++++ .../tools/Test-ViiperUdeSignedPackage.ps1 | 17 +- .../Test-ViiperUdeVersionMonotonicity.ps1 | 182 ++++++++ 19 files changed, 1202 insertions(+), 74 deletions(-) create mode 100644 native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 create mode 100644 native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 create mode 100644 native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 7cf2f6f2..a25dcebb 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -22,12 +22,41 @@ foreach ($workflow in $workflowFiles) { if ($source -match '(?m)^\s*go-version\s*:') { throw "$($workflow.Name) selects a floating Go toolchain. Use the exact version declared by go.mod." } + if ($source -match '(?m)^\s*runs-on:\s*(?:ubuntu|windows|macos)-latest\s*$') { + throw "$($workflow.Name) selects a floating hosted-runner generation. Pin the OS generation." + } + if ($source -match '(?mi)vswhere\.exe[^\r\n]*\s-latest(?:\s|$)') { + throw "$($workflow.Name) selects a floating Visual Studio toolchain with vswhere -latest." + } + foreach ($pattern in @( + '(?mi)^\s*(?:python|node|dotnet|cmake|nuget|just)-version:\s*["'']?(?:latest|stable|\d+(?:\.\d+)*\.x)["'']?\s*$', + '(?mi)^\s*toolchain:\s*["'']?(?:stable|beta|nightly)["'']?\s*$')) { + if ($source -match $pattern) { + throw "$($workflow.Name) selects a floating release toolchain: '$($Matches[0].Trim())'." + } + } + $justSetups = [regex]::Matches($source, 'extractions/setup-just@[0-9a-f]{40}').Count + $justPins = [regex]::Matches($source, '(?m)^\s*just-version:\s*"1\.58\.0"\s*$').Count + if ($justSetups -ne $justPins) { + throw "$($workflow.Name) must pin just 1.58.0 for every setup-just action." + } + $msbuildSetups = [regex]::Matches($source, 'microsoft/setup-msbuild@[0-9a-f]{40}').Count + $msbuildPins = [regex]::Matches($source, '(?m)^\s*vs-version:\s*"\[18\.0,19\.0\)"\s*$').Count + if ($msbuildSetups -ne $msbuildPins) { + throw "$($workflow.Name) must constrain every MSBuild setup to the Visual Studio 2026 generation." + } } $releaseSource = Get-Content -LiteralPath (Join-Path $workflowDirectory 'release.yml') -Raw foreach ($required in @( 'native-validation', 'native-package-transaction', + 'native-production-provenance', + 'native-user-mode-signing', + 'Protect-ViiperWindowsReleaseBinaries.ps1', + 'Test-ViiperUdeReleaseBundle.ps1', + '-RequireAuthenticode', + 'viiper-native-udecx-windows-amd64.zip', 'Test-WorkflowSecurity.ps1', 'actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a')) { if (-not $releaseSource.Contains($required)) { @@ -37,6 +66,12 @@ foreach ($required in @( if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-validation[^\]]*native-package-transaction[^\]]*\]') { throw 'create-release must depend on both native validation and package-transaction gates.' } +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-production-provenance[^\]]*\]') { + throw 'create-release must depend on an accepted Microsoft production-package artifact.' +} +if ($releaseSource -notmatch '(?ms)^\s{4}create-release:\s.*?^\s{8}needs:\s*\[[^\]]*native-user-mode-signing[^\]]*\]') { + throw 'create-release must depend on the fail-closed broker/helper Authenticode signing gate.' +} if ($releaseSource -notmatch '(?ms)^\s{4}release-policy:\s.*?current origin/main tip') { throw 'Release tags must be constrained to the workflow-protected current main tip.' } @@ -46,22 +81,110 @@ if ($releaseSource.Contains('ViiperUde-x64-test-signed')) { if ([regex]::Matches($releaseSource, 'pattern:\s*"\*-Release"').Count -ne 2) { throw 'Release artifact downloads must use the explicit *-Release artifact allowlist.' } +foreach ($requiredProductionBinding in @( + '.github/workflows/native-production-package.yml', + '.head_branch == "main"', + '.head_sha == $sha', + 'artifact-ids: ${{ needs.native-production-provenance.outputs.artifact_id }}', + 'ViiperUdeCtl-windows-amd64-${{ github.sha }}')) { + if (-not $releaseSource.Contains($requiredProductionBinding)) { + throw "The release workflow is missing production provenance binding '$requiredProductionBinding'." + } +} +if ($releaseSource -notmatch "(?ms)\`$expectedProduction\s*=\s*@\(\s*'submission-manifest\.json',\s*'ViiperUde/ViiperUde\.cat',\s*'ViiperUde/ViiperUde\.inf',\s*'ViiperUde/ViiperUde\.pdb',\s*'ViiperUde/ViiperUde\.sys'\)") { + throw 'Release composition must allowlist the exact validated Microsoft-returned package.' +} +if ($releaseSource -notmatch '(?ms)expected_runtime=\(\s*ViiperUde\.cat\s*ViiperUde\.inf\s*ViiperUde\.sys\s*ViiperUdeCtl\.exe\s*submission-manifest\.json\s*viiper\.exe\s*\)') { + throw 'The public native runtime archive must contain exactly broker, helper, INF, SYS, CAT, and manifest.' +} + +$signingJob = [regex]::Match( + $releaseSource, + '(?ms)^\s{4}native-user-mode-signing:\s.*?(?=^\s{4}build:)').Value +if ([string]::IsNullOrWhiteSpace($signingJob)) { + throw 'The release workflow is missing the mandatory native user-mode signing job.' +} +foreach ($requiredSigningGate in @( + 'WINDOWS_SIGNING_PFX_BASE64', + 'WINDOWS_SIGNING_PFX_PASSWORD', + 'WINDOWS_SIGNING_CERTIFICATE_SHA256', + 'Protect-ViiperWindowsReleaseBinaries.ps1', + 'ViiperUdeCtl.exe verify', + 'VIIPER-windows-amd64-authenticode-${{ github.sha }}', + 'VIIPER-windows-arm64-authenticode-${{ github.sha }}', + 'VIIPER-native-udecx-authenticode-${{ github.sha }}')) { + if (-not $signingJob.Contains($requiredSigningGate)) { + throw "The native signing job is missing fail-closed contract '$requiredSigningGate'." + } +} +if ([regex]::Matches($signingJob, '-RequireAuthenticode').Count -lt 2 -or + [regex]::Matches($signingJob, '-ExpectedSignerCertificateSHA256').Count -lt 2) { + throw 'The native signing job must Authenticode-validate both composition and archive roundtrip with the pinned signer fingerprint.' +} + +$createReleaseJob = [regex]::Match( + $releaseSource, + '(?ms)^\s{4}create-release:\s.*?(?=^\s{4}publish-client-registries:)').Value +foreach ($signedArtifact in @( + 'VIIPER-windows-amd64-authenticode-${{ github.sha }}', + 'VIIPER-windows-arm64-authenticode-${{ github.sha }}', + 'VIIPER-native-udecx-authenticode-${{ github.sha }}')) { + if (-not $createReleaseJob.Contains($signedArtifact)) { + throw "create-release must consume the exact signed artifact '$signedArtifact'." + } +} +if ($createReleaseJob.Contains('path: native-helper') -or + $createReleaseJob.Contains('path: native-production')) { + throw 'create-release must not reconstruct the public package from unsigned helper or production-intake inputs.' +} $nativeWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-ude.yml') -Raw if ($nativeWorkflow -notmatch '(?m)^\s*if:\s*\$\{\{\s*inputs\.upload_artifacts\s*==\s*true\s*\}\}\s*$') { throw 'Native test-signed artifacts may upload only through the explicit Boolean test-artifact input.' } +foreach ($requiredNativeGate in @( + 'branches: [main, "feature/**"]', + 'tags: ["v*.*.*"]', + 'Test-ViiperUdeVersionMonotonicity.ps1', + 'x64/Release/ViiperUde/ViiperUde.inf', + 'inputs.upload_release_helper == true')) { + if (-not $nativeWorkflow.Contains($requiredNativeGate)) { + throw "The native build workflow is missing gate '$requiredNativeGate'." + } +} + +$transactionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-package-transaction.yml') -Raw +foreach ($requiredTransactionTrigger in @( + 'branches: [main, "feature/**"]', + 'tags: ["v*.*.*"]', + 'pull_request:')) { + if (-not $transactionWorkflow.Contains($requiredTransactionTrigger)) { + throw "The native transaction workflow is missing trigger '$requiredTransactionTrigger'." + } +} +if ($transactionWorkflow.Contains('paths:')) { + throw 'Native transaction simulations must not be bypassable through a path filter.' +} $productionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-production-package.yml') -Raw foreach ($required in @( 'Test-ViiperUdeSignedPackage.ps1', '-ValidationMode Production', 'Microsoft-signed', - 'signingRoute')) { + 'signingRoute', + "POLICY_REF -cne 'refs/heads/main'", + 'Test-ViiperUdeTargetCompatibility.ps1')) { if (-not $productionWorkflow.Contains($required)) { throw "The production-native workflow is missing required validation contract '$required'." } } + +$justfileSource = Get-Content -LiteralPath (Join-Path $repositoryRoot 'justfile') -Raw +if ($justfileSource.Contains('@latest') -or + $justfileSource -notmatch 'goversioninfo/cmd/goversioninfo@v1\.7\.0' -or + $justfileSource -notmatch 'go-licenses/v2@v2\.0\.1') { + throw 'Release build helper dependencies in justfile must remain exactly pinned.' +} if ($productionWorkflow -match '(?m)^\s{2}(?:push|pull_request):') { throw 'Production Microsoft-signed package acceptance must remain an explicit manual intake path.' } diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index d7132140..c7b75022 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -21,7 +21,7 @@ env: jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -38,6 +38,8 @@ jobs: - name: Setup just uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Show Go version run: go version @@ -96,10 +98,10 @@ jobs: fail-fast: false matrix: target: - - { goos: linux, goarch: amd64, ext: "", runner: ubuntu-latest } - - { goos: linux, goarch: arm64, ext: "", runner: ubuntu-latest } - - { goos: windows, goarch: amd64, ext: ".exe", runner: windows-latest } - - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-latest } + - { goos: linux, goarch: amd64, ext: "", runner: ubuntu-24.04 } + - { goos: linux, goarch: arm64, ext: "", runner: ubuntu-24.04 } + - { goos: windows, goarch: amd64, ext: ".exe", runner: windows-2025 } + - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-2025 } steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -121,6 +123,8 @@ jobs: - name: Setup just uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Install goversioninfo shell: pwsh @@ -176,7 +180,7 @@ jobs: libviiper-linux: name: Build libVIIPER (linux/amd64) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: test steps: - name: Checkout code @@ -194,6 +198,8 @@ jobs: - name: Setup just uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Build run: | @@ -212,7 +218,7 @@ jobs: libviiper-windows: name: Build libVIIPER (windows/amd64) - runs-on: windows-latest + runs-on: windows-2025 needs: test steps: - name: Checkout code @@ -230,6 +236,8 @@ jobs: - name: Setup just uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + with: + just-version: "1.58.0" - name: Install build tools shell: pwsh diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index d296af16..6edba934 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -31,7 +31,7 @@ env: jobs: codegen: name: Code generation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -65,7 +65,7 @@ jobs: typescript: name: TypeScript Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -119,7 +119,7 @@ jobs: csharp: name: C# Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -155,7 +155,7 @@ jobs: cpp-sdk: name: C++ Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -198,7 +198,7 @@ jobs: rust: name: Rust Client Library needs: codegen - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index e8e53f16..7212c6c6 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,7 +20,7 @@ concurrency: jobs: deploy-docs: name: Deploy Documentation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -35,7 +35,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: 3.x + python-version: "3.14.6" - name: Install dependencies run: | diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 28e7ce33..eee564b8 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -18,7 +18,7 @@ on: jobs: generate: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: diff --git a/.github/workflows/native-package-transaction.yml b/.github/workflows/native-package-transaction.yml index 22b83616..a217ab04 100644 --- a/.github/workflows/native-package-transaction.yml +++ b/.github/workflows/native-package-transaction.yml @@ -2,19 +2,9 @@ name: Native package transaction on: push: - paths: - - "internal/cmd/native_package*" - - "internal/cmd/native_service_install_windows*" - - "native/udecx/tools/ViiperUdeCtl.cpp" - - "native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1" - - ".github/workflows/native-package-transaction.yml" + branches: [main, "feature/**"] + tags: ["v*.*.*"] pull_request: - paths: - - "internal/cmd/native_package*" - - "internal/cmd/native_service_install_windows*" - - "native/udecx/tools/ViiperUdeCtl.cpp" - - "native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1" - - ".github/workflows/native-package-transaction.yml" workflow_call: workflow_dispatch: @@ -27,7 +17,7 @@ env: jobs: fail-closed-simulation: - runs-on: windows-latest + runs-on: windows-2025-vs2026 timeout-minutes: 15 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -51,7 +41,7 @@ jobs: - name: Compile and self-test package helper shell: pwsh run: | - $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -version '[18.0,19.0)' -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath if (-not $vs) { throw "Visual C++ toolchain was not found" } $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path diff --git a/.github/workflows/native-production-package.yml b/.github/workflows/native-production-package.yml index 3069ff74..9f1daf1e 100644 --- a/.github/workflows/native-production-package.yml +++ b/.github/workflows/native-production-package.yml @@ -53,6 +53,8 @@ jobs: ARTIFACT_DIGEST: ${{ inputs.artifact_digest }} ARTIFACT_ID: ${{ inputs.artifact_id }} ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + POLICY_REF: ${{ github.ref }} + POLICY_REVISION: ${{ github.sha }} SOURCE_REVISION: ${{ inputs.source_revision }} GH_TOKEN: ${{ github.token }} run: | @@ -65,6 +67,10 @@ jobs: if ($env:ARTIFACT_DIGEST -cnotmatch '^[0-9a-f]{64}$') { throw 'artifact_digest must be a lowercase SHA-256 digest.' } + if ($env:POLICY_REF -cne 'refs/heads/main' -or + $env:POLICY_REVISION -cne $env:SOURCE_REVISION) { + throw 'Production acceptance must run from main at the exact reviewed source revision.' + } $metadata = gh api "/repos/$env:GITHUB_REPOSITORY/actions/artifacts/$env:ARTIFACT_ID" | ConvertFrom-Json if ([long]$metadata.id -ne [long]$env:ARTIFACT_ID -or [bool]$metadata.expired) { throw 'The selected artifact is missing, expired, or does not match artifact_id.' @@ -85,16 +91,18 @@ jobs: path: reviewed-source persist-credentials: false - - name: Require reviewed main history + - name: Require the exact current reviewed main source shell: pwsh env: SOURCE_REVISION: ${{ inputs.source_revision }} working-directory: reviewed-source run: | git fetch --no-tags origin main - git merge-base --is-ancestor $env:SOURCE_REVISION origin/main - if ($LASTEXITCODE -ne 0) { - throw 'Production native packages must represent reviewed origin/main history.' + $mainRevision = (git rev-parse origin/main).Trim() + $checkedOutRevision = (git rev-parse HEAD).Trim() + if ($mainRevision -cne $env:SOURCE_REVISION -or + $checkedOutRevision -cne $env:SOURCE_REVISION) { + throw 'Production native packages must represent the exact current origin/main tip.' } - name: Download only the explicit immutable artifact @@ -159,6 +167,16 @@ jobs: "package_path=$packagePath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 "manifest_path=$manifestPath" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Validate the actual Microsoft-returned stamped INF contract + shell: pwsh + env: + PACKAGE_PATH: ${{ steps.validate.outputs.package_path }} + run: | + & ./reviewed-source/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 ` + -ProjectPath ./reviewed-source/native/udecx/driver/ViiperUde.vcxproj ` + -InfPath (Join-Path $env:PACKAGE_PATH 'ViiperUde.inf') ` + -RequireStampedInf + - name: Package only validated production bytes id: package shell: pwsh diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 0764934b..ee3771b8 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -2,10 +2,12 @@ name: Native UdeCx bus on: push: - branches: [main, feature/native-udecx-bus] + branches: [main, "feature/**"] + tags: ["v*.*.*"] paths: - "go.mod" - "go.sum" + - "justfile" - "native/udecx/**" - "internal/transport/udecx/**" - "internal/server/usb/**" @@ -28,6 +30,7 @@ on: paths: - "go.mod" - "go.sum" + - "justfile" - "native/udecx/**" - "internal/transport/udecx/**" - "internal/server/usb/**" @@ -53,6 +56,11 @@ on: required: false type: boolean default: false + upload_release_helper: + description: Upload only the source-built runtime helper for an HLK/WHCP release composition. + required: false + type: boolean + default: false workflow_dispatch: permissions: @@ -76,6 +84,8 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod @@ -88,6 +98,14 @@ jobs: $actual = (go env GOVERSION).TrimStart('g', 'o') if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } go mod verify + - name: Gate native DriverVer and package-content monotonicity + shell: pwsh + env: + BASE_REVISION: ${{ github.event.pull_request.base.sha || github.event.before }} + run: >- + ./native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 + -BaseRevision $env:BASE_REVISION + -HeadRevision $env:GITHUB_SHA - name: Test complete VIIPER tree run: go test ./... - name: Stress native Windows client cancellation, close, pump failure, and reconnect @@ -105,7 +123,7 @@ jobs: run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=20s ./internal/transport/udecx race: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -136,6 +154,7 @@ jobs: - uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2 with: msbuild-architecture: x64 + vs-version: "[18.0,19.0)" - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 with: nuget-version: "6.11.1" @@ -170,7 +189,7 @@ jobs: - name: Enforce WHQL-aligned and Universal INF rules shell: pwsh run: | - $inf = (Resolve-Path ./native/udecx/x64/Release/ViiperUde.inf).Path + $inf = (Resolve-Path ./native/udecx/x64/Release/ViiperUde/ViiperUde.inf).Path foreach ($mode in @('/h', '/u')) { & infverif.exe $mode $inf if ($LASTEXITCODE -ne 0) { @@ -182,12 +201,12 @@ jobs: run: >- ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 -ProjectPath ./native/udecx/driver/ViiperUde.vcxproj - -InfPath ./native/udecx/x64/Release/ViiperUde.inf + -InfPath ./native/udecx/x64/Release/ViiperUde/ViiperUde.inf -RequireStampedInf - name: Build transactional root-devnode and live-media helpers shell: pwsh run: | - $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -version '[18.0,19.0)' -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath if (-not $vs) { throw "Visual C++ toolchain was not found" } $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path @@ -197,8 +216,8 @@ jobs: $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib" cmd.exe /d /s /c $command if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } - & $output status - if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl status smoke test failed" } + & $output self-test + if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl self-test failed" } $mediaSource = (Resolve-Path "native\udecx\tools\ViiperUdeMediaProbe.cpp").Path $mediaOutput = Join-Path $outputDir "ViiperUdeMediaProbe.exe" $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib" @@ -217,6 +236,14 @@ jobs: & $inputOutput snapshot $inputSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } Remove-Item -LiteralPath $inputSnapshot -Force + - name: Upload source-bound native runtime helper + if: ${{ inputs.upload_release_helper == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeCtl-windows-amd64-${{ github.sha }} + path: native/udecx/x64/Release/ViiperUdeCtl.exe + if-no-files-found: error + retention-days: 30 - name: Validate testing-only Hardware Dev Center CAB structure shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12a62a2c..166aa8b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,17 @@ jobs: shell: pwsh run: ./.github/scripts/Test-WorkflowSecurity.ps1 + - name: Require native DriverVer monotonicity from the previous release + shell: pwsh + run: | + $tags = @(git tag --merged "$env:GITHUB_SHA^" --list 'v*.*.*' | + Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } | + Sort-Object { [Version]$_.Substring(1) } -Descending) + $baseline = if ($tags.Count -gt 0) { $tags[0] } else { '' } + ./native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 ` + -BaseRevision $baseline ` + -HeadRevision $env:GITHUB_SHA + native-validation: name: Native UdeCx release gate needs: release-policy @@ -51,6 +62,7 @@ jobs: uses: ./.github/workflows/native-ude.yml with: upload_artifacts: false + upload_release_helper: true native-package-transaction: name: Native package transaction release gate @@ -59,6 +71,307 @@ jobs: contents: read uses: ./.github/workflows/native-package-transaction.yml + native-production-provenance: + name: Require accepted Microsoft HLK/WHCP package + needs: release-policy + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + outputs: + artifact_digest: ${{ steps.locate.outputs.artifact_digest }} + artifact_id: ${{ steps.locate.outputs.artifact_id }} + run_id: ${{ steps.locate.outputs.run_id }} + steps: + - name: Locate the exact trusted production-package acceptance + id: locate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_name="ViiperUde-x64-production-microsoft-signed-${GITHUB_SHA}" + response="$(gh api --paginate --slurp \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100&name=${artifact_name}")" + mapfile -t candidates < <(jq -r --arg name "$artifact_name" ' + [.[].artifacts[] | + select(.name == $name and (.expired | not))] | + sort_by(.id) | reverse | .[] | @base64' <<<"$response") + + selected='' + for encoded in "${candidates[@]}"; do + artifact="$(base64 --decode <<<"$encoded")" + run_id="$(jq -r '.workflow_run.id' <<<"$artifact")" + run="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}")" + if jq -e \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg sha "$GITHUB_SHA" ' + .path == ".github/workflows/native-production-package.yml" and + .event == "workflow_dispatch" and + .status == "completed" and + .conclusion == "success" and + .head_branch == "main" and + .head_sha == $sha and + .repository.full_name == $repo' <<<"$run" >/dev/null; then + selected="$artifact" + break + fi + done + if [[ -z "$selected" ]]; then + echo "::error::No successful main-branch Microsoft HLK/WHCP acceptance artifact exists for ${GITHUB_SHA}." + exit 1 + fi + + artifact_id="$(jq -r '.id' <<<"$selected")" + run_id="$(jq -r '.workflow_run.id' <<<"$selected")" + digest="$(jq -r '.digest' <<<"$selected")" + if [[ ! "$artifact_id" =~ ^[0-9]+$ || ! "$run_id" =~ ^[0-9]+$ || + ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Accepted production artifact metadata is malformed." + exit 1 + fi + echo "artifact_id=${artifact_id}" >> "$GITHUB_OUTPUT" + echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" + echo "artifact_digest=${digest#sha256:}" >> "$GITHUB_OUTPUT" + { + echo '### Required Microsoft production package' + echo + echo "- Source: \`${GITHUB_SHA}\`" + echo "- Acceptance run: \`${run_id}\`" + echo "- Artifact ID: \`${artifact_id}\`" + echo "- Artifact digest: \`${digest}\`" + } >> "$GITHUB_STEP_SUMMARY" + + native-user-mode-signing: + name: Sign and validate native runtime package + needs: [release-policy, native-validation, native-production-provenance, build] + runs-on: windows-2025-vs2026 + permissions: + actions: read + contents: read + steps: + - name: Checkout exact release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download unsigned x64 broker input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-amd64-Release + path: unsigned-broker-amd64 + + - name: Download unsigned ARM64 broker input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-arm64-Release + path: unsigned-broker-arm64 + + - name: Download source-built native helper input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ViiperUdeCtl-windows-amd64-${{ github.sha }} + path: unsigned-helper + + - name: Download accepted Microsoft production package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.native-production-provenance.outputs.artifact_id }} + run-id: ${{ needs.native-production-provenance.outputs.run_id }} + github-token: ${{ github.token }} + path: accepted-production + + - uses: NuGet/setup-nuget@d105a947828025cd7a980103c35ba2bfae586d0f # v2 + with: + nuget-version: "6.11.1" + + - name: Restore exact source-bound WDK signing tools + run: >- + nuget restore native/udecx/ViiperUde.sln + -PackagesDirectory native/udecx/packages + -NonInteractive + + - name: Select exact restored SignTool and InfVerif + id: wdk_tools + shell: pwsh + run: | + $tools = @(Get-ChildItem native/udecx/packages -Recurse -File -Filter *.exe) + foreach ($required in @('signtool.exe', 'infverif.exe')) { + if (-not ($tools | Where-Object Name -ieq $required | Select-Object -First 1)) { + throw "Restored WDK packages did not contain $required." + } + } + $signTools = @($tools | Where-Object { + $_.Name -ieq 'signtool.exe' -and $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' + }) + if ($signTools.Count -eq 0) { + throw 'Restored WDK packages did not contain an x64 SignTool.' + } + $hashes = @($signTools | ForEach-Object { + (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } | Sort-Object -Unique) + if ($hashes.Count -ne 1) { + throw 'Restored WDK packages contained non-identical x64 SignTool binaries.' + } + $signTool = ($signTools | Sort-Object FullName | Select-Object -First 1).FullName + "sign_tool=$signTool" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + $tools.DirectoryName | Sort-Object -Unique | + Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Extract and allowlist unsigned release inputs + shell: pwsh + run: | + New-Item -ItemType Directory -Force signed/amd64, signed/arm64, signed/helper | Out-Null + Expand-Archive -LiteralPath unsigned-broker-amd64/viiper-windows-amd64.zip -DestinationPath signed/amd64 + Expand-Archive -LiteralPath unsigned-broker-arm64/viiper-windows-arm64.zip -DestinationPath signed/arm64 + foreach ($architecture in @('amd64', 'arm64')) { + $root = (Resolve-Path "signed/$architecture").Path + $relative = @(Get-ChildItem $root -Recurse -File | ForEach-Object { + [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') + } | Sort-Object) + if ($relative.Count -ne 2 -or + $relative[0] -cne 'licenses.txt' -or $relative[1] -cne 'viiper.exe') { + throw "The $architecture broker archive is not the exact viiper.exe/licenses.txt input." + } + } + $helpers = @(Get-ChildItem unsigned-helper -Recurse -File) + if ($helpers.Count -ne 1 -or $helpers[0].Name -cne 'ViiperUdeCtl.exe') { + throw 'The helper artifact is not exactly one case-correct ViiperUdeCtl.exe.' + } + Copy-Item -LiteralPath $helpers[0].FullName -Destination signed/helper/ViiperUdeCtl.exe + + - name: Authenticode-sign and verify broker/helper release binaries + shell: pwsh + env: + CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PFX_PASSWORD }} + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + PFX_BASE64: ${{ secrets.WINDOWS_SIGNING_PFX_BASE64 }} + SIGN_TOOL: ${{ steps.wdk_tools.outputs.sign_tool }} + run: | + foreach ($required in @( + $env:PFX_BASE64, $env:CERTIFICATE_PASSWORD, $env:CERTIFICATE_SHA256, $env:SIGN_TOOL)) { + if ([string]::IsNullOrWhiteSpace($required)) { + throw 'Production release signing secrets and the exact SignTool are required.' + } + } + ./native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 ` + -Paths @( + 'signed/amd64/viiper.exe', + 'signed/arm64/viiper.exe', + 'signed/helper/ViiperUdeCtl.exe') ` + -CertificateBase64 $env:PFX_BASE64 ` + -CertificatePassword $env:CERTIFICATE_PASSWORD ` + -ExpectedCertificateSHA256 $env:CERTIFICATE_SHA256 ` + -SignToolPath $env:SIGN_TOOL + + - name: Revalidate Microsoft driver and compose exact runtime bundle + shell: pwsh + env: + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + run: | + $acceptedArchives = @(Get-ChildItem accepted-production -Recurse -File) + $expectedArchive = "ViiperUde-x64-production-$env:GITHUB_SHA.zip" + if ($acceptedArchives.Count -ne 1 -or $acceptedArchives[0].Name -cne $expectedArchive) { + throw "Accepted production artifact must contain only $expectedArchive." + } + Expand-Archive -LiteralPath $acceptedArchives[0].FullName -DestinationPath signed/production + $expectedProduction = @( + 'submission-manifest.json', + 'ViiperUde/ViiperUde.cat', + 'ViiperUde/ViiperUde.inf', + 'ViiperUde/ViiperUde.pdb', + 'ViiperUde/ViiperUde.sys') + $productionRoot = (Resolve-Path signed/production).Path + $actualProduction = @(Get-ChildItem $productionRoot -Recurse -File | ForEach-Object { + [IO.Path]::GetRelativePath($productionRoot, $_.FullName).Replace('\', '/') + } | Sort-Object) + if (Compare-Object ($expectedProduction | Sort-Object) $actualProduction) { + throw 'Accepted Microsoft production artifact has missing or unexpected files.' + } + ./native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 ` + -PackageDirectory signed/production/ViiperUde ` + -SubmissionManifestPath signed/production/submission-manifest.json ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ValidationMode Production + ./native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -InfPath signed/production/ViiperUde/ViiperUde.inf ` + -RequireStampedInf + $manifestHash = (Get-FileHash ` + -LiteralPath signed/production/submission-manifest.json ` + -Algorithm SHA256).Hash.ToLowerInvariant() + $deadline = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUnixTimeMilliseconds() + & signed/helper/ViiperUdeCtl.exe verify ` + signed/production/ViiperUde/ViiperUde.inf ` + --manifest signed/production/submission-manifest.json ` + --manifest-sha256 $manifestHash ` + --source-revision $env:GITHUB_SHA ` + --validation-mode production ` + --transaction-deadline-unix-ms $deadline + if ($LASTEXITCODE -ne 0) { + throw "The signed package helper rejected the production driver package (exit $LASTEXITCODE)." + } + + New-Item -ItemType Directory -Force signed/runtime | Out-Null + Copy-Item signed/amd64/viiper.exe signed/runtime/viiper.exe + Copy-Item signed/helper/ViiperUdeCtl.exe signed/runtime/ViiperUdeCtl.exe + Copy-Item signed/production/ViiperUde/ViiperUde.inf signed/runtime/ViiperUde.inf + Copy-Item signed/production/ViiperUde/ViiperUde.sys signed/runtime/ViiperUde.sys + Copy-Item signed/production/ViiperUde/ViiperUde.cat signed/runtime/ViiperUde.cat + Copy-Item signed/production/submission-manifest.json signed/runtime/submission-manifest.json + ./native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 ` + -BundleDirectory signed/runtime ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -RequireAuthenticode ` + -ExpectedSignerCertificateSHA256 $env:CERTIFICATE_SHA256 + + - name: Archive signed release outputs + shell: pwsh + env: + CERTIFICATE_SHA256: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_SHA256 }} + run: | + New-Item -ItemType Directory -Force signed/output | Out-Null + Compress-Archive -LiteralPath signed/amd64/viiper.exe, signed/amd64/licenses.txt ` + -DestinationPath signed/output/viiper-windows-amd64.zip -CompressionLevel Optimal + Compress-Archive -LiteralPath signed/arm64/viiper.exe, signed/arm64/licenses.txt ` + -DestinationPath signed/output/viiper-windows-arm64.zip -CompressionLevel Optimal + Compress-Archive -Path signed/runtime/* ` + -DestinationPath signed/output/viiper-native-udecx-windows-amd64.zip -CompressionLevel Optimal + Expand-Archive -LiteralPath signed/output/viiper-native-udecx-windows-amd64.zip ` + -DestinationPath signed/runtime-roundtrip + ./native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 ` + -BundleDirectory signed/runtime-roundtrip ` + -ExpectedSourceRevision $env:GITHUB_SHA ` + -ProjectPath native/udecx/driver/ViiperUde.vcxproj ` + -RequireAuthenticode ` + -ExpectedSignerCertificateSHA256 $env:CERTIFICATE_SHA256 + + - name: Upload signed x64 broker release input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-windows-amd64-authenticode-${{ github.sha }} + path: signed/output/viiper-windows-amd64.zip + if-no-files-found: error + retention-days: 30 + + - name: Upload signed ARM64 broker release input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-windows-arm64-authenticode-${{ github.sha }} + path: signed/output/viiper-windows-arm64.zip + if-no-files-found: error + retention-days: 30 + + - name: Upload validated signed native runtime bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: VIIPER-native-udecx-authenticode-${{ github.sha }} + path: signed/output/viiper-native-udecx-windows-amd64.zip + if-no-files-found: error + retention-days: 30 + build: needs: release-policy uses: ./.github/workflows/build_base.yml @@ -86,7 +399,7 @@ jobs: create-release: name: Create Release - needs: [release-policy, native-validation, native-package-transaction, build, generate-changelog, client-libraries] + needs: [release-policy, native-validation, native-package-transaction, native-production-provenance, native-user-mode-signing, build, generate-changelog, client-libraries] permissions: actions: read attestations: write @@ -99,21 +412,43 @@ jobs: with: fetch-depth: 0 - - name: Download all artifacts + - name: Download release build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts pattern: "*-Release" + - name: Download Authenticode-signed x64 broker release input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-amd64-authenticode-${{ github.sha }} + path: signed-windows-amd64 + + - name: Download Authenticode-signed ARM64 broker release input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-windows-arm64-authenticode-${{ github.sha }} + path: signed-windows-arm64 + + - name: Download validated Authenticode native runtime bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: VIIPER-native-udecx-authenticode-${{ github.sha }} + path: signed-native-runtime + - name: Organize and rename artifacts shell: bash run: | set -euo pipefail - mkdir -p release_files - for dir in artifacts/*; do - if [ -d "$dir" ]; then - base="$(basename "$dir")" - # Strip optional suffix from artifact name for filenames + mkdir -p release_files + for dir in artifacts/*; do + if [ -d "$dir" ]; then + base="$(basename "$dir")" + if [[ "$base" == 'VIIPER-windows-amd64-Release' || + "$base" == 'VIIPER-windows-arm64-Release' ]]; then + continue + fi + # Strip optional suffix from artifact name for filenames name_no_suffix="${base%-Release}" for file in "$dir"/*; do if [ -f "$file" ]; then @@ -131,11 +466,61 @@ jobs: cp "$file" "release_files/${fname}" fi fi - done - fi - done - ls -la release_files/ - + done + fi + done + require_exact_artifact() { + local root="$1" + local filename="$2" + local destination="$3" + mapfile -t actual < <(find "$root" -type f -printf '%P\n' | sort) + if [[ "${#actual[@]}" -ne 1 || "${actual[0]}" != "$filename" ]]; then + echo "::error::${root} must contain exactly ${filename}." + exit 1 + fi + cp "$root/$filename" "release_files/$destination" + } + require_exact_artifact \ + signed-windows-amd64 viiper-windows-amd64.zip viiper-windows-amd64.zip + require_exact_artifact \ + signed-windows-arm64 viiper-windows-arm64.zip viiper-windows-arm64.zip + require_exact_artifact \ + signed-native-runtime viiper-native-udecx-windows-amd64.zip \ + viiper-native-udecx-windows-amd64.zip + ls -la release_files/ + + - name: Recheck signed archive allowlists before publication + shell: bash + run: | + set -euo pipefail + expected_broker=(licenses.txt viiper.exe) + for architecture in amd64 arm64; do + archive="release_files/viiper-windows-${architecture}.zip" + mapfile -t actual < <(unzip -Z1 "$archive" | sort) + if ! diff -u \ + <(printf '%s\n' "${expected_broker[@]}") \ + <(printf '%s\n' "${actual[@]}"); then + echo "::error::The signed ${architecture} broker archive has unexpected files." + exit 1 + fi + done + expected_runtime=( + ViiperUde.cat + ViiperUde.inf + ViiperUde.sys + ViiperUdeCtl.exe + submission-manifest.json + viiper.exe + ) + mapfile -t archived < <( + unzip -Z1 release_files/viiper-native-udecx-windows-amd64.zip | sort) + if ! diff -u \ + <(printf '%s\n' "${expected_runtime[@]}") \ + <(printf '%s\n' "${archived[@]}"); then + echo '::error::The validated native runtime archive has unexpected files.' + exit 1 + fi + - name: Extract build info id: build_info shell: bash @@ -160,6 +545,7 @@ jobs: viiper-libVIIPER-windows-amd64.zip viiper-linux-amd64.tar.gz viiper-linux-arm64.tar.gz + viiper-native-udecx-windows-amd64.zip viiper-rust-client-library.crate viiper-typescript-client-library.tgz viiper-windows-amd64.zip diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 0392903a..14683d3f 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -10,7 +10,7 @@ permissions: jobs: calculate-version: name: Calculate Dev Version - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} steps: @@ -58,7 +58,7 @@ jobs: create-pre-release: name: Create Pre-Release needs: [build, generate-changelog, client-libraries] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') steps: - name: Checkout code diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index a9bcf5aa..9d20ed60 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -62,6 +62,10 @@ mode. That mode rejects the attestation EKU and requires a release-eligible - Returned packages contain only the canonical INF, SYS, PDB, and CAT in one directory. The unchanged INF/PDB must match the submission manifest, and SignTool must prove INF/SYS membership in the returned Microsoft catalog. +- PDB is certification evidence, not a runtime dependency. The public native + archive contains exactly the release `viiper.exe` broker, + `ViiperUdeCtl.exe`, INF, SYS, CAT, and the validated submission manifest. + Release composition rejects every missing or additional file. - Controlled-test and production signatures are separate validation modes; an attestation EKU can never satisfy the production release gate. - Test certificates, test-signing state, or disabled Secure Boot are never a @@ -73,12 +77,40 @@ mode. That mode rejects the attestation EKU and requires a release-eligible ## Current release gate -The branch currently proves compilation, static analysis, ABI/lifecycle tests, -fuzzing, race tests, deterministic package structure, and payload hashing. A -native driver is not production-ready until the HLK/WHCP dashboard-signed -package also passes Driver Verifier, the complete HLK matrix, repeated -install/update/rollback, process crash, sleep/resume, and multi-controller -media soak on a disposable test machine. +Feature branches, `main`, release tags, and the public release workflow all run +the native compile, static-analysis, ABI/lifecycle, fuzz, race, stamped-INF, +package-transaction, helper rollback/update/removal, and deterministic package +checks. Driver package source changes must strictly increase the four-part +`DriverVer` without regressing its date; a release also compares against the +previous SemVer tag. The `viiper uninstall` command does not yet invoke the +helper's exact root-devnode/Driver Store removal transaction, so the live +uninstall release criterion remains open even though that helper primitive is +source-checked and self-tested. + +Production driver acceptance is separate and manual because Microsoft signing +is external. The intake workflow must run from the exact current `main` commit, +downloads one artifact by immutable run ID, artifact ID, and SHA-256 digest, +and validates the Microsoft-returned INF/SYS/PDB/CAT package in literal +`Production` mode. It rejects test signatures and the attestation EKU, verifies +catalog membership, validates the actual returned stamped INF against the +reviewed project, and publishes one source-named accepted artifact. + +A tag release must point to the current `main` tip and cannot publish without a +successful production-intake run at that same commit. It downloads only that +accepted artifact plus the current release broker and source-built helper. A +mandatory Windows job signs both broker architectures and the helper with the +configured production Authenticode certificate, requires a trusted timestamp, +the exact certificate SHA-256 fingerprint, and Code Signing EKU, then validates +the exact six-file runtime bundle before and after archiving. Publication +consumes only those signed outputs, discards the certification PDB, and +allowlists every public release asset before checksumming and attesting it. The +test-signed CI artifact is never a release input. + +These automation gates do not manufacture certification evidence. A native +driver is not production-ready until the external HLK/WHCP dashboard-signed +package also passes Driver Verifier, the complete HLK matrix, repeated live +install/update/rollback/uninstall, process crash, sleep/resume, and +multi-controller soak on a disposable test machine. The first repeatable signed-driver gate is `native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1`. It validates the @@ -122,6 +154,12 @@ the HLK/DevFund matrix. - [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) - [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) - [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) +- [Components of a driver package](https://learn.microsoft.com/windows-hardware/drivers/install/components-of-a-driver-package) +- [SignTool command-line reference](https://learn.microsoft.com/windows-hardware/drivers/devtest/signtool) +- [Windows Hardware Lab Kit](https://learn.microsoft.com/windows-hardware/test/hlk/) +- [Add driver and supplemental content to an HLK package](https://learn.microsoft.com/windows-hardware/test/hlk/user/add-driver-and-supplemental-content-to-your-package) +- [INF DriverVer directive](https://learn.microsoft.com/windows-hardware/drivers/install/inf-driverver-directive) +- [InfVerif `/h`](https://learn.microsoft.com/windows-hardware/drivers/devtest/infverif_h) - [Driver Verifier](https://learn.microsoft.com/windows-hardware/drivers/devtest/driver-verifier) - [Driver Verifier command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/verifier-command-line) - [PnPUtil command syntax](https://learn.microsoft.com/windows-hardware/drivers/devtest/pnputil-command-syntax) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 911f92af..0d8f7d09 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -18,7 +18,7 @@ const ( // shipped with this service. Runtime negotiation proves the installed // driver speaks the exact ABI below; package installation additionally // verifies this release version and its signed catalog. - DriverPackageVersion = "0.1.0.0" + DriverPackageVersion = "0.1.0.1" HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/justfile b/justfile index de2c4fa3..98d4348e 100644 --- a/justfile +++ b/justfile @@ -49,7 +49,7 @@ test-coverage: [windows] generate-versioninfo: - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "versioninfo.json" "versioninfo.tmp.json" {{ if target_goarch == "amd64" { @@ -89,7 +89,7 @@ build type=build_type: [windows] build-libVIIPER type=build_type: {{ mkdir_p }} dist/libVIIPER - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "lib/viiper/versioninfo.json" "libviiper.versioninfo.tmp.json" goversioninfo -64 -o lib/viiper/resource.syso libviiper.versioninfo.tmp.json $env:CGO_ENABLED='1'; go build -buildmode=c-shared -trimpath {{ if type == "Release" { "-ldflags \"-s -w\"" } else { "" } }} -o dist/libVIIPER/libVIIPER.dll ./lib/viiper @@ -120,22 +120,22 @@ lint: [windows] licenses: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ dist_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue [windows] licenses-libVIIPER: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ licenses_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ lib_licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue [unix] licenses: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ dist_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ licenses_out }} && rm -f {{ licenses_template_work }} [unix] licenses-libVIIPER: - go install github.com/google/go-licenses/v2@latest + go install github.com/google/go-licenses/v2@v2.0.1 {{ mkdir_p }} {{ licenses_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ lib_licenses_out }} && rm -f {{ licenses_template_work }} run *args: build diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index a5d65c0f..13cdb1af 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/10/2026 - 0.1.0.0 + 0.1.0.1 diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 0fbef45b..d0079913 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/10/2026,0.1.0.0 +DriverVer=08/10/2026,0.1.0.1 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 b/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 new file mode 100644 index 00000000..c87460b7 --- /dev/null +++ b/native/udecx/tools/Protect-ViiperWindowsReleaseBinaries.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string[]]$Paths, + + [Parameter(Mandatory = $true)] + [string]$CertificateBase64, + + [Parameter(Mandatory = $true)] + [string]$CertificatePassword, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$ExpectedCertificateSHA256, + + [Parameter(Mandatory = $true)] + [string]$SignToolPath, + + [ValidatePattern('^https?://[^\s]+$')] + [string]$TimestampUrl = 'http://timestamp.digicert.com' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CertificateSha256 { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +function Test-CodeSigningEku { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + foreach ($extension in $Certificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { + continue + } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + return @($eku.EnhancedKeyUsages | Where-Object Value -ceq '1.3.6.1.5.5.7.3.3').Count -eq 1 + } + return $false +} + +$signTool = (Resolve-Path -LiteralPath $SignToolPath -ErrorAction Stop).Path +if ((Get-Item -LiteralPath $signTool).PSIsContainer -or + [IO.Path]::GetFileName($signTool) -ine 'signtool.exe') { + throw 'SignToolPath must identify the exact restored signtool.exe.' +} + +$resolvedPaths = @() +foreach ($path in $Paths) { + $item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path -Force + if ($item.PSIsContainer -or $item.Length -le 0 -or $item.Extension -ine '.exe') { + throw "Release signing accepts only nonempty .exe files; rejected '$path'." + } + $stream = [IO.File]::OpenRead($item.FullName) + try { + if ($stream.ReadByte() -ne 0x4d -or $stream.ReadByte() -ne 0x5a) { + throw "Release signing input '$path' is not a Windows PE image." + } + } + finally { + $stream.Dispose() + } + $resolvedPaths += $item.FullName +} +if ($resolvedPaths.Count -eq 0 -or @($resolvedPaths | Sort-Object -Unique).Count -ne $resolvedPaths.Count) { + throw 'Release signing requires one or more unique executable paths.' +} + +$expectedDigest = $ExpectedCertificateSHA256.ToLowerInvariant() +$pfxPath = Join-Path ([IO.Path]::GetTempPath()) ("viiper-release-signing-{0}.pfx" -f [Guid]::NewGuid().ToString('N')) +$importedCertificates = @() +$preexistingThumbprints = @( + Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | + ForEach-Object Thumbprint) +try { + try { + $pfxBytes = [Convert]::FromBase64String($CertificateBase64) + } + catch { + throw 'WINDOWS_SIGNING_PFX_BASE64 is not valid base64.' + } + if ($pfxBytes.Length -eq 0) { + throw 'WINDOWS_SIGNING_PFX_BASE64 decoded to an empty file.' + } + [IO.File]::WriteAllBytes($pfxPath, $pfxBytes) + $securePassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + $importedCertificates = @( + Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My ` + -Password $securePassword -Exportable:$false) + $signers = @($importedCertificates | Where-Object { $_.HasPrivateKey -and (Test-CodeSigningEku $_) }) + if ($signers.Count -ne 1) { + throw "The release PFX must contain exactly one private-key certificate with the Code Signing EKU; found $($signers.Count)." + } + $certificate = $signers[0] + if ((Get-CertificateSha256 $certificate) -cne $expectedDigest) { + throw 'The release PFX certificate does not match WINDOWS_SIGNING_CERTIFICATE_SHA256.' + } + if ($certificate.Subject -ceq $certificate.Issuer -or + $certificate.Subject -match '(?i)(^|[ ,])(test|self[- ]?signed)([ ,]|$)') { + throw 'Self-signed or test-named certificates cannot sign a public VIIPER release.' + } + $now = [DateTime]::UtcNow + if ($now -lt $certificate.NotBefore.ToUniversalTime() -or + $now -gt $certificate.NotAfter.ToUniversalTime()) { + throw 'The release code-signing certificate is not currently valid.' + } + + $chain = New-Object Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online + $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot + $chain.ChainPolicy.VerificationFlags = [Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag + if (-not $chain.Build($certificate)) { + $status = @($chain.ChainStatus | ForEach-Object StatusInformation) -join '; ' + throw "The release code-signing certificate did not build a trusted revocation-checked chain: $status" + } + } + finally { + $chain.Dispose() + } + + foreach ($path in $resolvedPaths) { + & $signTool sign /sha1 $certificate.Thumbprint /s My /fd SHA256 ` + /tr $TimestampUrl /td SHA256 $path + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed to sign '$path' (exit $LASTEXITCODE)." + } + & $signTool verify /pa /all /v $path + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed Authenticode policy verification for '$path' (exit $LASTEXITCODE)." + } + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or + $null -eq $signature.SignerCertificate -or + $null -eq $signature.TimeStamperCertificate -or + (Get-CertificateSha256 $signature.SignerCertificate) -cne $expectedDigest -or + -not (Test-CodeSigningEku $signature.SignerCertificate)) { + throw "'$path' does not have the expected trusted, timestamped production Authenticode signature." + } + Write-Host "Signed and verified $path with certificate SHA-256 $expectedDigest." + } +} +finally { + foreach ($certificate in $importedCertificates) { + if ($preexistingThumbprints -cnotcontains $certificate.Thumbprint) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue + } + } + if (Test-Path -LiteralPath $pfxPath) { + Remove-Item -LiteralPath $pfxPath -Force + } +} diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 new file mode 100644 index 00000000..34bf0ee4 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -0,0 +1,175 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BundleDirectory, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{40}$')] + [string]$ExpectedSourceRevision, + + [string]$ProjectPath, + + [switch]$RequireAuthenticode, + + [ValidatePattern('^$|^[0-9a-fA-F]{64}$')] + [string]$ExpectedSignerCertificateSHA256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ProjectPath)) { + $ProjectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +} + +$root = (Resolve-Path -LiteralPath $BundleDirectory -ErrorAction Stop).Path +if (-not (Get-Item -LiteralPath $root).PSIsContainer) { + throw 'The native release bundle must be a directory.' +} + +$expectedNames = @( + 'viiper.exe', + 'ViiperUdeCtl.exe', + 'ViiperUde.inf', + 'ViiperUde.sys', + 'ViiperUde.cat', + 'submission-manifest.json' +) +$allEntries = @(Get-ChildItem -LiteralPath $root -Force) +if (@($allEntries | Where-Object PSIsContainer).Count -ne 0) { + throw 'The native runtime files must be direct children of the bundle directory; subdirectories are forbidden.' +} +$allFiles = @($allEntries | Where-Object { -not $_.PSIsContainer }) +if ($allFiles.Count -ne $expectedNames.Count) { + throw "The runtime bundle must contain exactly $($expectedNames.Count) files; found $($allFiles.Count)." +} +$files = @{} +foreach ($name in $expectedNames) { + $matches = @($allFiles | Where-Object Name -CEQ $name) + if ($matches.Count -ne 1) { + throw "The runtime bundle must contain exactly one case-exact '$name'; found $($matches.Count)." + } + if ($matches[0].Length -le 0) { + throw "The runtime bundle file '$name' is empty." + } + $files[$name] = $matches[0] +} +if (@($allFiles | Where-Object { $_.DirectoryName -cne $root }).Count -ne 0) { + throw 'All native runtime files must reside directly in the canonical bundle directory.' +} +if (@($allFiles | Where-Object Extension -ieq '.pdb').Count -ne 0) { + throw 'Private PDB submission evidence must not be shipped in the public runtime bundle.' +} + +foreach ($name in @('viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUde.sys')) { + $stream = [IO.File]::OpenRead($files[$name].FullName) + try { + if ($stream.ReadByte() -ne 0x4d -or $stream.ReadByte() -ne 0x5a) { + throw "The runtime artifact '$name' is not a Windows PE image." + } + } + finally { + $stream.Dispose() + } +} + +if ($RequireAuthenticode) { + if ([string]::IsNullOrWhiteSpace($ExpectedSignerCertificateSHA256)) { + throw '-RequireAuthenticode also requires ExpectedSignerCertificateSHA256.' + } + $expectedSigner = $ExpectedSignerCertificateSHA256.ToLowerInvariant() + foreach ($name in @('viiper.exe', 'ViiperUdeCtl.exe')) { + $signature = Get-AuthenticodeSignature -LiteralPath $files[$name].FullName + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or + $null -eq $signature.SignerCertificate -or + $null -eq $signature.TimeStamperCertificate) { + throw "The runtime artifact '$name' lacks a valid timestamped Authenticode signature." + } + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $signerDigest = ([BitConverter]::ToString( + $sha256.ComputeHash($signature.SignerCertificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + } + if ($signerDigest -cne $expectedSigner) { + throw "The runtime artifact '$name' was not signed by the release certificate allowlist." + } + $codeSigningEkus = @( + foreach ($extension in $signature.SignerCertificate.Extensions) { + if ($extension.Oid.Value -ne '2.5.29.37') { continue } + $eku = if ($extension -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $extension + } + else { + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($extension, $false) + } + @($eku.EnhancedKeyUsages | Where-Object Value -ceq '1.3.6.1.5.5.7.3.3') + }) + if ($codeSigningEkus.Count -ne 1) { + throw "The runtime artifact '$name' signer lacks the Code Signing EKU." + } + } +} + +$manifest = Get-Content -LiteralPath $files['submission-manifest.json'].FullName -Raw | + ConvertFrom-Json +if ($manifest.schema -ne 1 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or + -not [bool]$manifest.releaseEligible -or + [string]$manifest.signingRoute -cne 'HLK/WHCP') { + throw 'The runtime bundle requires the exact release-eligible HLK/WHCP source manifest.' +} + +$submissionNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') +$manifestEntries = @($manifest.files) +if ($manifestEntries.Count -ne $submissionNames.Count) { + throw 'The HLK/WHCP submission manifest must describe exactly INF, SYS, PDB, and CAT submission inputs.' +} +$manifestByName = @{} +foreach ($entry in $manifestEntries) { + $name = [string]$entry.name + if ($submissionNames -cnotcontains $name -or $manifestByName.ContainsKey($name)) { + throw "The HLK/WHCP manifest contains an unexpected or duplicate file '$name'." + } + if ([long]$entry.length -le 0 -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { + throw "The HLK/WHCP manifest contains invalid metadata for '$name'." + } + $manifestByName[$name] = $entry +} + +# Microsoft signing changes the SYS and CAT bytes. The stamped INF remains +# unchanged and is the source-bound runtime member that can be compared to the +# pre-submission manifest after the signed package has passed the Windows gate. +$runtimeInf = $files['ViiperUde.inf'] +$runtimeInfHash = (Get-FileHash -LiteralPath $runtimeInf.FullName -Algorithm SHA256).Hash +if ($runtimeInf.Length -ne [long]$manifestByName['ViiperUde.inf'].length -or + $runtimeInfHash -cne ([string]$manifestByName['ViiperUde.inf'].sha256).ToUpperInvariant()) { + throw 'The runtime INF does not match the source-bound HLK/WHCP submission manifest.' +} + +[xml]$project = Get-Content -LiteralPath (Resolve-Path -LiteralPath $ProjectPath).Path -Raw +$namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$dateNodes = @($project.SelectNodes('//msb:ViiperUdeDriverDate', $namespace)) +$versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) +if ($dateNodes.Count -ne 1 -or $versionNodes.Count -ne 1) { + throw 'The reviewed native project must declare one deterministic DriverVer date and version.' +} +$driverDate = $dateNodes[0].InnerText.Trim() +$driverVersion = $versionNodes[0].InnerText.Trim() +$infContents = Get-Content -LiteralPath $runtimeInf.FullName -Raw +$driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($driverDate) + '\s*,\s*' + + [regex]::Escape($driverVersion) + '\s*$' +if ($infContents -notmatch $driverVerPattern -or + $infContents -notmatch '(?mi)^KmdfLibraryVersion\s*=\s*1\.27\s*$') { + throw "The runtime INF is not the stamped DriverVer/KMDF output reviewed at $ExpectedSourceRevision." +} + +foreach ($name in $expectedNames) { + $hash = (Get-FileHash -LiteralPath $files[$name].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host "$name sha256:$hash" +} +Write-Host "Validated exact six-file VIIPER native runtime bundle for $ExpectedSourceRevision (Microsoft HLK/WHCP route)." diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 6c85b607..7c50e49b 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -82,7 +82,11 @@ if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { } $expectedNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') -$allFiles = @(Get-ChildItem -LiteralPath $root.Path -Recurse -File) +$allEntries = @(Get-ChildItem -LiteralPath $root.Path -Force) +if (@($allEntries | Where-Object PSIsContainer).Count -ne 0) { + throw 'The signed package files must be direct children of the package directory; subdirectories are forbidden.' +} +$allFiles = @($allEntries | Where-Object { -not $_.PSIsContainer }) if ($allFiles.Count -ne $expectedNames.Count) { throw "The signed package must contain exactly $($expectedNames.Count) files; found $($allFiles.Count)." } @@ -92,11 +96,13 @@ foreach ($name in $expectedNames) { if ($matches.Count -ne 1) { throw "The signed package must contain exactly one case-exact '$name'; found $($matches.Count)." } + if ($matches[0].Length -le 0) { + throw "The signed package file '$name' is empty." + } $files[$name] = $matches[0].FullName } -$packageParents = @($allFiles.DirectoryName | Sort-Object -Unique) -if ($packageParents.Count -ne 1) { - throw 'The signed package files must share one canonical package directory.' +if (@($allFiles | Where-Object { $_.DirectoryName -cne $root.Path }).Count -ne 0) { + throw 'All signed package files must reside directly in the canonical package directory.' } $manifestFile = Resolve-Path -LiteralPath $SubmissionManifestPath -ErrorAction Stop @@ -124,6 +130,9 @@ foreach ($entry in $manifestFiles) { if ($expectedNames -cnotcontains $name -or $manifestByName.ContainsKey($name)) { throw "The submission manifest contains an unexpected or duplicate file '$name'." } + if ([long]$entry.length -le 0 -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$') { + throw "The submission manifest contains invalid metadata for '$name'." + } $manifestByName[$name] = $entry } foreach ($name in @('ViiperUde.inf', 'ViiperUde.pdb')) { diff --git a/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 b/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 new file mode 100644 index 00000000..fc8e6fe4 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeVersionMonotonicity.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param( + [string]$BaseRevision, + + [string]$HeadRevision = 'HEAD' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$projectPath = 'native/udecx/driver/ViiperUde.vcxproj' +$infPath = 'native/udecx/package/ViiperUde.inf' +$protocolPath = 'internal/transport/udecx/protocol.go' +$payloadPaths = @( + 'native/udecx/driver', + 'native/udecx/include', + 'native/udecx/package' +) + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [switch]$AllowFailure + ) + + $output = @(& git @Arguments 2>&1) + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "git $($Arguments -join ' ') failed with exit code $exitCode`n$($output -join [Environment]::NewLine)" + } + return [pscustomobject]@{ + ExitCode = $exitCode + Text = ($output -join "`n").Trim() + } +} + +function Resolve-Commit { + param([Parameter(Mandatory = $true)][string]$Revision) + + return (Invoke-Git -Arguments @('rev-parse', '--verify', "$Revision^{commit}")).Text +} + +function Test-GitPath { + param( + [Parameter(Mandatory = $true)][string]$Revision, + [Parameter(Mandatory = $true)][string]$Path + ) + + $result = Invoke-Git -Arguments @('cat-file', '-e', "$Revision`:$Path") -AllowFailure + return $result.ExitCode -eq 0 +} + +function ConvertTo-DriverVersion { + param( + [Parameter(Mandatory = $true)][string]$Value, + [Parameter(Mandatory = $true)][string]$Revision + ) + + if ($Value -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw "Driver version at $Revision is not a four-part numeric value: '$Value'." + } + $parts = @($Value.Split('.') | ForEach-Object { [int64]$_ }) + if (@($parts | Where-Object { $_ -gt 65535 }).Count -ne 0) { + throw "Driver version at $Revision exceeds the Windows 16-bit component limit: '$Value'." + } + return [Version]$Value +} + +function Get-DriverContract { + param( + [Parameter(Mandatory = $true)][string]$Revision, + [switch]$Required + ) + + if (-not (Test-GitPath -Revision $Revision -Path $projectPath)) { + if ($Required) { + throw "The native driver project is missing at $Revision." + } + return $null + } + + [xml]$project = (Invoke-Git -Arguments @('show', "$Revision`:$projectPath")).Text + $namespace = New-Object System.Xml.XmlNamespaceManager($project.NameTable) + $namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + + $dateNodes = @($project.SelectNodes('//msb:ViiperUdeDriverDate', $namespace)) + $versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) + if ($dateNodes.Count -ne 1 -or $versionNodes.Count -ne 1) { + if (-not $Required) { + return $null + } + throw "The native project at $Revision must contain one driver date and version." + } + + $dateText = $dateNodes[0].InnerText.Trim() + $date = [DateTime]::MinValue + if (-not [DateTime]::TryParseExact( + $dateText, + 'MM/dd/yyyy', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref]$date)) { + throw "Driver date at $Revision is not deterministic MM/dd/yyyy: '$dateText'." + } + $versionText = $versionNodes[0].InnerText.Trim() + $version = ConvertTo-DriverVersion -Value $versionText -Revision $Revision + + if (-not (Test-GitPath -Revision $Revision -Path $infPath)) { + throw "The native INF template is missing at $Revision." + } + $inf = (Invoke-Git -Arguments @('show', "$Revision`:$infPath")).Text + $driverVerPattern = '(?mi)^DriverVer\s*=\s*' + + [regex]::Escape($dateText) + '\s*,\s*' + + [regex]::Escape($versionText) + '\s*$' + if ($inf -notmatch $driverVerPattern) { + throw "The INF DriverVer at $Revision does not match the project contract '$dateText,$versionText'." + } + + if (-not (Test-GitPath -Revision $Revision -Path $protocolPath)) { + throw "The native broker package-version contract is missing at $Revision." + } + $protocol = (Invoke-Git -Arguments @('show', "$Revision`:$protocolPath")).Text + $matches = @([regex]::Matches( + $protocol, + '(?m)^\s*DriverPackageVersion\s*=\s*"(?\d+\.\d+\.\d+\.\d+)"\s*$')) + if ($matches.Count -ne 1 -or $matches[0].Groups['version'].Value -cne $versionText) { + throw "The Go broker package version at $Revision does not exactly match DriverVer '$versionText'." + } + + $tree = (Invoke-Git -Arguments (@('ls-tree', '-r', '--full-tree', $Revision, '--') + $payloadPaths)).Text + return [pscustomobject]@{ + Revision = $Revision + Date = $date.Date + DateText = $dateText + Version = $version + VersionText = $versionText + PayloadTree = $tree + } +} + +$head = Resolve-Commit -Revision $HeadRevision +$headContract = Get-DriverContract -Revision $head -Required + +$base = $null +if (-not [string]::IsNullOrWhiteSpace($BaseRevision) -and + $BaseRevision -notmatch '^0{40}$') { + $base = Resolve-Commit -Revision $BaseRevision +} +else { + $parent = Invoke-Git -Arguments @('rev-parse', '--verify', "$head^") -AllowFailure + if ($parent.ExitCode -eq 0) { + $base = $parent.Text + } +} + +if ($null -eq $base) { + Write-Host "Validated initial native DriverVer contract $($headContract.DateText),$($headContract.VersionText) at $head." + return +} + +$baseContract = Get-DriverContract -Revision $base +if ($null -eq $baseContract) { + Write-Host "Validated initial native DriverVer contract $($headContract.DateText),$($headContract.VersionText); baseline $base predates the contract." + return +} + +if ($headContract.Date -lt $baseContract.Date) { + throw "Native DriverVer date regressed from $($baseContract.DateText) at $base to $($headContract.DateText) at $head." +} +if ($headContract.Version -lt $baseContract.Version) { + throw "Native DriverVer version regressed from $($baseContract.VersionText) at $base to $($headContract.VersionText) at $head." +} + +$payloadChanged = $headContract.PayloadTree -cne $baseContract.PayloadTree +if ($payloadChanged -and $headContract.Version -le $baseContract.Version) { + throw "Native driver package content changed between $base and $head without a strict DriverVer version increase above $($baseContract.VersionText)." +} + +$changeState = if ($payloadChanged) { 'changed with a strict version increase' } else { 'is byte-identical' } +Write-Host "Native driver package content $changeState relative to $base; DriverVer is $($headContract.DateText),$($headContract.VersionText)." From 9a46d74916988f6b50742d4af83be7e40bccf13b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:06:55 -0500 Subject: [PATCH 150/240] Decouple controller engines from USB-IP directions --- device/dualsense/device.go | 11 +++++------ device/dualshock4/device.go | 9 ++++----- device/keyboard/device.go | 5 ++--- device/mouse/device.go | 3 +-- device/ns2pro/device.go | 9 ++++----- device/xbox360/device.go | 9 ++++----- internal/server/usb/native.go | 15 +++++++-------- internal/transport/udecx/host.go | 3 +-- usb/device.go | 11 ++++++++++- usb/device_test.go | 9 +++++++++ 10 files changed, 47 insertions(+), 37 deletions(-) create mode 100644 usb/device_test.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d5275f18..0d6f122f 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -16,7 +16,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) const ( @@ -471,10 +470,10 @@ func (d *DualSense) resetMicrophoneAudioLocked() { } func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - // USB/IP carries the endpoint number separately from transfer direction, - // so an IN descriptor address such as 0x82 arrives here as endpoint 2. + // The transport-neutral device contract carries the endpoint number + // separately from direction, so 0x82 arrives here as endpoint 2 plus IN. epNumber := ep & 0x0F - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch epNumber { case EndpointIn & 0x0F: select { @@ -500,12 +499,12 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o } } - if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointOut&0x0F { if d.handleOutputReport(out) { return nil } } - if dir == usbip.DirOut && epNumber == EndpointHapticsAudioOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointHapticsAudioOut&0x0F { d.handleHapticsAudioOut(out) return nil } diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 738cda62..cdc22c23 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -15,7 +15,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) const ( @@ -318,7 +317,7 @@ func (d *DualShock4) resetSpeakerPresentation(update func()) { func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { epNumber := ep & 0x0F - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch epNumber { case 4: select { @@ -344,7 +343,7 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, } } - if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointOut&0x0F { if len(out) >= 11 && out[0] == ReportIDOutput { feedback := parseOutputReport(out) d.outputPublishMu.RLock() @@ -359,10 +358,10 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, d.outputPublishMu.RUnlock() } } - if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { + if dir == usb.DirectionOut && epNumber == EndpointAudioOut&0x0F { d.mtx.Lock() if d.speakerInterfaceActive && d.speakerFunc != nil && len(out) > 0 { - // The USB/IP receive buffer is owned by the transfer handler. Give the + // The transport receive buffer is owned by the transfer handler. Give the // device-stream writer an immutable copy; its owned enqueue path then // forwards this same allocation without making a second copy. pcm := append([]byte(nil), out...) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index b6210180..34f922a3 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -9,7 +9,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usb/hid" - "github.com/Alia5/VIIPER/usbip" ) // Keyboard implements the Device interface for a full HID keyboard with LED support. @@ -86,7 +85,7 @@ func (k *Keyboard) UpdateInputState(state InputState) { // HandleTransfer implements interrupt IN/OUT for Keyboard. func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - keyboard input reports select { @@ -99,7 +98,7 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou return nil } } - if dir == usbip.DirOut && ep == 1 { + if dir == usb.DirectionOut && ep == 1 { // 0x01 - LED state from host if len(out) >= 1 { ledState := ledStateFromMask(out[0]) diff --git a/device/mouse/device.go b/device/mouse/device.go index 703813d0..f4f60f9d 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -9,7 +9,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usb/hid" - "github.com/Alia5/VIIPER/usbip" ) // Mouse implements the minimal Device interface for a 5-button HID mouse @@ -50,7 +49,7 @@ func (m *Mouse) UpdateInputState(state InputState) { } func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - main input reports select { diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 4f9a7416..739a1049 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -11,7 +11,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) type NS2Pro struct { @@ -131,7 +130,7 @@ func (d *NS2Pro) SetMetaState(meta MetaState) { func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { switch { - case dir == usbip.DirIn && ep == 1: + case dir == usb.DirectionIn && ep == 1: for { select { case <-ctx.Done(): @@ -145,7 +144,7 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out } } } - case dir == usbip.DirIn && ep == 2: + case dir == usb.DirectionIn && ep == 2: for { if resp := d.popBulkIn(); resp != nil { return resp @@ -156,9 +155,9 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out case <-d.bulkCh: } } - case dir == usbip.DirOut && ep == 1: + case dir == usb.DirectionOut && ep == 1: d.handleOutputReport(out) - case dir == usbip.DirOut && ep == 2: + case dir == usb.DirectionOut && ep == 2: d.handleBulkOut(out) } return nil diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 42f10345..05906d54 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -10,7 +10,6 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) type Xbox360 struct { @@ -87,7 +86,7 @@ func (x *Xbox360) UpdateInputState(state InputState) { // HandleTransfer implements interrupt IN/OUT for Xbox360. func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { + if dir == usb.DirectionIn { switch ep { case 1: // 0x81 - main input reports select { @@ -110,7 +109,7 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out return nil } } - if dir == usbip.DirOut && ep == 1 { + if dir == usb.DirectionOut && ep == 1 { // Host->Device output reports used by the wired Xbox 360 controller include // an 8-byte rumble packet: [0]=ReportID(0x00), [1]=Len(0x08), [2]=Reserved/Status(0x00), // [3]=Left (low-frequency/large) motor 0-255, [4]=Right (high-frequency/small) motor 0-255, @@ -197,8 +196,8 @@ func MakeDescriptor() usb.Descriptor { }, Endpoints: []usb.EndpointDescriptor{ // Full-speed interrupt bInterval=1 advertises a 1 ms maximum - // input service cadence. The USB/IP scheduler still presents only - // the newest feeder state, so idle pads do not create a busy loop. + // input service cadence. Transport schedulers present only the newest + // feeder state, so idle pads do not create a user-mode busy loop. {BEndpointAddress: 0x81, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x01}, {BEndpointAddress: 0x01, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x08}, }, diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index cbf884ab..27847eef 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -9,7 +9,6 @@ import ( "github.com/Alia5/VIIPER/internal/transport/udecx" usbdevice "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) type nativeLaneKey struct { @@ -393,10 +392,10 @@ func resolveNativeIsoEndpoint(dev usbdevice.Device, op udecx.Operation) (nativeI "native ISO operation has invalid endpoint signature %+v", signature) } direction := uint8(0) - usbDirection := uint32(usbip.DirOut) + usbDirection := usbdevice.DirectionOut if signature.address&0x80 != 0 { direction = 1 - usbDirection = usbip.DirIn + usbDirection = usbdevice.DirectionIn } flagDirection := uint8(0) if op.TransferFlags&udecx.TransferFlagDirectionIn != 0 { @@ -444,22 +443,22 @@ func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op if err != nil { return udecx.Completion{}, err } - if endpoint.direction == usbip.DirIn { + if endpoint.direction == usbdevice.DirectionIn { return p.processIsoIn(ctx, dev, op, endpoint) } return p.processIsoOut(ctx, dev, op, endpoint) } ep := uint32(op.EndpointAddress & 0x0f) - dir := uint32(usbip.DirOut) + dir := usbdevice.DirectionOut if op.Direction != 0 { - dir = usbip.DirIn + dir = usbdevice.DirectionIn } key := nativeLaneKeyFromOperation(op) switch { case op.Kind == udecx.OperationControl: return p.processControl(ctx, dev, op, ep, dir) - case dir == usbip.DirIn: + case dir == usbdevice.DirectionIn: return p.processInterruptIn(ctx, dev, op, ep, dir, key) default: p.server.processSubmit(ctx, dev, ep, dir, nil, op.Payload) @@ -486,7 +485,7 @@ func (p *NativeProcessor) processControl(ctx context.Context, dev usbdevice.Devi if err := ctx.Err(); err != nil { return udecx.Completion{}, err } - if dir == usbip.DirOut { + if dir == usbdevice.DirectionOut { return successCompletion(op, op.TransferLength, nil, nil), nil } if uint32(len(response)) > op.TransferLength { diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 0369f8a1..88bdc87d 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -9,7 +9,6 @@ import ( "time" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" ) const ( @@ -534,7 +533,7 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p payload = reportBuffer[:written] } else { payload = entry.device.HandleTransfer( - ctx, uint32(publisher.endpoint&0x0f), usbip.DirIn, nil) + ctx, uint32(publisher.endpoint&0x0f), usb.DirectionIn, nil) } if ctx.Err() != nil { return diff --git a/usb/device.go b/usb/device.go index 624b8a07..26ee9b31 100644 --- a/usb/device.go +++ b/usb/device.go @@ -2,11 +2,20 @@ package usb import "context" +// Transfer directions belong to the USB device contract, not to any concrete +// transport. Keep these values aligned with the USB host convention used by +// both the legacy USB/IP adapter and the native UdeCx broker. +const ( + DirectionOut uint32 = 0 + DirectionIn uint32 = 1 +) + // Device is the minimal interface a device must implement. // It only handles non-EP0 (interrupt/bulk) transfers. type Device interface { // HandleTransfer processes a non-EP0 transfer (interrupt/bulk). - // ep is the endpoint number (without direction). dir is usbip.DirIn or usbip.DirOut. + // ep is the endpoint number (without direction). dir is DirectionIn or + // DirectionOut. // For IN transfers the implementation should block until data is available or ctx is // cancelled, then return the payload. For OUT transfers, consume 'out' and return nil. HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte diff --git a/usb/device_test.go b/usb/device_test.go new file mode 100644 index 00000000..d2f60142 --- /dev/null +++ b/usb/device_test.go @@ -0,0 +1,9 @@ +package usb + +import "testing" + +func TestTransferDirectionWireContract(t *testing.T) { + if DirectionOut != 0 || DirectionIn != 1 { + t.Fatalf("USB transfer directions changed: OUT=%d IN=%d", DirectionOut, DirectionIn) + } +} From 920b5868516a41da00eff1e46d1cf66211526e48 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:12:36 -0500 Subject: [PATCH 151/240] Fence stale UdeCx lifecycle notifications --- .../udecx/driver_lifecycle_contract_test.go | 64 ++++++++++++++++- native/udecx/driver/Broker.c | 68 +++++++++++++++++-- native/udecx/driver/Device.c | 20 +++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index be766622..3dfe2d7a 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -151,10 +151,10 @@ func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { "*Device = current;") cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) requireContractOrder(t, cleanup, + "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);", "ViiperRetireActiveDevice(controllerContext, deviceContext);", - "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", - "WdfObjectDereference(deviceContext->OwnerFile);") + "WdfObjectDereference(ownerFile);") destroyOwned := nativeCFunction(t, device, "ViiperDestroyOwnedDevices") for _, forbidden := range []string{"EvtVirtualDeviceCleanup", "ActiveDevices", "CleanupRetries"} { @@ -164,6 +164,66 @@ func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { } } +func TestKernelStaleChildCannotNotifySuccessorOwner(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + ownerGate := normalizedContract(nativeCFunction( + t, broker, "ViiperLifecycleOwnerSessionActiveLocked")) + requireContractOrder(t, ownerGate, + "InterlockedCompareExchange(&DeviceContext->Purging, 0, 0) != 0", + "InterlockedCompareExchange(&DeviceContext->OwnerReferenced, 0, 0) == 0", + "ownerFile = DeviceContext->OwnerFile;", + "if (ownerFile == WDF_NO_HANDLE)", + "fileContext = ViiperGetFileContext(ownerFile);", + "InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) != 0", + "InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0", + "InterlockedCompareExchange(&fileContext->Closing, 0, 0) == 0") + if strings.Contains(ownerGate, "WdfWaitLockAcquire(") { + t.Fatalf("lifecycle owner gate reverses cleanup lock order: %s", ownerGate) + } + + insert := normalizedContract(nativeCFunction(t, broker, "ViiperQueueLifecycleEventLocked")) + requireContractOrder(t, insert, + "if (!ViiperLifecycleOwnerSessionActiveLocked(DeviceContext))", + "event = &ControllerContext->Notifications[ControllerContext->NotificationTail];", + "InterlockedIncrement64( &DeviceContext->EndpointSequences[event->EndpointAddress])", + "InterlockedIncrement64( &DeviceContext->DeviceSequence)") + + for _, name := range []string{ + "ViiperQueueEndpointLifecycleEvent", + "ViiperQueueDeviceLifecycleEvent", + "ViiperQueueInterfaceLifecycleEvent", + "ViiperQueueAcknowledgedLifecycleEvent", + } { + producer := normalizedContract(nativeCFunction(t, broker, name)) + requireContractOrder(t, producer, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperLifecycleOwnerSessionActiveLocked(deviceContext)", + "ViiperQueueLifecycleEventLocked(", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + + remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) + requireContractOrder(t, remove, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "WdfSpinLockRelease(ControllerContext->BrokerLock);", + "ControllerContext->Devices[index] = WDF_NO_HANDLE;") + + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, cleanup, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->Purging, TRUE);", + "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", + "ownerFile = deviceContext->OwnerFile;", + "deviceContext->OwnerFile = WDF_NO_HANDLE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);", + "if (ownerFile != WDF_NO_HANDLE)", + "WdfObjectDereference(ownerFile);") +} + func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 79c8e401..7868f9fb 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -659,6 +659,41 @@ ViiperDrainUrbCompletions( } } +static +BOOLEAN +ViiperLifecycleOwnerSessionActiveLocked( + _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext + ) +{ + WDFFILEOBJECT ownerFile; + VIIPER_UDE_FILE_CONTEXT *fileContext; + + // Device removal is asynchronous in UdeCx. The old child can therefore + // deliver endpoint/power callbacks after its logical table slot has been + // released and a successor broker has connected. The child retains its + // creating file object until EvtCleanup, so that file's permanent Closing + // transition is the generation fence which prevents those callbacks from + // entering the controller-wide notification FIFO of the new session. + // + // BrokerLock is the lifecycle admission linearization point. Do not take + // OwnerLock here: cleanup takes OwnerLock before BrokerLock and reversing + // that order would deadlock. Closing is set before cleanup takes either + // lock, while Purging is set under BrokerLock before the logical slot is + // released. + if (InterlockedCompareExchange(&DeviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&DeviceContext->OwnerReferenced, 0, 0) == 0) { + return FALSE; + } + ownerFile = DeviceContext->OwnerFile; + if (ownerFile == WDF_NO_HANDLE) { + return FALSE; + } + fileContext = ViiperGetFileContext(ownerFile); + return InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) != 0 && + InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) != 0 && + InterlockedCompareExchange(&fileContext->Closing, 0, 0) == 0; +} + static BOOLEAN ViiperQueueLifecycleEventLocked( @@ -673,6 +708,13 @@ ViiperQueueLifecycleEventLocked( { VIIPER_UDE_NOTIFICATION *event; + // Keep this defensive check in the common insertion primitive so a future + // lifecycle producer cannot bypass the old-owner generation fence. It is + // deliberately before both sequence increments: a stale child must leave + // no observable hole in its successor's lifecycle stream. + if (!ViiperLifecycleOwnerSessionActiveLocked(DeviceContext)) { + return FALSE; + } if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { (VOID)ViiperFaultBrokerLocked(ControllerContext); return FALSE; @@ -712,10 +754,13 @@ ViiperQueueEndpointLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN active; BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + queued = active && ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, @@ -725,6 +770,9 @@ ViiperQueueEndpointLifecycleEvent( 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); + if (!active) { + return STATUS_DEVICE_NOT_READY; + } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } @@ -741,13 +789,19 @@ ViiperQueueDeviceLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN active; BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + queued = active && ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, NULL, Kind, 0, 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); + if (!active) { + return STATUS_DEVICE_NOT_READY; + } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } @@ -765,10 +819,13 @@ ViiperQueueInterfaceLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN active; BOOLEAN queued; WdfSpinLockAcquire(controllerContext->BrokerLock); - queued = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + queued = active && ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, @@ -778,6 +835,9 @@ ViiperQueueInterfaceLifecycleEvent( InterfaceSetting, 0); WdfSpinLockRelease(controllerContext->BrokerLock); + if (!active) { + return STATUS_DEVICE_NOT_READY; + } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } @@ -811,7 +871,7 @@ ViiperQueueAcknowledgedLifecycleEvent( WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + !ViiperLifecycleOwnerSessionActiveLocked(deviceContext)) { status = STATUS_DEVICE_NOT_READY; canAllocate = FALSE; } else if (controllerContext->NotificationCount >= diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 97bdb001..8de40b94 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -746,18 +746,34 @@ ViiperEvtVirtualDeviceCleanup( UDECXUSBDEVICE device = (UDECXUSBDEVICE)DeviceObject; VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; PAGED_CODE(); if (deviceContext->Controller == WDF_NO_HANDLE) { return; } controllerContext = ViiperGetControllerContext(deviceContext->Controller); + + // Lifecycle notification admission reads OwnerFile while holding + // BrokerLock. Revoke both that admission and the reference which pins the + // file context under the same lock, then release the reference outside the + // lock. An atomic OwnerReferenced test alone is not a lifetime pin: cleanup + // could otherwise dereference the file after the test and before the + // notifier reads its context. + WdfSpinLockAcquire(controllerContext->BrokerLock); + InterlockedExchange(&deviceContext->Purging, TRUE); + if (InterlockedExchange(&deviceContext->OwnerReferenced, 0) != 0) { + ownerFile = deviceContext->OwnerFile; + deviceContext->OwnerFile = WDF_NO_HANDLE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot); // Normal removal retired the logical count before PlugOutAndDelete. This // is only the fallback for an unexpected framework-owned deletion. ViiperRetireActiveDevice(controllerContext, deviceContext); - if (InterlockedExchange(&deviceContext->OwnerReferenced, 0) != 0) { - WdfObjectDereference(deviceContext->OwnerFile); + if (ownerFile != WDF_NO_HANDLE) { + WdfObjectDereference(ownerFile); } } From 88f1ac4722a431ffca3cc5c1d5fa80c95562b8e8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:24:13 -0500 Subject: [PATCH 152/240] Prove native PlayStation transport parity --- internal/server/usb/native.go | 16 +- .../usb/native_playstation_parity_test.go | 845 ++++++++++++++++++ 2 files changed, 857 insertions(+), 4 deletions(-) create mode 100644 internal/server/usb/native_playstation_parity_test.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 27847eef..95370693 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -157,10 +157,16 @@ func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op p.activateEndpointLocked(dev, op, session) case udecx.OperationEndpointPurge: p.clearEndpointLanes(key) - if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { + // Closing the last endpoint of an alternate setting already establishes + // the controller's media-generation boundary through + // SetInterfaceAltSetting(0). Reset the individual pipe only when the + // interface stays active (or the endpoint cannot be mapped). Otherwise a + // native purge would flush the PlayStation stream twice while USB/IP + // closes it once. + resetByInterfaceClose := p.deactivateEndpointLocked(dev, op, session) + if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok && !resetByInterfaceClose { resetter.ResetEndpoint(op.EndpointAddress) } - p.deactivateEndpointLocked(dev, op, session) case udecx.OperationEndpointReset: p.clearEndpointLanes(key) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { @@ -281,19 +287,21 @@ func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx. } func (p *NativeProcessor) deactivateEndpointLocked(dev usbdevice.Device, op udecx.Operation, - session *nativeSessionState) { + session *nativeSessionState) bool { signature := signatureFromOperation(op) interfaceNumber, alternateSetting, ok := descriptorInterfaceAltForEndpoint( dev.GetDescriptor(), signature) if !ok { - return + return false } delete(session.active, signature) if p.server.getInterfaceAlt(dev, interfaceNumber) == alternateSetting && !descriptorInterfaceAltIsActive(dev.GetDescriptor(), interfaceNumber, alternateSetting, session.active) { p.server.setInterfaceAlt(dev, interfaceNumber, 0) p.server.notifyInterfaceAlt(dev, interfaceNumber, 0) + return true } + return false } func (p *NativeProcessor) applyInterfaceHintLocked(dev usbdevice.Device, op udecx.Operation) { diff --git a/internal/server/usb/native_playstation_parity_test.go b/internal/server/usb/native_playstation_parity_test.go new file mode 100644 index 00000000..26ee1d5a --- /dev/null +++ b/internal/server/usb/native_playstation_parity_test.go @@ -0,0 +1,845 @@ +package usb_test + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "log/slog" + "testing" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + usbserver "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +const ( + dualSenseHIDInterface = 3 + hidRequestTypeOut = 0x21 + hidRequestSetReport = 0x09 + hidReportTypeOutput = 0x02 +) + +// playStationParityHarness treats the established USB/IP adapter and the +// controller engine behind it as the oracle. Native operations are applied to +// an independent controller instance and compared at the controller-stream +// boundary. The harness deliberately does not duplicate media algorithms. +type playStationParityHarness struct { + native *usbserver.NativeProcessor + identity udecx.DeviceIdentity + token uint64 +} + +func newPlayStationParityHarness(t *testing.T) *playStationParityHarness { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + native, err := usbserver.NewNativeProcessor(usbserver.New(usbserver.ServerConfig{}, logger, nil)) + if err != nil { + t.Fatal(err) + } + + return &playStationParityHarness{ + native: native, + identity: udecx.DeviceIdentity{DeviceID: 0x5053, Generation: 1}, + } +} + +func (h *playStationParityHarness) nextToken() uint64 { + h.token++ + return h.token +} + +func parityEndpoint(t *testing.T, dev usbdevice.Device, address uint8) usbdevice.EndpointDescriptor { + t.Helper() + for _, iface := range dev.GetDescriptor().Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress == address { + return endpoint + } + } + } + t.Fatalf("endpoint 0x%02x is absent from the controller descriptor", address) + return usbdevice.EndpointDescriptor{} +} + +func endpointOperation(identity udecx.DeviceIdentity, kind udecx.OperationKind, + endpoint usbdevice.EndpointDescriptor, +) udecx.Operation { + return udecx.Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, Kind: kind, + EndpointAddress: endpoint.BEndpointAddress, + EndpointAttributes: endpoint.BMAttributes, + EndpointInterval: endpoint.BInterval, + EndpointMaxPacketSize: endpoint.WMaxPacketSize, + } +} + +func (h *playStationParityHarness) nativeLifecycle(t *testing.T, dev usbdevice.Device, + kind udecx.OperationKind, address uint8, +) { + t.Helper() + op := udecx.Operation{ + DeviceID: h.identity.DeviceID, Generation: h.identity.Generation, Kind: kind, + } + if address != 0 { + op = endpointOperation(h.identity, kind, parityEndpoint(t, dev, address)) + } + if err := h.native.Lifecycle(context.Background(), dev, op); err != nil { + t.Fatalf("native lifecycle kind %d endpoint 0x%02x: %v", kind, address, err) + } +} + +func setupPacket(bmRequestType, request uint8, value, index, length uint16) [8]byte { + var setup [8]byte + setup[0] = bmRequestType + setup[1] = request + binary.LittleEndian.PutUint16(setup[2:4], value) + binary.LittleEndian.PutUint16(setup[4:6], index) + binary.LittleEndian.PutUint16(setup[6:8], length) + return setup +} + +func (h *playStationParityHarness) legacySetInterface(dev usbdevice.Device, iface, alt uint8) { + dev.(usbdevice.InterfaceAltSettingDevice).SetInterfaceAltSetting(iface, alt) +} + +func (h *playStationParityHarness) legacyResetEndpoint(dev usbdevice.Device, address uint8) { + dev.(usbdevice.EndpointResetDevice).ResetEndpoint(address) +} + +func (h *playStationParityHarness) legacyOutput(dev usbdevice.Device, address uint8, payload []byte) { + dev.HandleTransfer(context.Background(), uint32(address&0x0f), + usbdevice.DirectionOut, payload) +} + +func (h *playStationParityHarness) nativeOutput(t *testing.T, dev usbdevice.Device, + address uint8, payload []byte, +) { + t.Helper() + endpoint := parityEndpoint(t, dev, address) + op := endpointOperation(h.identity, udecx.OperationTransfer, endpoint) + op.Token = h.nextToken() + op.TransferLength = uint32(len(payload)) + op.Payload = append([]byte(nil), payload...) + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native OUT endpoint 0x%02x: %v", address, err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native OUT completion length=%d payload=% x, want length=%d and no echo", + completion.TransferLength, completion.Payload, len(payload)) + } +} + +func (h *playStationParityHarness) legacyHIDSetReport(t *testing.T, dev usbdevice.Device, + interfaceNumber, reportID uint8, payload []byte, +) { + t.Helper() + _, handled := dev.(usbdevice.ControlDevice).HandleControl( + hidRequestTypeOut, hidRequestSetReport, + uint16(hidReportTypeOutput)<<8|uint16(reportID), + uint16(interfaceNumber), uint16(len(payload)), payload) + if !handled { + t.Fatal("USB/IP oracle rejected HID SET_REPORT") + } +} + +func (h *playStationParityHarness) nativeHIDSetReport(t *testing.T, dev usbdevice.Device, + interfaceNumber, reportID uint8, payload []byte, +) { + t.Helper() + op := udecx.Operation{ + Token: h.nextToken(), DeviceID: h.identity.DeviceID, Generation: h.identity.Generation, + Kind: udecx.OperationControl, Direction: 0, TransferLength: uint32(len(payload)), + SetupPacket: setupPacket(hidRequestTypeOut, hidRequestSetReport, + uint16(hidReportTypeOutput)<<8|uint16(reportID), uint16(interfaceNumber), uint16(len(payload))), + Payload: append([]byte(nil), payload...), + } + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native HID SET_REPORT: %v", err) + } + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 { + t.Fatalf("native SET_REPORT completion=%+v", completion) + } +} + +func sequentialIsoPackets(totalBytes, packetBytes int) []udecx.IsoPacket { + packets := make([]udecx.IsoPacket, 0, (totalBytes+packetBytes-1)/packetBytes) + for offset := 0; offset < totalBytes; offset += packetBytes { + length := min(packetBytes, totalBytes-offset) + packets = append(packets, udecx.IsoPacket{Offset: uint32(offset), Length: uint32(length)}) + } + return packets +} + +func sparseIsoPackets(count int, packetBytes, gap uint32) ([]udecx.IsoPacket, uint32) { + packets := make([]udecx.IsoPacket, count) + var transferLength uint32 + for index := range packets { + offset := uint32(index) * (packetBytes + gap) + packets[index] = udecx.IsoPacket{Offset: offset, Length: packetBytes} + transferLength = offset + packetBytes + } + return packets, transferLength +} + +func (h *playStationParityHarness) nativeISO(t *testing.T, dev usbdevice.Device, + address uint8, transferLength uint32, payload []byte, packets []udecx.IsoPacket, +) udecx.Completion { + t.Helper() + endpoint := parityEndpoint(t, dev, address) + op := endpointOperation(h.identity, udecx.OperationTransfer, endpoint) + op.Token = h.nextToken() + op.TransferLength = transferLength + op.IsoPackets = append([]udecx.IsoPacket(nil), packets...) + op.TransferFlags = udecx.TransferFlagStartIsoASAP + if address&0x80 != 0 { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } else { + op.Payload = append([]byte(nil), payload...) + } + completion, err := h.native.Process(context.Background(), dev, op) + if err != nil { + t.Fatalf("native ISO endpoint 0x%02x: %v", address, err) + } + return completion +} + +func legacyISOIn(t *testing.T, dev usbdevice.Device, address uint8, + packets []udecx.IsoPacket, +) ([]byte, []usbip.IsoPacketDescriptor) { + t.Helper() + payload := make([]byte, 0) + completed := make([]usbip.IsoPacketDescriptor, len(packets)) + for index, packet := range packets { + packetData := dev.HandleTransfer(context.Background(), uint32(address&0x0f), + usbdevice.DirectionIn, nil) + actual := min(packet.Length, uint32(len(packetData))) + payload = append(payload, packetData[:actual]...) + completed[index] = usbip.IsoPacketDescriptor{ + Offset: packet.Offset, Length: packet.Length, ActualLength: actual, + } + } + return payload, completed +} + +func compactNativeISO(t *testing.T, completion udecx.Completion) ([]byte, []uint32) { + t.Helper() + compact := make([]byte, 0, completion.TransferLength) + lengths := make([]uint32, len(completion.IsoPackets)) + for index, packet := range completion.IsoPackets { + end := packet.Offset + packet.Length + if end > uint32(len(completion.Payload)) { + t.Fatalf("native completed packet %d exceeds payload: %+v payload=%d", + index, packet, len(completion.Payload)) + } + compact = append(compact, completion.Payload[packet.Offset:end]...) + lengths[index] = packet.Length + } + return compact, lengths +} + +func compactLegacyISOLengths(completed []usbip.IsoPacketDescriptor) []uint32 { + lengths := make([]uint32, len(completed)) + for index, packet := range completed { + lengths[index] = packet.ActualLength + } + return lengths +} + +func normalizeDualSenseInput(report []byte) []byte { + normalized := append([]byte(nil), report...) + if len(normalized) >= dualsense.InputReportSize { + clear(normalized[28:32]) + clear(normalized[49:53]) + } + return normalized +} + +func normalizeDualShock4Input(report []byte) []byte { + normalized := append([]byte(nil), report...) + if len(normalized) >= dualshock4.InputReportSize { + clear(normalized[10:12]) + } + return normalized +} + +func patternedPCM(size int, seed byte) []byte { + pcm := make([]byte, size) + for index := range pcm { + pcm[index] = seed + byte(index*29+index/7) + } + return pcm +} + +func requireDeviceBool(t *testing.T, dev usbdevice.Device, key string, want bool) { + t.Helper() + got, ok := dev.GetDeviceSpecificArgs()[key].(bool) + if !ok || got != want { + t.Fatalf("device state %s=%v (bool=%t), want %t", key, got, ok, want) + } +} + +type dualSenseAtomicCapture struct { + feedback dualsense.OutputState + speaker []byte +} + +type dualSenseParityCapture struct { + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int + events []string +} + +func (capture *dualSenseParityCapture) attach(dev *dualsense.DualSense) { + dev.SetOutputCallback(func(state dualsense.OutputState) { + capture.outputs = append(capture.outputs, state) + capture.events = append(capture.events, "output") + }) + dev.SetAtomicAudioHapticsCallback(func(state dualsense.OutputState, speaker []byte) { + capture.atomic = append(capture.atomic, dualSenseAtomicCapture{ + feedback: state, speaker: append([]byte(nil), speaker...), + }) + capture.events = append(capture.events, "atomic") + }) + dev.SetRealtimeHapticsCallback(func(state dualsense.OutputState) { + capture.realtime = append(capture.realtime, state) + capture.events = append(capture.events, "realtime") + }) + dev.SetSpeakerResetCallback(func() { + capture.resets++ + capture.events = append(capture.events, "reset") + }) +} + +func dualSensePCM(frames int, bias int16) []byte { + pcm := make([]byte, frames*dualsense.USBHapticsAudioFrameSize) + for frame := 0; frame < frames; frame++ { + offset := frame * dualsense.USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(pcm[offset:offset+2], uint16(bias+int16(frame))) + binary.LittleEndian.PutUint16(pcm[offset+2:offset+4], uint16(-bias-int16(frame))) + binary.LittleEndian.PutUint16(pcm[offset+4:offset+6], uint16(2*bias+int16(frame*3))) + binary.LittleEndian.PutUint16(pcm[offset+6:offset+8], uint16(-2*bias-int16(frame*3))) + } + return pcm +} + +func dualSenseFrontStereo(pcm []byte) []byte { + frames := len(pcm) / dualsense.USBHapticsAudioFrameSize + front := make([]byte, frames*dualsense.USBHapticsAudioBytesPerSample*2) + for frame := 0; frame < frames; frame++ { + copy(front[frame*4:frame*4+4], + pcm[frame*dualsense.USBHapticsAudioFrameSize:frame*dualsense.USBHapticsAudioFrameSize+4]) + } + return front +} + +func requireDualSenseCapturesEqual(t *testing.T, legacy, native *dualSenseParityCapture) { + t.Helper() + if len(legacy.outputs) != len(native.outputs) || + len(legacy.atomic) != len(native.atomic) || + len(legacy.realtime) != len(native.realtime) || legacy.resets != native.resets || + !bytes.Equal([]byte(joinParityEvents(legacy.events)), []byte(joinParityEvents(native.events))) { + t.Fatalf("DualSense callback boundary mismatch:\nlegacy outputs=%d atomic=%d realtime=%d resets=%d events=%v\nnative outputs=%d atomic=%d realtime=%d resets=%d events=%v", + len(legacy.outputs), len(legacy.atomic), len(legacy.realtime), legacy.resets, legacy.events, + len(native.outputs), len(native.atomic), len(native.realtime), native.resets, native.events) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualSense output state %d differs across transports", index) + } + } + for index := range legacy.atomic { + if legacy.atomic[index].feedback != native.atomic[index].feedback || + !bytes.Equal(legacy.atomic[index].speaker, native.atomic[index].speaker) { + t.Fatalf("DualSense atomic media generation %d differs across transports", index) + } + } + for index := range legacy.realtime { + if legacy.realtime[index] != native.realtime[index] { + t.Fatalf("DualSense realtime haptics generation %d differs across transports", index) + } + } +} + +func joinParityEvents(events []string) string { + var joined string + for _, event := range events { + joined += event + "\x00" + } + return joined +} + +func TestNativeDualSenseMatchesUSBIPOracle(t *testing.T) { + harness := newPlayStationParityHarness(t) + legacy, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + native, err := dualsense.New(nil) + if err != nil { + t.Fatal(err) + } + legacyCapture := &dualSenseParityCapture{} + nativeCapture := &dualSenseParityCapture{} + legacyCapture.attach(legacy) + nativeCapture.attach(native) + + t.Run("native fast HID input preserves state bytes", func(t *testing.T) { + state := dualsense.NewInputState() + state.LX, state.LY, state.RX, state.RY = -101, 87, 45, -32 + state.Buttons = dualsense.ButtonCross | dualsense.ButtonR1 | dualsense.ButtonPS + state.DPad = dualsense.DPadUp | dualsense.DPadRight + state.L2, state.R2 = 0x39, 0xe4 + state.Touch1Active, state.Touch1Tracking = true, 7 + state.Touch1X, state.Touch1Y = 1234, 567 + state.GyroX, state.GyroY, state.GyroZ = 101, -202, 303 + legacy.UpdateInputState(state) + native.UpdateInputState(state) + + legacyReport := legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointIn&0x0f), usbdevice.DirectionIn, nil) + nativeReport := make([]byte, dualsense.InputReportSize) + written, readErr := native.ReadInterruptInput(context.Background(), + uint32(dualsense.EndpointIn), nativeReport) + if readErr != nil || written != dualsense.InputReportSize { + t.Fatalf("native DualSense HID read wrote %d: %v", written, readErr) + } + if !bytes.Equal(normalizeDualSenseInput(legacyReport), normalizeDualSenseInput(nativeReport)) { + t.Fatalf("DualSense HID state differs:\nlegacy=% x\nnative=% x", legacyReport, nativeReport) + } + if nativeReport[1] != uint8(int16(state.LX)+128) || nativeReport[5] != state.L2 || + nativeReport[6] != state.R2 || nativeReport[34] != byte(state.Touch1X) { + t.Fatalf("native DualSense HID report did not encode the requested state: % x", nativeReport) + } + }) + + t.Run("HID feedback preserves rumble lightbar player LEDs and triggers", func(t *testing.T) { + triggers := make([]byte, dualsense.OutputReportSize) + triggers[0] = dualsense.ReportIDOutput + triggers[1] = 0x0c + copy(triggers[11:21], []byte{0x21, 0xf0, 0x03, 0x04, 0x05, 0x06, 0x07, 0, 0, 0x44}) + copy(triggers[22:32], []byte{0x25, 0x40, 0x05, 0x14, 0x15, 0x16, 0x17, 0, 0, 0x55}) + harness.legacyOutput(legacy, dualsense.EndpointOut, triggers) + harness.nativeOutput(t, native, dualsense.EndpointOut, triggers) + + visualRumble := make([]byte, dualsense.OutputReportSize) + visualRumble[0] = dualsense.ReportIDOutput + visualRumble[1] = 0x03 + visualRumble[2] = 0x14 + visualRumble[3], visualRumble[4] = 0x2a, 0xb4 + visualRumble[44] = 0x1f + visualRumble[45], visualRumble[46], visualRumble[47] = 0x12, 0x67, 0xcd + harness.legacyHIDSetReport(t, legacy, dualSenseHIDInterface, dualsense.ReportIDOutput, visualRumble) + harness.nativeHIDSetReport(t, native, dualSenseHIDInterface, + dualsense.ReportIDOutput, visualRumble) + + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + got := nativeCapture.outputs[len(nativeCapture.outputs)-1] + if got.RumbleSmall != 0x2a || got.RumbleLarge != 0xb4 || + got.LedRed != 0x12 || got.LedGreen != 0x67 || got.LedBlue != 0xcd || + got.PlayerLeds != 0x1f || got.TriggerR2Mode != 0x21 || + got.TriggerL2Mode != 0x25 || !bytes.Equal(got.RawOutputReport[:], visualRumble) { + t.Fatalf("native DualSense feedback state lost a host field: %+v", got) + } + }) + + t.Run("haptics OUT preserves bytes and independent 480/512-frame boundaries", func(t *testing.T) { + harness.legacySetInterface(legacy, dualsense.InterfaceHapticsAudio, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualsense.EndpointHapticsAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + + pcm := dualSensePCM(512, 700) + parts := [][2]int{{0, 240}, {240, 480}, {480, 512}} + for index, part := range parts { + payload := pcm[part[0]*dualsense.USBHapticsAudioFrameSize : part[1]*dualsense.USBHapticsAudioFrameSize] + packets := sequentialIsoPackets(len(payload), dualsense.USBHapticsAudioPacketSize) + legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointHapticsAudioOut&0x0f), usbdevice.DirectionOut, payload) + completion := harness.nativeISO(t, native, dualsense.EndpointHapticsAudioOut, + uint32(len(payload)), payload, packets) + if completion.TransferLength != uint32(len(payload)) || len(completion.Payload) != 0 || + len(completion.IsoPackets) != len(packets) { + t.Fatalf("native DualSense ISO OUT part %d completion=%+v", index, completion) + } + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + switch index { + case 0: + if len(nativeCapture.atomic) != 0 || len(nativeCapture.realtime) != 0 { + t.Fatal("DualSense emitted media before either source-clock boundary") + } + case 1: + if len(nativeCapture.atomic) != 1 || len(nativeCapture.realtime) != 0 { + t.Fatal("DualSense 480-frame speaker boundary was not independent") + } + wantSpeaker := dualSenseFrontStereo(pcm[:480*dualsense.USBHapticsAudioFrameSize]) + if !bytes.Equal(nativeCapture.atomic[0].speaker, wantSpeaker) { + t.Fatal("native DualSense front-channel PCM changed byte order") + } + case 2: + if len(nativeCapture.atomic) != 1 || len(nativeCapture.realtime) != 1 { + t.Fatal("DualSense 512-frame haptics boundary was not preserved") + } + } + } + + // Leave 32 old speaker frames pending, reset the pipe, then prove that + // 448 fresh frames cannot complete a stale 480-frame generation. + harness.legacyResetEndpoint(legacy, dualsense.EndpointHapticsAudioOut) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualsense.EndpointHapticsAudioOut) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + + fresh := dualSensePCM(480, 2_000) + for _, part := range [][2]int{{0, 448}, {448, 480}} { + payload := fresh[part[0]*dualsense.USBHapticsAudioFrameSize : part[1]*dualsense.USBHapticsAudioFrameSize] + packets := sequentialIsoPackets(len(payload), dualsense.USBHapticsAudioPacketSize) + legacy.HandleTransfer(context.Background(), + uint32(dualsense.EndpointHapticsAudioOut&0x0f), usbdevice.DirectionOut, payload) + harness.nativeISO(t, native, dualsense.EndpointHapticsAudioOut, + uint32(len(payload)), payload, packets) + if part[1] == 448 && len(nativeCapture.atomic) != 1 { + t.Fatal("stale DualSense speaker PCM crossed the endpoint reset") + } + } + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + if len(nativeCapture.atomic) != 2 || + !bytes.Equal(nativeCapture.atomic[1].speaker, dualSenseFrontStereo(fresh)) { + t.Fatal("fresh DualSense speaker generation was not byte-exact after reset") + } + + harness.legacySetInterface(legacy, dualsense.InterfaceHapticsAudio, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualsense.EndpointHapticsAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", false) + requireDeviceBool(t, native, "speakerInterfaceActive", false) + requireDualSenseCapturesEqual(t, legacyCapture, nativeCapture) + }) + + t.Run("microphone IN preserves sparse packet bytes and reset priming", func(t *testing.T) { + harness.legacySetInterface(legacy, dualsense.InterfaceMicrophone, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + + queued := make([]byte, 0, 6*dualsense.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualsense.USBMicrophoneClientFrameSize, byte(0x10+frame*17)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + queued = append(queued, pcm...) + } + packets, transferLength := sparseIsoPackets(3, dualsense.USBMicrophoneMaxPacketSize, 11) + legacyPayload, legacyCompleted := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, packets) + nativeCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + transferLength, nil, packets) + nativePayload, nativeLengths := compactNativeISO(t, nativeCompletion) + legacyLengths := compactLegacyISOLengths(legacyCompleted) + if !bytes.Equal(legacyPayload, nativePayload) || + !bytes.Equal(uint32sAsBytes(legacyLengths), uint32sAsBytes(nativeLengths)) || + !bytes.Equal(nativePayload, queued[:len(nativePayload)]) { + t.Fatalf("DualSense microphone packet parity failed:\nlegacy lengths=%v payload=% x\nnative lengths=%v payload=% x", + legacyLengths, legacyPayload, nativeLengths, nativePayload) + } + + harness.legacyResetEndpoint(legacy, dualsense.EndpointMicrophoneIn) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + zeroPackets, zeroLength := sparseIsoPackets(1, dualsense.USBMicrophoneMaxPacketSize, 0) + legacyZero, legacyZeroCompleted := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, zeroPackets) + nativeZeroCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeZero, nativeZeroLengths := compactNativeISO(t, nativeZeroCompletion) + if !bytes.Equal(legacyZero, nativeZero) || + !bytes.Equal(nativeZero, make([]byte, dualsense.USBMicrophonePacketSize)) || + legacyZeroCompleted[0].ActualLength != uint32(dualsense.USBMicrophonePacketSize) || + nativeZeroLengths[0] != uint32(dualsense.USBMicrophonePacketSize) { + t.Fatalf("DualSense reset silence differs: legacy=% x native=% x", legacyZero, nativeZero) + } + + freshQueued := make([]byte, 0, 6*dualsense.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualsense.USBMicrophoneClientFrameSize, byte(0x90+frame*9)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + freshQueued = append(freshQueued, pcm...) + } + legacyFresh, _ := legacyISOIn(t, legacy, + dualsense.EndpointMicrophoneIn, zeroPackets) + nativeFreshCompletion := harness.nativeISO(t, native, dualsense.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeFresh, _ := compactNativeISO(t, nativeFreshCompletion) + if !bytes.Equal(legacyFresh, nativeFresh) || + !bytes.Equal(nativeFresh, freshQueued[:len(nativeFresh)]) { + t.Fatal("DualSense microphone reset replayed stale capture bytes") + } + + harness.legacySetInterface(legacy, dualsense.InterfaceMicrophone, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualsense.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", false) + requireDeviceBool(t, native, "microphoneInterfaceActive", false) + }) +} + +func uint32sAsBytes(values []uint32) []byte { + result := make([]byte, len(values)*4) + for index, value := range values { + binary.LittleEndian.PutUint32(result[index*4:index*4+4], value) + } + return result +} + +type dualShock4ParityCapture struct { + outputs []dualshock4.OutputState + speaker [][]byte + resets int + events []string +} + +func (capture *dualShock4ParityCapture) attach(dev *dualshock4.DualShock4) { + dev.SetOutputCallback(func(state dualshock4.OutputState) { + capture.outputs = append(capture.outputs, state) + capture.events = append(capture.events, "output") + }) + dev.SetSpeakerCallback(func(pcm []byte) { + capture.speaker = append(capture.speaker, append([]byte(nil), pcm...)) + capture.events = append(capture.events, "speaker") + }) + dev.SetSpeakerResetCallback(func() { + capture.resets++ + capture.events = append(capture.events, "reset") + }) +} + +func requireDualShock4CapturesEqual(t *testing.T, legacy, native *dualShock4ParityCapture) { + t.Helper() + if len(legacy.outputs) != len(native.outputs) || len(legacy.speaker) != len(native.speaker) || + legacy.resets != native.resets || joinParityEvents(legacy.events) != joinParityEvents(native.events) { + t.Fatalf("DualShock 4 callback boundary mismatch:\nlegacy outputs=%d speaker=%d resets=%d events=%v\nnative outputs=%d speaker=%d resets=%d events=%v", + len(legacy.outputs), len(legacy.speaker), legacy.resets, legacy.events, + len(native.outputs), len(native.speaker), native.resets, native.events) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualShock 4 output state %d differs across transports", index) + } + } + for index := range legacy.speaker { + if !bytes.Equal(legacy.speaker[index], native.speaker[index]) { + t.Fatalf("DualShock 4 speaker generation %d differs across transports", index) + } + } +} + +func TestNativeDualShock4MatchesUSBIPOracle(t *testing.T) { + harness := newPlayStationParityHarness(t) + legacy, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + native, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + legacyCapture := &dualShock4ParityCapture{} + nativeCapture := &dualShock4ParityCapture{} + legacyCapture.attach(legacy) + nativeCapture.attach(native) + + t.Run("native fast HID input preserves state bytes", func(t *testing.T) { + state := dualshock4.NewInputState() + state.LX, state.LY, state.RX, state.RY = -95, 71, 44, -23 + state.Buttons = dualshock4.ButtonCircle | dualshock4.ButtonL1 | + dualshock4.ButtonPS | dualshock4.ButtonTouchpadClick + state.DPad = dualshock4.DPadDown | dualshock4.DPadLeft + state.L2, state.R2 = 0x28, 0xdd + state.Touch2Active, state.Touch2X, state.Touch2Y = true, 777, 999 + state.GyroX, state.GyroY, state.GyroZ = -111, 222, -333 + legacy.UpdateInputState(state) + native.UpdateInputState(state) + + legacyReport := legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointIn&0x0f), usbdevice.DirectionIn, nil) + nativeReport := make([]byte, dualshock4.InputReportSize) + written, readErr := native.ReadInterruptInput(context.Background(), + uint32(dualshock4.EndpointIn), nativeReport) + if readErr != nil || written != dualshock4.InputReportSize { + t.Fatalf("native DualShock 4 HID read wrote %d: %v", written, readErr) + } + if !bytes.Equal(normalizeDualShock4Input(legacyReport), normalizeDualShock4Input(nativeReport)) { + t.Fatalf("DualShock 4 HID state differs:\nlegacy=% x\nnative=% x", legacyReport, nativeReport) + } + if nativeReport[1] != uint8(int16(state.LX)+128) || nativeReport[8] != state.L2 || + nativeReport[9] != state.R2 || nativeReport[7]&0x03 != 0x03 { + t.Fatalf("native DualShock 4 HID report did not encode the requested state: % x", nativeReport) + } + }) + + t.Run("HID feedback preserves rumble lightbar and flash state", func(t *testing.T) { + first := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x12, 0xfe, 1, 2, 3, 4, 5} + harness.legacyOutput(legacy, dualshock4.EndpointOut, first) + harness.nativeOutput(t, native, dualshock4.EndpointOut, first) + + second := []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x39, 0xa4, 0x10, 0x20, 0x30, 0x40, 0x50} + harness.legacyHIDSetReport(t, legacy, dualshock4.InterfaceHID, + dualshock4.ReportIDOutput, second) + harness.nativeHIDSetReport(t, native, dualshock4.InterfaceHID, + dualshock4.ReportIDOutput, second) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + got := nativeCapture.outputs[len(nativeCapture.outputs)-1] + want := dualshock4.OutputState{ + RumbleSmall: 0x39, RumbleLarge: 0xa4, + LedRed: 0x10, LedGreen: 0x20, LedBlue: 0x30, + FlashOn: 0x40, FlashOff: 0x50, + } + if got != want { + t.Fatalf("native DualShock 4 feedback=%+v want=%+v", got, want) + } + }) + + t.Run("speaker OUT preserves URB byte order and reset boundaries", func(t *testing.T) { + harness.legacySetInterface(legacy, dualshock4.InterfaceSpeaker, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualshock4.EndpointAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + + payloads := [][]byte{ + patternedPCM(4*128, 0x11), + patternedPCM(3*128, 0x83), + } + for index, payload := range payloads { + packets := sequentialIsoPackets(len(payload), 128) + legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointAudioOut&0x0f), usbdevice.DirectionOut, payload) + completion := harness.nativeISO(t, native, dualshock4.EndpointAudioOut, + uint32(len(payload)), payload, packets) + if completion.TransferLength != uint32(len(payload)) || len(completion.IsoPackets) != len(packets) { + t.Fatalf("native DualShock 4 ISO OUT part %d completion=%+v", index, completion) + } + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + } + if len(nativeCapture.speaker) != len(payloads) { + t.Fatalf("native DualShock 4 combined or split speaker URBs: %d callbacks", len(nativeCapture.speaker)) + } + for index := range payloads { + if !bytes.Equal(nativeCapture.speaker[index], payloads[index]) { + t.Fatalf("native DualShock 4 speaker payload %d changed byte order", index) + } + } + + harness.legacyResetEndpoint(legacy, dualshock4.EndpointAudioOut) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualshock4.EndpointAudioOut) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + requireDeviceBool(t, legacy, "speakerInterfaceActive", true) + requireDeviceBool(t, native, "speakerInterfaceActive", true) + + fresh := patternedPCM(2*128, 0x57) + packets := sequentialIsoPackets(len(fresh), 128) + legacy.HandleTransfer(context.Background(), + uint32(dualshock4.EndpointAudioOut&0x0f), usbdevice.DirectionOut, fresh) + harness.nativeISO(t, native, dualshock4.EndpointAudioOut, + uint32(len(fresh)), fresh, packets) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + if !bytes.Equal(nativeCapture.speaker[len(nativeCapture.speaker)-1], fresh) { + t.Fatal("DualShock 4 endpoint reset changed the fresh speaker generation") + } + + harness.legacySetInterface(legacy, dualshock4.InterfaceSpeaker, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualshock4.EndpointAudioOut) + requireDeviceBool(t, legacy, "speakerInterfaceActive", false) + requireDeviceBool(t, native, "speakerInterfaceActive", false) + requireDualShock4CapturesEqual(t, legacyCapture, nativeCapture) + }) + + t.Run("microphone IN preserves sparse packet bytes and reset priming", func(t *testing.T) { + harness.legacySetInterface(legacy, dualshock4.InterfaceMicrophone, 1) + harness.nativeLifecycle(t, native, udecx.OperationEndpointStart, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + + queued := make([]byte, 0, 6*dualshock4.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualshock4.USBMicrophoneClientFrameSize, byte(0x20+frame*13)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + queued = append(queued, pcm...) + } + packets, transferLength := sparseIsoPackets(4, dualshock4.USBMicrophoneMaxPacketSize, 7) + legacyPayload, legacyCompleted := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, packets) + nativeCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + transferLength, nil, packets) + nativePayload, nativeLengths := compactNativeISO(t, nativeCompletion) + legacyLengths := compactLegacyISOLengths(legacyCompleted) + if !bytes.Equal(legacyPayload, nativePayload) || + !bytes.Equal(uint32sAsBytes(legacyLengths), uint32sAsBytes(nativeLengths)) || + !bytes.Equal(nativePayload, queued[:len(nativePayload)]) { + t.Fatalf("DualShock 4 microphone packet parity failed:\nlegacy lengths=%v payload=% x\nnative lengths=%v payload=% x", + legacyLengths, legacyPayload, nativeLengths, nativePayload) + } + + harness.legacyResetEndpoint(legacy, dualshock4.EndpointMicrophoneIn) + harness.nativeLifecycle(t, native, udecx.OperationEndpointReset, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", true) + requireDeviceBool(t, native, "microphoneInterfaceActive", true) + zeroPackets, zeroLength := sparseIsoPackets(1, dualshock4.USBMicrophoneMaxPacketSize, 0) + legacyZero, legacyZeroCompleted := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, zeroPackets) + nativeZeroCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeZero, nativeZeroLengths := compactNativeISO(t, nativeZeroCompletion) + if !bytes.Equal(legacyZero, nativeZero) || + !bytes.Equal(nativeZero, make([]byte, dualshock4.USBMicrophonePacketSize)) || + legacyZeroCompleted[0].ActualLength != uint32(dualshock4.USBMicrophonePacketSize) || + nativeZeroLengths[0] != uint32(dualshock4.USBMicrophonePacketSize) { + t.Fatalf("DualShock 4 reset silence differs: legacy=% x native=% x", legacyZero, nativeZero) + } + + freshQueued := make([]byte, 0, 6*dualshock4.USBMicrophoneClientFrameSize) + for frame := 0; frame < 6; frame++ { + pcm := patternedPCM(dualshock4.USBMicrophoneClientFrameSize, byte(0xa0+frame*7)) + legacy.QueueMicrophonePCMFrame(pcm) + native.QueueMicrophonePCMFrame(pcm) + freshQueued = append(freshQueued, pcm...) + } + legacyFresh, _ := legacyISOIn(t, legacy, + dualshock4.EndpointMicrophoneIn, zeroPackets) + nativeFreshCompletion := harness.nativeISO(t, native, dualshock4.EndpointMicrophoneIn, + zeroLength, nil, zeroPackets) + nativeFresh, _ := compactNativeISO(t, nativeFreshCompletion) + if !bytes.Equal(legacyFresh, nativeFresh) || + !bytes.Equal(nativeFresh, freshQueued[:len(nativeFresh)]) { + t.Fatal("DualShock 4 microphone reset replayed stale capture bytes") + } + + harness.legacySetInterface(legacy, dualshock4.InterfaceMicrophone, 0) + harness.nativeLifecycle(t, native, udecx.OperationEndpointPurge, + dualshock4.EndpointMicrophoneIn) + requireDeviceBool(t, legacy, "microphoneInterfaceActive", false) + requireDeviceBool(t, native, "microphoneInterfaceActive", false) + }) +} From cb19f16fdbee915a00e8d16ca3d2c6044687f5b9 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:27:09 -0500 Subject: [PATCH 153/240] Normalize native UDE transfer scheduling metadata Use Windows ISO packet units when advancing the full-speed virtual StartFrame clock, while preserving DS4 bInterval=1 and DualSense high-speed cadence. Derive non-control transfer direction from the endpoint descriptor and normalize TransferFlags so stale Windows URB direction bits cannot invert or reject media/output traffic. Add deterministic source and cadence contracts. --- .../udecx/driver_iso_contract_test.go | 95 +++++++++++++++++++ .../udecx/driver_transfer_contract_test.go | 47 +++++++++ native/udecx/driver/Broker.c | 26 ++++- 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 internal/transport/udecx/driver_iso_contract_test.go create mode 100644 internal/transport/udecx/driver_transfer_contract_test.go diff --git a/internal/transport/udecx/driver_iso_contract_test.go b/internal/transport/udecx/driver_iso_contract_test.go new file mode 100644 index 00000000..82acccae --- /dev/null +++ b/internal/transport/udecx/driver_iso_contract_test.go @@ -0,0 +1,95 @@ +package udecx + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func nativeDriverBrokerSource(t *testing.T) string { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve native driver contract test path") + } + path := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "native", "udecx", "driver", "Broker.c") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native UdeCx broker source: %v", err) + } + return string(source) +} + +func TestNativeDriverIsoFrameSpanUsesWindowsPacketUnits(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperIsoFrameSpan(") + if start < 0 { + t.Fatal("native ISO frame reservation helpers are missing") + } + end := strings.Index(source[start:], "ViiperReserveIsoStartFrame(") + if end < 0 { + t.Fatal("native ISO frame reservation helpers are missing") + } + span := source[start : start+end] + + // A high/super-speed IsoPacket is one service opportunity measured in + // microframes, so the descriptor exponent is converted to 1-ms StartFrame + // units. A full-speed IsoPacket is already one 1-ms frame according to the + // Windows URB contract; multiplying by bInterval here schedules the same + // polling period twice and leaves artificial holes between reservations. + for _, required := range []string{ + "deviceContext->Speed == UdecxUsbHighSpeed", + "deviceContext->Speed == UdecxUsbSuperSpeed", + "PacketCount * ((ULONGLONG)1 << (interval - 1))", + "span = (span + 7) / 8;", + "span = PacketCount;", + } { + if !strings.Contains(span, required) { + t.Fatalf("native ISO frame span is missing %q", required) + } + } + if strings.Contains(span, "PacketCount * interval") { + t.Fatal("full-speed ISO frame span still multiplies Windows packet units by bInterval") + } +} + +func TestNativeDriverIsoFrameSpanPreservesPlayStationCadence(t *testing.T) { + frameSpan := func(highSpeed bool, interval uint8, packets uint32) uint32 { + if packets == 0 { + return 1 + } + if interval == 0 { + return packets + } + if !highSpeed { + return packets + } + if interval > 16 { + return packets + } + microframes := uint64(packets) * (uint64(1) << (interval - 1)) + return uint32((microframes + 7) / 8) + } + + tests := []struct { + name string + highSpeed bool + interval uint8 + packets uint32 + want uint32 + }{ + {name: "DualShock 4 full-speed audio", interval: 1, packets: 32, want: 32}, + {name: "DualSense high-speed one-ms audio", highSpeed: true, interval: 4, packets: 32, want: 32}, + {name: "high-speed 125-us service", highSpeed: true, interval: 1, packets: 32, want: 4}, + {name: "full-speed descriptor interval is not applied twice", interval: 4, packets: 32, want: 32}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := frameSpan(test.highSpeed, test.interval, test.packets); got != test.want { + t.Fatalf("frame span=%d want=%d", got, test.want) + } + }) + } +} diff --git a/internal/transport/udecx/driver_transfer_contract_test.go b/internal/transport/udecx/driver_transfer_contract_test.go new file mode 100644 index 00000000..905dc5dc --- /dev/null +++ b/internal/transport/udecx/driver_transfer_contract_test.go @@ -0,0 +1,47 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeDriverUsesEndpointDirectionForNonControlTransfers(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperSerializeOperation(") + if start < 0 { + t.Fatal("native transfer serializer is missing") + } + end := strings.Index(source[start:], "ViiperDispatchAvailable(") + if end < 0 { + t.Fatal("native transfer serializer boundary is missing") + } + serializer := source[start : start+end] + + for _, required := range []string{ + "urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER", + "urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER_EX", + "endpointContext->Descriptor.bEndpointAddress &", + "USB_ENDPOINT_DIRECTION_MASK", + "transferFlags |= USBD_TRANSFER_DIRECTION_IN;", + "transferFlags &= ~USBD_TRANSFER_DIRECTION_IN;", + "operation->Direction = directionIn ? 1 : 0;", + "operation->TransferFlags = transferFlags;", + } { + if !strings.Contains(serializer, required) { + t.Fatalf("native transfer direction normalization is missing %q", required) + } + } + + metadataStart := strings.Index(source, "ViiperGetTransferMetadata(") + if metadataStart < 0 { + t.Fatal("native transfer metadata helper is missing") + } + metadataEnd := strings.Index(source[metadataStart:], "ViiperSerializeOperation(") + if metadataEnd < 0 { + t.Fatal("native transfer metadata helper boundary is missing") + } + metadata := source[metadataStart : metadataStart+metadataEnd] + if !strings.Contains(metadata, "*DirectionIn = ((SetupPacket[0] & USB_ENDPOINT_DIRECTION_MASK) != 0);") { + t.Fatal("control transfer direction no longer comes from its setup packet") + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 7868f9fb..5254c574 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1247,7 +1247,15 @@ ViiperIsoFrameSpan( span = (ULONGLONG)PacketCount * ((ULONGLONG)1 << (interval - 1)); span = (span + 7) / 8; } else { - span = (ULONGLONG)PacketCount * interval; + // Windows defines each full-speed IsoPacket entry as one 1-ms frame. + // bInterval describes the endpoint's polling contract; it must not be + // multiplied into the URB packet-array span a second time. In + // particular, doing so creates holes in the virtual StartFrame clock + // after the USB stack has already expressed the schedule as one packet + // entry per frame. Production DS4 audio uses bInterval=1, so this + // correction preserves its proven cadence while making the generic + // UdeCx clock obey the Windows full-speed URB contract. + span = PacketCount; } if (span == 0) { return 1; @@ -1473,6 +1481,22 @@ ViiperSerializeOperation( if (!NT_SUCCESS(status)) { return status; } + if (urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER && + urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER_EX) { + // Windows can supply stale or inconsistent direction bits in + // TransferFlags (usbip-win2 observes this for bulk URBs). The endpoint + // descriptor is authoritative for every non-control pipe; only a + // control setup packet owns its direction. Normalize both ABI fields + // together so user mode never rejects or inverts an otherwise valid + // media/output transfer. + directionIn = (endpointContext->Descriptor.bEndpointAddress & + USB_ENDPOINT_DIRECTION_MASK) != 0; + if (directionIn) { + transferFlags |= USBD_TRANSFER_DIRECTION_IN; + } else { + transferFlags &= ~USBD_TRANSFER_DIRECTION_IN; + } + } if (packetCount != 0) { startFrame = ViiperReserveIsoStartFrame( endpointContext, transferFlags, startFrame, packetCount); From f760901d8a2365741b1e3782521e24c33432c2a4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:30:01 -0500 Subject: [PATCH 154/240] Implement exact native package uninstall transaction Route viiper uninstall through hash-bound ViiperUdeCtl removal under package-then-service locking. Preserve exact service, broker, credential, devnode, and Driver Store topology on verified rollback while leaving the broker stopped for every ambiguous helper outcome. Add protected rollback backups, cooperative deadlines, reboot-safe self-image cleanup, marked-delete reconciliation, idempotence, and deterministic failure/concurrency/3010 tests. --- .../native-udecx-package-install.md | 97 +- docs/architecture/native-udecx-signing.md | 10 +- internal/cmd/install.go | 18 +- internal/cmd/install_linux.go | 8 +- internal/cmd/install_windows.go | 19 +- internal/cmd/native_package_contract_test.go | 66 +- internal/cmd/native_package_uninstall.go | 265 ++++ internal/cmd/native_package_uninstall_test.go | 438 +++++++ .../cmd/native_package_uninstall_windows.go | 1091 +++++++++++++++++ .../native_package_uninstall_windows_test.go | 255 ++++ .../cmd/native_service_install_windows.go | 21 - native/udecx/README.md | 19 + .../tools/Test-ViiperUdeCtlTransaction.ps1 | 12 + native/udecx/tools/ViiperUdeCtl.cpp | 268 +++- 14 files changed, 2525 insertions(+), 62 deletions(-) create mode 100644 internal/cmd/native_package_uninstall.go create mode 100644 internal/cmd/native_package_uninstall_test.go create mode 100644 internal/cmd/native_package_uninstall_windows.go create mode 100644 internal/cmd/native_package_uninstall_windows_test.go diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index a09be56f..0c65efbe 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -1,4 +1,4 @@ -# Native UDE package installation transaction +# Native UDE package installation and removal transactions The native UDE release is installed through one fail-closed composition transaction. `viiper native-package-install` is a hidden bootstrapper boundary; @@ -7,6 +7,12 @@ reviewed SHA-256 values. It cannot turn a CI test-signed package into production media. The driver must first satisfy the production HLK/WHCP contract in [`native-udecx-signing.md`](native-udecx-signing.md). +Production removal enters through `viiper uninstall`. The signed installer must +pass the packaged `ViiperUdeCtl.exe` path, its installer-bound SHA-256, and the +interactive-user SID. A direct Windows uninstall without those immutable helper +inputs fails before mutation; the command does not fall back to deleting only +the broker and leaving the devnode or Driver Store package behind. + ## Trust inputs The signed bootstrapper supplies all of the following as immutable build data: @@ -87,6 +93,80 @@ uses its own non-canceled two-minute context. Synchronous SetupAPI work is checked immediately before and after each mutating boundary; no new phase may start after expiry, and no process is killed mid-rollback. +## Exact package removal + +Removal is a separate fail-closed composition transaction; it does not reuse +the historical broker-only uninstall routine. + +1. Acquire the package mutex and then the broker-service mutex. Lock every local + ancestor of the packaged helper, hold its leaf handle without write/delete + sharing, and require the installer-bound SHA-256, one-link identity, + non-reparse identity, and PE header. +2. Inventory only the exact `VIIPERNativeBroker` name. A service is eligible to + stop only when its LocalSystem configuration, arguments, service DACL, + recovery policy, credential path, and managed Program Files executable are + canonical. The executable, credential, and protected directory chains remain + locked and hash-snapshotted. A running broker's optional log is first held by + file identity with sharing compatible with its trusted writer. A same-named + weak, non-LocalSystem, or non-managed service fails preflight and is never + adopted or deleted. +3. Stop the exact trusted service but keep its SCM registration, credential, and + managed files intact. Before launching the helper, upgrade any live-log probe + to a non-write-shared delete handle and require the same volume/file identity, + one-link state, non-reparse identity, and a stable hash. Launch + `ViiperUdeCtl remove` with the outer absolute + deadline. The Go parent never uses a context-killed process or hard + termination after launch. The helper checks the cooperative deadline before + and after each SetupAPI boundary and owns a separate two-minute cooperative + rollback ceiling. If the live-log identity cannot be upgraded exactly, the + helper is not launched and the broker stays stopped rather than reopening an + ambiguous LocalSystem-managed path. +4. Accept only one structured helper outcome whose process and reported exit + codes agree. Exit 0 is verified final success. Exit 3010 is verified Windows + reboot-success. A preflight rejection proves no driver mutation; exit 1 with + `rollback=succeeded` proves that the exact captured package/devnode topology + was restored. A no-reboot result in those two failure classes permits the + exact prior broker run-state to be restored after its locked service/files + are revalidated. Rollback that still requires a reboot preserves the files + but leaves the service stopped until Windows can settle the binding. + Exit 3, a crash, a missing/malformed proof, or any ambiguous wait cannot prove + a safe binding, so the broker remains stopped and the command reports that + external reconciliation is required. + Before mutation, rollback copies are placed below the non-reparse Windows + temporary directory in a cryptographically unpredictable, protected + Administrators/LocalSystem-only directory. The parent and backup root remain + locked against rename, and the exact INF/SYS/CAT handles deny write/delete + sharing until rollback is no longer possible. +5. Only after exit 0 or 3010 does cleanup revalidate and delete the exact service, + credential, broker log, and installer-owned broker images. Deletion uses the + retained file identities rather than a second untrusted path lookup. It does + not recursively delete either managed directory, and it never enumerates or + changes unrelated devnodes, Driver Store packages, files, scheduled tasks, + Run registrations, processes, or USB/IP state. A repeat after partial cleanup + safely reconciles exact protected leftovers; complete service/driver absence + is idempotent. If the uninstalling process is itself the exact locked broker + image and Windows will not mark the mapped image for immediate deletion, the + transaction proves that identity by volume/file ID, schedules only that + protected path with `MoveFileEx(..., MOVEFILE_DELAY_UNTIL_REBOOT)`, and folds + the result into exit 3010. + A retry that finds the exact service already marked for deletion waits under + the same transaction deadline, then continues from the proven-absent service + state and reconciles only retained exact files. + +This ordering follows Microsoft's separation between +[`DiUninstallDevice`](https://learn.microsoft.com/windows/win32/api/newdev/nf-newdev-diuninstalldevice), +which removes a selected devnode and its child topology, and +[`DiUninstallDriverW`](https://learn.microsoft.com/windows/win32/api/newdev/nf-newdev-diuninstalldriverw), +which removes a specified package from devices and then the Driver Store. Both +APIs return a `NeedReboot` result; the caller must aggregate that result while it +finishes its other required uninstall operations. VIIPER therefore preserves +3010 only after exact owned cleanup has reconciled. The +[usbip-win2 uninstall sequence](https://github.com/vadimgrn/usbip-win2#uninstallation-of-usbip) +is used only as the devnode-before-package lifecycle reference, while +[ViGEmBus releases](https://github.com/ViGEm/ViGEmBus/releases) are used only as +the root-bus installer lifecycle reference. Neither product's broad package or +registration cleanup is treated as VIIPER ownership authority. + ## Reference-backed Windows invariants - The machine transaction lock uses a private namespace bounded to the local @@ -135,6 +215,14 @@ bootstrapper for the signed installer. After restart, the installer retries the complete preflight and transaction from the beginning. No cross-reboot journal is trusted as executable authority. +For removal, 3010 means the helper accepted the exact devnode/package removal +but Windows needs a restart to finish it. The service and exact managed +ownership are cleaned first, then 3010 is returned. A retry before or after the +restart performs a fresh exact inventory and is idempotent; no pending-removal +journal is trusted as authority. If owned cleanup itself fails, the command +reports failure (including that Windows still requires restart) rather than +misrepresenting a partial uninstall as 3010 success. + ## Deterministic gates The normal Go suite runs a failpoint matrix for every transaction phase, @@ -146,3 +234,10 @@ driver/broker rollback, protected nested-commit token, global lock ordering, and authenticated proof. It also rejects hard process termination, context-killed helper processes, recursive deletion, or direct legacy/USB-IP removal in the outer layer. + +The removal matrix independently covers both mutex acquisitions, immutable +preflight, service inventory, partial stop, helper launch/outcome, exact cleanup, +restore failure, close failure, 3010, structured preflight, verified rollback, +unverified rollback, malformed proof, idempotent absence, and exact ownership. +The targeted matrix is also run repeatedly to catch state leakage and ordering +regressions. diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 9d20ed60..9b0e7a5c 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -82,10 +82,12 @@ the native compile, static-analysis, ABI/lifecycle, fuzz, race, stamped-INF, package-transaction, helper rollback/update/removal, and deterministic package checks. Driver package source changes must strictly increase the four-part `DriverVer` without regressing its date; a release also compares against the -previous SemVer tag. The `viiper uninstall` command does not yet invoke the -helper's exact root-devnode/Driver Store removal transaction, so the live -uninstall release criterion remains open even though that helper primitive is -source-checked and self-tested. +previous SemVer tag. The `viiper uninstall` command now hash-locks and invokes +the helper's exact root-devnode/Driver Store removal transaction under the +package-then-service lock order, and its deterministic gates cover structured +success, reboot-success, preflight, verified rollback, indeterminate rollback, +and exact owned cleanup. Live uninstall on the Microsoft-signed package remains +part of the external acceptance matrix below rather than an implementation gap. Production driver acceptance is separate and manual because Microsoft signing is external. The intake workflow must run from the exact current `main` commit, diff --git a/internal/cmd/install.go b/internal/cmd/install.go index da9efd8b..a43a70b1 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -18,10 +18,13 @@ type Install struct { TargetUserSID string `help:"Interactive Windows user SID that owns DS4Windows startup state." hidden:""` } -// Uninstall removes VIIPER startup configuration. +// Uninstall removes VIIPER's platform-owned service/startup state. Production +// Windows packages also remove their exact native devnode and Driver Store package. type Uninstall struct { - Yes bool `help:"Confirm removal without prompting." short:"y"` - TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` + Yes bool `help:"Confirm removal without prompting." short:"y"` + TargetUserSID string `help:"Interactive Windows user SID that owns VIIPER startup state." hidden:""` + DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe used for exact native package removal." hidden:""` + ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe used for exact native package removal." hidden:""` } func (c *Install) Run(logger *slog.Logger) error { @@ -53,7 +56,7 @@ func (c *Uninstall) Run(logger *slog.Logger) error { } if !c.Yes { - fmt.Print("Remove VIIPER startup registration and stop its server? [y/N]: ") + fmt.Print("Remove VIIPER's installed service/startup ownership and any exact native device/driver package managed by this installation? [y/N]: ") answer, readErr := bufio.NewReader(os.Stdin).ReadString('\n') if readErr != nil && len(answer) == 0 { return fmt.Errorf("could not read uninstall confirmation: %w", readErr) @@ -65,7 +68,12 @@ func (c *Uninstall) Run(logger *slog.Logger) error { } } - return uninstall(logger, strings.TrimSpace(c.TargetUserSID)) + return uninstall( + logger, + strings.TrimSpace(c.TargetUserSID), + strings.TrimSpace(c.DriverHelper), + strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), + ) } func currentExecutable() (string, error) { diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index 350da94c..a8d1f397 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -51,10 +51,16 @@ func install(logger *slog.Logger, transport, targetUserSID string) error { return nil } -func uninstall(logger *slog.Logger, targetUserSID string) error { +func uninstall( + logger *slog.Logger, + targetUserSID, driverHelper, expectedHelperSHA256 string, +) error { if targetUserSID != "" { return errors.New("--target-user-sid is supported only by the Windows native broker installer") } + if driverHelper != "" || expectedHelperSHA256 != "" { + return errors.New("native package uninstall helper inputs are supported only on Windows") + } var errs []error if err := runSystemctl("stop", serviceName); err != nil { diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 5cb80d30..e20e7c20 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -134,15 +134,20 @@ func requireNativeUDEBroker() error { return nil } -func uninstall(logger *slog.Logger, targetUserSID string) error { - release, err := acquireNamedNativePackageMutex( - nativePackageMutexName, nativePackageTransactionTimeout, - ) - if err != nil { +func uninstall( + logger *slog.Logger, + targetUserSID, driverHelper, expectedHelperSHA256 string, +) error { + request := nativePackageUninstallRequest{ + driverHelper: driverHelper, expectedHelperSHA256: expectedHelperSHA256, + targetUserSID: targetUserSID, + } + if err := request.validate(); err != nil { return err } - defer release() - return uninstallNativeBroker(logger, targetUserSID) + ctx, cancel := context.WithTimeout(context.Background(), nativePackageTransactionTimeout) + defer cancel() + return uninstallNativePackage(ctx, logger, request) } func currentScheduledTaskExe() (string, error) { diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 902f9f31..797d3f44 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -17,10 +17,14 @@ func TestNativePackageProductionSourceContract(t *testing.T) { root := filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")) windowsSource := readNativePackageContractFile(t, filepath.Join(root, "internal", "cmd", "native_package_windows.go")) + uninstallWindowsSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_uninstall_windows.go")) helperSource := readNativePackageContractFile(t, filepath.Join(root, "native", "udecx", "tools", "ViiperUdeCtl.cpp")) transactionSource := readNativePackageContractFile(t, filepath.Join(root, "internal", "cmd", "native_package.go")) + uninstallTransactionSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_uninstall.go")) serviceSource := readNativePackageContractFile(t, filepath.Join(root, "internal", "cmd", "native_service_install_windows.go")) @@ -51,6 +55,33 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("Windows package orchestrator lost %q", fragment) } } + requiredUninstallWindows := []string{ + "acquireNamedNativePackageMutex(nativePackageMutexName", + "acquireNativeInstallMutex(budget)", + "lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper))", + "expectedHelperSHA256", "hashNativePackageHandle(helper)", + "isCanonicalNativePackageService(", "nativeBrokerServiceConfiguration(", + "nativeBrokerExecutableSDDL", "nativeCredentialDirectorySDDL(t.userSID)", + "nativeCredentialFileSDDL(t.userSID)", "lockNativePackageUninstallFile(", + "lockExactBrokerDirectoryChain(path)", + "lockNativePackageUninstallLiveLog(", "promoteNativePackageUninstallLiveLog(ctx)", + "stopNativeService(ctx, t.service", "--transaction-deadline-unix-ms", + "exec.Command(t.request.driverHelper", "parseNativePackageRemoveProof(", + "serviceRestoreVerified", "t.service.Delete()", + "waitForNativePackageServiceDeletion", "deleteNativePackageUninstallFileHandle(", + "errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE)", + "nativePackageUninstallIsCurrentExecutable(file)", + "windows.MOVEFILE_DELAY_UNTIL_REBOOT|windows.MOVEFILE_WRITE_THROUGH", + } + for _, fragment := range requiredUninstallWindows { + if !strings.Contains(uninstallWindowsSource, fragment) { + t.Errorf("Windows package uninstall orchestrator lost %q", fragment) + } + } + if strings.Index(uninstallWindowsSource, "acquireNamedNativePackageMutex(nativePackageMutexName") > + strings.Index(uninstallWindowsSource, "acquireNativeInstallMutex(budget)") { + t.Error("native package uninstall no longer acquires package mutex before service mutex") + } requiredHelper := []string{ "Outcome Verify(", "ValidateCandidateInputs(", "RunBrokerInstall(", "--manifest-sha256", "manifest-installer-hash", "--broker-sha256", @@ -84,6 +115,19 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("package transaction lost %q", fragment) } } + for _, fragment := range []string{ + "transaction.LockPackage(ctx)", "transaction.LockService(ctx)", + "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", + "restoreArmed = snapshot.exists", "transaction.StopService(ctx, snapshot)", + "transaction.RemoveDriver(ctx)", "serviceRestoreVerified", + "deliberately left stopped", "transaction.RestoreService(rollbackCtx, snapshot)", + "driverRemovalSucceeded = true", "transaction.Cleanup(cleanupCtx, snapshot)", + "nativePackageUninstallRebootRequiredError", + } { + if !strings.Contains(uninstallTransactionSource, fragment) { + t.Errorf("package uninstall transaction lost %q", fragment) + } + } for _, fragment := range []string{ "func acquireNativeInstallMutex(", "runtime.LockOSThread()", "runtime.UnlockOSThread()", } { @@ -92,15 +136,31 @@ func TestNativePackageProductionSourceContract(t *testing.T) { } } for name, source := range map[string]string{ - "Windows package orchestrator": windowsSource, - "driver helper": helperSource, + "Windows package orchestrator": windowsSource, + "Windows package uninstall orchestrator": uninstallWindowsSource, + "driver helper": helperSource, } { - for _, forbidden := range []string{"TerminateProcess(", "exec.CommandContext(", "os.RemoveAll("} { + for _, forbidden := range []string{ + "TerminateProcess(", "exec.CommandContext(", "os.RemoveAll(", + } { if strings.Contains(source, forbidden) { t.Errorf("%s contains unsafe %q", name, forbidden) } } } + if strings.Contains(uninstallWindowsSource, ".Process.Kill(") { + t.Error("package uninstall must not hard-kill the mutating driver helper") + } + if strings.Contains(uninstallWindowsSource, "lockNativePriorServiceExecutable(path)") { + t.Error("package uninstall must not retain a non-delete-shared broker leaf before its exact DELETE-capable snapshot") + } + for _, forbidden := range []string{ + "removeLegacy", "snapshotLegacy", "scheduled task", "RunVIIPER", "usbip", + } { + if strings.Contains(uninstallWindowsSource, forbidden) { + t.Errorf("package uninstall must not mutate unrelated legacy ownership; found %q", forbidden) + } + } if strings.Contains(helperSource, "WaitForSingleObject(processHandle.get(), INFINITE)") { t.Error("driver helper retained an unbounded nested broker wait") } diff --git a/internal/cmd/native_package_uninstall.go b/internal/cmd/native_package_uninstall.go new file mode 100644 index 00000000..8b1c8623 --- /dev/null +++ b/internal/cmd/native_package_uninstall.go @@ -0,0 +1,265 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const nativePackageUninstallCleanupTimeout = 2 * time.Minute + +var nativePackageRemoveProofPattern = regexp.MustCompile( + `(?m)^result=(success|error) operation=remove changed=([01]) rebootRequired=([01]) rollback=(not-needed|succeeded|failed) exitCode=([0-9]+)(?: .*)?\r?$`, +) + +type nativePackageUninstallRequest struct { + driverHelper string + expectedHelperSHA256 string + targetUserSID string +} + +func (r nativePackageUninstallRequest) validate() error { + if strings.TrimSpace(r.driverHelper) == "" { + return errors.New("native package uninstall driver helper is empty") + } + if strings.TrimSpace(r.targetUserSID) == "" { + return errors.New("native package uninstall target user SID is empty") + } + if strings.IndexByte(r.driverHelper, 0) >= 0 || strings.IndexByte(r.targetUserSID, 0) >= 0 { + return errors.New("native package uninstall input contains NUL") + } + if !filepath.IsAbs(r.driverHelper) { + return fmt.Errorf("native package uninstall driver helper must be an absolute path: %s", r.driverHelper) + } + if !strings.EqualFold(filepath.Base(r.driverHelper), "ViiperUdeCtl.exe") { + return fmt.Errorf("native package uninstall helper must be named ViiperUdeCtl.exe: %s", r.driverHelper) + } + if !nativePackageSHA256.MatchString(r.expectedHelperSHA256) { + return errors.New("native package uninstall helper SHA-256 must contain exactly 64 hexadecimal characters") + } + return nil +} + +type nativePackageRemoveResult struct { + rebootRequired bool + serviceRestoreVerified bool +} + +type nativePackageRemoveProof struct { + success bool + changed bool + rebootRequired bool + rollback string + exitCode int +} + +func parseNativePackageRemoveProof(output string, processExitCode int) (nativePackageRemoveResult, error) { + matches := nativePackageRemoveProofPattern.FindAllStringSubmatch(output, -1) + if len(matches) != 1 { + return nativePackageRemoveResult{}, errors.New("driver helper did not emit exactly one structured remove outcome") + } + proofExitCode, err := strconv.Atoi(matches[0][5]) + if err != nil { + return nativePackageRemoveResult{}, fmt.Errorf("parse driver helper remove exit code: %w", err) + } + proof := nativePackageRemoveProof{ + success: matches[0][1] == "success", + changed: matches[0][2] == "1", + rebootRequired: matches[0][3] == "1", + rollback: matches[0][4], + exitCode: proofExitCode, + } + if proof.exitCode != processExitCode { + return nativePackageRemoveResult{}, fmt.Errorf( + "driver helper remove process exit %d disagreed with structured exit %d", + processExitCode, proof.exitCode, + ) + } + switch proof.exitCode { + case 0: + if !proof.success || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid success remove outcome") + } + return nativePackageRemoveResult{}, nil + case nativePackageRebootRequiredCode: + if !proof.success || !proof.changed || !proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid reboot-success remove outcome") + } + return nativePackageRemoveResult{rebootRequired: true}, nil + case 4: + if proof.success || proof.changed || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid preflight-rejection outcome") + } + return nativePackageRemoveResult{serviceRestoreVerified: true}, fmt.Errorf("driver helper rejected package removal before mutation: %s", strings.TrimSpace(output)) + case 1: + if proof.success || !proof.changed || proof.rollback != "succeeded" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rolled-back failure outcome") + } + return nativePackageRemoveResult{serviceRestoreVerified: !proof.rebootRequired}, fmt.Errorf("driver helper package removal failed and rolled back: %s", strings.TrimSpace(output)) + case 3: + if proof.success || !proof.changed || proof.rollback != "failed" { + return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rollback-failure outcome") + } + return nativePackageRemoveResult{}, fmt.Errorf("driver helper package removal and rollback failed: %s", strings.TrimSpace(output)) + default: + return nativePackageRemoveResult{}, fmt.Errorf( + "driver helper returned unsupported structured remove exit %d: %s", + proof.exitCode, strings.TrimSpace(output), + ) + } +} + +type nativePackageUninstallServiceSnapshot struct { + exists bool + wasRunning bool + opaque any +} + +type nativePackageUninstallUnsafeRestoreError struct { + cause error +} + +func (e *nativePackageUninstallUnsafeRestoreError) Error() string { + return e.cause.Error() +} + +func (e *nativePackageUninstallUnsafeRestoreError) Unwrap() error { + return e.cause +} + +type nativePackageUninstallTransaction interface { + LockPackage(context.Context) error + LockService(context.Context) error + Preflight(context.Context) error + InspectService(context.Context) (nativePackageUninstallServiceSnapshot, error) + StopService(context.Context, nativePackageUninstallServiceSnapshot) error + RemoveDriver(context.Context) (nativePackageRemoveResult, error) + Cleanup(context.Context, nativePackageUninstallServiceSnapshot) (bool, error) + RestoreService(context.Context, nativePackageUninstallServiceSnapshot) error + Close() error +} + +func runNativePackageUninstallTransaction( + ctx context.Context, + logger *slog.Logger, + transaction nativePackageUninstallTransaction, +) (resultErr error) { + if transaction == nil { + return errors.New("native package uninstall transaction is nil") + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("close native package uninstall transaction: %w", closeErr)) + } + }() + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before package lock: %w", err) + } + if err := transaction.LockPackage(ctx); err != nil { + return fmt.Errorf("acquire native package uninstall mutex: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service lock: %w", err) + } + if err := transaction.LockService(ctx); err != nil { + return fmt.Errorf("acquire native broker service mutex after package mutex: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before preflight: %w", err) + } + if err := transaction.Preflight(ctx); err != nil { + return fmt.Errorf("native package uninstall preflight rejected before mutation: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service inspection: %w", err) + } + snapshot, err := transaction.InspectService(ctx) + if err != nil { + return fmt.Errorf("inspect exact native broker service before package removal: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before service stop: %w", err) + } + + restoreArmed := false + serviceRestoreVerified := true + driverRemovalSucceeded := false + defer func() { + if !restoreArmed || driverRemovalSucceeded { + return + } + if !serviceRestoreVerified { + resultErr = errors.Join(resultErr, errors.New( + "native driver or managed-file restoration safety is unverified; exact broker was deliberately left stopped for external reconciliation", + )) + return + } + rollbackCtx, cancelRollback := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageUninstallCleanupTimeout, + ) + defer cancelRollback() + if rollbackErr := transaction.RestoreService(rollbackCtx, snapshot); rollbackErr != nil { + resultErr = errors.Join(resultErr, + fmt.Errorf("restore exact native broker after package removal failure: %w", rollbackErr)) + } + }() + + // Sending STOP is the first mutation. Arm exact run-state restoration before + // entering the method because it can fail while the service is StopPending. + restoreArmed = snapshot.exists + if err := transaction.StopService(ctx, snapshot); err != nil { + var unsafeRestore *nativePackageUninstallUnsafeRestoreError + if errors.As(err, &unsafeRestore) { + serviceRestoreVerified = false + } + return fmt.Errorf("stop exact native broker before package removal: %w", err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("native package uninstall canceled before driver removal: %w", err) + } + removeResult, err := transaction.RemoveDriver(ctx) + if err != nil { + serviceRestoreVerified = removeResult.serviceRestoreVerified + return fmt.Errorf("remove exact native driver package: %w", err) + } + // The helper owns the authoritative Driver Store snapshot and reports success + // only after either final verification or a Windows reboot-success boundary. + // Never restart a now-driverless broker after this point, even if cleanup fails. + driverRemovalSucceeded = true + if err := ctx.Err(); err != nil { + logger.Warn("Native driver removal completed at the transaction deadline; reconciling exact owned cleanup", + "deadline", err) + } + cleanupCtx, cancelCleanup := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageUninstallCleanupTimeout, + ) + defer cancelCleanup() + cleanupRebootRequired, err := transaction.Cleanup(cleanupCtx, snapshot) + if err != nil { + if removeResult.rebootRequired { + return fmt.Errorf("clean up exact native broker ownership after reboot-successful driver removal (restart still required): %w", err) + } + return fmt.Errorf("clean up exact native broker ownership after driver removal: %w", err) + } + if removeResult.rebootRequired || cleanupRebootRequired { + return &nativePackageUninstallRebootRequiredError{} + } + return nil +} + +type nativePackageUninstallRebootRequiredError struct{} + +func (*nativePackageUninstallRebootRequiredError) Error() string { + return "native package removal succeeded; restart Windows to complete exact driver removal" +} + +func (*nativePackageUninstallRebootRequiredError) ExitCode() int { + return nativePackageRebootRequiredCode +} diff --git a/internal/cmd/native_package_uninstall_test.go b/internal/cmd/native_package_uninstall_test.go new file mode 100644 index 00000000..64895327 --- /dev/null +++ b/internal/cmd/native_package_uninstall_test.go @@ -0,0 +1,438 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" +) + +type fakeNativePackageUninstallTransaction struct { + events []string + fail string + closeErr error + restoreErr error + removeResult nativePackageRemoveResult + snapshot nativePackageUninstallServiceSnapshot + cancelAt string + cancel context.CancelFunc + restoreHadDeadline bool + cleanupHadDeadline bool + unsafeStop bool + cleanupReboot bool +} + +func (f *fakeNativePackageUninstallTransaction) event(name string) error { + f.events = append(f.events, name) + if f.cancelAt == name && f.cancel != nil { + f.cancel() + } + if f.fail == name { + return errors.New(name + " failure") + } + return nil +} + +func (f *fakeNativePackageUninstallTransaction) LockPackage(context.Context) error { + return f.event("package-lock") +} + +func (f *fakeNativePackageUninstallTransaction) LockService(context.Context) error { + return f.event("service-lock") +} + +func (f *fakeNativePackageUninstallTransaction) Preflight(context.Context) error { + return f.event("preflight") +} + +func (f *fakeNativePackageUninstallTransaction) InspectService(context.Context) (nativePackageUninstallServiceSnapshot, error) { + return f.snapshot, f.event("inspect") +} + +func (f *fakeNativePackageUninstallTransaction) StopService( + _ context.Context, snapshot nativePackageUninstallServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + err := f.event("stop") + if err != nil && f.unsafeStop { + return &nativePackageUninstallUnsafeRestoreError{cause: err} + } + return err +} + +func (f *fakeNativePackageUninstallTransaction) RemoveDriver(context.Context) (nativePackageRemoveResult, error) { + return f.removeResult, f.event("remove") +} + +func (f *fakeNativePackageUninstallTransaction) Cleanup( + ctx context.Context, snapshot nativePackageUninstallServiceSnapshot, +) (bool, error) { + if snapshot != f.snapshot { + return false, errors.New("service snapshot changed") + } + _, f.cleanupHadDeadline = ctx.Deadline() + return f.cleanupReboot, f.event("cleanup") +} + +func (f *fakeNativePackageUninstallTransaction) RestoreService( + ctx context.Context, snapshot nativePackageUninstallServiceSnapshot, +) error { + if snapshot != f.snapshot { + return errors.New("service snapshot changed") + } + f.events = append(f.events, "restore") + _, f.restoreHadDeadline = ctx.Deadline() + return f.restoreErr +} + +func (f *fakeNativePackageUninstallTransaction) Close() error { + f.events = append(f.events, "close") + return f.closeErr +} + +func TestNativePackageUninstallUsesFixedLockAndCommitOrder(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{snapshot: nativePackageUninstallServiceSnapshot{ + exists: true, wasRunning: true, + }} + if err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ); err != nil { + t.Fatalf("run uninstall: %v", err) + } + want := []string{ + "package-lock", "service-lock", "preflight", "inspect", + "stop", "remove", "cleanup", "close", + } + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } + if !fake.cleanupHadDeadline { + t.Fatal("committed driver removal cleanup did not receive a bounded reconciliation context") + } +} + +func TestNativePackageUninstallFailureMatrix(t *testing.T) { + t.Parallel() + for _, fail := range []string{ + "package-lock", "service-lock", "preflight", "inspect", "stop", "remove", "cleanup", + } { + fail := fail + t.Run(fail, func(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: fail, snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + if fail == "remove" { + fake.removeResult.serviceRestoreVerified = true + } + err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ) + if err == nil || !strings.Contains(err.Error(), fail+" failure") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + restoreExpected := fail == "stop" || fail == "remove" + restoreSeen := false + for _, event := range fake.events { + restoreSeen = restoreSeen || event == "restore" + } + if restoreSeen != restoreExpected { + t.Fatalf("events=%v restoreExpected=%v", fake.events, restoreExpected) + } + if restoreSeen && !fake.restoreHadDeadline { + t.Fatal("service restoration did not receive a bounded independent context") + } + if fake.events[len(fake.events)-1] != "close" { + t.Fatalf("transaction did not close: %v", fake.events) + } + }) + } +} + +func TestNativePackageUninstallCancellationBoundaries(t *testing.T) { + t.Parallel() + cases := []struct { + cancelAt string + wantEvents []string + wantRestore bool + }{ + {cancelAt: "package-lock", wantEvents: []string{"package-lock", "close"}}, + {cancelAt: "service-lock", wantEvents: []string{"package-lock", "service-lock", "close"}}, + {cancelAt: "preflight", wantEvents: []string{"package-lock", "service-lock", "preflight", "close"}}, + {cancelAt: "inspect", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "close"}}, + {cancelAt: "stop", wantEvents: []string{"package-lock", "service-lock", "preflight", "inspect", "stop", "restore", "close"}, wantRestore: true}, + } + for _, test := range cases { + test := test + t.Run(test.cancelAt, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageUninstallTransaction{ + cancelAt: test.cancelAt, cancel: cancel, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(ctx, nativePackageTestLogger(), fake) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if !reflect.DeepEqual(fake.events, test.wantEvents) { + t.Fatalf("events=%v want=%v", fake.events, test.wantEvents) + } + if test.wantRestore && !fake.restoreHadDeadline { + t.Fatal("cancellation restoration did not receive an independent deadline") + } + }) + } +} + +func TestNativePackageUninstallHelperSuccessAtDeadlineStillCleans(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakeNativePackageUninstallTransaction{cancelAt: "remove", cancel: cancel} + if err := runNativePackageUninstallTransaction(ctx, nativePackageTestLogger(), fake); err != nil { + t.Fatalf("authoritative helper success was contradicted by caller cancellation: %v", err) + } + if !slicesContainString(fake.events, "cleanup") || slicesContainString(fake.events, "restore") { + t.Fatalf("helper success reconciliation events=%v", fake.events) + } +} + +func TestNativePackageUninstallReportsRestoreAndCloseFailures(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", restoreErr: errors.New("restore failure"), closeErr: errors.New("close failure"), + removeResult: nativePackageRemoveResult{serviceRestoreVerified: true}, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + for _, fragment := range []string{"remove failure", "restore failure", "close failure"} { + if err == nil || !strings.Contains(err.Error(), fragment) { + t.Fatalf("error=%v missing %q", err, fragment) + } + } +} + +func TestNativePackageUninstallLeavesBrokerStoppedWhenDriverRollbackIsUnverified(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", + removeResult: nativePackageRemoveResult{serviceRestoreVerified: false}, + snapshot: nativePackageUninstallServiceSnapshot{exists: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "deliberately left stopped") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("unverified driver rollback restarted broker: %v", fake.events) + } +} + +func TestNativePackageUninstallDoesNotRestoreAbsentBroker(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "remove", snapshot: nativePackageUninstallServiceSnapshot{}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "remove failure") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if strings.Contains(err.Error(), "deliberately left stopped") || slicesContainString(fake.events, "restore") { + t.Fatalf("absent broker was treated as restorable state: error=%v events=%v", err, fake.events) + } +} + +func TestNativePackageUninstallLeavesBrokerStoppedWhenManagedFileIdentityChanges(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "stop", unsafeStop: true, + snapshot: nativePackageUninstallServiceSnapshot{exists: true, wasRunning: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + if err == nil || !strings.Contains(err.Error(), "deliberately left stopped") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("changed managed file identity restarted broker: %v", fake.events) + } +} + +func TestNativePackageUninstallRebootSuccessCleansBefore3010(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + removeResult: nativePackageRemoveResult{rebootRequired: true}, + } + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + want := []string{ + "package-lock", "service-lock", "preflight", "inspect", + "stop", "remove", "cleanup", "close", + } + if !reflect.DeepEqual(fake.events, want) { + t.Fatalf("events=%v want=%v", fake.events, want) + } +} + +func TestNativePackageUninstallDoesNotReport3010WhenOwnedCleanupFails(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{ + fail: "cleanup", + removeResult: nativePackageRemoveResult{rebootRequired: true}, + } + err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ) + if err == nil || !strings.Contains(err.Error(), "cleanup failure") || + !strings.Contains(err.Error(), "restart still required") { + t.Fatalf("error=%v events=%v", err, fake.events) + } + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + t.Fatalf("partial owned cleanup was misreported as reboot-success exit %d", exitCoder.ExitCode()) + } + if slicesContainString(fake.events, "restore") { + t.Fatalf("driverless broker was restored after cleanup failure: %v", fake.events) + } +} + +func TestNativePackageUninstallSelfImageCleanupAggregates3010(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{cleanupReboot: true} + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), fake) + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != nativePackageRebootRequiredCode { + t.Fatalf("error=%v exitCoder=%T", err, exitCoder) + } + if !slicesContainString(fake.events, "cleanup") || slicesContainString(fake.events, "restore") { + t.Fatalf("self-image cleanup reconciliation events=%v", fake.events) + } +} + +func TestNativePackageUninstallIdempotentAbsenceStillReconcilesDriver(t *testing.T) { + t.Parallel() + fake := &fakeNativePackageUninstallTransaction{snapshot: nativePackageUninstallServiceSnapshot{}} + if err := runNativePackageUninstallTransaction( + context.Background(), nativePackageTestLogger(), fake, + ); err != nil { + t.Fatalf("idempotent uninstall: %v", err) + } + for _, required := range []string{"stop", "remove", "cleanup"} { + if !strings.Contains(strings.Join(fake.events, ","), required) { + t.Fatalf("absence skipped %s reconciliation: %v", required, fake.events) + } + } +} + +func TestNativePackageRemoveStructuredExitSemantics(t *testing.T) { + t.Parallel() + cases := []struct { + name string + line string + exit int + reboot bool + wantErr bool + errContains string + }{ + {name: "success", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, + {name: "idempotent success", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, + {name: "reboot success", line: "result=success operation=remove changed=1 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, reboot: true}, + {name: "preflight", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology"`, exit: 4, wantErr: true, errContains: "before mutation"}, + {name: "rolled back", line: `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rolled back pending reboot", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=succeeded exitCode=1 phase="remove-driver"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rollback failed", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=failed exitCode=3 phase="remove-rollback"`, exit: 3, wantErr: true, errContains: "rollback failed"}, + {name: "exit mismatch", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 1, wantErr: true, errContains: "disagreed"}, + {name: "invalid 3010", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, + {name: "unchanged 3010", line: "result=success operation=remove changed=0 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, + {name: "unstructured", line: "removed", exit: 0, wantErr: true, errContains: "exactly one"}, + {name: "duplicate proof", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0\nresult=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0, wantErr: true, errContains: "exactly one"}, + } + for _, test := range cases { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + result, err := parseNativePackageRemoveProof(test.line, test.exit) + if (err != nil) != test.wantErr { + t.Fatalf("result=%+v error=%v", result, err) + } + if test.errContains != "" && (err == nil || !strings.Contains(err.Error(), test.errContains)) { + t.Fatalf("error=%v missing %q", err, test.errContains) + } + if err == nil && result.rebootRequired != test.reboot { + t.Fatalf("reboot=%v want=%v", result.rebootRequired, test.reboot) + } + if (test.name == "preflight" || test.name == "rolled back") && + !result.serviceRestoreVerified { + t.Fatal("structured no-mutation/rollback proof did not authorize exact broker restoration") + } + if (test.name == "rollback failed" || test.name == "unstructured" || + test.name == "rolled back pending reboot") && + result.serviceRestoreVerified { + t.Fatal("indeterminate helper outcome authorized broker restoration") + } + }) + } +} + +func slicesContainString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func TestNativePackageUninstallRequestFailsClosed(t *testing.T) { + t.Parallel() + base := nativePackageUninstallRequest{ + driverHelper: `C:\bundle\ViiperUdeCtl.exe`, + expectedHelperSHA256: strings.Repeat("a", 64), + targetUserSID: "S-1-5-21-1-2-3-1001", + } + if err := base.validate(); err != nil { + t.Fatalf("valid request: %v", err) + } + cases := map[string]func(*nativePackageUninstallRequest){ + "empty helper": func(r *nativePackageUninstallRequest) { r.driverHelper = "" }, + "empty SID": func(r *nativePackageUninstallRequest) { r.targetUserSID = "" }, + "relative helper": func(r *nativePackageUninstallRequest) { r.driverHelper = "ViiperUdeCtl.exe" }, + "wrong helper": func(r *nativePackageUninstallRequest) { r.driverHelper = `C:\bundle\other.exe` }, + "bad hash": func(r *nativePackageUninstallRequest) { r.expectedHelperSHA256 = strings.Repeat("z", 64) }, + "embedded NUL": func(r *nativePackageUninstallRequest) { r.targetUserSID += "\x00evil" }, + } + for name, mutate := range cases { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + request := base + mutate(&request) + if err := request.validate(); err == nil { + t.Fatal("invalid request accepted") + } + }) + } +} + +func TestNativePackageUninstallNilTransaction(t *testing.T) { + t.Parallel() + err := runNativePackageUninstallTransaction(context.Background(), nativePackageTestLogger(), nil) + if err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("error=%v", err) + } +} + +func Example_parseNativePackageRemoveProof() { + result, err := parseNativePackageRemoveProof( + "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", 0, + ) + fmt.Println(result.rebootRequired, err) + // Output: false +} diff --git a/internal/cmd/native_package_uninstall_windows.go b/internal/cmd/native_package_uninstall_windows.go new file mode 100644 index 00000000..389d4e2e --- /dev/null +++ b/internal/cmd/native_package_uninstall_windows.go @@ -0,0 +1,1091 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const nativeFileDispositionInfoClass = 4 + +var setNativeFileInformationByHandle = windows.NewLazySystemDLL( + "kernel32.dll", +).NewProc("SetFileInformationByHandle") + +type windowsNativePackageUninstallSnapshot struct { + config mgr.Config + status svc.Status + securityDescriptor string + recoveryActions []mgr.RecoveryAction + recoveryResetSeconds uint32 + recoverNonCrash bool + serviceExecutable string + serviceExecutableSHA256 string +} + +type windowsNativePackageUninstallFile struct { + kind string + path string + hash string + identity windowsNativePackageUninstallFileIdentity + handle windows.Handle +} + +type windowsNativePackageUninstallFileIdentity struct { + volumeSerialNumber uint32 + fileIndex uint64 +} + +type windowsNativePackageUninstallLiveLog struct { + path string + identity windowsNativePackageUninstallFileIdentity + handle windows.Handle +} + +type windowsNativePackageUninstallTransaction struct { + logger *slog.Logger + request nativePackageUninstallRequest + + releasePackageMutex func() + releaseServiceMutex func() + helperHandles []windows.Handle + managedDirectories []windows.Handle + helperHandle windows.Handle + + userSID string + manager nativeSCM + service nativeManagedService + snapshot *windowsNativePackageUninstallSnapshot + ownedFiles []*windowsNativePackageUninstallFile + liveLog *windowsNativePackageUninstallLiveLog + liveLogPath string + + closed bool +} + +func uninstallNativePackage( + ctx context.Context, + logger *slog.Logger, + request nativePackageUninstallRequest, +) error { + transaction := &windowsNativePackageUninstallTransaction{logger: logger, request: request} + return runNativePackageUninstallTransaction(ctx, logger, transaction) +} + +func remainingNativePackageUninstallBudget(ctx context.Context) (time.Duration, error) { + deadline, ok := ctx.Deadline() + if !ok { + return nativePackageTransactionTimeout, nil + } + remaining := time.Until(deadline) + if remaining <= 0 { + return 0, context.DeadlineExceeded + } + return remaining, nil +} + +func (t *windowsNativePackageUninstallTransaction) LockPackage(ctx context.Context) error { + budget, err := remainingNativePackageUninstallBudget(ctx) + if err != nil { + return err + } + release, err := acquireNamedNativePackageMutex(nativePackageMutexName, budget) + if err != nil { + return err + } + t.releasePackageMutex = release + return nil +} + +func (t *windowsNativePackageUninstallTransaction) LockService(ctx context.Context) error { + if t.releasePackageMutex == nil { + return errors.New("native package mutex must be held before the broker service mutex") + } + budget, err := remainingNativePackageUninstallBudget(ctx) + if err != nil { + return err + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return err + } + t.releaseServiceMutex = release + return nil +} + +func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context) error { + if t.releasePackageMutex == nil || t.releaseServiceMutex == nil { + return errors.New("native package uninstall mutex order is incomplete") + } + if err := ctx.Err(); err != nil { + return err + } + userSID, err := resolveNativeInstallingUserSID(t.request.targetUserSID) + if err != nil { + return fmt.Errorf("resolve exact native broker credential owner: %w", err) + } + t.userSID = userSID + + directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper)) + if err != nil { + return fmt.Errorf("lock packaged driver helper directory chain: %w", err) + } + t.helperHandles = append(t.helperHandles, directoryHandles...) + helper, err := lockNativePackageInput(t.request.driverHelper) + if err != nil { + return fmt.Errorf("lock packaged driver helper: %w", err) + } + t.helperHandle = helper + helperHash, err := hashNativePackageHandle(helper) + if err != nil { + return fmt.Errorf("hash packaged driver helper: %w", err) + } + if !strings.EqualFold(helperHash, t.request.expectedHelperSHA256) { + return fmt.Errorf("packaged driver helper SHA-256=%s expected=%s", + helperHash, t.request.expectedHelperSHA256) + } + if err := requireNativePackagePE(helper); err != nil { + return fmt.Errorf("validate packaged driver helper image: %w", err) + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) InspectService( + ctx context.Context, +) (nativePackageUninstallServiceSnapshot, error) { + manager, err := mgr.Connect() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + service, err := t.manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + if waitErr := waitForNativePackageServiceDeletion(ctx, t.manager); waitErr != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "reconcile previously committed %s deletion: %w", + NativeBrokerServiceName, waitErr, + ) + } + if inspectErr := t.inspectOrphanedExactManagedFiles(); inspectErr != nil { + return nativePackageUninstallServiceSnapshot{}, inspectErr + } + return nativePackageUninstallServiceSnapshot{}, nil + } + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + if err := t.inspectOrphanedExactManagedFiles(); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + return nativePackageUninstallServiceSnapshot{}, nil + } + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("open %s: %w", + NativeBrokerServiceName, err) + } + t.service = service + config, err := service.Config() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s config: %w", + NativeBrokerServiceName, err) + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("parse %s executable: %w", + NativeBrokerServiceName, err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("resolve Program Files: %w", err) + } + if _, err := nativeServiceExecutableParent(programFiles, executable); err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing to stop non-owned %s: %w", NativeBrokerServiceName, err, + ) + } + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + expectedConfig, _, err := nativeBrokerServiceConfiguration(executable, keyPath) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + securityDescriptor, err := service.SecurityDescriptor() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s security: %w", + NativeBrokerServiceName, err) + } + recovery, err := service.RecoveryActions() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery actions: %w", + NativeBrokerServiceName, err) + } + reset, err := service.ResetPeriod() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery reset: %w", + NativeBrokerServiceName, err) + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s recovery mode: %w", + NativeBrokerServiceName, err) + } + if !isCanonicalNativePackageService( + config, expectedConfig, securityDescriptor, recovery, reset, nonCrash, + ) { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing to stop %s because its LocalSystem configuration, security, or recovery ownership is not exact", + NativeBrokerServiceName, + ) + } + status, err := service.Query() + if err != nil { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf("query %s state: %w", + NativeBrokerServiceName, err) + } + status, err = settleNativeServiceSnapshot(ctx, service, status, waitContext) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + if status.State != svc.Running && status.State != svc.Stopped { + return nativePackageUninstallServiceSnapshot{}, fmt.Errorf( + "refusing native package removal while %s is in state %d", + NativeBrokerServiceName, status.State, + ) + } + broker, err := t.inspectExactBrokerFile(executable, true) + if err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + if broker == nil { + return nativePackageUninstallServiceSnapshot{}, errors.New("exact native broker executable is absent") + } + if err := t.inspectCredentialFiles(true, status.State == svc.Running); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + t.snapshot = &windowsNativePackageUninstallSnapshot{ + config: config, status: status, securityDescriptor: securityDescriptor, + recoveryActions: append([]mgr.RecoveryAction(nil), recovery...), + recoveryResetSeconds: reset, recoverNonCrash: nonCrash, + serviceExecutable: executable, serviceExecutableSHA256: broker.hash, + } + if err := t.inspectOtherExactBrokerFiles(executable); err != nil { + return nativePackageUninstallServiceSnapshot{}, err + } + return nativePackageUninstallServiceSnapshot{ + exists: true, wasRunning: status.State == svc.Running, opaque: t.snapshot, + }, nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectOrphanedExactManagedFiles() error { + if err := t.inspectCredentialFiles(false, false); err != nil { + return err + } + return t.inspectOtherExactBrokerFiles("") +} + +func (t *windowsNativePackageUninstallTransaction) inspectOtherExactBrokerFiles(exclude string) error { + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files: %w", err) + } + candidates := []string{ + filepath.Join(filepath.Clean(programFiles), "VIIPER", "viiper.exe"), + filepath.Join(filepath.Clean(programFiles), "DS4Windows", "VIIPER", "viiper.exe"), + } + for _, candidate := range candidates { + if exclude != "" && strings.EqualFold(filepath.Clean(candidate), filepath.Clean(exclude)) { + continue + } + if t.hasOwnedFile(candidate) { + continue + } + if _, err := t.inspectExactBrokerFile(candidate, false); err != nil { + return err + } + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectExactBrokerFile( + path string, + required bool, +) (*windowsNativePackageUninstallFile, error) { + attributes, err := nativePathAttributes(path) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + if required { + return nil, fmt.Errorf("exact native broker executable is missing: %s", path) + } + return nil, nil + } + return nil, fmt.Errorf("inspect exact managed broker path %s: %w", path, err) + } + if attributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + if required { + return nil, fmt.Errorf("exact native broker path is not a regular non-reparse file: %s", path) + } + t.logger.Warn("Leaving non-owned file at an exact native broker path", "path", path) + return nil, nil + } + if err := t.lockExactBrokerDirectoryChain(path); err != nil { + if required { + return nil, fmt.Errorf("lock exact installer-owned native broker directories: %w", err) + } + t.logger.Warn("Leaving broker path whose exact installer ownership did not verify", + "path", path, "error", err) + return nil, nil + } + owned, err := lockNativePackageUninstallFile( + path, "broker", nativeBrokerExecutableSDDL, true, + ) + if err != nil { + if required { + return nil, fmt.Errorf("snapshot exact installer-owned native broker: %w", err) + } + t.logger.Warn("Leaving broker path that changed during exact ownership snapshot", + "path", path, "error", err) + return nil, nil + } + t.ownedFiles = append(t.ownedFiles, owned) + return owned, nil +} + +// lockExactBrokerDirectoryChain retains every ancestor against rename while +// validating the package-owned directories below Program Files. Do not call +// lockNativePriorServiceExecutable here: its read-only leaf handle deliberately +// denies delete sharing and would make the subsequent exact DELETE-capable +// snapshot fail with ERROR_SHARING_VIOLATION on every healthy installation. +func (t *windowsNativePackageUninstallTransaction) lockExactBrokerDirectoryChain( + executable string, +) error { + programFiles, err := windows.KnownFolderPath( + windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT, + ) + if err != nil { + return fmt.Errorf("resolve Program Files: %w", err) + } + parent, err := nativeServiceExecutableParent(programFiles, executable) + if err != nil { + return err + } + chain, err := lockNativePackageDirectoryChain(parent) + if err != nil { + return err + } + validated := make([]windows.Handle, 0, 2) + fail := func(failErr error) error { + closeNativePackageUninstallHandles(validated) + closeNativePackageUninstallHandles(chain) + return failErr + } + relative, err := filepath.Rel(filepath.Clean(programFiles), filepath.Clean(parent)) + if err != nil || relative == "." || filepath.IsAbs(relative) || + relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fail(fmt.Errorf("exact native broker parent escaped Program Files: %s", parent)) + } + current := filepath.Clean(programFiles) + for _, component := range strings.Split(relative, string(filepath.Separator)) { + if component == "" || component == "." || component == ".." { + return fail(fmt.Errorf("exact native broker parent contains an unsafe component: %s", parent)) + } + current = filepath.Join(current, component) + handle, openErr := openNativePathWithoutReparse( + current, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if openErr != nil { + return fail(fmt.Errorf("open protected broker directory %s: %w", current, openErr)) + } + validated = append(validated, handle) + if securityErr := validateNativeSecurityDescriptor( + handle, nativeBrokerDirectorySDDL, + ); securityErr != nil { + return fail(fmt.Errorf("validate protected broker directory %s: %w", current, securityErr)) + } + } + t.managedDirectories = append(t.managedDirectories, chain...) + t.managedDirectories = append(t.managedDirectories, validated...) + return nil +} + +func (t *windowsNativePackageUninstallTransaction) inspectCredentialFiles( + required bool, + brokerMayWriteLog bool, +) error { + keyPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + directory := filepath.Dir(keyPath) + attributes, err := nativePathAttributes(directory) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + if required { + return errors.New("exact native broker credential directory is missing") + } + return nil + } + return fmt.Errorf("inspect exact credential directory: %w", err) + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + if required { + return errors.New("exact native broker credential path is not a regular directory") + } + t.logger.Warn("Leaving non-owned native credential path", "path", directory) + return nil + } + chain, err := lockNativePackageDirectoryChain(directory) + if err != nil { + if required { + return fmt.Errorf("lock exact native credential directory chain: %w", err) + } + t.logger.Warn("Leaving native credential path with unsafe ancestors", + "path", directory, "error", err) + return nil + } + directoryHandle, err := openNativePathWithoutReparse( + directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + closeNativePackageUninstallHandles(chain) + return fmt.Errorf("open exact native credential directory: %w", err) + } + if err := validateNativeSecurityDescriptor( + directoryHandle, nativeCredentialDirectorySDDL(t.userSID), + ); err != nil { + windows.CloseHandle(directoryHandle) //nolint:errcheck + closeNativePackageUninstallHandles(chain) + if required { + return fmt.Errorf("validate exact native credential directory ownership: %w", err) + } + t.logger.Warn("Leaving native credential directory whose exact ownership did not verify", + "path", directory, "error", err) + return nil + } + t.managedDirectories = append(t.managedDirectories, chain...) + t.managedDirectories = append(t.managedDirectories, directoryHandle) + credential, err := lockNativePackageUninstallFile( + keyPath, "credential", nativeCredentialFileSDDL(t.userSID), false, + ) + if err != nil { + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && !required { + credential = nil + } else { + return fmt.Errorf("snapshot exact native broker credential: %w", err) + } + } + if required && credential == nil { + return errors.New("exact native broker credential is missing") + } + if credential != nil { + t.ownedFiles = append(t.ownedFiles, credential) + } + logPath := filepath.Join(directory, nativeBrokerLogName) + if brokerMayWriteLog { + t.liveLogPath = logPath + liveLog, err := lockNativePackageUninstallLiveLog(logPath) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return fmt.Errorf("snapshot active exact native broker log identity: %w", err) + } + t.liveLog = liveLog + return nil + } + logFile, err := lockNativePackageUninstallFile(logPath, "broker-log", "", false) + if err != nil { + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return fmt.Errorf("snapshot exact native broker log: %w", err) + } + logFile = nil + } + if logFile != nil { + t.ownedFiles = append(t.ownedFiles, logFile) + } + return nil +} + +func lockNativePackageUninstallLiveLog( + path string, +) (*windowsNativePackageUninstallLiveLog, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return nil, err + } + // The trusted broker opens its log for writing with read/write sharing. This + // probe therefore cannot request DELETE yet, but its retained identity lets + // us prove that the stronger post-STOP handle names the same exact file. + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + fail := func(failErr error) (*windowsNativePackageUninstallLiveLog, error) { + windows.CloseHandle(handle) //nolint:errcheck + return nil, failErr + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return fail(err) + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("active managed broker log is not a regular non-reparse file")) + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return fail(err) + } + return &windowsNativePackageUninstallLiveLog{ + path: filepath.Clean(path), identity: identity, handle: handle, + }, nil +} + +func lockNativePackageUninstallFile( + path, kind, expectedSDDL string, + requirePE bool, +) (*windowsNativePackageUninstallFile, error) { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + fail := func(failErr error) (*windowsNativePackageUninstallFile, error) { + windows.CloseHandle(handle) //nolint:errcheck + return nil, failErr + } + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return fail(err) + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("managed uninstall target is not a regular non-reparse file")) + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return fail(err) + } + if expectedSDDL != "" { + if err := validateNativeSecurityDescriptor(handle, expectedSDDL); err != nil { + return fail(err) + } + } + if requirePE { + if err := requireNativePackagePE(handle); err != nil { + return fail(err) + } + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return fail(err) + } + return &windowsNativePackageUninstallFile{ + kind: kind, path: filepath.Clean(path), hash: hash, + identity: identity, handle: handle, + }, nil +} + +func nativePackageUninstallFileIdentity( + handle windows.Handle, +) (windowsNativePackageUninstallFileIdentity, error) { + info := windows.ByHandleFileInformation{} + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsNativePackageUninstallFileIdentity{}, fmt.Errorf("query managed file identity: %w", err) + } + if err := validateNativeFileLinkCount(info.NumberOfLinks); err != nil { + return windowsNativePackageUninstallFileIdentity{}, err + } + return windowsNativePackageUninstallFileIdentity{ + volumeSerialNumber: info.VolumeSerialNumber, + fileIndex: uint64(info.FileIndexHigh)<<32 | uint64(info.FileIndexLow), + }, nil +} + +func (t *windowsNativePackageUninstallTransaction) hasOwnedFile(path string) bool { + for _, file := range t.ownedFiles { + if strings.EqualFold(file.path, filepath.Clean(path)) { + return true + } + } + return false +} + +func (t *windowsNativePackageUninstallTransaction) StopService( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) error { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return err + } + if windowsSnapshot == nil { + return nil + } + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, false); err != nil { + return err + } + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return err + } + status, err := t.service.Query() + if err != nil { + return fmt.Errorf("verify exact native broker stopped: %w", err) + } + if status.State != svc.Stopped { + return fmt.Errorf("exact native broker remained in state %d after stop", status.State) + } + if err := t.promoteNativePackageUninstallLiveLog(ctx); err != nil { + return &nativePackageUninstallUnsafeRestoreError{cause: err} + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) promoteNativePackageUninstallLiveLog( + ctx context.Context, +) error { + if t.liveLogPath == "" { + return nil + } + var owned *windowsNativePackageUninstallFile + for { + var err error + owned, err = lockNativePackageUninstallFile(t.liveLogPath, "broker-log", "", false) + if err == nil { + break + } + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && + t.liveLog == nil { + t.liveLogPath = "" + return nil + } + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + return fmt.Errorf("lock stopped exact native broker log: %w", err) + } + if err := waitContext(ctx, 25*time.Millisecond); err != nil { + return fmt.Errorf("wait for stopped exact native broker log handle: %w", err) + } + } + if t.liveLog != nil && owned.identity != t.liveLog.identity { + windows.CloseHandle(owned.handle) //nolint:errcheck + return errors.New("exact native broker log identity changed across service stop") + } + if t.liveLog != nil { + if err := windows.CloseHandle(t.liveLog.handle); err != nil { + windows.CloseHandle(owned.handle) //nolint:errcheck + return fmt.Errorf("close active exact native broker log identity: %w", err) + } + t.liveLog = nil + } + t.liveLogPath = "" + t.ownedFiles = append(t.ownedFiles, owned) + return nil +} + +func (t *windowsNativePackageUninstallTransaction) RemoveDriver( + ctx context.Context, +) (nativePackageRemoveResult, error) { + if err := ctx.Err(); err != nil { + return nativePackageRemoveResult{serviceRestoreVerified: true}, err + } + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return nativePackageRemoveResult{serviceRestoreVerified: true}, context.DeadlineExceeded + } + arguments := []string{ + "remove", "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + } + // Never use CommandContext or kill this process: once SetupAPI mutation starts, + // ViiperUdeCtl owns the exact Driver Store backup and cooperative rollback. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + return nativePackageRemoveResult{serviceRestoreVerified: true}, err + } + waitErr := command.Wait() + exitCode := 0 + if waitErr != nil { + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return nativePackageRemoveResult{}, waitErr + } + exitCode = exitError.ExitCode() + } + result, proofErr := parseNativePackageRemoveProof(output.String(), exitCode) + if proofErr != nil { + if waitErr != nil { + return nativePackageRemoveResult{}, fmt.Errorf("%w (process: %v)", proofErr, waitErr) + } + return nativePackageRemoveResult{}, proofErr + } + return result, nil +} + +func (t *windowsNativePackageUninstallTransaction) Cleanup( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) (bool, error) { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return false, err + } + if windowsSnapshot != nil { + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, true); err != nil { + return false, fmt.Errorf("revalidate exact native broker before delete: %w", err) + } + if err := t.service.Delete(); err != nil && + !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return false, fmt.Errorf("delete exact %s after driver removal: %w", + NativeBrokerServiceName, err) + } + if err := t.service.Close(); err != nil { + return false, fmt.Errorf("close exact %s after delete: %w", NativeBrokerServiceName, err) + } + t.service = nil + if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + return false, fmt.Errorf("reconcile exact %s deletion: %w", NativeBrokerServiceName, err) + } + } + var cleanupErrors []error + cleanupRebootRequired := false + for _, file := range t.ownedFiles { + if file.handle == 0 { + continue + } + actualHash, hashErr := hashNativePackageHandle(file.handle) + if hashErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("revalidate exact %s before delete: %w", file.kind, hashErr)) + continue + } + if !strings.EqualFold(actualHash, file.hash) { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("refusing to delete exact %s because its locked hash changed", file.kind)) + continue + } + if err := deleteNativePackageUninstallFileHandle(file.handle); err != nil { + isCurrentExecutable, identityErr := nativePackageUninstallIsCurrentExecutable(file) + if identityErr == nil && isCurrentExecutable && + (errors.Is(err, windows.ERROR_ACCESS_DENIED) || + errors.Is(err, windows.ERROR_SHARING_VIOLATION)) { + if scheduleErr := scheduleNativePackageUninstallFileAtReboot(file.path); scheduleErr == nil { + cleanupRebootRequired = true + if closeErr := windows.CloseHandle(file.handle); closeErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("close reboot-scheduled exact %s %s: %w", file.kind, file.path, closeErr)) + } + file.handle = 0 + continue + } else { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("schedule running exact %s %s for reboot deletion: %w", file.kind, file.path, scheduleErr)) + continue + } + } + cleanupErrors = append(cleanupErrors, + fmt.Errorf("delete exact installer-owned %s %s: %w", file.kind, file.path, err)) + if identityErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("identify failed exact %s deletion as the running executable: %w", file.kind, identityErr)) + } + continue + } + if err := windows.CloseHandle(file.handle); err != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("close deleted exact %s %s: %w", file.kind, file.path, err)) + } + file.handle = 0 + } + return cleanupRebootRequired, errors.Join(cleanupErrors...) +} + +func deleteNativePackageUninstallFileHandle(handle windows.Handle) error { + disposition := struct{ DeleteFile byte }{DeleteFile: 1} + result, _, callErr := setNativeFileInformationByHandle.Call( + uintptr(handle), + nativeFileDispositionInfoClass, + uintptr(unsafe.Pointer(&disposition)), + unsafe.Sizeof(disposition), + ) + if result != 0 { + return nil + } + if callErr != nil && !errors.Is(callErr, syscall.Errno(0)) { + return callErr + } + return syscall.EINVAL +} + +func nativePackageUninstallIsCurrentExecutable( + file *windowsNativePackageUninstallFile, +) (bool, error) { + if file == nil || file.handle == 0 { + return false, errors.New("exact managed file snapshot is unavailable") + } + executable, err := currentExecutable() + if err != nil { + return false, err + } + pointer, err := windows.UTF16PtrFromString(filepath.Clean(executable)) + if err != nil { + return false, err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + info := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + return false, err + } + if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return false, errors.New("current executable is not a regular non-reparse file") + } + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + return false, err + } + return identity == file.identity, nil +} + +func scheduleNativePackageUninstallFileAtReboot(path string) error { + pointer, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return err + } + return windows.MoveFileEx( + pointer, nil, + windows.MOVEFILE_DELAY_UNTIL_REBOOT|windows.MOVEFILE_WRITE_THROUGH, + ) +} + +func (t *windowsNativePackageUninstallTransaction) RestoreService( + ctx context.Context, + snapshot nativePackageUninstallServiceSnapshot, +) error { + windowsSnapshot, err := t.requireSnapshot(snapshot) + if err != nil { + return err + } + if windowsSnapshot == nil { + return nil + } + if err := t.verifyOwnedFileSnapshots(); err != nil { + return fmt.Errorf("revalidate exact native broker files before restart: %w", err) + } + if err := t.verifyExactServiceSnapshot(ctx, windowsSnapshot, false); err != nil { + return fmt.Errorf("revalidate exact native broker service before restart: %w", err) + } + if snapshot.wasRunning { + return reconcileNativePackageServiceRunning(ctx, t.service) + } + return stopNativeService(ctx, t.service, waitContext) +} + +func (t *windowsNativePackageUninstallTransaction) requireSnapshot( + snapshot nativePackageUninstallServiceSnapshot, +) (*windowsNativePackageUninstallSnapshot, error) { + if snapshot.exists != (t.snapshot != nil) { + return nil, errors.New("native broker service existence changed after snapshot") + } + if t.snapshot == nil { + if snapshot.opaque != nil || snapshot.wasRunning { + return nil, errors.New("absent native broker snapshot carried mutable state") + } + return nil, nil + } + if snapshot.opaque != t.snapshot || snapshot.wasRunning != (t.snapshot.status.State == svc.Running) { + return nil, errors.New("native broker service snapshot identity changed") + } + return t.snapshot, nil +} + +func (t *windowsNativePackageUninstallTransaction) verifyExactServiceSnapshot( + ctx context.Context, + snapshot *windowsNativePackageUninstallSnapshot, + requireStopped bool, +) error { + if t.service == nil { + return errors.New("exact native broker service handle is unavailable") + } + config, err := t.service.Config() + if err != nil { + return fmt.Errorf("query exact native broker config: %w", err) + } + if !nativeServiceConfigsEqual(config, snapshot.config) { + return errors.New("exact native broker configuration changed during package removal") + } + securityDescriptor, err := t.service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("query exact native broker security: %w", err) + } + if err := compareNativeSecurityDescriptorStrings( + securityDescriptor, snapshot.securityDescriptor, + ); err != nil { + return fmt.Errorf("exact native broker security changed during package removal: %w", err) + } + recovery, err := t.service.RecoveryActions() + if err != nil { + return err + } + reset, err := t.service.ResetPeriod() + if err != nil { + return err + } + nonCrash, err := t.service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return err + } + if !slices.Equal(recovery, snapshot.recoveryActions) || + reset != snapshot.recoveryResetSeconds || nonCrash != snapshot.recoverNonCrash { + return errors.New("exact native broker recovery ownership changed during package removal") + } + status, err := t.service.Query() + if err != nil { + return err + } + status, err = settleNativeServiceSnapshot(ctx, t.service, status, waitContext) + if err != nil { + return err + } + if status.State != svc.Running && status.State != svc.Stopped { + return fmt.Errorf("exact native broker entered unexpected state %d", status.State) + } + if requireStopped && status.State != svc.Stopped { + return errors.New("exact native broker restarted before owned cleanup") + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) verifyOwnedFileSnapshots() error { + for _, file := range t.ownedFiles { + if file.handle == 0 { + return fmt.Errorf("exact %s snapshot handle was released", file.kind) + } + hash, err := hashNativePackageHandle(file.handle) + if err != nil { + return fmt.Errorf("hash exact %s snapshot: %w", file.kind, err) + } + if !strings.EqualFold(hash, file.hash) { + return fmt.Errorf("exact %s snapshot hash changed", file.kind) + } + } + return nil +} + +func (t *windowsNativePackageUninstallTransaction) Close() error { + if t.closed { + return nil + } + t.closed = true + var closeErrors []error + if t.liveLog != nil { + if err := windows.CloseHandle(t.liveLog.handle); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close active exact native broker log identity: %w", err)) + } + t.liveLog = nil + } + t.liveLogPath = "" + for index := len(t.ownedFiles) - 1; index >= 0; index-- { + file := t.ownedFiles[index] + if file.handle != 0 { + if err := windows.CloseHandle(file.handle); err != nil { + closeErrors = append(closeErrors, + fmt.Errorf("close exact %s snapshot: %w", file.kind, err)) + } + file.handle = 0 + } + } + closeNativePackageUninstallHandles(t.managedDirectories) + t.managedDirectories = nil + if t.helperHandle != 0 { + if err := windows.CloseHandle(t.helperHandle); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close packaged driver helper: %w", err)) + } + t.helperHandle = 0 + } + closeNativePackageUninstallHandles(t.helperHandles) + t.helperHandles = nil + if t.service != nil { + if err := t.service.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close exact native broker service: %w", err)) + } + t.service = nil + } + if t.manager != nil { + if err := t.manager.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close SCM: %w", err)) + } + t.manager = nil + } + // Release nested thread-owned mutexes in reverse global acquisition order. + if t.releaseServiceMutex != nil { + t.releaseServiceMutex() + t.releaseServiceMutex = nil + } + if t.releasePackageMutex != nil { + t.releasePackageMutex() + t.releasePackageMutex = nil + } + return errors.Join(closeErrors...) +} + +func closeNativePackageUninstallHandles(handles []windows.Handle) { + for index := len(handles) - 1; index >= 0; index-- { + windows.CloseHandle(handles[index]) //nolint:errcheck + } +} diff --git a/internal/cmd/native_package_uninstall_windows_test.go b/internal/cmd/native_package_uninstall_windows_test.go new file mode 100644 index 00000000..877523ca --- /dev/null +++ b/internal/cmd/native_package_uninstall_windows_test.go @@ -0,0 +1,255 @@ +//go:build windows + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestNativePackageUninstallSerializesConcurrentPackageTransactions(t *testing.T) { + name := `Local\VIIPER_NATIVE_UNINSTALL_TEST_` + filepath.Base(t.TempDir()) + releaseFirst, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("acquire first package owner: %v", err) + } + secondResult := make(chan error, 1) + go func() { + releaseSecond, secondErr := acquireNamedNativePackageMutex(name, 40*time.Millisecond) + if releaseSecond != nil { + releaseSecond() + } + secondResult <- secondErr + }() + select { + case secondErr := <-secondResult: + if secondErr == nil || + (!strings.Contains(secondErr.Error(), "still running") && + !errors.Is(secondErr, windows.ERROR_ACCESS_DENIED)) { + releaseFirst() + t.Fatalf("concurrent package owner error=%v", secondErr) + } + case <-time.After(2 * time.Second): + releaseFirst() + t.Fatal("concurrent package owner did not respect its bounded wait") + } + releaseFirst() + + releaseAfter, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("package mutex remained stranded after release: %v", err) + } + releaseAfter() +} + +func TestNativePackageUninstallDeletesRetainedExactFileHandle(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "owned.log") + if err := os.WriteFile(path, []byte("exact-owned"), 0o600); err != nil { + t.Fatal(err) + } + owned, err := lockNativePackageUninstallFile(path, "test", "", false) + if err != nil { + t.Fatalf("lock exact file: %v", err) + } + if err := deleteNativePackageUninstallFileHandle(owned.handle); err != nil { + _ = os.NewFile(uintptr(owned.handle), path).Close() + t.Fatalf("mark exact handle for deletion: %v", err) + } + if err := os.NewFile(uintptr(owned.handle), path).Close(); err != nil { + t.Fatalf("close exact deleted handle: %v", err) + } + owned.handle = 0 + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("exact handle path still exists: %v", err) + } +} + +func TestNativePackageUninstallDoesNotPrelockBrokerLeafWithoutDeleteSharing(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "broker.exe") + if err := os.WriteFile(path, []byte("MZ-test-image"), 0o600); err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + readOnly, err := windows.CreateFile( + pointer, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, + windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0, + ) + if err != nil { + t.Fatal(err) + } + if owned, lockErr := lockNativePackageUninstallFile(path, "broker", "", true); lockErr == nil { + if owned != nil && owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + _ = windows.CloseHandle(readOnly) + t.Fatal("a non-delete-shared prelock unexpectedly allowed the exact DELETE-capable snapshot") + } else if !errors.Is(lockErr, windows.ERROR_SHARING_VIOLATION) { + _ = windows.CloseHandle(readOnly) + t.Fatalf("conflicting prelock error=%v, want sharing violation", lockErr) + } + if err := windows.CloseHandle(readOnly); err != nil { + t.Fatal(err) + } + owned, err := lockNativePackageUninstallFile(path, "broker", "", true) + if err != nil { + t.Fatalf("direct exact broker snapshot: %v", err) + } + if err := windows.CloseHandle(owned.handle); err != nil { + t.Fatal(err) + } + owned.handle = 0 +} + +func TestNativePackageUninstallIdentifiesExactRunningImageByFileID(t *testing.T) { + t.Parallel() + executable, err := currentExecutable() + if err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(executable) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile( + pointer, windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + identity, err := nativePackageUninstallFileIdentity(handle) + if err != nil { + t.Fatal(err) + } + file := &windowsNativePackageUninstallFile{handle: handle, identity: identity} + current, err := nativePackageUninstallIsCurrentExecutable(file) + if err != nil { + t.Fatal(err) + } + if !current { + t.Fatal("exact current executable file ID was not recognized for safe reboot cleanup") + } +} + +func TestNativePackageUninstallRejectsHardLinkedManagedFile(t *testing.T) { + t.Parallel() + directory := t.TempDir() + path := filepath.Join(directory, "owned.log") + link := filepath.Join(directory, "alias.log") + if err := os.WriteFile(path, []byte("not-single-link"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(path, link); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + owned, err := lockNativePackageUninstallFile(path, "test", "", false) + if owned != nil || err == nil { + if owned != nil && owned.handle != 0 { + _ = os.NewFile(uintptr(owned.handle), path).Close() + } + t.Fatalf("hard-linked managed file accepted: owned=%+v err=%v", owned, err) + } +} + +func TestNativePackageUninstallPromotesActiveLogIdentityAfterWriterStops(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "active.log") + writer, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + probe, err := lockNativePackageUninstallLiveLog(path) + if err != nil { + _ = writer.Close() + t.Fatalf("take active-log identity probe: %v", err) + } + defer func() { + if probe.handle != 0 { + _ = windows.CloseHandle(probe.handle) + } + }() + if owned, err := lockNativePackageUninstallFile(path, "broker-log", "", false); err == nil { + if owned != nil && owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + _ = writer.Close() + t.Fatal("delete-capable log lock unexpectedly succeeded while trusted writer was active") + } + if err := writer.Close(); err != nil { + t.Fatalf("stop active log writer: %v", err) + } + owned, err := lockNativePackageUninstallFile(path, "broker-log", "", false) + if err != nil { + t.Fatalf("promote stopped log lock: %v", err) + } + defer func() { + if owned.handle != 0 { + _ = windows.CloseHandle(owned.handle) + } + }() + if owned.identity != probe.identity { + t.Fatalf("promoted identity=%+v probe=%+v", owned.identity, probe.identity) + } +} + +func TestNativePackageUninstallRejectsActiveLogIdentitySwap(t *testing.T) { + t.Parallel() + directory := t.TempDir() + path := filepath.Join(directory, "active.log") + moved := filepath.Join(directory, "moved.log") + if err := os.WriteFile(path, []byte("captured"), 0o600); err != nil { + t.Fatal(err) + } + probe, err := lockNativePackageUninstallLiveLog(path) + if err != nil { + t.Fatalf("take active-log identity probe: %v", err) + } + transaction := &windowsNativePackageUninstallTransaction{ + liveLog: probe, liveLogPath: path, + } + defer func() { _ = transaction.Close() }() + if err := os.Rename(path, moved); err != nil { + t.Skipf("rename while delete-shared identity probe is held: %v", err) + } + if err := os.WriteFile(path, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + err = transaction.promoteNativePackageUninstallLiveLog(context.Background()) + if err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("replacement log identity accepted: %v", err) + } + if len(transaction.ownedFiles) != 0 { + t.Fatalf("replacement log became installer-owned: %+v", transaction.ownedFiles) + } +} + +func TestNativePackageUninstallCapturesLogCreatedBeforeStop(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "created-during-stop.log") + transaction := &windowsNativePackageUninstallTransaction{liveLogPath: path} + defer func() { _ = transaction.Close() }() + if err := os.WriteFile(path, []byte("trusted broker output"), 0o600); err != nil { + t.Fatal(err) + } + if err := transaction.promoteNativePackageUninstallLiveLog(context.Background()); err != nil { + t.Fatalf("capture log created before service stop: %v", err) + } + if len(transaction.ownedFiles) != 1 || transaction.ownedFiles[0].path != path { + t.Fatalf("created exact log was not locked: %+v", transaction.ownedFiles) + } +} diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 16f280db..d372c80c 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -441,27 +441,6 @@ func installNativeBrokerUntil( return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) } -func uninstallNativeBroker(logger *slog.Logger, explicitUserSID string) error { - release, err := acquireNativeInstallMutex(nativeServiceInstallTimeout) - if err != nil { - return err - } - defer release() - userSID, err := resolveNativeInstallingUserSID(explicitUserSID) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) - defer cancel() - dependencies := productionNativeInstallDependencies(userSID) - manager, err := dependencies.connectSCM() - if err != nil { - return fmt.Errorf("connect to Windows Service Control Manager: %w", err) - } - defer manager.Close() //nolint:errcheck - return uninstallNativeBrokerTransaction(ctx, logger, manager, dependencies) -} - func uninstallNativeBrokerTransaction( ctx context.Context, logger *slog.Logger, diff --git a/native/udecx/README.md b/native/udecx/README.md index 3a30fb44..c2ce29de 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -121,6 +121,25 @@ means verified installation/removal requires a restart, `4` is a preflight rejection, and `3` means rollback itself failed. Every command emits one final key/value result line including `rebootRequired` and rollback status. +Production uninstall is similarly owned by the signed installer. It calls +`viiper uninstall` with the packaged `ViiperUdeCtl.exe`, the installer-bound +helper SHA-256, and the target-user SID. The broker is only stopped while the +helper transaction runs; its SCM registration, credential, and managed files +remain available for exact restart unless removal succeeds. For a direct +operator inspection of the helper boundary, use a cooperative deadline: + +```powershell +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds() +.\ViiperUdeCtl.exe remove --transaction-deadline-unix-ms $deadline +``` + +Only exit `0` or `3010` authorizes exact broker/credential/file cleanup. A +preflight rejection or verified no-reboot `rollback=succeeded` preserves the +prior broker run-state; a reboot-pending or unverified rollback leaves it +stopped for explicit reconciliation. +The production outer command never removes legacy tasks, Run registrations, or +USB/IP state. + After a Microsoft-signed native driver package has been installed and verified, the developer-only standalone registration can persist the preview transport: diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index ae495db5..100d33b9 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -44,6 +44,14 @@ $requiredContracts = [ordered]@{ 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' 'reboot boundary rollback' = 'broker-reboot-boundary' 'remove rollback backup' = 'BackupPackages\(' + 'protected rollback directory' = 'kRollbackDirectorySecurity' + 'inherited rollback protection' = 'O:BAD:P\(A;OICI;FA;;;SY\)\(A;OICI;FA;;;BA\)' + 'unpredictable rollback directory' = 'CryptGenRandom\(' + 'immutable rollback package files' = 'LockPackageFiles\(destination, &locks' + 'remove deadline parser' = 'ParseRemoveOptions\(' + 'remove mutation deadline' = 'remove-deadline-before-device' + 'finite remove rollback ceiling' = 'kDriverRollbackCeilingMs' + 'remove rollback deadline' = 'remove-rollback-deadline-package' 'transaction mutex' = 'VIIPER_UDE_DRIVER_TRANSACTION_V1' 'protected private transaction namespace' = 'CreatePrivateNamespaceW\(' 'protected transaction object DACL' = 'D:P\(A;;GA;;;SY\)\(A;;GA;;;BA\)' @@ -88,6 +96,10 @@ if ($source -match 'WaitForSingleObject\(processHandle\.get\(\),\s*INFINITE\)') throw 'The nested broker wait must use the cooperative package deadline contract.' } +if ($source -match 'std::max\(CurrentUnixMilliseconds\(\),\s*options\.transactionDeadlineUnixMs\)\s*\+\s*kDriverRollbackCeilingMs') { + throw 'Remove rollback must receive a fresh finite ceiling, not the unused forward deadline plus a rollback budget.' +} + if ([regex]::Matches($source, ',\s*DICD_GENERATE_ID\s*,').Count -ne 1) { throw 'Generated root identities are allowed only for first-time forward creation, never rollback.' } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b76abc38..37fdc08f 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -87,7 +87,10 @@ constexpr wchar_t kTransactionObjectSecurity[] = constexpr size_t kMaximumManifestBytes = 1024U * 1024U; constexpr uint64_t kMaximumTransactionDurationMs = 4ULL * 60ULL * 1000ULL; constexpr uint64_t kBrokerRollbackCeilingMs = 60ULL * 1000ULL; +constexpr uint64_t kDriverRollbackCeilingMs = 2ULL * 60ULL * 1000ULL; constexpr DWORD kCancelledIoDrainMs = 5000; +constexpr wchar_t kRollbackDirectorySecurity[] = + L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; @@ -2187,6 +2190,14 @@ bool CheckTransactionDeadline(const InstallOptions& options, const wchar_t* phas return true; } +bool CheckTransactionDeadline(uint64_t deadlineUnixMs, const wchar_t* phase, Error* error) { + if (deadlineUnixMs == 0 || CurrentUnixMilliseconds() >= deadlineUnixMs) { + return SetError(error, phase, ERROR_TIMEOUT, + L"native package transaction deadline expired before the next mutation"); + } + return true; +} + bool ValidateTransactionDeadlineBudget(const InstallOptions& options, Error* error) { const uint64_t now = CurrentUnixMilliseconds(); if (options.transactionDeadlineUnixMs <= now || @@ -2197,6 +2208,15 @@ bool ValidateTransactionDeadlineBudget(const InstallOptions& options, Error* err return true; } +bool ValidateTransactionDeadlineBudget(uint64_t deadlineUnixMs, Error* error) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now || deadlineUnixMs - now > kMaximumTransactionDurationMs) { + return SetError(error, L"transaction-deadline", ERROR_INVALID_PARAMETER, + L"transaction deadline is expired or exceeds the four-minute package budget"); + } + return true; +} + bool ValidateCandidateInputs( const InstallOptions& options, std::filesystem::path* packageDirectory, @@ -2631,39 +2651,135 @@ struct PackageBackup { PackageInfo original; std::filesystem::path directory; std::filesystem::path infPath; + std::vector locks; }; class BackupDirectory final { public: ~BackupDirectory() { if (!path_.empty()) { + root_.reset(); std::error_code ignored; std::filesystem::remove_all(path_, ignored); } } bool Create(Error* error) { - std::vector temp(MAX_PATH); - const DWORD length = GetTempPathW(static_cast(temp.size()), temp.data()); - if (length == 0 || static_cast(length) >= temp.size()) { + std::vector windowsDirectory(MAX_PATH); + const UINT length = GetWindowsDirectoryW( + windowsDirectory.data(), static_cast(windowsDirectory.size())); + if (length == 0 || static_cast(length) >= windowsDirectory.size()) { return SetLastErrorDetail(error, L"rollback-backup-root"); } - wchar_t candidate[MAX_PATH]{}; - if (!GetTempFileNameW(temp.data(), L"VUC", 0, candidate)) { - return SetLastErrorDetail(error, L"rollback-backup-root"); + const std::filesystem::path parent = + std::filesystem::path(windowsDirectory.data()) / L"Temp"; + parent_.reset(CreateFileW( + parent.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!parent_) { + return SetLastErrorDetail(error, L"rollback-backup-parent"); + } + FILE_ATTRIBUTE_TAG_INFO parentAttributes{}; + if (!GetFileInformationByHandleEx( + parent_.get(), FileAttributeTagInfo, &parentAttributes, + sizeof(parentAttributes)) || + (parentAttributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (parentAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"rollback-backup-parent", + ERROR_REPARSE_TAG_MISMATCH, + L"Windows temporary directory must be a regular non-reparse directory"); } - DeleteFileW(candidate); - if (!CreateDirectoryW(candidate, nullptr)) { - return SetLastErrorDetail(error, L"rollback-backup-root"); + + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + kRollbackDirectorySecurity, SDDL_REVISION_1, &descriptor, nullptr)) { + return SetLastErrorDetail(error, L"rollback-backup-security"); } - path_ = candidate; - return true; + SECURITY_ATTRIBUTES security{}; + security.nLength = sizeof(security); + security.lpSecurityDescriptor = descriptor; + security.bInheritHandle = FALSE; + + HCRYPTPROV provider = 0; + if (!CryptAcquireContextW( + &provider, nullptr, nullptr, PROV_RSA_AES, + CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) { + const DWORD code = GetLastError(); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-random", code); + } + static constexpr wchar_t digits[] = L"0123456789abcdef"; + for (size_t attempt = 0; attempt < 32; ++attempt) { + std::array random{}; + if (!CryptGenRandom(provider, static_cast(random.size()), random.data())) { + const DWORD code = GetLastError(); + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-random", code); + } + std::wstring suffix; + suffix.reserve(random.size() * 2); + for (BYTE value : random) { + suffix.push_back(digits[value >> 4U]); + suffix.push_back(digits[value & 0x0fU]); + } + const std::filesystem::path candidate = + parent / (L"VIIPER-UDE-rollback-" + suffix); + if (!CreateDirectoryW(candidate.c_str(), &security)) { + if (GetLastError() == ERROR_ALREADY_EXISTS) { + continue; + } + const DWORD code = GetLastError(); + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-root", code); + } + root_.reset(CreateFileW( + candidate.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!root_) { + const DWORD code = GetLastError(); + RemoveDirectoryW(candidate.c_str()); + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-root-lock", code); + } + FILE_ATTRIBUTE_TAG_INFO rootAttributes{}; + if (!GetFileInformationByHandleEx( + root_.get(), FileAttributeTagInfo, &rootAttributes, + sizeof(rootAttributes)) || + (rootAttributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (rootAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + root_.reset(); + RemoveDirectoryW(candidate.c_str()); + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-root-lock", + ERROR_REPARSE_TAG_MISMATCH); + } + path_ = candidate; + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return true; + } + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-root", ERROR_ALREADY_EXISTS, + L"could not allocate a unique protected rollback directory"); } const std::filesystem::path& path() const noexcept { return path_; } private: std::filesystem::path path_; + WinHandle parent_; + WinHandle root_; }; bool BackupPackages( @@ -2739,7 +2855,13 @@ bool BackupPackages( if (!LoadOwnedPackage(backupInf, true, &verified, &owned, error) || !owned) { return false; } - backups->push_back(PackageBackup{packages[index], destination, backupInf}); + std::vector locks; + if (!LockPackageFiles(destination, &locks, error)) { + error->phase = L"rollback-backup-lock"; + return false; + } + backups->push_back(PackageBackup{ + packages[index], destination, backupInf, std::move(locks)}); } return true; } @@ -2747,15 +2869,24 @@ bool BackupPackages( bool RollbackRemove( const Snapshot& prior, const std::vector& backups, + uint64_t rollbackDeadlineUnixMs, bool* rebootRequired, Error* error) { for (const PackageBackup& backup : backups) { + if (!CheckTransactionDeadline( + rollbackDeadlineUnixMs, L"remove-rollback-deadline-package", error)) { + return false; + } BOOL reboot = FALSE; if (!DiInstallDriverW(nullptr, backup.infPath.c_str(), 0, &reboot)) { return SetLastErrorDetail(error, L"remove-rollback-package"); } *rebootRequired = *rebootRequired || reboot != FALSE; } + if (!CheckTransactionDeadline( + rollbackDeadlineUnixMs, L"remove-rollback-deadline-binding", error)) { + return false; + } Snapshot restorablePrior = prior; std::vector reinstalledPackages; if (!EnumerateOwnedPackages(&reinstalledPackages, error)) { @@ -2778,6 +2909,10 @@ bool RollbackRemove( return false; } + if (!CheckTransactionDeadline( + rollbackDeadlineUnixMs, L"remove-rollback-deadline-verify", error)) { + return false; + } Snapshot restored; if (!CaptureSnapshot(&restored, error)) { return false; @@ -2800,16 +2935,31 @@ bool RollbackRemove( return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, L"rollback restored a different devnode identity or active package"); } - if (!*rebootRequired && prior.devices[0].started && - !VerifyAbiHealth(CurrentUnixMilliseconds() + 15000, error)) { - return false; + if (!*rebootRequired && prior.devices[0].started) { + if (!CheckTransactionDeadline( + rollbackDeadlineUnixMs, L"remove-rollback-deadline-health", error)) { + return false; + } + const uint64_t healthDeadline = std::min( + rollbackDeadlineUnixMs, CurrentUnixMilliseconds() + 15000); + if (!VerifyAbiHealth(healthDeadline, error)) { + return false; + } } } return true; } -Outcome Remove() { +struct RemoveOptions { + uint64_t transactionDeadlineUnixMs = 0; +}; + +Outcome Remove(const RemoveOptions& options) { Outcome outcome; + if (!ValidateTransactionDeadlineBudget(options.transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } if (!IsElevated()) { SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); outcome.exitCode = ExitCode::PreflightRejected; @@ -2820,6 +2970,11 @@ Outcome Remove() { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-before-snapshot", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } Snapshot prior; if (!CaptureSnapshot(&prior, &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; @@ -2843,16 +2998,41 @@ Outcome Remove() { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-before-device", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } outcome.changed = true; bool reboot = false; Error mutationError; bool mutationSucceeded = RemoveAllExactDevices(&reboot, &mutationError); + if (mutationSucceeded && !CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-after-device", &mutationError)) { + mutationSucceeded = false; + } if (mutationSucceeded) { for (const PackageInfo& package : prior.packages) { + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-before-package", &mutationError)) { + mutationSucceeded = false; + break; + } if (!UninstallPackage(package, &reboot, &mutationError)) { mutationSucceeded = false; break; } + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-after-package", &mutationError)) { + mutationSucceeded = false; + break; + } + } + } + if (mutationSucceeded && !reboot) { + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, L"remove-deadline-before-verify", &mutationError)) { + mutationSucceeded = false; } } if (mutationSucceeded && !reboot) { @@ -2868,7 +3048,14 @@ Outcome Remove() { if (!mutationSucceeded) { Error rollbackError; bool rollbackReboot = reboot; - if (RollbackRemove(prior, backups, &rollbackReboot, &rollbackError)) { + // Forward work owns the caller's absolute deadline. Rollback receives + // one fresh, bounded ceiling from the instant failure is observed; it + // must not inherit the unused portion of a long forward deadline and + // silently expand into a six-minute transaction. + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; + if (RollbackRemove( + prior, backups, rollbackDeadline, &rollbackReboot, &rollbackError)) { outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = mutationError; @@ -3130,6 +3317,37 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro return true; } +bool ParseRemoveOptions(int argc, wchar_t** argv, RemoveOptions* options, Error* error) { + if (argc == 2) { + options->transactionDeadlineUnixMs = + CurrentUnixMilliseconds() + kMaximumTransactionDurationMs; + return true; + } + if (argc != 4 || + _wcsicmp(argv[2], L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"remove accepts only an optional absolute transaction deadline"); + } + const std::wstring value = argv[3]; + if (value.empty() || value.size() > 20 || + !std::all_of(value.begin(), value.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = value.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + value.size() || parsed == 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + options->transactionDeadlineUnixMs = static_cast(parsed); + return true; +} + void Usage() { std::wcerr << L"usage:\n" @@ -3143,7 +3361,7 @@ void Usage() { << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " L"--source-revision <40-64 hex> --validation-mode " L"--transaction-deadline-unix-ms \n" - << L" ViiperUdeCtl.exe remove\n" + << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe status\n" << L" ViiperUdeCtl.exe self-test\n"; } @@ -3176,8 +3394,18 @@ int wmain(int argc, wchar_t** argv) { EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } - if (argc == 2 && _wcsicmp(argv[1], L"remove") == 0) { - Outcome outcome = Remove(); + if (argc >= 2 && _wcsicmp(argv[1], L"remove") == 0) { + RemoveOptions options; + Error argumentError; + if (!ParseRemoveOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"remove", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = Remove(options); EmitOutcome(L"remove", outcome); return static_cast(outcome.exitCode); } From a86d1861060f872441a3bd9d914df9f1c7f2afe1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:50:07 -0500 Subject: [PATCH 155/240] Remove native UDE broker dispatch scans Keep allocation and dispatch cursors independent so the common sequential path no longer wraps the 4096-slot table before finding the request it just admitted. Replace the second controller-wide admission-order scan with a BrokerLock-protected FIFO on each endpoint. Retire FIFO heads atomically on publication, cancellation, abort, and terminal cleanup, and explicitly restart dispatch when a pre-publication cancellation exposes a successor. Add deterministic source and scheduler contracts for cursor alignment, per-endpoint ordering, cross-endpoint concurrency, and cancellation progress. PlayStation media payloads and cadence are unchanged. --- .../udecx/driver_dispatch_contract_test.go | 161 ++++++++++++++++++ native/udecx/driver/Broker.c | 105 ++++++++---- native/udecx/driver/Device.c | 1 + native/udecx/driver/ViiperUde.h | 7 + 4 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 internal/transport/udecx/driver_dispatch_contract_test.go diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go new file mode 100644 index 00000000..50cd7c54 --- /dev/null +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -0,0 +1,161 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + + for _, required := range []string{ + "LIST_ENTRY AdmissionEntry;", + "BOOLEAN AdmissionLinked;", + "ULONG NextDispatchSlot;", + "LIST_ENTRY AdmissionQueue;", + } { + if !strings.Contains(header, required) { + t.Fatalf("native dispatch contract lost %q", required) + } + } + if !strings.Contains(device, + "InitializeListHead(&endpointContext->AdmissionQueue);") { + t.Fatal("endpoint admission FIFO is not initialized before broker use") + } + if strings.Contains(broker, "ViiperHasEarlierUnpublishedAdmissionLocked") { + t.Fatal("native dispatch still performs the controller-wide admission-order scan") + } + + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "pending->AdmissionLinked = TRUE;", + "InsertTailList(&endpointContext->AdmissionQueue, &pending->AdmissionEntry);", + "ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS;") + + head := normalizedContract(nativeCFunction(t, broker, "ViiperAdmissionCanPublishLocked")) + requireContractOrder(t, head, + "if (!Pending->AdmissionLinked || Pending->Endpoint == WDF_NO_HANDLE)", + "endpointContext = ViiperGetEndpointContext(Pending->Endpoint);", + "return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry;") + + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchAvailable")) + if strings.Contains(dispatch, "NextPendingSlot") { + t.Fatal("allocation cursor is still coupled to broker dispatch") + } + requireContractOrder(t, dispatch, + "controllerContext->NextDispatchSlot + index", + "ViiperAdmissionCanPublishLocked(pending)", + "controllerContext->NextDispatchSlot = (candidate + 1)", + "ViiperUnlinkAdmissionLocked(pending)") +} + +func TestNativeBrokerAdmissionRetirementCannotStrandSuccessor(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + clear := normalizedContract(nativeCFunction(t, broker, "ViiperClearSlotLocked")) + requireContractOrder(t, clear, + "ViiperUnlinkAdmissionLocked(pending);", + "pending->Request = WDF_NO_HANDLE;") + + cancel := normalizedContract(nativeCFunction(t, broker, "ViiperEvtUrbCancel")) + requireContractOrder(t, cancel, + "dispatchSuccessor = pending->AdmissionLinked", + "pending->State == ViiperUdePendingPreparing", + "pending->State == ViiperUdePendingQueued", + "ViiperUnlinkAdmissionLocked(pending);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (dispatchSuccessor)", + "ViiperDispatchAvailable(controller);") + + queue := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrb")) + requireContractOrder(t, queue, + "pending->State = ViiperUdePendingDpcCompletion;", + "ViiperUnlinkAdmissionLocked(pending);", + "if (queueCancelledCompletion)", + "ViiperQueueUrbCompletion(", + "ViiperDispatchAvailable(deviceContext->Controller);") + + abort := normalizedContract(nativeCFunction(t, broker, "ViiperAbortMatchingOperations")) + requireContractOrder(t, abort, + "pending->AbortPending = TRUE;", + "ViiperUnlinkAdmissionLocked(pending);") +} + +func TestNativeBrokerIndependentCursorEliminatesCommonFullWrap(t *testing.T) { + const slots = 4096 + scan := func(start, target int) int { + for offset := 0; offset < slots; offset++ { + if (start+offset)%slots == target { + return offset + 1 + } + } + return slots + } + + // The old scheduler advanced the allocation cursor after choosing slot 0, + // then reused it as the dispatch start. Its only queued operation was the + // last slot inspected after wrapping the entire table. + if got := scan(1, 0); got != slots { + t.Fatalf("coupled-cursor baseline inspected %d slots, want %d", got, slots) + } + if got := scan(0, 0); got != 1 { + t.Fatalf("independent dispatch cursor inspected %d slots, want 1", got) + } + + allocationCursor := 0 + dispatchCursor := 0 + for iteration := 0; iteration < slots*2; iteration++ { + allocated := allocationCursor + allocationCursor = (allocated + 1) % slots + if got := scan(dispatchCursor, allocated); got != 1 { + t.Fatalf("iteration %d inspected %d slots, want 1", iteration, got) + } + dispatchCursor = (allocated + 1) % slots + } +} + +func TestNativeBrokerEndpointFIFOModelsPublishAndCancelOrdering(t *testing.T) { + type admission struct { + id int + endpoint int + } + queues := map[int][]admission{} + appendAdmission := func(item admission) { + queues[item.endpoint] = append(queues[item.endpoint], item) + } + canPublish := func(item admission) bool { + queue := queues[item.endpoint] + return len(queue) != 0 && queue[0].id == item.id + } + retire := func(item admission) { + queue := queues[item.endpoint] + if len(queue) == 0 || queue[0].id != item.id { + t.Fatalf("retired non-head admission %+v from %+v", item, queue) + } + queues[item.endpoint] = queue[1:] + } + + a1 := admission{id: 1, endpoint: 0x01} + a2 := admission{id: 2, endpoint: 0x01} + b1 := admission{id: 3, endpoint: 0x82} + appendAdmission(a1) + appendAdmission(a2) + appendAdmission(b1) + if !canPublish(a1) || canPublish(a2) || !canPublish(b1) { + t.Fatal("per-endpoint heads did not preserve FIFO order and cross-endpoint concurrency") + } + + retire(a1) // publication + if !canPublish(a2) { + t.Fatal("publication did not expose the next same-endpoint admission") + } + + a3 := admission{id: 4, endpoint: 0x01} + appendAdmission(a3) + retire(a2) // cancellation/abort uses the same unlink transition + if !canPublish(a3) { + t.Fatal("cancellation did not expose the next same-endpoint admission") + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 5254c574..2b71834c 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -294,6 +294,35 @@ ViiperSetEndpointResettingByIdentity( ExReleaseFastMutex(&ControllerContext->DeviceLock); } +static +VOID +ViiperUnlinkAdmissionLocked( + _In_ VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + if (!Pending->AdmissionLinked) { + return; + } + RemoveEntryList(&Pending->AdmissionEntry); + InitializeListHead(&Pending->AdmissionEntry); + Pending->AdmissionLinked = FALSE; +} + +static +BOOLEAN +ViiperAdmissionCanPublishLocked( + _In_ const VIIPER_UDE_PENDING_SLOT *Pending + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + + if (!Pending->AdmissionLinked || Pending->Endpoint == WDF_NO_HANDLE) { + return FALSE; + } + endpointContext = ViiperGetEndpointContext(Pending->Endpoint); + return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry; +} + static VOID ViiperClearSlotLocked( @@ -309,6 +338,7 @@ ViiperClearSlotLocked( deviceContext = ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device); } + ViiperUnlinkAdmissionLocked(pending); pending->Request = WDF_NO_HANDLE; pending->Endpoint = WDF_NO_HANDLE; pending->Token = 0; @@ -318,6 +348,7 @@ ViiperClearSlotLocked( pending->State = ViiperUdePendingEmpty; pending->AbortPending = FALSE; pending->PublishedToOwner = FALSE; + pending->AdmissionLinked = FALSE; pending->EndpointAddress = 0; pending->AbortStatus = STATUS_SUCCESS; pending->CompletionStatus = STATUS_SUCCESS; @@ -1011,6 +1042,7 @@ ViiperAllocatePendingSlot( if (pending->State != ViiperUdePendingEmpty) { continue; } + NT_ASSERT(!pending->AdmissionLinked); ++pending->Generation; if (pending->Generation == 0) { ++pending->Generation; @@ -1028,8 +1060,10 @@ ViiperAllocatePendingSlot( pending->State = ViiperUdePendingPreparing; pending->AbortPending = FALSE; pending->PublishedToOwner = FALSE; + pending->AdmissionLinked = TRUE; pending->EndpointAddress = endpointContext->Descriptor.bEndpointAddress; pending->AbortStatus = STATUS_SUCCESS; + InsertTailList(&endpointContext->AdmissionQueue, &pending->AdmissionEntry); ControllerContext->NextPendingSlot = (index + 1) % VIIPER_UDE_MAX_PENDING_OPERATIONS; ViiperPendingOperationStartedLocked(ControllerContext); InterlockedIncrement(&deviceContext->PendingOperations); @@ -1046,39 +1080,6 @@ ViiperAllocatePendingSlot( return status; } -static -BOOLEAN -ViiperHasEarlierUnpublishedAdmissionLocked( - _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, - _In_ ULONG CandidateSlot - ) -{ - const VIIPER_UDE_PENDING_SLOT *candidate = - &ControllerContext->PendingSlots[CandidateSlot]; - ULONG index; - - for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { - const VIIPER_UDE_PENDING_SLOT *other; - if (index == CandidateSlot) { - continue; - } - other = &ControllerContext->PendingSlots[index]; - if (other->State == ViiperUdePendingEmpty || other->PublishedToOwner || - other->AbortPending || - other->State == ViiperUdePendingCompleting || - other->State == ViiperUdePendingDpcCompletion || - other->DeviceId != candidate->DeviceId || - other->DeviceGeneration != candidate->DeviceGeneration || - other->EndpointAddress != candidate->EndpointAddress || - other->AdmissionSequence == 0 || - other->AdmissionSequence >= candidate->AdmissionSequence) { - continue; - } - return TRUE; - } - return FALSE; -} - VOID ViiperEvtUrbCanceledOnQueue( _In_ WDFQUEUE Queue, @@ -1132,17 +1133,22 @@ ViiperEvtUrbCancel( ViiperGetControllerContext(controller); BOOLEAN ownsRequest = FALSE; BOOLEAN notifyOwner = FALSE; + BOOLEAN dispatchSuccessor = FALSE; WdfSpinLockAcquire(controllerContext->BrokerLock); if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; if (ViiperSlotMatches(pending, Request, token)) { + dispatchSuccessor = pending->AdmissionLinked && + (pending->State == ViiperUdePendingPreparing || + pending->State == ViiperUdePendingQueued); notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); pending->CompletionStatus = STATUS_CANCELLED; pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; pending->CompleteWithNtStatus = TRUE; pending->State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(pending); ownsRequest = TRUE; } } @@ -1162,6 +1168,14 @@ ViiperEvtUrbCancel( if (notifyOwner) { ViiperDispatchNotificationEvents(controller); } + if (dispatchSuccessor) { + // A queued endpoint head can be canceled without another broker + // IOCTL arriving to restart publication. Wake the dispatcher + // after retiring that head so an already-waiting dequeue cannot + // strand its successor. Publishing cancellations are excluded: + // their active dispatch loop performs this continuation itself. + ViiperDispatchAvailable(controller); + } } } @@ -1684,6 +1698,7 @@ ViiperRemovePublishingRequest( ControllerContext->PendingSlots[Slot].CompletionUsbdStatus = USBD_STATUS_CANCELED; ControllerContext->PendingSlots[Slot].CompleteWithNtStatus = TRUE; ControllerContext->PendingSlots[Slot].State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(&ControllerContext->PendingSlots[Slot]); ownsRequest = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); @@ -1734,11 +1749,11 @@ ViiperDispatchAvailable( break; } for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { - ULONG candidate = (controllerContext->NextPendingSlot + index) % + ULONG candidate = (controllerContext->NextDispatchSlot + index) % VIIPER_UDE_MAX_PENDING_OPERATIONS; VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[candidate]; if (pending->State == ViiperUdePendingQueued && - !ViiperHasEarlierUnpublishedAdmissionLocked(controllerContext, candidate)) { + ViiperAdmissionCanPublishLocked(pending)) { status = WdfIoQueueRetrieveNextRequest( controllerContext->WaitingDequeues, &dequeueRequest); if (!NT_SUCCESS(status)) { @@ -1750,6 +1765,8 @@ ViiperDispatchAvailable( endpoint = pending->Endpoint; token = pending->Token; slot = candidate; + controllerContext->NextDispatchSlot = (candidate + 1) % + VIIPER_UDE_MAX_PENDING_OPERATIONS; WdfObjectReference(urbRequest); InterlockedDecrement(&controllerContext->WaitingDequeueCount); break; @@ -1824,6 +1841,10 @@ ViiperDispatchAvailable( pending->State = abortPending ? ViiperUdePendingCompleting : ViiperUdePendingInFlight; + // Publication or terminal abort retires the FIFO head. The + // next same-endpoint admission may now be selected without a + // controller-wide slot scan. + ViiperUnlinkAdmissionLocked(pending); if (!abortPending) { serializedOperation->EndpointSequence = (ULONGLONG)InterlockedIncrement64( @@ -1971,6 +1992,9 @@ ViiperQueueUrb( pending->State = abortPending ? ViiperUdePendingCompleting : ViiperUdePendingQueued; + if (abortPending) { + ViiperUnlinkAdmissionLocked(pending); + } } } else { VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; @@ -1978,6 +2002,7 @@ ViiperQueueUrb( pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; pending->CompleteWithNtStatus = TRUE; pending->State = ViiperUdePendingDpcCompletion; + ViiperUnlinkAdmissionLocked(pending); queueCancelledCompletion = TRUE; } } else { @@ -1998,6 +2023,10 @@ ViiperQueueUrb( USBD_STATUS_CANCELED, TRUE); InterlockedIncrement64(&controllerContext->OperationsCancelled); + // MarkCancelableEx can reject a request before it ever reaches + // dispatch. Retiring that admission exposes the next endpoint + // head, so consume any dequeue that was already waiting. + ViiperDispatchAvailable(deviceContext->Controller); } else if (!cancelClaimed) { NT_ASSERT(FALSE); } @@ -2392,6 +2421,12 @@ ViiperAbortMatchingOperations( pending->AbortPending = TRUE; pending->AbortStatus = Status; } + // AbortPending admissions were deliberately ignored by the old + // full-table ordering scan. Retire the equivalent FIFO node now; + // request/DPC ownership remains unchanged until terminal clear. + if (pending->AbortPending) { + ViiperUnlinkAdmissionLocked(pending); + } } WdfSpinLockRelease(controllerContext->BrokerLock); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 8de40b94..c417437e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1092,6 +1092,7 @@ ViiperEvtEndpointAdd( RtlZeroMemory(endpointContext, sizeof(*endpointContext)); endpointContext->Device = Device; endpointContext->Descriptor = descriptor; + InitializeListHead(&endpointContext->AdmissionQueue); KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); WDF_OBJECT_ATTRIBUTES_INIT(&attributes); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 2a3ba651..e05c88ae 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -30,6 +30,7 @@ typedef enum VIIPER_UDE_PENDING_STATE { } VIIPER_UDE_PENDING_STATE; typedef struct VIIPER_UDE_PENDING_SLOT { + LIST_ENTRY AdmissionEntry; WDFREQUEST Request; UDECXUSBENDPOINT Endpoint; ULONGLONG Token; @@ -40,6 +41,7 @@ typedef struct VIIPER_UDE_PENDING_SLOT { VIIPER_UDE_PENDING_STATE State; BOOLEAN AbortPending; BOOLEAN PublishedToOwner; + BOOLEAN AdmissionLinked; UCHAR EndpointAddress; NTSTATUS AbortStatus; NTSTATUS CompletionStatus; @@ -105,6 +107,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; ULONG NextPendingSlot; + ULONG NextDispatchSlot; ULONG NextManagementSlot; WDFMEMORY NotificationStorage; VIIPER_UDE_NOTIFICATION *Notifications; @@ -210,6 +213,10 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { volatile LONG CachedDeliveryPending; ULONG InputReportLength; UCHAR InputReport[VIIPER_UDE_MAX_INPUT_REPORT_BYTES]; + // BrokerLock protects this FIFO and every slot AdmissionEntry. It keeps + // same-endpoint publication ordered without scanning the controller-wide + // 4096-slot table on every USB transfer. + LIST_ENTRY AdmissionQueue; ULONGLONG NextAdmissionSequence; BOOLEAN FastInput; } VIIPER_UDE_ENDPOINT_CONTEXT; From 7172e54549a33a3d95cd68cb256d2211c2df5e3f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:50:17 -0500 Subject: [PATCH 156/240] Make native UDE live evidence fail closed --- .github/workflows/native-ude.yml | 22 ++ .../server/usb/native_live_windows_test.go | 191 ++++++++++++++++- .../udecx/live_validation_contract_test.go | 76 ++++++- native/udecx/README.md | 40 ++-- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 202 +++++++++++++++++- .../Invoke-ViiperUdePerformanceValidation.ps1 | 115 ++++++++-- native/udecx/tools/ViiperUdeMediaProbe.cpp | 16 +- 7 files changed, 629 insertions(+), 33 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index ee3771b8..ecdb9a5b 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -236,6 +236,28 @@ jobs: & $inputOutput snapshot $inputSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } Remove-Item -LiteralPath $inputSnapshot -Force + $probeManifest = [ordered]@{ + schemaVersion = 1 + sourceRevision = $env:GITHUB_SHA.ToLowerInvariant() + probes = [ordered]@{ + 'ViiperUdeMediaProbe.exe' = (Get-FileHash -LiteralPath $mediaOutput -Algorithm SHA256).Hash.ToLowerInvariant() + 'ViiperUdeInputProbe.exe' = (Get-FileHash -LiteralPath $inputOutput -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + $probeManifestPath = Join-Path $outputDir 'ViiperUdeLiveProbes.manifest.json' + $probeManifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $probeManifestPath -Encoding utf8NoBOM + if (-not (Test-Path -LiteralPath $probeManifestPath -PathType Leaf)) { throw "Live-probe manifest was not created" } + - name: Upload source-bound native live probes + if: ${{ inputs.upload_release_helper == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeLiveProbes-windows-amd64-${{ github.sha }} + path: | + native/udecx/x64/Release/ViiperUdeMediaProbe.exe + native/udecx/x64/Release/ViiperUdeInputProbe.exe + native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json + if-no-files-found: error + retention-days: 30 - name: Upload source-bound native runtime helper if: ${{ inputs.upload_release_helper == true }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index 33130c7e..c6c38858 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" "unsafe" @@ -52,6 +53,180 @@ type liveNativeController struct { new func() (usbdevice.Device, func(uint64), func(byte), error) } +// liveNativeMediaWitness observes the controller-engine side of the same +// CoreAudio stream exercised by ViiperUdeMediaProbe. CoreAudio frame counts and +// kernel byte counters alone can both advance while a broken broker returns +// silence or drops the payload before it reaches the preserved PlayStation +// media logic. The witness therefore proves non-silent render data arrives at +// that existing logic and feeds deterministic non-silent microphone PCM back +// through the virtual USB endpoint. It does not alter media construction. +type liveNativeMediaWitness struct { + speakerBytes atomic.Uint64 + speakerNonZeroBytes atomic.Uint64 + hapticsGenerations atomic.Uint64 + hapticsNonSilent atomic.Uint64 + queueMicrophone func([]byte) + microphoneFrame []byte + speakerBytesPerSec uint64 + requireHaptics bool +} + +func countNonZeroBytes(data []byte) uint64 { + var count uint64 + for _, value := range data { + if value != 0 { + count++ + } + } + return count +} + +func nonZeroPCMFrame(size int) []byte { + frame := make([]byte, size) + for offset := 0; offset+1 < len(frame); offset += 2 { + frame[offset] = 0x34 + frame[offset+1] = 0x12 + } + return frame +} + +func armLiveNativeMediaWitness(dev usbdevice.Device) (*liveNativeMediaWitness, error) { + switch controller := dev.(type) { + case *dualshock4.DualShock4: + witness := &liveNativeMediaWitness{ + queueMicrophone: controller.QueueMicrophonePCMFrame, + microphoneFrame: nonZeroPCMFrame(dualshock4.USBMicrophoneClientFrameSize), + speakerBytesPerSec: dualshock4.USBSpeakerSampleRate * + dualshock4.USBSpeakerChannels * dualshock4.USBSpeakerBytesPerSample, + } + controller.SetSpeakerCallback(func(pcm []byte) { + witness.speakerBytes.Add(uint64(len(pcm))) + witness.speakerNonZeroBytes.Add(countNonZeroBytes(pcm)) + }) + return witness, nil + case *dualsense.DualSense: + witness := &liveNativeMediaWitness{ + queueMicrophone: controller.QueueMicrophonePCMFrame, + microphoneFrame: nonZeroPCMFrame(dualsense.USBMicrophoneClientFrameSize), + speakerBytesPerSec: dualsense.USBHapticsAudioSampleRate * 2 * + dualsense.USBHapticsAudioBytesPerSample, + requireHaptics: true, + } + controller.SetAtomicAudioHapticsCallback(func(_ dualsense.OutputState, speaker []byte) { + witness.speakerBytes.Add(uint64(len(speaker))) + witness.speakerNonZeroBytes.Add(countNonZeroBytes(speaker)) + }) + controller.SetRealtimeHapticsCallback(func(feedback dualsense.OutputState) { + witness.hapticsGenerations.Add(1) + sample := feedback.BluetoothCombinedOutputReport[dualsense.BluetoothCombinedHapticsOffset:(dualsense.BluetoothCombinedHapticsOffset + dualsense.BluetoothHapticsSampleSize)] + if countNonZeroBytes(sample) != 0 { + witness.hapticsNonSilent.Add(1) + } + }) + return witness, nil + default: + return nil, fmt.Errorf("media witness does not support %T", dev) + } +} + +func (w *liveNativeMediaWitness) startMicrophone(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + // Queueing slightly ahead of the 10 ms client-frame cadence makes the + // content assertion independent of the instant CoreAudio selects alt 1; + // the controller's existing bounded/adaptive microphone queue remains + // responsible for presentation cadence. + ticker := time.NewTicker(8 * time.Millisecond) + defer ticker.Stop() + for { + w.queueMicrophone(w.microphoneFrame) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return done +} + +func (w *liveNativeMediaWitness) validate(duration time.Duration) error { + seconds := uint64(duration / time.Second) + minimumSpeakerBytes := w.speakerBytesPerSec * seconds * 9 / 10 + speakerBytes := w.speakerBytes.Load() + if speakerBytes < minimumSpeakerBytes { + return fmt.Errorf("controller engine received only %d speaker bytes; want at least %d", + speakerBytes, minimumSpeakerBytes) + } + if nonZero := w.speakerNonZeroBytes.Load(); nonZero < speakerBytes/4 { + return fmt.Errorf("controller engine speaker payload was silent or malformed: nonzero=%d total=%d", + nonZero, speakerBytes) + } + if w.requireHaptics { + minimumHaptics := seconds * 50 + haptics := w.hapticsGenerations.Load() + if haptics < minimumHaptics { + return fmt.Errorf("controller engine received only %d realtime haptics generations; want at least %d", + haptics, minimumHaptics) + } + if nonSilent := w.hapticsNonSilent.Load(); nonSilent < haptics/2 { + return fmt.Errorf("controller engine haptics payload was silent or malformed: nonSilent=%d total=%d", + nonSilent, haptics) + } + } + return nil +} + +func TestLiveNativeMediaWitnessRejectsSilentOrIncompleteContent(t *testing.T) { + valid := &liveNativeMediaWitness{speakerBytesPerSec: 100, requireHaptics: true} + valid.speakerBytes.Store(100) + valid.speakerNonZeroBytes.Store(30) + valid.hapticsGenerations.Store(50) + valid.hapticsNonSilent.Store(25) + if err := valid.validate(time.Second); err != nil { + t.Fatalf("complete non-silent media was rejected: %v", err) + } + + for _, testCase := range []struct { + name string + prepare func(*liveNativeMediaWitness) + }{ + {name: "short speaker stream", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(89) + w.speakerNonZeroBytes.Store(89) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(50) + }}, + {name: "silent speaker stream", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(24) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(50) + }}, + {name: "missing realtime haptics", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(100) + w.hapticsGenerations.Store(49) + w.hapticsNonSilent.Store(49) + }}, + {name: "silent realtime haptics", prepare: func(w *liveNativeMediaWitness) { + w.speakerBytes.Store(100) + w.speakerNonZeroBytes.Store(100) + w.hapticsGenerations.Store(50) + w.hapticsNonSilent.Store(24) + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + witness := &liveNativeMediaWitness{speakerBytesPerSec: 100, requireHaptics: true} + testCase.prepare(witness) + if err := witness.validate(time.Second); err == nil { + t.Fatal("incomplete or silent media was accepted") + } + }) + } +} + func armDualShock4FeedbackProbe(dev usbdevice.Device) (func(context.Context) error, error) { controller, ok := dev.(*dualshock4.DualShock4) if !ok { @@ -475,7 +650,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { iterations := liveNativeIterationCount(t) mediaDuration := liveNativeMediaDuration(t) testCtx, cancelTest := context.WithTimeout(context.Background(), - time.Duration(iterations)*5*time.Minute+2*mediaDuration+2*time.Minute) + time.Duration(iterations)*5*time.Minute+3*mediaDuration+2*time.Minute) defer cancelTest() client, err := udecx.Open(testCtx) @@ -545,8 +720,14 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { } mediaSnapshot := "" mediaController := iteration == 1 && mediaProbe != "" && - (controller.name == "DualShock4" || controller.name == "DualSense") + (controller.name == "DualShock4" || controller.name == "DualSense" || + controller.name == "DualSenseEdge") + var mediaWitness *liveNativeMediaWitness if mediaController { + mediaWitness, createErr = armLiveNativeMediaWitness(dev) + if createErr != nil { + t.Fatalf("arm %s media witness: %v", controller.name, createErr) + } snapshot, snapshotErr := os.CreateTemp("", "viiper-ude-media-*.snapshot") if snapshotErr != nil { t.Fatalf("create media endpoint snapshot: %v", snapshotErr) @@ -642,6 +823,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { } mediaCtx, cancelMedia := context.WithCancel(testCtx) defer cancelMedia() + microphoneDone := mediaWitness.startMicrophone(mediaCtx) probeDone := startLiveProbe( mediaCtx, mediaProbe, "exercise", mediaSnapshot, strconv.Itoa(int(mediaDuration/time.Second)), @@ -666,12 +848,17 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { } probeResult := <-probeDone cancelMedia() + <-microphoneDone cancelStress() <-stressDone if probeResult.err != nil { t.Fatalf("run native CoreAudio probe: %v\n%s", probeResult.err, probeResult.output) } + if witnessErr := mediaWitness.validate(mediaDuration); witnessErr != nil { + t.Fatalf("%s media content did not survive the native bus: %v; probe=%s", + controller.name, witnessErr, probeResult.output) + } mediaAfter, mediaErr := client.QueryStats(testCtx) if mediaErr != nil { t.Fatalf("query %s media result: %v", controller.name, mediaErr) diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index b9a25f02..9d21f895 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -20,11 +20,32 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "-RequireDriverVerifier is required", "-MediaProbePath is required", "-InputProbePath is required", + "-ProbeManifestPath is required", "-RestartRootDevice is required", "-DisposableTestMachine is required", "$Iterations -lt 3", "$MediaDurationSeconds -lt 180", "VIIPER_UDE_LIVE_MEDIA_SECONDS", + "Confirm-SecureBootUEFI", + "$build -lt 22000", + "0x001209BB", + "Driver Verifier must target only ViiperUde.sys", + "Test-LiveProbeManifest", + "sourceRevision", + "Get-FileHash -LiteralPath $path -Algorithm SHA256", + "-ProbeManifestPath is required whenever a production live probe is used", + "rev-parse --verify HEAD", + "status --porcelain=v1 --untracked-files=all", + "submodule status --recursive", + "$env:GOFLAGS = '-mod=readonly'", + "$env:GOWORK = 'off'", + "$env:GOENV = 'off'", + "$env:GOTOOLCHAIN = 'local'", + "$env:GOOS = 'windows'", + "$env:GOARCH = 'amd64'", + "$env:CGO_ENABLED = '0'", + "$go.Source env GOMOD", + "Go reported success without executing required live test", } { if !strings.Contains(contract, required) { t.Fatalf("native release gate omitted %q", required) @@ -48,6 +69,7 @@ func TestNativeMediaProbeRejectsObservableDiscontinuity(t *testing.T) { "renderStats.underruns != 0", "ValidateFrameCount(\"render\"", "ValidateFrameCount(\"capture\"", + "captureStats.nonSilentFrames < captureStats.frames / 2", "seconds > 300", } { if !strings.Contains(contract, required) { @@ -66,12 +88,64 @@ func TestNativeLiveSoakKeepsMediaInputAndFeedbackConcurrent(t *testing.T) { for _, required := range []string{ "startLiveProbe(", "mediaCtx, mediaProbe, \"exercise\"", + "armLiveNativeMediaWitness(dev)", + "mediaWitness.startMicrophone(mediaCtx)", + "mediaWitness.validate(mediaDuration)", "publishInput(sequence)", "if feedbackController {\n\t\t\t\t\t\tverifyFeedback()", - "2*mediaDuration+2*time.Minute", + "3*mediaDuration+2*time.Minute", + "controller.name == \"DualSenseEdge\"", } { if !strings.Contains(contract, required) { t.Fatalf("native concurrent media soak omitted %q", required) } } } + +func TestNativePerformanceTraceCapturesAttributableCriticalPath(t *testing.T) { + path := filepath.Join("..", "..", "..", "native", "udecx", "tools", + "Invoke-ViiperUdePerformanceValidation.ps1") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native performance validator: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "[string]$ProbeManifestPath", + "ProbeManifestPath = $ProbeManifestPath", + "$profile = 'GeneralProfile.Verbose'", + "GeneralProfile\\.Verbose\\.Memory", + "@('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')", + "@('CSwitch', 'ReadyThread', 'SampledProfile')", + "Count -lt 2", + "Dropped Event\\s*:\\s*(?\\d+)", + "$resolvedOutput.evidence.json", + "analysisRequired = $true", + "Performance acceptance still requires WPA analysis", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native performance trace contract omitted %q", required) + } + } +} + +func TestNativeWorkflowPublishesSourceBoundLiveProbes(t *testing.T) { + path := filepath.Join("..", "..", "..", ".github", "workflows", "native-ude.yml") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read native workflow: %v", err) + } + contract := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, required := range []string{ + "schemaVersion = 1", + "sourceRevision = $env:GITHUB_SHA.ToLowerInvariant()", + "'ViiperUdeMediaProbe.exe' = (Get-FileHash", + "'ViiperUdeInputProbe.exe' = (Get-FileHash", + "ViiperUdeLiveProbes.manifest.json", + "ViiperUdeLiveProbes-windows-amd64-${{ github.sha }}", + } { + if !strings.Contains(contract, required) { + t.Fatalf("native workflow source-bound probes omitted %q", required) + } + } +} diff --git a/native/udecx/README.md b/native/udecx/README.md index c2ce29de..5db96c25 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -165,13 +165,17 @@ Microsoft-signed package with: -Iterations 10 ` -MediaDurationSeconds 30 ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` - -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json ``` The command refuses an unsigned package, a package/service hash mismatch, a non-Microsoft root devnode, a dirty driver session, or any increase in invalid messages, queue exhaustion, notification overflow, late completion, or cleanup -retry counters. After validating each controller and repeated generation +retry counters. Production mode also requires this repository to be an exact, +clean checkout of `-ExpectedSourceRevision` and runs the Go harness with module, +workspace, environment, and toolchain overrides disabled. After validating +each controller and repeated generation rollover independently, it enumerates the complete production controller set, publishes input, and removes every child concurrently. A subprocess then exits without running cleanup; the driver must remove its child, drain pending URBs, @@ -225,20 +229,25 @@ The Driver Verifier pass is a separate, explicit disposable-machine gate: -DisposableTestMachine ` -MediaDurationSeconds 180 ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` - -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json ``` -`-ReleaseGate` is fail-closed: it requires a production Microsoft signature, -Driver Verifier, three lifecycle generations, both independent probes, an -active root-device restart, the disposable-machine acknowledgement, and a -three-minute clean duplex media run for each PlayStation controller. Omitting -any one of those inputs cannot print a production-pass result. +`-ReleaseGate` is fail-closed: it requires a 64-bit Windows 11 client with +Secure Boot, a production Microsoft signature, Driver Verifier `/standard` +including KMDF verification, three lifecycle generations, both independent +source/hash-bound probes, an active root-device restart, the disposable-machine +acknowledgement, and a three-minute clean duplex media run for each PlayStation +controller, including DualSense Edge. Omitting any one of those inputs cannot +print a production-pass result. Microsoft warns that Driver Verifier can intentionally bugcheck a machine; this workflow is never run by ordinary CI, an installer, or DS4Windows. For evidence-based CPU and scheduler analysis, run the same signed workload -inside WPR's bounded `GeneralProfile.Light` memory profile: +inside WPR's bounded `GeneralProfile.Verbose` memory profile. The verbose form +is required because the light form records scheduler events but omits the +CSwitch, ReadyThread, and sampled-profile stacks needed to attribute latency: ```powershell .\native\udecx\tools\Invoke-ViiperUdePerformanceValidation.ps1 ` @@ -248,10 +257,15 @@ inside WPR's bounded `GeneralProfile.Light` memory profile: -SignatureValidationMode Production ` -OutputPath C:\ViiperUde\Traces\native-ude.etl ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` - -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe + -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json ``` Open the ETL in Windows Performance Analyzer and inspect CPU Usage (Sampled), -CPU Usage (Precise), and DPC/ISR by module and stack. The script never uses -WPR file mode, which Microsoft documents as unbounded, and never mutates an -unnamed or foreign recording session. +CPU Usage (Precise), scheduler stacks, and DPC/ISR module activity. A non-empty +ETL is evidence capture, not a performance pass: acceptance still requires WPA +analysis against the architecture thresholds. The adjacent `.evidence.json` +hash-binds the ETL, signed-package manifest, exact source revision, and both +probes. The script rejects dropped events, never uses WPR file mode (which +Microsoft documents as unbounded), and never mutates an unnamed or foreign +recording session. diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 3fcb5f99..7294bfed 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -24,6 +24,8 @@ param( [string]$InputProbePath, + [string]$ProbeManifestPath, + [switch]$RestartRootDevice, [switch]$DisposableTestMachine, @@ -56,6 +58,62 @@ function Resolve-DriverImagePath { return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path } +function Test-LiveProbeManifest { + param( + [Parameter(Mandatory = $true)][string]$ManifestPath, + [Parameter(Mandatory = $true)][string]$SourceRevision, + [Parameter(Mandatory = $true)][string]$ResolvedMediaProbe, + [Parameter(Mandatory = $true)][string]$ResolvedInputProbe + ) + + $resolvedManifest = (Resolve-Path -LiteralPath $ManifestPath -ErrorAction Stop).Path + try { + $manifest = Get-Content -LiteralPath $resolvedManifest -Raw -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "The native live-probe manifest is not valid JSON: '$resolvedManifest'. $($_.Exception.Message)" + } + if ([int]$manifest.schemaVersion -ne 1) { + throw "The native live-probe manifest has unsupported schemaVersion '$($manifest.schemaVersion)'." + } + if (-not [string]::Equals([string]$manifest.sourceRevision, $SourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The native live-probe manifest represents source '$($manifest.sourceRevision)', not '$SourceRevision'." + } + if ($null -eq $manifest.probes) { + throw 'The native live-probe manifest has no probes object.' + } + + $expected = [ordered]@{ + 'ViiperUdeMediaProbe.exe' = $ResolvedMediaProbe + 'ViiperUdeInputProbe.exe' = $ResolvedInputProbe + } + $properties = @($manifest.probes.PSObject.Properties) + $actualNames = @($properties | ForEach-Object { $_.Name } | Sort-Object) + $expectedNames = @($expected.Keys | Sort-Object) + if ($actualNames.Count -ne $expectedNames.Count -or + (Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count -ne 0) { + throw "The native live-probe manifest must contain exactly: $($expectedNames -join ', ')." + } + + foreach ($name in $expected.Keys) { + $path = [string]$expected[$name] + if ([IO.Path]::GetFileName($path) -cne $name) { + throw "The live probe path must retain its source-built name '$name': '$path'." + } + $expectedHash = [string]$manifest.probes.PSObject.Properties[$name].Value + if ($expectedHash -notmatch '^[0-9a-fA-F]{64}$') { + throw "The native live-probe manifest has an invalid SHA-256 for '$name'." + } + $actualHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + if (-not [string]::Equals($actualHash, $expectedHash, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The live probe '$name' does not match the source-bound manifest." + } + } +} + if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' } @@ -74,6 +132,9 @@ if ($ReleaseGate) { if ([string]::IsNullOrWhiteSpace($InputProbePath)) { [void]$releaseGateFailures.Add('-InputProbePath is required') } + if ([string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + [void]$releaseGateFailures.Add('-ProbeManifestPath is required') + } if (-not $RestartRootDevice) { [void]$releaseGateFailures.Add('-RestartRootDevice is required') } @@ -91,7 +152,38 @@ if ($ReleaseGate) { } } +$hasMediaProbe = -not [string]::IsNullOrWhiteSpace($MediaProbePath) +$hasInputProbe = -not [string]::IsNullOrWhiteSpace($InputProbePath) +if ($SignatureValidationMode -eq 'Production' -and ($hasMediaProbe -or $hasInputProbe) -and + [string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + throw '-ProbeManifestPath is required whenever a production live probe is used.' +} + $repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +if ($SignatureValidationMode -eq 'Production') { + $git = Get-Command git.exe -ErrorAction Stop + $headOutput = & $git.Source -C $repository rev-parse --verify HEAD 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "The production live-test harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" + } + $headRevision = ($headOutput | Select-Object -First 1).Trim() + if (-not [string]::Equals($headRevision, $ExpectedSourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The production live-test harness is source '$headRevision', not '$ExpectedSourceRevision'." + } + $treeStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not verify the production live-test source tree.`n$($treeStatus -join [Environment]::NewLine)" + } + if ($treeStatus.Count -ne 0) { + throw ("The production live-test source tree is not clean; refusing unreviewed test code or data:`n" + + ($treeStatus -join [Environment]::NewLine)) + } + $submoduleStatus = @(& $git.Source -C $repository submodule status --recursive 2>&1) + if ($LASTEXITCODE -ne 0 -or @($submoduleStatus | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) { + throw "The production live-test source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" + } +} $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' & $signatureGate ` -PackageDirectory $SignedPackageDirectory ` @@ -132,6 +224,29 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' } +if ($ReleaseGate) { + $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $build = 0 + if (-not [int]::TryParse([string]$operatingSystem.BuildNumber, [ref]$build) -or + [uint32]$operatingSystem.ProductType -ne 1 -or $build -lt 22000 -or + -not [Environment]::Is64BitOperatingSystem) { + throw "The release live gate requires a 64-bit Windows 11 client HLK target; got '$($operatingSystem.Caption)' build '$($operatingSystem.BuildNumber)'." + } + $secureBootCommand = Get-Command Confirm-SecureBootUEFI -ErrorAction SilentlyContinue + if ($null -eq $secureBootCommand) { + throw 'The release live gate could not verify Secure Boot because Confirm-SecureBootUEFI is unavailable.' + } + try { + $secureBootEnabled = [bool](& $secureBootCommand -ErrorAction Stop) + } + catch { + throw "The release live gate could not verify Secure Boot: $($_.Exception.Message)" + } + if (-not $secureBootEnabled) { + throw 'The release live gate requires Secure Boot to be enabled.' + } +} + if ($RestartRootDevice) { if (-not $DisposableTestMachine) { throw 'Root-device restart validation is destructive to the active native session. Pass -DisposableTestMachine on a dedicated test system.' @@ -149,6 +264,26 @@ if ($RequireDriverVerifier) { if ($verifierOutput -notmatch '(?im)\bViiperUde\.sys\b') { throw 'Driver Verifier is not currently active for ViiperUde.sys. Configure one-boot verification, restart, and retry.' } + $verifiedDrivers = @([regex]::Matches( + $verifierOutput, '(?im)\b[^\s\\/:*?"<>|]+\.sys\b') | + ForEach-Object { $_.Value } | + Sort-Object -Unique) + if ($verifiedDrivers.Count -ne 1 -or $verifiedDrivers[0] -ine 'ViiperUde.sys') { + throw "Driver Verifier must target only ViiperUde.sys; active list: $($verifiedDrivers -join ', ')." + } + $flagsMatch = [regex]::Match($verifierOutput, '(?i)\b0x(?[0-9a-f]{8})\b') + if (-not $flagsMatch.Success) { + throw "Driver Verifier did not report its current flag level.`n$verifierOutput" + } + $verifierFlags = [Convert]::ToUInt32($flagsMatch.Groups['flags'].Value, 16) + # /standard is 0x209BB. Supported Windows 10/11 adds 0x100000 for KMDF + # verification. Additional stress flags are allowed, but a subset is not. + [uint32]$requiredVerifierFlags = 0x001209BB + if (($verifierFlags -band $requiredVerifierFlags) -ne $requiredVerifierFlags) { + throw (("Driver Verifier is active for ViiperUde.sys with flags 0x{0:X8}, " + + "but /standard plus KMDF requires 0x{1:X8}.") -f + $verifierFlags, $requiredVerifierFlags) + } } $resolvedMediaProbe = $null @@ -167,6 +302,17 @@ if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { } } +if (-not [string]::IsNullOrWhiteSpace($ProbeManifestPath)) { + if ($null -eq $resolvedMediaProbe -or $null -eq $resolvedInputProbe) { + throw '-ProbeManifestPath requires both -MediaProbePath and -InputProbePath.' + } + Test-LiveProbeManifest ` + -ManifestPath $ProbeManifestPath ` + -SourceRevision $ExpectedSourceRevision ` + -ResolvedMediaProbe $resolvedMediaProbe ` + -ResolvedInputProbe $resolvedInputProbe +} + $go = Get-Command go.exe -ErrorAction Stop $oldLive = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE', 'Process') $oldIterations = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', 'Process') @@ -174,6 +320,13 @@ $oldMediaProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PR $oldMediaSeconds = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', 'Process') $oldInputProbe = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', 'Process') $oldRestartInstance = [Environment]::GetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', 'Process') +$oldGoFlags = [Environment]::GetEnvironmentVariable('GOFLAGS', 'Process') +$oldGoWork = [Environment]::GetEnvironmentVariable('GOWORK', 'Process') +$oldGoEnv = [Environment]::GetEnvironmentVariable('GOENV', 'Process') +$oldGoToolchain = [Environment]::GetEnvironmentVariable('GOTOOLCHAIN', 'Process') +$oldGoOS = [Environment]::GetEnvironmentVariable('GOOS', 'Process') +$oldGoArch = [Environment]::GetEnvironmentVariable('GOARCH', 'Process') +$oldCgoEnabled = [Environment]::GetEnvironmentVariable('CGO_ENABLED', 'Process') try { $env:VIIPER_UDE_LIVE = '1' $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations @@ -197,17 +350,51 @@ try { else { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $null, 'Process') } + # The live harness is part of the certification evidence. User GOFLAGS, + # go.work redirection, GOENV defaults, or automatic toolchain downloads + # must not select different packages, tests, or source during the gate. + $env:GOFLAGS = '-mod=readonly' + $env:GOWORK = 'off' + $env:GOENV = 'off' + $env:GOTOOLCHAIN = 'local' + $env:GOOS = 'windows' + $env:GOARCH = 'amd64' + $env:CGO_ENABLED = '0' $mediaMinutes = if ($null -ne $resolvedMediaProbe) { - [Math]::Ceiling(($MediaDurationSeconds * 2) / 60.0) + [Math]::Ceiling(($MediaDurationSeconds * 3) / 60.0) } else { 0 } $timeoutMinutes = ($Iterations * 5) + $mediaMinutes + $(if ($RestartRootDevice) { 5 } else { 2 }) Push-Location $repository try { - & $go.Source test -count=1 -timeout "${timeoutMinutes}m" ` + $modulePath = (& $go.Source env GOMOD 2>&1 | Select-Object -First 1) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$modulePath) -or + -not [string]::Equals( + [IO.Path]::GetFullPath([string]$modulePath), + [IO.Path]::GetFullPath((Join-Path $repository 'go.mod')), + [StringComparison]::OrdinalIgnoreCase)) { + throw "The live test selected an unexpected Go module '$modulePath'." + } + $goTestOutput = @(& $go.Source test -v -count=1 -timeout "${timeoutMinutes}m" ` -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ./internal/server/usb - if ($LASTEXITCODE -ne 0) { - throw "Native UDE live validation failed with exit code $LASTEXITCODE." + ) + $goTestExitCode = $LASTEXITCODE + $goTestOutput | ForEach-Object { Write-Host $_ } + if ($goTestExitCode -ne 0) { + throw "Native UDE live validation failed with exit code $goTestExitCode." + } + $goTestText = $goTestOutput | Out-String + $requiredLiveTests = @( + 'TestNativeUDELiveProductionControllers', + 'TestNativeUDELiveOwnerCrashRecovery' + ) + if ($RestartRootDevice) { + $requiredLiveTests += 'TestNativeUDELiveRootRestartRecovery' + } + foreach ($testName in $requiredLiveTests) { + if ($goTestText -notmatch "(?m)^--- PASS: $([regex]::Escape($testName)) ") { + throw "Go reported success without executing required live test '$testName'." + } } } finally { @@ -221,6 +408,13 @@ finally { [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_SECONDS', $oldMediaSeconds, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_INPUT_PROBE', $oldInputProbe, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_RESTART_INSTANCE_ID', $oldRestartInstance, 'Process') + [Environment]::SetEnvironmentVariable('GOFLAGS', $oldGoFlags, 'Process') + [Environment]::SetEnvironmentVariable('GOWORK', $oldGoWork, 'Process') + [Environment]::SetEnvironmentVariable('GOENV', $oldGoEnv, 'Process') + [Environment]::SetEnvironmentVariable('GOTOOLCHAIN', $oldGoToolchain, 'Process') + [Environment]::SetEnvironmentVariable('GOOS', $oldGoOS, 'Process') + [Environment]::SetEnvironmentVariable('GOARCH', $oldGoArch, 'Process') + [Environment]::SetEnvironmentVariable('CGO_ENABLED', $oldCgoEnabled, 'Process') } $verifierSuffix = if ($RequireDriverVerifier) { ' with Driver Verifier active' } else { '' } diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 index 54b67c6f..89b5ede2 100644 --- a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -19,10 +19,15 @@ param( [ValidateRange(1, 1000)] [int]$Iterations = 10, + [Parameter(Mandatory = $true)] [string]$MediaProbePath, + [Parameter(Mandatory = $true)] [string]$InputProbePath, + [Parameter(Mandatory = $true)] + [string]$ProbeManifestPath, + [switch]$RequireDriverVerifier, [switch]$RestartRootDevice, @@ -57,6 +62,10 @@ $resolvedOutput = [IO.Path]::GetFullPath($OutputPath) if (Test-Path -LiteralPath $resolvedOutput) { throw "Refusing to overwrite the existing trace '$resolvedOutput'." } +$evidencePath = "$resolvedOutput.evidence.json" +if (Test-Path -LiteralPath $evidencePath) { + throw "Refusing to overwrite the existing trace evidence '$evidencePath'." +} $outputDirectory = Split-Path -Parent $resolvedOutput if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw 'The trace output path must include a parent directory.' @@ -67,22 +76,45 @@ if ([string]::IsNullOrWhiteSpace($outputDirectory)) { # carries it as the final argument, as required by WPR, so this gate can never # stop or cancel an unrelated recording on the test machine. $instanceName = 'ViiperUdePerf_{0}_{1}' -f $PID, [Guid]::NewGuid().ToString('N') -$profile = 'GeneralProfile.Light' +$profile = 'GeneralProfile.Verbose' $started = $false $validationFailure = $null +# GeneralProfile.Light records scheduler events, but it intentionally omits +# the CSwitch, ReadyThread, and sampled-profile stacks needed to attribute a +# tail-latency stall to the actual user/kernel critical path. Fail closed if a +# future Windows image changes the bounded-memory verbose profile contract. +$profileDetailsOutput = & $wprPath -profiledetails $profile 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "WPR could not describe '$profile' (exit $LASTEXITCODE).`n$($profileDetailsOutput -join [Environment]::NewLine)" +} +$profileDetails = $profileDetailsOutput | Out-String +if ($profileDetails -notmatch '(?im)^Profile\s*:\s*GeneralProfile\.Verbose\.Memory\s*$') { + throw "WPR '$profile' is not the required bounded-memory profile.`n$profileDetails" +} +foreach ($eventName in @('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$eventName\s*$").Count -lt 1) { + throw "WPR '$profile' does not capture the required $eventName evidence." + } +} +foreach ($stackName in @('CSwitch', 'ReadyThread', 'SampledProfile')) { + # Each required name must appear once under System Keywords and again + # under System Stacks. A single occurrence is event-only evidence and + # cannot explain the ready/scheduled critical path in WPA. + if ([regex]::Matches($profileDetails, "(?im)^\s*$stackName\s*$").Count -lt 2) { + throw "WPR '$profile' does not capture the required $stackName events and stacks." + } +} + $validationArguments = @{ SignedPackageDirectory = $SignedPackageDirectory SubmissionManifestPath = $SubmissionManifestPath ExpectedSourceRevision = $ExpectedSourceRevision SignatureValidationMode = $SignatureValidationMode Iterations = $Iterations -} -if (-not [string]::IsNullOrWhiteSpace($MediaProbePath)) { - $validationArguments.MediaProbePath = $MediaProbePath -} -if (-not [string]::IsNullOrWhiteSpace($InputProbePath)) { - $validationArguments.InputProbePath = $InputProbePath + MediaProbePath = $MediaProbePath + InputProbePath = $InputProbePath + ProbeManifestPath = $ProbeManifestPath } if ($RequireDriverVerifier) { $validationArguments.RequireDriverVerifier = $true @@ -105,14 +137,44 @@ try { & $validationPath @validationArguments } catch { - $validationFailure = $_ + $validationFailure = $_.Exception } } finally { if ($started) { + $statusOutput = & $wprPath -status -instancename $instanceName 2>&1 + $statusExitCode = $LASTEXITCODE + $statusText = $statusOutput | Out-String + $statusFailure = $null + if ($statusExitCode -ne 0) { + $statusFailure = [InvalidOperationException]::new( + "WPR status failed with exit $statusExitCode. $($statusOutput -join ' ')") + } + else { + $droppedMatch = [regex]::Match($statusText, '(?im)^\s*Dropped Event\s*:\s*(?\d+)\s*$') + if (-not $droppedMatch.Success) { + $statusFailure = [InvalidOperationException]::new( + "WPR did not report its dropped-event count. $($statusOutput -join ' ')") + } + elseif ([uint64]$droppedMatch.Groups['count'].Value -ne 0) { + $statusFailure = [InvalidOperationException]::new( + "WPR dropped $($droppedMatch.Groups['count'].Value) event(s); the performance trace is incomplete.") + } + } + if ($null -ne $statusFailure) { + if ($null -eq $validationFailure) { + $validationFailure = $statusFailure + } + else { + $validationFailure = [AggregateException]::new( + 'Native UDE validation and WPR capture integrity both failed.', + @($validationFailure, $statusFailure)) + } + } + # Stop, rather than cancel, after a workload failure. The trace is most # valuable when a latency or lifecycle gate failed. GeneralProfile is - # intentionally left in its bounded default memory mode; file mode is + # intentionally left in its bounded verbose memory mode; file mode is # never enabled by this script. $stopOutput = & $wprPath -stop $resolvedOutput -instancename $instanceName 2>&1 $stopExitCode = $LASTEXITCODE @@ -121,7 +183,7 @@ finally { throw [AggregateException]::new( 'Native UDE validation and WPR trace finalization both failed.', @( - $validationFailure.Exception, + $validationFailure, [InvalidOperationException]::new( "WPR stop failed with exit $stopExitCode. $($stopOutput -join ' ')") )) @@ -136,10 +198,39 @@ if (-not (Test-Path -LiteralPath $resolvedOutput -PathType Leaf) -or throw "WPR reported success but did not create a non-empty trace at '$resolvedOutput'." } +# An ETL has no trustworthy provenance merely because its filename resembles a +# reviewed build. Bind the exact trace, signed-package manifest, and source-built +# probes into a sidecar before reporting completion. The live validator already +# checked that the probe manifest's declared hashes and source revision match. +$evidence = [ordered]@{ + schemaVersion = 1 + sourceRevision = $ExpectedSourceRevision.ToLowerInvariant() + profile = 'GeneralProfile.Verbose.Memory' + trace = [ordered]@{ + name = [IO.Path]::GetFileName($resolvedOutput) + sha256 = (Get-FileHash -LiteralPath $resolvedOutput -Algorithm SHA256).Hash.ToLowerInvariant() + } + signedPackageManifestSha256 = (Get-FileHash -LiteralPath $SubmissionManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + probeManifestSha256 = (Get-FileHash -LiteralPath $ProbeManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + mediaProbeSha256 = (Get-FileHash -LiteralPath $MediaProbePath -Algorithm SHA256).Hash.ToLowerInvariant() + inputProbeSha256 = (Get-FileHash -LiteralPath $InputProbePath -Algorithm SHA256).Hash.ToLowerInvariant() + signatureValidationMode = $SignatureValidationMode + iterations = $Iterations + analysisRequired = $true +} +$evidenceJson = $evidence | ConvertTo-Json -Depth 4 +[IO.File]::WriteAllText($evidencePath, $evidenceJson, [Text.UTF8Encoding]::new($false)) +if (-not (Test-Path -LiteralPath $evidencePath -PathType Leaf) -or + (Get-Item -LiteralPath $evidencePath).Length -eq 0) { + throw "The source-bound trace evidence was not written to '$evidencePath'." +} + if ($null -ne $validationFailure) { throw [InvalidOperationException]::new( "Native UDE live validation failed; the diagnostic trace was preserved at '$resolvedOutput'.", - $validationFailure.Exception) + $validationFailure) } -Write-Host "Native UDE performance validation passed. Trace: '$resolvedOutput'." +Write-Host ("Native UDE workload and trace-integrity validation passed. " + + "Performance acceptance still requires WPA analysis of '$resolvedOutput'. " + + "Source-bound evidence: '$evidencePath'.") diff --git a/native/udecx/tools/ViiperUdeMediaProbe.cpp b/native/udecx/tools/ViiperUdeMediaProbe.cpp index 9ff3c24d..d5819e63 100644 --- a/native/udecx/tools/ViiperUdeMediaProbe.cpp +++ b/native/udecx/tools/ViiperUdeMediaProbe.cpp @@ -145,6 +145,7 @@ struct RenderStats final { struct CaptureStats final { uint64_t frames = 0; + uint64_t nonSilentFrames = 0; uint64_t packets = 0; uint64_t discontinuities = 0; uint64_t timestampErrors = 0; @@ -437,6 +438,15 @@ CaptureStats ExerciseCapture(const std::wstring& endpointId, std::chrono::second firstPacket = false; ++stats.packets; stats.frames += packetFrames; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0 && data != nullptr) { + for (UINT32 frame = 0; frame < packetFrames; ++frame) { + const BYTE* sample = data + static_cast(frame) * format->nBlockAlign; + if (std::any_of(sample, sample + format->nBlockAlign, + [](BYTE value) { return value != 0; })) { + ++stats.nonSilentFrames; + } + } + } CheckHRESULT("IAudioCaptureClient::ReleaseBuffer", capture->ReleaseBuffer(packetFrames)); } } @@ -517,12 +527,16 @@ int Exercise(const std::filesystem::path& snapshotPath, int seconds, captureStats.positionRegressions != 0 || captureStats.qpcRegressions != 0) { throw std::runtime_error("capture stream reported a discontinuity or non-monotonic clock"); } + if (captureStats.nonSilentFrames < captureStats.frames / 2) { + throw std::runtime_error("capture stream did not preserve the injected non-silent microphone PCM"); + } std::cout << "renderFrames=" << renderStats.frames << " renderEvents=" << renderStats.events << " renderBufferFrames=" << renderStats.bufferFrames << " renderUnderruns=" << renderStats.underruns << " renderMaxEventGapMs=" << renderStats.maximumEventGapMilliseconds << " captureFrames=" << captureStats.frames + << " captureNonSilentFrames=" << captureStats.nonSilentFrames << " capturePackets=" << captureStats.packets << " captureDiscontinuities=" << captureStats.discontinuities << " captureTimestampErrors=" << captureStats.timestampErrors @@ -548,7 +562,7 @@ int wmain(int argc, wchar_t** argv) { } std::wcerr << L"Usage:\n" << L" ViiperUdeMediaProbe.exe snapshot \n" - << L" ViiperUdeMediaProbe.exe exercise \n"; + << L" ViiperUdeMediaProbe.exe exercise \n"; return 2; } catch (const std::exception& error) { std::cerr << "VIIPER UDE media probe failed: " << error.what() << "\n"; From 880178a050e8349e6a5123a5e3c4ae0383c0350d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 22:56:59 -0500 Subject: [PATCH 157/240] Wake native dispatch after publishing cancellation WdfRequestUnmarkCancelable may report STATUS_CANCELLED before EvtRequestCancel has run. If the old dispatch loop scans during that window, a still-linked Publishing head blocks its queued same-endpoint successor and the loop can return before the callback unlinks it. Treat every linked cancellation as a dispatch wake source, including Publishing, and model the adversarial callback ordering deterministically. --- .../udecx/driver_dispatch_contract_test.go | 35 +++++++++++++++++-- native/udecx/driver/Broker.c | 15 ++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 50cd7c54..c3d320b4 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -61,9 +61,7 @@ func TestNativeBrokerAdmissionRetirementCannotStrandSuccessor(t *testing.T) { cancel := normalizedContract(nativeCFunction(t, broker, "ViiperEvtUrbCancel")) requireContractOrder(t, cancel, - "dispatchSuccessor = pending->AdmissionLinked", - "pending->State == ViiperUdePendingPreparing", - "pending->State == ViiperUdePendingQueued", + "dispatchSuccessor = pending->AdmissionLinked;", "ViiperUnlinkAdmissionLocked(pending);", "WdfSpinLockRelease(controllerContext->BrokerLock);", "if (dispatchSuccessor)", @@ -83,6 +81,37 @@ func TestNativeBrokerAdmissionRetirementCannotStrandSuccessor(t *testing.T) { "ViiperUnlinkAdmissionLocked(pending);") } +func TestNativeBrokerPublishingCancelCannotMissDispatchWake(t *testing.T) { + // WdfRequestUnmarkCancelable is allowed to return STATUS_CANCELLED before + // EvtRequestCancel has run. Model the worst ordering: the old dispatcher + // scans while the publishing admission is still linked, finds no eligible + // successor, and returns. The later callback must both unlink and explicitly + // wake a new dispatch pass. + type admission struct { + linked bool + state string + } + head := admission{linked: true, state: "publishing"} + successor := admission{linked: true, state: "queued"} + canPublishSuccessor := func() bool { + return successor.linked && !head.linked + } + + if canPublishSuccessor() { + t.Fatal("successor published ahead of the same-endpoint head") + } + oldDispatchReturned := true // it scanned before EvtRequestCancel ran + dispatchWake := false + if head.linked { + dispatchWake = true + head.linked = false + head.state = "dpc-completion" + } + if !oldDispatchReturned || !dispatchWake || !canPublishSuccessor() { + t.Fatal("publishing-head cancellation failed to wake its queued successor") + } +} + func TestNativeBrokerIndependentCursorEliminatesCommonFullWrap(t *testing.T) { const slots = 4096 scan := func(start, target int) int { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 2b71834c..5e7abdc5 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1140,9 +1140,7 @@ ViiperEvtUrbCancel( VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; if (ViiperSlotMatches(pending, Request, token)) { - dispatchSuccessor = pending->AdmissionLinked && - (pending->State == ViiperUdePendingPreparing || - pending->State == ViiperUdePendingQueued); + dispatchSuccessor = pending->AdmissionLinked; notifyOwner = ViiperQueueCancelEventLocked(controllerContext, pending); pending->CompletionStatus = STATUS_CANCELLED; pending->CompletionUsbdStatus = USBD_STATUS_CANCELED; @@ -1169,11 +1167,12 @@ ViiperEvtUrbCancel( ViiperDispatchNotificationEvents(controller); } if (dispatchSuccessor) { - // A queued endpoint head can be canceled without another broker - // IOCTL arriving to restart publication. Wake the dispatcher - // after retiring that head so an already-waiting dequeue cannot - // strand its successor. Publishing cancellations are excluded: - // their active dispatch loop performs this continuation itself. + // An endpoint head can be canceled without another broker IOCTL + // arriving to restart publication. WdfRequestUnmarkCancelable may + // return STATUS_CANCELLED before this callback runs, so even a + // Publishing head cannot rely on its old dispatch loop to observe + // the unlink. Wake dispatch after retiring every linked head so an + // already-waiting dequeue cannot strand its successor. ViiperDispatchAvailable(controller); } } From ff2896886117a62f4ab51d8be17768cc09d61866 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 23:12:40 -0500 Subject: [PATCH 158/240] Adapt full-speed endpoints for native UdeCx UDE/USBHUB3 schedules emulated endpoints with high-speed rules even when the child is declared full speed. Present the usbip-win2-proven endpoint projection to UdeCx so DualShock 4 speaker and microphone ISO pipes reach the broker at their original 1 ms cadence, while leaving all PlayStation media payload construction untouched. Map projected lifecycle and transfer signatures back to the immutable logical endpoint, retain byte-identical DualSense descriptors, fail closed on unsafe ISO intervals in both owner and kernel boundaries, and exercise production controller topologies plus native-vs-USB/IP PlayStation parity. --- internal/server/usb/native.go | 37 ++++-- .../usb/native_playstation_parity_test.go | 18 ++- internal/server/usb/native_production_test.go | 18 ++- internal/server/usb/native_test.go | 31 +++++ .../udecx/controller_descriptors_test.go | 55 ++++++++- internal/transport/udecx/descriptors.go | 90 ++++++++++++++- .../udecx/descriptors_integration_test.go | 9 +- internal/transport/udecx/descriptors_test.go | 109 ++++++++++++++++++ .../udecx/driver_descriptor_contract_test.go | 29 +++++ native/udecx/driver/Device.c | 65 ++++++++++- usb/usbdesc.go | 6 +- 11 files changed, 440 insertions(+), 27 deletions(-) create mode 100644 internal/transport/udecx/driver_descriptor_contract_test.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 95370693..79e7bc77 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -206,6 +206,16 @@ func signatureFromDescriptor(endpoint usbdevice.EndpointDescriptor) nativeEndpoi } } +func nativeSignatureFromDescriptor(speed uint32, + endpoint usbdevice.EndpointDescriptor) (nativeEndpointSignature, bool) { + projected, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(speed), endpoint) + if err != nil { + return nativeEndpointSignature{}, false + } + return signatureFromDescriptor(projected), true +} + func descriptorInterfaceAltForEndpoint(desc *usbdevice.Descriptor, signature nativeEndpointSignature) (uint8, uint8, bool) { if desc == nil || signature.address == 0 { @@ -218,7 +228,8 @@ func descriptorInterfaceAltForEndpoint(desc *usbdevice.Descriptor, continue } for _, endpoint := range iface.Endpoints { - if signatureFromDescriptor(endpoint) != signature { + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if !valid || projected != signature { continue } candidateInterface := iface.Descriptor.BInterfaceNumber @@ -256,8 +267,11 @@ func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, continue } for _, endpoint := range iface.Endpoints { - if _, ok := active[signatureFromDescriptor(endpoint)]; ok { - return true + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if valid { + if _, ok := active[projected]; ok { + return true + } } } } @@ -359,18 +373,20 @@ func nativeLaneKeyFromOperation(op udecx.Operation) nativeLaneKey { } } -func descriptorHasEndpointSignature(desc *usbdevice.Descriptor, signature nativeEndpointSignature) bool { +func logicalEndpointForNativeSignature(desc *usbdevice.Descriptor, + signature nativeEndpointSignature) (usbdevice.EndpointDescriptor, bool) { if desc == nil { - return false + return usbdevice.EndpointDescriptor{}, false } for _, iface := range desc.Interfaces { for _, endpoint := range iface.Endpoints { - if signatureFromDescriptor(endpoint) == signature { - return true + projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) + if valid && projected == signature { + return endpoint, true } } } - return false + return usbdevice.EndpointDescriptor{}, false } func nativeIsoServiceInterval(speed uint32, bInterval uint8) (time.Duration, error) { @@ -414,11 +430,12 @@ func resolveNativeIsoEndpoint(dev usbdevice.Device, op udecx.Operation) (nativeI "native ISO endpoint 0x%02x direction %d disagrees with operation %d/flags %d", signature.address, direction, op.Direction, flagDirection) } - if !descriptorHasEndpointSignature(desc, signature) { + logicalEndpoint, ok := logicalEndpointForNativeSignature(desc, signature) + if !ok { return nativeIsoEndpoint{}, fmt.Errorf( "native ISO endpoint signature %+v is not present in the device descriptor", signature) } - interval, err := nativeIsoServiceInterval(desc.Device.Speed, signature.interval) + interval, err := nativeIsoServiceInterval(desc.Device.Speed, logicalEndpoint.BInterval) if err != nil { return nativeIsoEndpoint{}, err } diff --git a/internal/server/usb/native_playstation_parity_test.go b/internal/server/usb/native_playstation_parity_test.go index 26ee1d5a..1a5e341d 100644 --- a/internal/server/usb/native_playstation_parity_test.go +++ b/internal/server/usb/native_playstation_parity_test.go @@ -65,9 +65,14 @@ func parityEndpoint(t *testing.T, dev usbdevice.Device, address uint8) usbdevice return usbdevice.EndpointDescriptor{} } -func endpointOperation(identity udecx.DeviceIdentity, kind udecx.OperationKind, - endpoint usbdevice.EndpointDescriptor, +func endpointOperation(t *testing.T, identity udecx.DeviceIdentity, kind udecx.OperationKind, + speed uint32, endpoint usbdevice.EndpointDescriptor, ) udecx.Operation { + t.Helper() + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx(udecx.DeviceSpeed(speed), endpoint) + if err != nil { + t.Fatal(err) + } return udecx.Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, Kind: kind, EndpointAddress: endpoint.BEndpointAddress, @@ -85,7 +90,8 @@ func (h *playStationParityHarness) nativeLifecycle(t *testing.T, dev usbdevice.D DeviceID: h.identity.DeviceID, Generation: h.identity.Generation, Kind: kind, } if address != 0 { - op = endpointOperation(h.identity, kind, parityEndpoint(t, dev, address)) + op = endpointOperation(t, h.identity, kind, dev.GetDescriptor().Device.Speed, + parityEndpoint(t, dev, address)) } if err := h.native.Lifecycle(context.Background(), dev, op); err != nil { t.Fatalf("native lifecycle kind %d endpoint 0x%02x: %v", kind, address, err) @@ -120,7 +126,8 @@ func (h *playStationParityHarness) nativeOutput(t *testing.T, dev usbdevice.Devi ) { t.Helper() endpoint := parityEndpoint(t, dev, address) - op := endpointOperation(h.identity, udecx.OperationTransfer, endpoint) + op := endpointOperation(t, h.identity, udecx.OperationTransfer, + dev.GetDescriptor().Device.Speed, endpoint) op.Token = h.nextToken() op.TransferLength = uint32(len(payload)) op.Payload = append([]byte(nil), payload...) @@ -192,7 +199,8 @@ func (h *playStationParityHarness) nativeISO(t *testing.T, dev usbdevice.Device, ) udecx.Completion { t.Helper() endpoint := parityEndpoint(t, dev, address) - op := endpointOperation(h.identity, udecx.OperationTransfer, endpoint) + op := endpointOperation(t, h.identity, udecx.OperationTransfer, + dev.GetDescriptor().Device.Speed, endpoint) op.Token = h.nextToken() op.TransferLength = transferLength op.IsoPackets = append([]udecx.IsoPacket(nil), packets...) diff --git a/internal/server/usb/native_production_test.go b/internal/server/usb/native_production_test.go index 0a2924be..7dff379a 100644 --- a/internal/server/usb/native_production_test.go +++ b/internal/server/usb/native_production_test.go @@ -462,6 +462,11 @@ func populateProductionEndpointMetadata(dev usbdevice.Device, op *udecx.Operatio if endpoint.BEndpointAddress != op.EndpointAddress { continue } + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), endpoint) + if err != nil { + panic(err) + } op.EndpointAttributes = endpoint.BMAttributes op.EndpointInterval = endpoint.BInterval op.EndpointMaxPacketSize = endpoint.WMaxPacketSize @@ -491,7 +496,12 @@ func startNativeEndpoint(t *testing.T, processor *serverusb.NativeProcessor, if endpoint.BEndpointAddress != endpointAddress { continue } - err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + endpoint, err := udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), endpoint) + if err != nil { + t.Fatal(err) + } + err = processor.Lifecycle(context.Background(), dev, udecx.Operation{ DeviceID: 1, Generation: 1, Kind: udecx.OperationEndpointStart, EndpointAddress: endpoint.BEndpointAddress, EndpointAttributes: endpoint.BMAttributes, @@ -524,6 +534,12 @@ func processNativeIso(t *testing.T, processor *serverusb.NativeProcessor, for _, iface := range dev.GetDescriptor().Interfaces { for _, descEndpoint := range iface.Endpoints { if descEndpoint.BEndpointAddress == endpoint { + var err error + descEndpoint, err = udecx.EndpointDescriptorForNativeUdeCx( + udecx.DeviceSpeed(dev.GetDescriptor().Device.Speed), descEndpoint) + if err != nil { + t.Fatal(err) + } op.EndpointAttributes = descEndpoint.BMAttributes op.EndpointInterval = descEndpoint.BInterval op.EndpointMaxPacketSize = descEndpoint.WMaxPacketSize diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 3fe79bb8..69f3f45d 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -533,6 +533,37 @@ func TestResolveNativeIsoEndpointUsesDirectionAndAlternateSignature(t *testing.T } } +func TestResolveNativeIsoEndpointMapsProjectedFullSpeedSignatureToLogicalCadence(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedFull)}, + Interfaces: []usbdevice.InterfaceConfig{{ + Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 1, BAlternateSetting: 1}, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x01, BMAttributes: 0x09, + WMaxPacketSize: 132, BInterval: 1, + }}, + }}, + } + dev := &altSettingTestDevice{desc: desc} + endpoint, err := resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x01, + EndpointAttributes: 0x09, EndpointInterval: 4, EndpointMaxPacketSize: 132, + }) + if err != nil { + t.Fatal(err) + } + if endpoint.interval != time.Millisecond { + t.Fatalf("projected full-speed interval=%s want=1ms logical cadence", endpoint.interval) + } + _, err = resolveNativeIsoEndpoint(dev, udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x01, + EndpointAttributes: 0x09, EndpointInterval: 1, EndpointMaxPacketSize: 132, + }) + if err == nil { + t.Fatal("unprojected full-speed UdeCx signature was accepted") + } +} + func TestNativeIsoExplicitFrameRangeHandlesWrap(t *testing.T) { base := time.Unix(700, 0) processor := nativeProcessorForTest(t) diff --git a/internal/transport/udecx/controller_descriptors_test.go b/internal/transport/udecx/controller_descriptors_test.go index 7e39346a..80c0998b 100644 --- a/internal/transport/udecx/controller_descriptors_test.go +++ b/internal/transport/udecx/controller_descriptors_test.go @@ -58,9 +58,60 @@ func TestNativeSnapshotsPreserveSupportedControllerTopologies(t *testing.T) { if !bytes.Equal(gotDevice, desc.Bytes()) { t.Fatalf("native device descriptor changed: got=%x want=%x", gotDevice, desc.Bytes()) } - if !bytes.Equal(gotConfig, wantConfig) { - t.Fatalf("native configuration changed: got=%x want=%x", gotConfig, wantConfig) + if desc.Device.Speed >= uint32(udecx.DeviceSpeedHigh) { + if !bytes.Equal(gotConfig, wantConfig) { + t.Fatalf("native high-speed configuration changed: got=%x want=%x", gotConfig, wantConfig) + } + } else { + assertFullSpeedUdeCxProjection(t, wantConfig, gotConfig) } }) } } + +func assertFullSpeedUdeCxProjection(t *testing.T, logical, projected []byte) { + t.Helper() + if len(logical) != len(projected) { + t.Fatalf("projected configuration length=%d want=%d", len(projected), len(logical)) + } + restored := append([]byte(nil), projected...) + for offset := 0; offset < len(logical); { + length := int(logical[offset]) + if length < 2 || offset+length > len(logical) || projected[offset] != logical[offset] || + projected[offset+1] != logical[offset+1] { + t.Fatalf("invalid or reordered descriptor at offset %d", offset) + } + if logical[offset+1] == usb.EndpointDescType { + transferType := logical[offset+3] & 0x03 + logicalMax := uint16(logical[offset+4]) | uint16(logical[offset+5])<<8 + logicalInterval := logical[offset+6] + wantMax, wantInterval := logicalMax, logicalInterval + switch transferType { + case 0x01: + if logicalInterval != 1 { + t.Fatalf("production full-speed ISO interval=%d want=1", logicalInterval) + } + wantInterval = 4 + case 0x02: + wantMax = 512 + case 0x03: + microframes := uint32(logicalInterval) * 8 + wantInterval = 1 + for period := uint32(1); wantInterval < 16 && period < microframes; period <<= 1 { + wantInterval++ + } + } + gotMax := uint16(projected[offset+4]) | uint16(projected[offset+5])<<8 + if gotMax != wantMax || projected[offset+6] != wantInterval { + t.Fatalf("endpoint %#x projected max/interval=%d/%d want=%d/%d", + logical[offset+2], gotMax, projected[offset+6], wantMax, wantInterval) + } + restored[offset+4], restored[offset+5] = logical[offset+4], logical[offset+5] + restored[offset+6] = logicalInterval + } + offset += length + } + if !bytes.Equal(restored, logical) { + t.Fatal("native full-speed projection changed fields other than endpoint scheduling") + } +} diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go index 8e26cc13..2c487420 100644 --- a/internal/transport/udecx/descriptors.go +++ b/internal/transport/udecx/descriptors.go @@ -9,17 +9,99 @@ import ( const defaultDevicePendingOperations = 512 +// EndpointDescriptorForNativeUdeCx translates the scheduling fields which +// USBHUB3 interprets using high-speed rules even when UdeCx is told that the +// emulated device is full speed. usbip-win2 applies the same translation in +// its UDE transport: without it, Windows rejects full-speed audio ISO +// bInterval=1 before an URB ever reaches the client driver. +// +// This is a UdeCx presentation adapter only. The controller's logical USB +// descriptor remains unchanged, so the device engine continues to produce and +// consume the proven media payloads at its original cadence. +func EndpointDescriptorForNativeUdeCx(speed DeviceSpeed, endpoint usb.EndpointDescriptor) (usb.EndpointDescriptor, error) { + if endpoint.BMAttributes&0x03 == 0x01 { + switch speed { + case DeviceSpeedLow: + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx low-speed endpoint 0x%02x cannot be isochronous", + endpoint.BEndpointAddress) + case DeviceSpeedFull: + // Windows supports full-speed isochronous endpoints only at one + // transfer per frame. UdeCx must see the equivalent high-speed + // exponent (eight microframes = bInterval 4). + if endpoint.BInterval != 1 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx full-speed ISO endpoint 0x%02x has unsupported bInterval %d", + endpoint.BEndpointAddress, endpoint.BInterval) + } + endpoint.BInterval = 4 + return endpoint, nil + case DeviceSpeedHigh, DeviceSpeedSuper: + // The Windows USB stack supports HS/SS ISO periods of one, two, + // four, or eight microframes. Larger exponents are not a safe + // UdeCx contract. + if endpoint.BInterval == 0 || endpoint.BInterval > 4 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx high-speed ISO endpoint 0x%02x has unsupported bInterval %d", + endpoint.BEndpointAddress, endpoint.BInterval) + } + } + } + if speed != DeviceSpeedLow && speed != DeviceSpeedFull { + return endpoint, nil + } + + switch endpoint.BMAttributes & 0x03 { + case 0x02: // Bulk: USBHUB3 validates the pipe as high speed. + endpoint.WMaxPacketSize = 512 + case 0x03: // Interrupt: milliseconds -> the next HS microframe exponent. + if endpoint.BInterval == 0 { + return usb.EndpointDescriptor{}, fmt.Errorf( + "native UdeCx full-speed interrupt endpoint 0x%02x has zero bInterval", + endpoint.BEndpointAddress) + } + microframes := uint32(endpoint.BInterval) * 8 + interval := uint8(1) + period := uint32(1) + for interval < 16 && period < microframes { + interval++ + period <<= 1 + } + endpoint.BInterval = interval + } + return endpoint, nil +} + +func configurationDescriptorForNativeUdeCx(desc *usb.Descriptor) ([]byte, error) { + projected := *desc + projected.Interfaces = append([]usb.InterfaceConfig(nil), desc.Interfaces...) + for interfaceIndex := range projected.Interfaces { + logical := desc.Interfaces[interfaceIndex] + projected.Interfaces[interfaceIndex].Endpoints = append( + []usb.EndpointDescriptor(nil), logical.Endpoints...) + for endpointIndex, endpoint := range logical.Endpoints { + adapted, err := EndpointDescriptorForNativeUdeCx( + DeviceSpeed(desc.Device.Speed), endpoint) + if err != nil { + return nil, err + } + projected.Interfaces[interfaceIndex].Endpoints[endpointIndex] = adapted + } + } + return projected.ConfigurationBytes() +} + // SnapshotDevice builds the immutable descriptor payload used to create one -// native UdeCx child. It intentionally consumes the same usb.Descriptor object -// as the existing USB/IP server so switching transports cannot silently change -// a controller's VID/PID, HID reports, audio topology, or string descriptors. +// native UdeCx child. It consumes the same logical usb.Descriptor object as the +// existing USB/IP server; only the UdeCx-required full-speed endpoint schedule +// projection above may differ in the immutable native snapshot. func SnapshotDevice(deviceID uint64, generation uint32, dev usb.Device) (CreateDevice, error) { if dev == nil || dev.GetDescriptor() == nil { return CreateDevice{}, fmt.Errorf("snapshot native UDE device: nil USB device") } desc := dev.GetDescriptor() deviceDescriptor := desc.Bytes() - configurationDescriptor, err := desc.ConfigurationBytes() + configurationDescriptor, err := configurationDescriptorForNativeUdeCx(desc) if err != nil { return CreateDevice{}, fmt.Errorf("snapshot native UDE configuration: %w", err) } diff --git a/internal/transport/udecx/descriptors_integration_test.go b/internal/transport/udecx/descriptors_integration_test.go index ccc0ea35..a0643610 100644 --- a/internal/transport/udecx/descriptors_integration_test.go +++ b/internal/transport/udecx/descriptors_integration_test.go @@ -1,6 +1,7 @@ package udecx_test import ( + "bytes" "encoding/binary" "testing" @@ -61,8 +62,12 @@ func TestSnapshotDeviceCoversEveryProductionControllerTopology(t *testing.T) { break } } - if string(nativeConfiguration) != string(configuration) { - t.Fatal("native UDE snapshot changed the production USB topology") + if desc.Device.Speed >= uint32(udecx.DeviceSpeedHigh) { + if !bytes.Equal(nativeConfiguration, configuration) { + t.Fatal("native UDE snapshot changed a high-speed production USB topology") + } + } else if bytes.Equal(nativeConfiguration, configuration) { + t.Fatal("native UDE snapshot omitted the required USBHUB3 full-speed endpoint projection") } }) } diff --git a/internal/transport/udecx/descriptors_test.go b/internal/transport/udecx/descriptors_test.go index f554d9a4..eee71aaa 100644 --- a/internal/transport/udecx/descriptors_test.go +++ b/internal/transport/udecx/descriptors_test.go @@ -3,6 +3,7 @@ package udecx import ( "context" "encoding/binary" + "reflect" "testing" "github.com/Alia5/VIIPER/usb" @@ -53,6 +54,114 @@ func TestSnapshotDevicePreservesDescriptorBytes(t *testing.T) { } } +func TestEndpointDescriptorForNativeUdeCxMatchesUSBHubSchedulingContract(t *testing.T) { + tests := []struct { + name string + speed DeviceSpeed + endpoint usb.EndpointDescriptor + wantMax uint16 + wantIntvl uint8 + wantError bool + }{ + { + name: "full-speed ISO one frame becomes eight microframes", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x01, BMAttributes: 0x09, WMaxPacketSize: 132, BInterval: 1}, + wantMax: 132, wantIntvl: 4, + }, + { + name: "full-speed one millisecond interrupt", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x84, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 1}, + wantMax: 64, wantIntvl: 4, + }, + { + name: "full-speed five millisecond interrupt rounds up", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x03, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 5}, + wantMax: 64, wantIntvl: 7, + }, + { + name: "full-speed bulk uses USBHUB3 high-speed packet size", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x82, BMAttributes: 0x02, WMaxPacketSize: 64}, + wantMax: 512, + }, + { + name: "high-speed DualSense ISO is unchanged", speed: DeviceSpeedHigh, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x02, BMAttributes: 0x09, WMaxPacketSize: 196, BInterval: 4}, + wantMax: 196, wantIntvl: 4, + }, + { + name: "low-speed ISO is impossible", speed: DeviceSpeedLow, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x81, BMAttributes: 0x01, WMaxPacketSize: 8, BInterval: 1}, + wantError: true, + }, + { + name: "Windows rejects non-one-frame full-speed ISO", speed: DeviceSpeedFull, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x01, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 2}, + wantError: true, + }, + { + name: "Windows rejects high-speed ISO exponent above four", speed: DeviceSpeedHigh, + endpoint: usb.EndpointDescriptor{BEndpointAddress: 0x81, BMAttributes: 0x01, WMaxPacketSize: 32, BInterval: 5}, + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + original := tc.endpoint + got, err := EndpointDescriptorForNativeUdeCx(tc.speed, tc.endpoint) + if (err != nil) != tc.wantError { + t.Fatalf("error=%v wantError=%v", err, tc.wantError) + } + if tc.wantError { + return + } + if got.WMaxPacketSize != tc.wantMax || got.BInterval != tc.wantIntvl { + t.Fatalf("projected endpoint max/intvl=%d/%d want=%d/%d", + got.WMaxPacketSize, got.BInterval, tc.wantMax, tc.wantIntvl) + } + if !reflect.DeepEqual(tc.endpoint, original) { + t.Fatal("logical endpoint descriptor was mutated") + } + }) + } +} + +func TestSnapshotDeviceProjectsFullSpeedEndpointScheduleWithoutMutatingDevice(t *testing.T) { + dev := &snapshotDevice{descriptor: usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, BMaxPacketSize0: 64, IDVendor: 0x054c, + IDProduct: 0x09cc, BNumConfigurations: 1, Speed: uint32(DeviceSpeedFull), + }, + Interfaces: []usb.InterfaceConfig{{ + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0, BNumEndpoints: 2}, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: 0x84, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 1}, + {BEndpointAddress: 0x01, BMAttributes: 0x09, WMaxPacketSize: 132, BInterval: 1}, + }, + }}, + }} + original, err := dev.descriptor.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + snapshot, err := SnapshotDevice(9, 4, dev) + if err != nil { + t.Fatal(err) + } + config := snapshot.DescriptorData[snapshot.Descriptors[1].Offset:(snapshot.Descriptors[1].Offset + snapshot.Descriptors[1].Length)] + // Configuration (9) + interface (9), then the two seven-byte endpoints. + if config[18+6] != 4 || config[25+6] != 4 { + t.Fatalf("projected endpoint intervals=%d/%d want=4/4", config[24], config[31]) + } + after, err := dev.descriptor.ConfigurationBytes() + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatal("native snapshot mutated the controller's logical descriptor") + } +} + func TestSnapshotDevicePublishesMicrosoftOS10ReservedString(t *testing.T) { msOS := &usb.MicrosoftOS10Descriptor{VendorCode: 0x20, CompatibleID: "WINUSB"} dev := &snapshotDevice{descriptor: usb.Descriptor{ diff --git a/internal/transport/udecx/driver_descriptor_contract_test.go b/internal/transport/udecx/driver_descriptor_contract_test.go new file mode 100644 index 00000000..a0ae24ad --- /dev/null +++ b/internal/transport/udecx/driver_descriptor_contract_test.go @@ -0,0 +1,29 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeDriverValidatesUdeCxEndpointSchedulesBeforePublication(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + validator := normalizedContract(nativeCFunction(t, device, "ViiperValidateEndpointSchedules")) + for _, required := range []string{ + "transferType = item[3] & USB_ENDPOINT_TYPE_MASK;", + "if (transferType == USB_ENDPOINT_TYPE_ISOCHRONOUS)", + "if (Speed == 1) { return FALSE; }", + "if (Speed == 2)", + "if (item[6] != 4) { return FALSE; }", + "else if (item[6] == 0 || item[6] > 4)", + "USB_ENDPOINT_TYPE_INTERRUPT && (item[6] == 0 || item[6] > 16)", + } { + if !strings.Contains(validator, required) { + t.Fatalf("native descriptor schedule gate lost %q in:\n%s", required, validator) + } + } + + create := normalizedContract(nativeCFunction(t, device, "ViiperValidateCreateDevice")) + requireContractOrder(t, create, + "!ViiperValidateDescriptorChain( descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE) ||", + "!ViiperValidateEndpointSchedules( descriptor, record->Length, Input->Speed)") +} diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index c417437e..db8a9b76 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -58,6 +58,67 @@ ViiperValidateDescriptorChain( return offset == Length; } +static +BOOLEAN +ViiperValidateEndpointSchedules( + _In_reads_bytes_(Length) const UCHAR *Descriptor, + _In_ ULONG Length, + _In_ ULONG Speed + ) +{ + ULONG offset = 0; + + // The owner sends the UdeCx-facing descriptor, not the controller's + // logical full-speed descriptor. USBHUB3 schedules every UDE endpoint + // using high-speed interval rules. Reject an old or malformed privileged + // owner here, before UdecxUsbDeviceInitAddDescriptor can expose an unsafe + // ISO pipe to a client driver. + while (offset < Length) { + const UCHAR *item; + ULONG itemLength; + UCHAR transferType; + + if (Length - offset < 2) { + return FALSE; + } + item = Descriptor + offset; + itemLength = item[0]; + if (itemLength < 2 || itemLength > Length - offset) { + return FALSE; + } + if (item[1] != USB_ENDPOINT_DESCRIPTOR_TYPE) { + offset += itemLength; + continue; + } + if (itemLength < sizeof(USB_ENDPOINT_DESCRIPTOR)) { + return FALSE; + } + + transferType = item[3] & USB_ENDPOINT_TYPE_MASK; + if (transferType == USB_ENDPOINT_TYPE_ISOCHRONOUS) { + if (Speed == 1) { + return FALSE; + } + if (Speed == 2) { + // Full-speed one-frame ISO is projected to the equivalent + // high-speed exponent before crossing this ABI. + if (item[6] != 4) { + return FALSE; + } + } else if (item[6] == 0 || item[6] > 4) { + // Windows supports HS/SS ISO polling periods only through + // eight microframes. Client I/O above that may bugcheck. + return FALSE; + } + } else if (transferType == USB_ENDPOINT_TYPE_INTERRUPT && + (item[6] == 0 || item[6] > 16)) { + return FALSE; + } + offset += itemLength; + } + return offset == Length; +} + static const UCHAR microsoftOS10StringPrefix[] = { 0x12, 0x03, 0x4d, 0x00, 0x53, 0x00, 0x46, 0x00, 0x54, 0x00, @@ -160,7 +221,9 @@ ViiperValidateCreateDevice( descriptor[1] != USB_CONFIGURATION_DESCRIPTOR_TYPE || ((USHORT)descriptor[2] | ((USHORT)descriptor[3] << 8)) != (USHORT)record->Length || !ViiperValidateDescriptorChain( - descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE)) { + descriptor, record->Length, USB_CONFIGURATION_DESCRIPTOR_TYPE) || + !ViiperValidateEndpointSchedules( + descriptor, record->Length, Input->Speed)) { return FALSE; } foundConfiguration = TRUE; diff --git a/usb/usbdesc.go b/usb/usbdesc.go index 8a10b0e2..0c67cad0 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -255,8 +255,10 @@ func (d Descriptor) Bytes() []byte { // ConfigurationBytes builds the complete active USB configuration descriptor, // including IADs, alternate interfaces, HID/class descriptors, and endpoints. -// Both the USB/IP server and the native UdeCx host use this single encoder so -// Windows sees byte-identical device topology on either transport. +// USB/IP emits these logical bytes directly. The native UdeCx host also uses +// this encoder after applying only the endpoint scheduling projection required +// by USBHUB3 for full-speed devices; interface topology and class data remain +// identical. func (d Descriptor) ConfigurationBytes() ([]byte, error) { var b bytes.Buffer configValue := d.Configuration.BConfigurationValue From c0b1244c1ac7d899423218a7f29f41fc5122c634 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 23:23:20 -0500 Subject: [PATCH 159/240] Preserve native ISO tail on rejected explicit frames Validate explicit StartFrame reservations against the current Windows frame window and the already-published endpoint tail before advancing the kernel clock. Use a CAS reservation shared with ASAP traffic so rejected range/overlap requests cannot create a deterministic hole for the next valid media URB. Carry USBD_STATUS_BAD_START_FRAME through the Go processor and native completion ABI under a successful NTSTATUS envelope, retaining ISO packet offsets so the kernel can validate and deliver the protocol failure instead of collapsing it into INTERNAL_HC_ERROR. Add deterministic wrap, overlap, tail-continuity, typed-completion, and source-contract tests. PlayStation media payloads, cadence, mixing, state construction, and ASAP scheduling are unchanged. --- internal/server/usb/native.go | 22 ++++++- internal/server/usb/native_test.go | 13 ++++ .../udecx/driver_iso_contract_test.go | 61 +++++++++++++++++ internal/transport/udecx/host.go | 30 ++++++++- internal/transport/udecx/host_test.go | 66 +++++++++++++++++++ internal/transport/udecx/protocol.go | 3 + native/udecx/driver/Broker.c | 35 ++++++++-- 7 files changed, 222 insertions(+), 8 deletions(-) diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 79e7bc77..3cdb8359 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -54,6 +54,24 @@ const ( usbdIsoStartFrameRange = int64(1024) ) +type nativeUSBDCompletionError struct { + status uint32 + err error +} + +func (e *nativeUSBDCompletionError) Error() string { return e.err.Error() } +func (e *nativeUSBDCompletionError) Unwrap() error { return e.err } +func (e *nativeUSBDCompletionError) USBDCompletionStatus() uint32 { + return e.status +} + +func nativeBadStartFrameError(format string, args ...any) error { + return &nativeUSBDCompletionError{ + status: udecx.USBDStatusBadStartFrame, + err: fmt.Errorf(format, args...), + } +} + // NativeProcessor adapts the native UdeCx broker to the same control and // transfer engine used by USB/IP. Transport-specific clocks live here; device // state, feedback, HID, audio, and descriptor behavior remain in usb.Device. @@ -677,7 +695,7 @@ func (p *NativeProcessor) reserveIsoServiceWindow( delta := int64(int32(startFrame - sample.frame)) explicit := transferFlags&udecx.TransferFlagStartIsoASAP == 0 if explicit && (delta <= 0 || delta >= usbdIsoStartFrameRange) { - return time.Time{}, time.Time{}, fmt.Errorf( + return time.Time{}, time.Time{}, nativeBadStartFrameError( "native explicit ISO start frame %d is outside the future frame range from %d", startFrame, sample.frame) } @@ -696,7 +714,7 @@ func (p *NativeProcessor) reserveIsoServiceWindow( defer p.mu.Unlock() if previousEnd := p.next[key]; previousEnd.After(plannedStart) { if explicit { - return time.Time{}, time.Time{}, fmt.Errorf( + return time.Time{}, time.Time{}, nativeBadStartFrameError( "native explicit ISO start frame %d overlaps the previous endpoint window", startFrame) } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 69f3f45d..1cf2ab60 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -3,6 +3,7 @@ package usb import ( "bytes" "context" + "errors" "log/slog" "sync" "sync/atomic" @@ -585,9 +586,21 @@ func TestNativeIsoExplicitFrameRangeHandlesWrap(t *testing.T) { if _, _, err := processor.reserveIsoServiceWindow( key, 100+uint32(usbdIsoStartFrameRange), 0, time.Millisecond); err == nil { t.Fatal("out-of-range explicit frame was accepted") + } else { + var statusError interface{ USBDCompletionStatus() uint32 } + if !errors.As(err, &statusError) || + statusError.USBDCompletionStatus() != udecx.USBDStatusBadStartFrame { + t.Fatalf("out-of-range explicit error=%v does not report BAD_START_FRAME", err) + } } if _, _, err := processor.reserveIsoServiceWindow(key, 99, 0, time.Millisecond); err == nil { t.Fatal("past explicit frame was accepted") + } else { + var statusError interface{ USBDCompletionStatus() uint32 } + if !errors.As(err, &statusError) || + statusError.USBDCompletionStatus() != udecx.USBDStatusBadStartFrame { + t.Fatalf("past explicit error=%v does not report BAD_START_FRAME", err) + } } } diff --git a/internal/transport/udecx/driver_iso_contract_test.go b/internal/transport/udecx/driver_iso_contract_test.go index 82acccae..9f8b4533 100644 --- a/internal/transport/udecx/driver_iso_contract_test.go +++ b/internal/transport/udecx/driver_iso_contract_test.go @@ -93,3 +93,64 @@ func TestNativeDriverIsoFrameSpanPreservesPlayStationCadence(t *testing.T) { }) } } + +func TestNativeDriverRejectedExplicitIsoReservationDoesNotAdvanceTail(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperReserveIsoStartFrame(") + if start < 0 { + t.Fatal("native ISO reservation helper is missing") + } + end := strings.Index(source[start:], "ViiperCopyTransferBuffer(") + if end < 0 { + t.Fatal("native ISO reservation helper boundary is missing") + } + reservation := source[start : start+end] + for _, required := range []string{ + "requestedDelta <= 0", + "requestedDelta >= USBD_ISO_START_FRAME_RANGE", + "(LONG)(RequestedStartFrame - startFrame) < 0", + "InterlockedCompareExchange64(", + } { + if !strings.Contains(reservation, required) { + t.Fatalf("explicit ISO reservation is missing %q", required) + } + } + if strings.Contains(reservation, "InterlockedExchange64(") { + t.Fatal("explicit ISO reservation can still overwrite the endpoint tail unconditionally") + } + + reserve := func(tail, current, requested, span uint32, asap bool) (uint32, uint32) { + if !asap { + delta := int32(requested - current) + if delta <= 0 || delta >= 1024 { + return requested, tail + } + if tail != 0 && int32(tail-current) > 0 && int32(requested-tail) < 0 { + return requested, tail + } + return requested, requested + span + } + startFrame := tail + if tail == 0 || int32(startFrame-current) <= 0 { + startFrame = current + 1 + } + return startFrame, startFrame + span + } + + const current = uint32(90) + const previousTail = uint32(132) + startFrame, tail := reserve(previousTail, current, 110, 32, false) + if startFrame != 110 || tail != previousTail { + t.Fatalf("overlapping explicit reservation start=%d tail=%d want start=110 tail=%d", + startFrame, tail, previousTail) + } + startFrame, tail = reserve(tail, current, 0, 32, true) + if startFrame != previousTail || tail != 164 { + t.Fatalf("ASAP after rejected explicit start=%d tail=%d want start=132 tail=164", + startFrame, tail) + } + _, tail = reserve(previousTail, current, current+1024, 32, false) + if tail != previousTail { + t.Fatalf("out-of-range explicit reservation advanced tail to %d", tail) + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 88bdc87d..5df927d0 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -1157,7 +1157,7 @@ func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operatio return nil } if err != nil { - completion = failureCompletion(op) + completion = processorErrorCompletion(op, err) } if h.operationCancelled(op.Token) { h.finishOperation(op.Token) @@ -1341,3 +1341,31 @@ func failureCompletion(op Operation) Completion { Status: statusUnsuccessful, } } + +type usbdCompletionStatusError interface { + error + USBDCompletionStatus() uint32 +} + +func processorErrorCompletion(op Operation, err error) Completion { + var usbdError usbdCompletionStatusError + if errors.As(err, &usbdError) { + if status := usbdError.USBDCompletionStatus(); status != 0 { + // UdeCx consumes USBD protocol failures through UdecxUrbComplete, + // which requires a successful NTSTATUS envelope. A generic + // processor failure still uses the NTSTATUS failure path below. ISO + // completions must retain the submitted packet table even when no + // bytes were serviced; the kernel validates those offsets before it + // can deliver the protocol status to UdeCx. + packets := make([]IsoPacket, len(op.IsoPackets)) + for index, packet := range op.IsoPackets { + packets[index] = IsoPacket{Offset: packet.Offset, Status: int32(status)} + } + return Completion{ + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + USBDStatus: status, IsoPackets: packets, + } + } + } + return failureCompletion(op) +} diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index d727e98b..0f718225 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -177,6 +177,23 @@ func (*noopProcessor) Process(context.Context, usb.Device, Operation) (Completio func (*noopProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } func (*noopProcessor) Reset(usb.Device, DeviceIdentity) {} +type usbdFailureProcessor struct { + err error +} + +func (p *usbdFailureProcessor) Process(context.Context, usb.Device, Operation) (Completion, error) { + return Completion{}, p.err +} +func (*usbdFailureProcessor) Lifecycle(context.Context, usb.Device, Operation) error { return nil } +func (*usbdFailureProcessor) Reset(usb.Device, DeviceIdentity) {} + +type testUSBDCompletionError struct { + status uint32 +} + +func (e testUSBDCompletionError) Error() string { return "USB protocol failure" } +func (e testUSBDCompletionError) USBDCompletionStatus() uint32 { return e.status } + type deviceGateProcessor struct { blockedDevice uint64 started chan struct{} @@ -2600,6 +2617,55 @@ func TestHostRejectsStaleOperationGeneration(t *testing.T) { } } +func TestHostCompletesTypedUSBDFailureWithoutCollapsingToNTStatus(t *testing.T) { + driver := newFakeHostDriver() + processor := &usbdFailureProcessor{err: testUSBDCompletionError{ + status: USBDStatusBadStartFrame, + }} + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + identity, err := host.Register(context.Background(), 109, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + driver.operations <- Operation{ + Token: 1, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x02, EndpointSequence: 1, Kind: OperationTransfer, + IsoPackets: []IsoPacket{{Offset: 0, Length: 64}, {Offset: 64, Length: 64}}, + } + select { + case completion := <-driver.completions: + if completion.Status != 0 || completion.USBDStatus != USBDStatusBadStartFrame { + t.Fatalf("typed USBD completion=%+v want NT success and BAD_START_FRAME", completion) + } + if len(completion.IsoPackets) != 2 || + completion.IsoPackets[0].Offset != 0 || + completion.IsoPackets[1].Offset != 64 || + completion.IsoPackets[0].Length != 0 || + uint32(completion.IsoPackets[0].Status) != USBDStatusBadStartFrame { + t.Fatalf("typed USBD ISO packet table=%+v", completion.IsoPackets) + } + case <-time.After(time.Second): + t.Fatal("typed USBD failure was not completed") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatalf("host returned error after clean cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after cancellation") + } +} + func TestHostCancelBeforeOperationSkipsProcessingAndCompletion(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 0d8f7d09..dc74d5bc 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -44,6 +44,9 @@ const ( // TransferFlagStartIsoASAP is the wire value of // USBD_START_ISO_TRANSFER_ASAP from usb.h. TransferFlagStartIsoASAP uint32 = 0x00000004 + // USBDStatusBadStartFrame is the wire value of + // USBD_STATUS_BAD_START_FRAME from the Microsoft WDK usb.h contract. + USBDStatusBadStartFrame uint32 = 0xC0000A00 MicrosoftOS10StringIndex = 0x00EE MicrosoftOS10StringLength = 18 diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 5e7abdc5..d1589860 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1287,19 +1287,44 @@ ViiperReserveIsoStartFrame( { LONG64 observed; ULONG currentFrame; + LONG requestedDelta; ULONG startFrame; ULONG nextFrame; ULONG span; span = ViiperIsoFrameSpan(EndpointContext, PacketCount); + currentFrame = (ULONG)(KeQueryInterruptTimePrecise(NULL) / 10000ULL); if ((TransferFlags & USBD_START_ISO_TRANSFER_ASAP) == 0) { - InterlockedExchange64( - &EndpointContext->NextIsoStartFrame, - (LONG64)(ULONGLONG)(RequestedStartFrame + span)); - return RequestedStartFrame; + // An explicit URB is valid only in the future 1024-frame window. Do + // not let a rejected request advance the shared endpoint tail: doing + // so makes the next valid ASAP URB inherit a silent hole. + requestedDelta = (LONG)(RequestedStartFrame - currentFrame); + if (requestedDelta <= 0 || + requestedDelta >= USBD_ISO_START_FRAME_RANGE) { + return RequestedStartFrame; + } + for (;;) { + observed = InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, 0, 0); + startFrame = (ULONG)observed; + if (observed != 0 && + (LONG)(startFrame - currentFrame) > 0 && + (LONG)(RequestedStartFrame - startFrame) < 0) { + // This explicit window overlaps a reservation already + // published for the same endpoint. Leave the tail unchanged; + // user mode will return USBD_STATUS_BAD_START_FRAME. + return RequestedStartFrame; + } + nextFrame = RequestedStartFrame + span; + if (InterlockedCompareExchange64( + &EndpointContext->NextIsoStartFrame, + (LONG64)(ULONGLONG)nextFrame, + observed) == observed) { + return RequestedStartFrame; + } + } } - currentFrame = (ULONG)(KeQueryInterruptTimePrecise(NULL) / 10000ULL); for (;;) { observed = InterlockedCompareExchange64( &EndpointContext->NextIsoStartFrame, 0, 0); From 0435d146757c57cf4301066a2fde9e338d920e41 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 23:26:02 -0500 Subject: [PATCH 160/240] Remove redundant native input queue hop Complete SUBMIT_INPUT_REPORT directly on the existing parallel, passive, unsynchronized default KMDF queue. Continue forwarding every control, lifecycle, broker, and media IOCTL to the sequential control queue so those operations cannot head-of-line block fresh controller input. This removes one WdfRequestForwardToIoQueue transition and a second KMDF dispatch callback from every encoded input sample without changing report bytes, per-endpoint ordering, lifecycle admission, cached HID delivery, or PlayStation media scheduling. Remove the now-unused queue and teardown path, document the topology, and add a source contract test that pins the fast-path routing and parallel/control isolation. --- docs/architecture/native-udecx.md | 9 ++-- .../udecx/driver_dispatch_contract_test.go | 31 +++++++++++++ native/udecx/driver/Controller.c | 11 ----- native/udecx/driver/Device.c | 6 +-- native/udecx/driver/Ioctl.c | 46 +++++-------------- native/udecx/driver/ViiperUde.h | 2 - 6 files changed, 51 insertions(+), 54 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 65dcce36..6870129f 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -419,10 +419,11 @@ a wedged provider cannot retain the installer mutex indefinitely. Post-enumeration reset, device initialization, and configuration replacement share this one-child-at-a-time gate; concurrent reset transactions are rejected instead of interleaving two controller resets. -- The controller's default KMDF queue only routes requests: interrupt-IN - submissions run on an independent parallel queue, while mutation, broker, - and lifecycle IOCTLs retain their serialized control queue. Large media - completions therefore cannot head-of-line block fresh controller input. +- The controller's default KMDF queue is parallel and completes interrupt-IN + submissions directly. Mutation, broker, and lifecycle IOCTLs alone move to + the serialized control queue. This removes a redundant KMDF forwarding and + dispatch boundary from every fresh report while large media completions still + cannot head-of-line block controller input. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index c3d320b4..4b7a2777 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -188,3 +188,34 @@ func TestNativeBrokerEndpointFIFOModelsPublishAndCancelOrdering(t *testing.T) { t.Fatal("cancellation did not expose the next same-endpoint admission") } } + +func TestNativeFastInputSubmissionAvoidsSecondKMDFQueueHop(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + all := controller + ioctl + header + if strings.Contains(all, "WDFQUEUE InputQueue;") || + strings.Contains(all, "context->InputQueue") || + strings.Contains(all, "ViiperEvtInputIoDeviceControl") { + t.Fatal("native input still crosses the redundant parallel KMDF queue") + } + + queues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, queues, + "WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);", + "queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControlRoute;", + "WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchSequential);", + "queueConfig.EvtIoDeviceControl = ViiperEvtIoDeviceControl;") + + route := normalizedContract(nativeCFunction(t, ioctl, "ViiperEvtIoDeviceControlRoute")) + requireContractOrder(t, route, + "InterlockedCompareExchange(&context->ShuttingDown, 0, 0)", + "if (IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT)", + "status = ViiperSubmitInputReport(Queue, Request);", + "WdfRequestComplete(Request, status);", + "return;", + "WdfRequestForwardToIoQueue(Request, context->ControlQueue)") + if strings.Contains(route, "WdfRequestForwardToIoQueue(Request, context->InputQueue)") { + t.Fatal("hot input report is still forwarded before completion") + } +} diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 5d474ba7..b0698fcb 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -288,9 +288,6 @@ ViiperEvtDeviceSelfManagedIoCleanup( if (context->ControlQueue != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->ControlQueue); } - if (context->InputQueue != WDF_NO_HANDLE) { - WdfIoQueuePurgeSynchronously(context->InputQueue); - } if (context->WaitingDequeues != WDF_NO_HANDLE) { WdfIoQueuePurgeSynchronously(context->WaitingDequeues); InterlockedExchange(&context->WaitingDequeueCount, 0); @@ -495,14 +492,6 @@ ViiperCreateQueues( return status; } - WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchParallel); - queueConfig.PowerManaged = WdfFalse; - queueConfig.EvtIoDeviceControl = ViiperEvtInputIoDeviceControl; - status = WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->InputQueue); - if (!NT_SUCCESS(status)) { - return status; - } - WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual); queueConfig.PowerManaged = WdfFalse; return WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->WaitingDequeues); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index db8a9b76..5e6eeed1 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1506,9 +1506,9 @@ ViiperSubmitInputReport( return STATUS_INVALID_DEVICE_STATE; } - // InputQueue is parallel so independent controllers never block one - // another. Serialize only this endpoint, preserving report order even if - // a faulty or hostile owner submits concurrent updates for the same pad. + // The default IOCTL queue is parallel so independent controllers never + // block one another. Serialize only this endpoint, preserving report order + // even if a faulty or hostile owner submits concurrent updates for one pad. WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 22886dea..bccba360 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -177,9 +177,6 @@ ViiperEvtIoDeviceControlRoute( { VIIPER_UDE_CONTROLLER_CONTEXT *context = ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); - WDFQUEUE destination = IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT - ? context->InputQueue - : context->ControlQueue; NTSTATUS status; UNREFERENCED_PARAMETER(OutputBufferLength); @@ -190,40 +187,21 @@ ViiperEvtIoDeviceControlRoute( return; } - // The default queue performs routing only. Keeping it parallel prevents a - // large media completion or lifecycle mutation on the serialized control - // queue from delaying an already encoded interrupt-IN report. - status = WdfRequestForwardToIoQueue(Request, destination); - if (!NT_SUCCESS(status)) { + if (IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT) { + // The default queue already has parallel/passive/no-synchronization + // semantics. Complete the hot interrupt-IN submission here instead of + // forwarding it through a second identically configured KMDF queue. + // Control, lifecycle, and media IOCTLs still move to the serialized + // control queue and therefore cannot head-of-line block fresh input. + status = ViiperSubmitInputReport(Queue, Request); WdfRequestComplete(Request, status); - } -} - -VOID -ViiperEvtInputIoDeviceControl( - _In_ WDFQUEUE Queue, - _In_ WDFREQUEST Request, - _In_ size_t OutputBufferLength, - _In_ size_t InputBufferLength, - _In_ ULONG IoControlCode - ) -{ - VIIPER_UDE_CONTROLLER_CONTEXT *context = - ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); - NTSTATUS status; - - UNREFERENCED_PARAMETER(OutputBufferLength); - UNREFERENCED_PARAMETER(InputBufferLength); - - if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { - WdfRequestComplete(Request, STATUS_DEVICE_REMOVED); return; } - status = IoControlCode == IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT - ? ViiperSubmitInputReport(Queue, Request) - : STATUS_INVALID_DEVICE_REQUEST; - WdfRequestComplete(Request, status); + status = WdfRequestForwardToIoQueue(Request, context->ControlQueue); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + } } VOID @@ -267,7 +245,7 @@ ViiperEvtIoDeviceControl( status = ViiperCompleteOperation(Queue, Request); break; case IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT: - // The router sends this IOCTL to the independent parallel input queue. + // The parallel default queue completes this hot-path IOCTL directly. // Reject it here rather than silently restoring head-of-line blocking. status = STATUS_INVALID_DEVICE_REQUEST; break; diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index e05c88ae..5e8145e0 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -124,7 +124,6 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFFILEOBJECT OwnerFile; WDFQUEUE DefaultQueue; WDFQUEUE ControlQueue; - WDFQUEUE InputQueue; WDFQUEUE WaitingDequeues; KEVENT BrokerOperationsDrained; KEVENT CompletionOperationsDrained; @@ -234,7 +233,6 @@ EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControlRoute; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; -EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtInputIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; From 402d1335bdd2427716cf0002f03d1cbcdd1ab661 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 10 Aug 2026 23:27:42 -0500 Subject: [PATCH 161/240] Deliver cached native input without worker latency Retrieve a cached interrupt-IN poll directly from the passive manual-queue ReadyNotify callback, copy the latest report under the existing endpoint admission lock, and transfer terminal ownership to the shared completion DPC. Microsoft's KMDF contract explicitly permits ReadyNotify to retrieve requests, while the host-controller contract requires only that terminal URB completion happen on a separate DPC. Removing the intervening generic work item cuts startup/resume/idle-recovery scheduling jitter without changing report bytes, live publisher cadence, UdeCx queue ownership, or lifecycle fences. Remove the unused endpoint work item and pin PASSIVE retrieval plus asynchronous DPC completion with deterministic source-contract tests. --- docs/architecture/native-udecx.md | 5 +++ .../udecx/driver_dispatch_contract_test.go | 30 ++++++++++++++++ native/udecx/driver/Device.c | 35 ++++--------------- native/udecx/driver/ViiperUde.h | 2 -- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 6870129f..5f26c6ca 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -427,6 +427,11 @@ a wedged provider cannot retain the installer mutex indefinitely. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. +- If a fresh report arrives before Windows posts its next HID poll, the passive + manual-queue ready callback copies that cached state immediately and hands + terminal ownership to the shared completion DPC. The required asynchronous + DISPATCH_LEVEL completion remains intact without an intervening system-worker + scheduling hop on first poll, resume, or idle recovery. - A lost ordered lifecycle notification faults both the broker and the direct interrupt-IN producer lane. Already-published broker completions remain drainable, but no new controller state is admitted into a generation whose diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 4b7a2777..02c1d0a9 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -219,3 +219,33 @@ func TestNativeFastInputSubmissionAvoidsSecondKMDFQueueHop(t *testing.T) { t.Fatal("hot input report is still forwarded before completion") } } + +func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + if strings.Contains(device+header, "InputReadyWorkItem") || + strings.Contains(device+header, "ViiperEvtFastInputWorkItem") { + t.Fatal("cached input delivery still crosses a generic system work item") + } + + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, "attributes.ExecutionLevel = WdfExecutionLevelPassive;") { + t.Fatal("manual fast-input queue no longer pins ReadyNotify to PASSIVE_LEVEL") + } + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfIoQueueRetrieveNextRequest(Queue, &request)", + "ViiperCompleteCachedInputUrb(endpoint, request);", + "WdfWaitLockRelease(endpointContext->InputLock);") + if strings.Contains(ready, "WdfWorkItemEnqueue") || + strings.Contains(ready, "UdecxUrbComplete(") { + t.Fatal("ReadyNotify either retains a worker hop or completes a UDE URB synchronously") + } + + complete := normalizedContract(nativeCFunction(t, device, "ViiperCompleteRetrievedInputUrb")) + if !strings.Contains(complete, "ViiperQueueUrbCompletion(") { + t.Fatal("cached input no longer transfers terminal completion to the shared DPC") + } +} diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 5e6eeed1..e2de175b 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1185,14 +1185,6 @@ ViiperEvtEndpointAdd( if (!NT_SUCCESS(status)) { return status; } - WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtFastInputWorkItem); - WDF_OBJECT_ATTRIBUTES_INIT(&attributes); - attributes.ParentObject = endpoint; - status = WdfWorkItemCreate( - &workItemConfig, &attributes, &endpointContext->InputReadyWorkItem); - if (!NT_SUCCESS(status)) { - return status; - } } else { dispatchType = WdfIoQueueDispatchParallel; } @@ -1332,25 +1324,6 @@ ViiperEvtFastInputQueueReady( { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); - - UNREFERENCED_PARAMETER(Queue); - PAGED_CODE(); - // WdfIoQueueReadyNotify is allowed to invoke this callback synchronously - // on UdeCx's URB submitter thread, including before registration returns. - // A cached poll must therefore cross a real execution boundary before it - // is retrieved and completed. KMDF 1.7+ safely coalesces repeated enqueue - // calls for one reusable work item while it is already queued. - WdfWorkItemEnqueue(endpointContext->InputReadyWorkItem); -} - -VOID -ViiperEvtFastInputWorkItem( - _In_ WDFWORKITEM WorkItem - ) -{ - UDECXUSBENDPOINT endpoint = - (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); - VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); @@ -1359,6 +1332,12 @@ ViiperEvtFastInputWorkItem( BOOLEAN completionQueued = FALSE; PAGED_CODE(); + // KMDF explicitly permits a passive ReadyNotify callback to retrieve the + // request that made a manual queue non-empty. Copy the already-cached + // latest state here, then transfer terminal ownership to the driver's + // completion DPC. The DPC is the separate DISPATCH_LEVEL boundary required + // by the UDE/host-controller completion contract; a system work item before + // that DPC only adds scheduler latency to the first poll after idle/resume. WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && @@ -1384,7 +1363,7 @@ ViiperEvtFastInputWorkItem( // Completing it can cause HIDClass to post a successor; leaving that poll // parked prevents a cache replay loop and lets the next producer update // complete it on the allocation-free direct path. - if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &request))) { + if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); ViiperInvalidateInputIfLifecycleClosed(endpoint); (VOID)ViiperCompleteCachedInputUrb(endpoint, request); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 5e8145e0..24e4374c 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -197,7 +197,6 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; WDFWAITLOCK InputLock; - WDFWORKITEM InputReadyWorkItem; WDFWORKITEM PurgeWorkItem; WDFWORKITEM ResetWorkItem; WDFREQUEST ResetRequest; @@ -247,7 +246,6 @@ EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; -EVT_WDF_WORKITEM ViiperEvtFastInputWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; From fa4f02f2d49a6f2a4e538c523de5ec79dbbfc65d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 05:35:49 -0500 Subject: [PATCH 162/240] Gate native PlayStation transport against dropout races Exercise concurrent DualSense, DualSense Edge, and DualShock 4 speaker/haptics, microphone, HID/state, and direct input through the real Host and NativeProcessor. Verify exact caller-buffer ISO-IN bytes and actual packet lengths using Windows wMaxPacketSize URB layout, exact callback totals, cadence, reset/D0/purge boundaries, cancellation, fairness, and repeated broker ownership. Reject already-cancelled transfers before native endpoint/media mutation and recheck lifecycle cancellation after the session lock so superseded reset/purge work cannot cross a newer device barrier. --- internal/server/usb/native.go | 18 +- .../native_playstation_transport_soak_test.go | 1230 +++++++++++++++++ 2 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 internal/server/usb/native_playstation_transport_soak_test.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 3cdb8359..c2bcc02b 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -158,11 +158,20 @@ func (p *NativeProcessor) clearDeviceTransportLocked(identity udecx.DeviceIdenti clear(session.active) } -func (p *NativeProcessor) Lifecycle(_ context.Context, dev usbdevice.Device, op udecx.Operation) error { +func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, op udecx.Operation) error { + if err := ctx.Err(); err != nil { + return err + } identity := udecx.DeviceIdentity{DeviceID: op.DeviceID, Generation: op.Generation} sessionKey := nativeSessionKey{deviceID: op.DeviceID, generation: op.Generation} session := p.lockSession(sessionKey) defer session.mu.Unlock() + // A newer device barrier may cancel this lifecycle operation while it waits + // behind an older callback on the same session. Recheck after acquiring the + // mutation lock so the retired reset/purge cannot cross the new boundary. + if err := ctx.Err(); err != nil { + return err + } key := nativeLaneKey{ deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, attributes: op.EndpointAttributes, interval: op.EndpointInterval, @@ -464,6 +473,13 @@ func resolveNativeIsoEndpoint(dev usbdevice.Device, op udecx.Operation) (nativeI } func (p *NativeProcessor) Process(ctx context.Context, dev usbdevice.Device, op udecx.Operation) (udecx.Completion, error) { + // The kernel cancel notification can win after Host's final cancellation + // check but immediately before this callback. Reject that already-retired + // request before reserving an ISO service window, activating an endpoint, or + // publishing an output/state report into the immutable controller engine. + if err := ctx.Err(); err != nil { + return udecx.Completion{}, err + } if dev == nil { return udecx.Completion{}, errors.New("native UDE operation has no device") } diff --git a/internal/server/usb/native_playstation_transport_soak_test.go b/internal/server/usb/native_playstation_transport_soak_test.go new file mode 100644 index 00000000..dc9bbd97 --- /dev/null +++ b/internal/server/usb/native_playstation_transport_soak_test.go @@ -0,0 +1,1230 @@ +package usb_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" +) + +const nativePlayStationSoakTimeout = 5 * time.Second + +type nativePlayStationEndpointKey struct { + deviceID uint64 + address uint8 +} + +// nativePlayStationSoakDriver is an in-memory model of the exclusive UdeCx +// broker session. It deliberately assigns endpoint and device sequences at the +// submission boundary, then lets several Host dequeue workers observe them out +// of order. Every token gets exactly one waiter, so a missing, duplicate, stale, +// or cross-device completion fails the transport gate instead of being hidden +// by callback counts. +type nativePlayStationSoakDriver struct { + operations chan udecx.Operation + + mu sync.Mutex + nextToken uint64 + endpointSequences map[nativePlayStationEndpointKey]uint64 + deviceSequences map[uint64]uint64 + waiters map[uint64]chan udecx.Completion + completed map[uint64]struct{} + inputs map[udecx.DeviceIdentity][]udecx.InputReport + created []udecx.CreateDevice + destroyed []udecx.DeviceIdentity + failures []error +} + +func newNativePlayStationSoakDriver() *nativePlayStationSoakDriver { + return &nativePlayStationSoakDriver{ + operations: make(chan udecx.Operation, 4096), + nextToken: 1, + endpointSequences: make(map[nativePlayStationEndpointKey]uint64), + deviceSequences: make(map[uint64]uint64), + waiters: make(map[uint64]chan udecx.Completion), + completed: make(map[uint64]struct{}), + inputs: make(map[udecx.DeviceIdentity][]udecx.InputReport), + } +} + +func (d *nativePlayStationSoakDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) error { + d.mu.Lock() + d.created = append(d.created, device) + d.mu.Unlock() + return nil +} + +func (d *nativePlayStationSoakDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.mu.Lock() + d.destroyed = append(d.destroyed, identity) + d.mu.Unlock() + return nil +} + +func (d *nativePlayStationSoakDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + select { + case op := <-d.operations: + return op, nil + case <-ctx.Done(): + return udecx.Operation{}, ctx.Err() + } +} + +func cloneNativeCompletion(completion udecx.Completion) udecx.Completion { + completion.Payload = append([]byte(nil), completion.Payload...) + completion.IsoPackets = append([]udecx.IsoPacket(nil), completion.IsoPackets...) + return completion +} + +func (d *nativePlayStationSoakDriver) Complete( + ctx context.Context, completion udecx.Completion, +) error { + completion = cloneNativeCompletion(completion) + d.mu.Lock() + waiter := d.waiters[completion.Token] + _, duplicate := d.completed[completion.Token] + if waiter == nil { + d.failures = append(d.failures, fmt.Errorf( + "completion token %d had no live UdeCx request", completion.Token)) + } else if duplicate { + d.failures = append(d.failures, fmt.Errorf( + "completion token %d was delivered more than once", completion.Token)) + } else { + d.completed[completion.Token] = struct{}{} + } + d.mu.Unlock() + if waiter == nil || duplicate { + return nil + } + select { + case waiter <- completion: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *nativePlayStationSoakDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +func (d *nativePlayStationSoakDriver) SubmitInputReport( + ctx context.Context, report udecx.InputReport, +) error { + if err := ctx.Err(); err != nil { + return err + } + report.Payload = append([]byte(nil), report.Payload...) + identity := udecx.DeviceIdentity{DeviceID: report.DeviceID, Generation: report.Generation} + d.mu.Lock() + d.inputs[identity] = append(d.inputs[identity], report) + d.mu.Unlock() + return nil +} + +func (d *nativePlayStationSoakDriver) submit( + identity udecx.DeviceIdentity, op udecx.Operation, acknowledged bool, +) (uint64, <-chan udecx.Completion) { + d.mu.Lock() + op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + if op.Kind != udecx.OperationCancel { + key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} + d.endpointSequences[key]++ + d.deviceSequences[identity.DeviceID]++ + op.EndpointSequence = d.endpointSequences[key] + op.DeviceSequence = d.deviceSequences[identity.DeviceID] + } + if op.Kind == udecx.OperationTransfer || op.Kind == udecx.OperationControl || acknowledged { + op.Token = d.nextToken + d.nextToken++ + waiter := make(chan udecx.Completion, 1) + d.waiters[op.Token] = waiter + d.mu.Unlock() + d.operations <- op + return op.Token, waiter + } + d.mu.Unlock() + d.operations <- op + return 0, nil +} + +// submitCancellable models a kernel-owned request: it has a stable token and +// ordered endpoint/device sequences, but cancellation retires it in the driver +// and therefore no user-mode completion waiter may ever observe it. +func (d *nativePlayStationSoakDriver) submitCancellable( + identity udecx.DeviceIdentity, op udecx.Operation, +) uint64 { + d.mu.Lock() + op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} + d.endpointSequences[key]++ + d.deviceSequences[identity.DeviceID]++ + op.EndpointSequence = d.endpointSequences[key] + op.DeviceSequence = d.deviceSequences[identity.DeviceID] + op.Token = d.nextToken + d.nextToken++ + d.mu.Unlock() + d.operations <- op + return op.Token +} + +func (d *nativePlayStationSoakDriver) cancel( + identity udecx.DeviceIdentity, token uint64, endpoint uint8, +) { + d.operations <- udecx.Operation{ + Kind: udecx.OperationCancel, Token: token, + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: endpoint, + } +} + +func (d *nativePlayStationSoakDriver) wait( + t *testing.T, token uint64, waiter <-chan udecx.Completion, +) udecx.Completion { + t.Helper() + select { + case completion := <-waiter: + if completion.Token != token { + t.Fatalf("completion token=%d want=%d", completion.Token, token) + } + return completion + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("timed out waiting for native completion token %d", token) + return udecx.Completion{} + } +} + +func (d *nativePlayStationSoakDriver) waitFromWorker( + token uint64, waiter <-chan udecx.Completion, +) (udecx.Completion, error) { + select { + case completion := <-waiter: + if completion.Token != token { + return completion, fmt.Errorf("completion token=%d want=%d", completion.Token, token) + } + return completion, nil + case <-time.After(nativePlayStationSoakTimeout): + return udecx.Completion{}, fmt.Errorf("timed out waiting for native completion token %d", token) + } +} + +func (d *nativePlayStationSoakDriver) inputSnapshot( + identity udecx.DeviceIdentity, +) []udecx.InputReport { + d.mu.Lock() + defer d.mu.Unlock() + reports := make([]udecx.InputReport, len(d.inputs[identity])) + for index, report := range d.inputs[identity] { + reports[index] = report + reports[index].Payload = append([]byte(nil), report.Payload...) + } + return reports +} + +func (d *nativePlayStationSoakDriver) requireClean(t *testing.T) { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + if len(d.failures) != 0 { + t.Fatalf("native broker failures: %v", d.failures) + } + for token := range d.waiters { + if _, ok := d.completed[token]; !ok { + t.Fatalf("native request token %d never reached a terminal completion", token) + } + } +} + +// nativePlayStationCancelGate creates the real scheduling race in a controlled +// place: a dequeued media request is held immediately before NativeProcessor, +// then the kernel cancel notification wins ownership. NativeProcessor is still +// invoked with the cancelled context so this gate proves the adapter itself +// does not consume or publish media after cancellation. +type nativePlayStationCancelGate struct { + inner udecx.OperationProcessor + + mu sync.Mutex + armed bool + identity udecx.DeviceIdentity + endpoint uint8 + started chan struct{} + result chan error +} + +func (g *nativePlayStationCancelGate) arm( + identity udecx.DeviceIdentity, endpoint uint8, +) (<-chan struct{}, <-chan error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.armed { + panic("native PlayStation cancellation gate was armed twice") + } + g.armed = true + g.identity = identity + g.endpoint = endpoint + g.started = make(chan struct{}) + g.result = make(chan error, 1) + return g.started, g.result +} + +func (g *nativePlayStationCancelGate) Process( + ctx context.Context, dev usbdevice.Device, op udecx.Operation, +) (udecx.Completion, error) { + g.mu.Lock() + blocked := g.armed && op.Kind == udecx.OperationTransfer && + op.DeviceID == g.identity.DeviceID && op.Generation == g.identity.Generation && + op.EndpointAddress == g.endpoint + started, result := g.started, g.result + if blocked { + g.armed = false + } + g.mu.Unlock() + if !blocked { + return g.inner.Process(ctx, dev, op) + } + close(started) + <-ctx.Done() + completion, err := g.inner.Process(ctx, dev, op) + result <- err + return completion, err +} + +func (g *nativePlayStationCancelGate) Lifecycle( + ctx context.Context, dev usbdevice.Device, op udecx.Operation, +) error { + return g.inner.Lifecycle(ctx, dev, op) +} + +func (g *nativePlayStationCancelGate) Reset( + dev usbdevice.Device, identity udecx.DeviceIdentity, +) { + g.inner.Reset(dev, identity) +} + +type synchronizedDualSenseCapture struct { + mu sync.Mutex + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int +} + +type dualSenseCaptureSnapshot struct { + outputs []dualsense.OutputState + atomic []dualSenseAtomicCapture + realtime []dualsense.OutputState + resets int +} + +func (capture *synchronizedDualSenseCapture) attach(dev *dualsense.DualSense) { + dev.SetOutputCallback(func(state dualsense.OutputState) { + capture.mu.Lock() + capture.outputs = append(capture.outputs, state) + capture.mu.Unlock() + }) + dev.SetAtomicAudioHapticsCallback(func(state dualsense.OutputState, speaker []byte) { + capture.mu.Lock() + capture.atomic = append(capture.atomic, dualSenseAtomicCapture{ + feedback: state, speaker: append([]byte(nil), speaker...), + }) + capture.mu.Unlock() + }) + dev.SetRealtimeHapticsCallback(func(state dualsense.OutputState) { + capture.mu.Lock() + capture.realtime = append(capture.realtime, state) + capture.mu.Unlock() + }) + dev.SetSpeakerResetCallback(func() { + capture.mu.Lock() + capture.resets++ + capture.mu.Unlock() + }) +} + +func (capture *synchronizedDualSenseCapture) snapshot() dualSenseCaptureSnapshot { + capture.mu.Lock() + defer capture.mu.Unlock() + result := dualSenseCaptureSnapshot{ + outputs: append([]dualsense.OutputState(nil), capture.outputs...), + atomic: append([]dualSenseAtomicCapture(nil), capture.atomic...), + realtime: append([]dualsense.OutputState(nil), capture.realtime...), + resets: capture.resets, + } + for index := range result.atomic { + result.atomic[index].speaker = append([]byte(nil), result.atomic[index].speaker...) + } + return result +} + +type synchronizedDualShock4Capture struct { + mu sync.Mutex + outputs []dualshock4.OutputState + speaker [][]byte + resets int +} + +type dualShock4CaptureSnapshot struct { + outputs []dualshock4.OutputState + speaker [][]byte + resets int +} + +func (capture *synchronizedDualShock4Capture) attach(dev *dualshock4.DualShock4) { + dev.SetOutputCallback(func(state dualshock4.OutputState) { + capture.mu.Lock() + capture.outputs = append(capture.outputs, state) + capture.mu.Unlock() + }) + dev.SetSpeakerCallback(func(pcm []byte) { + capture.mu.Lock() + capture.speaker = append(capture.speaker, append([]byte(nil), pcm...)) + capture.mu.Unlock() + }) + dev.SetSpeakerResetCallback(func() { + capture.mu.Lock() + capture.resets++ + capture.mu.Unlock() + }) +} + +func (capture *synchronizedDualShock4Capture) snapshot() dualShock4CaptureSnapshot { + capture.mu.Lock() + defer capture.mu.Unlock() + result := dualShock4CaptureSnapshot{ + outputs: append([]dualshock4.OutputState(nil), capture.outputs...), + resets: capture.resets, + } + for _, pcm := range capture.speaker { + result.speaker = append(result.speaker, append([]byte(nil), pcm...)) + } + return result +} + +type nativePlayStationSoakCase struct { + name string + identity udecx.DeviceIdentity + native usbdevice.Device + legacy usbdevice.Device + speakerEP uint8 + microphoneEP uint8 + hidInEP uint8 + hidOutEP uint8 + speakerMeta udecx.Operation + microphoneMeta udecx.Operation + hidInMeta udecx.Operation + hidOutMeta udecx.Operation + + setLegacyAudioActive func(bool) + resetLegacyEndpoint func(uint8) + queueMicrophone func([]byte) + oracleMicrophone func([]udecx.IsoPacket) nativeSoakIsoExpectation + legacySpeaker func([]byte) + legacyHID func([]byte) + makeSpeaker func(int) ([]byte, int, uint32) + makeMicrophone func(int) ([]byte, int, uint32) + makeHID func() []byte + setInputMarker func(int8) + inputMarker func([]byte) int8 + requireParity func(*testing.T) + requireFinalCounts func(*testing.T, int, int) +} + +func nativeSoakEndpointMetadata(t *testing.T, dev usbdevice.Device, address uint8) udecx.Operation { + t.Helper() + return endpointOperation(t, udecx.DeviceIdentity{DeviceID: 1, Generation: 1}, + udecx.OperationTransfer, dev.GetDescriptor().Device.Speed, + parityEndpoint(t, dev, address)) +} + +func copyNativeEndpointMetadata(dst *udecx.Operation, source udecx.Operation) { + dst.EndpointAddress = source.EndpointAddress + dst.EndpointAttributes = source.EndpointAttributes + dst.EndpointInterval = source.EndpointInterval + dst.EndpointMaxPacketSize = source.EndpointMaxPacketSize +} + +func makeNativePlayStationSoakCases(t *testing.T) []*nativePlayStationSoakCase { + t.Helper() + cases := make([]*nativePlayStationSoakCase, 0, 3) + + for index, edge := range []bool{false, true} { + var native, legacy *dualsense.DualSense + var err error + if edge { + native, err = dualsense.NewEdge(nil) + if err == nil { + legacy, err = dualsense.NewEdge(nil) + } + } else { + native, err = dualsense.New(nil) + if err == nil { + legacy, err = dualsense.New(nil) + } + } + if err != nil { + t.Fatal(err) + } + nativeCapture, legacyCapture := &synchronizedDualSenseCapture{}, &synchronizedDualSenseCapture{} + nativeCapture.attach(native) + legacyCapture.attach(legacy) + name := "DualSense" + if edge { + name = "DualSense Edge" + } + soakCase := &nativePlayStationSoakCase{ + name: name, identity: udecx.DeviceIdentity{DeviceID: uint64(index + 1)}, + native: native, legacy: legacy, + speakerEP: dualsense.EndpointHapticsAudioOut, + microphoneEP: dualsense.EndpointMicrophoneIn, + hidInEP: dualsense.EndpointIn, hidOutEP: dualsense.EndpointOut, + setLegacyAudioActive: func(active bool) { + alt := uint8(0) + if active { + alt = 1 + } + legacy.SetInterfaceAltSetting(dualsense.InterfaceHapticsAudio, alt) + legacy.SetInterfaceAltSetting(dualsense.InterfaceMicrophone, alt) + }, + resetLegacyEndpoint: legacy.ResetEndpoint, + queueMicrophone: func(frame []byte) { + legacy.QueueMicrophonePCMFrame(frame) + native.QueueMicrophonePCMFrame(frame) + }, + oracleMicrophone: func(packets []udecx.IsoPacket) nativeSoakIsoExpectation { + return readOracleIsoPackets(t, legacy, dualsense.EndpointMicrophoneIn, packets) + }, + legacySpeaker: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualsense.EndpointHapticsAudioOut&0x0f, + usbdevice.DirectionOut, payload) + }, + legacyHID: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualsense.EndpointOut&0x0f, + usbdevice.DirectionOut, payload) + }, + makeSpeaker: func(iteration int) ([]byte, int, uint32) { + return dualSensePCM(480, int16(300+iteration*7)), 10, + dualsense.USBHapticsAudioPacketSize + }, + makeMicrophone: func(iteration int) ([]byte, int, uint32) { + return patternedPCM(dualsense.USBMicrophoneClientFrameSize, + byte(0x31+iteration*13)), 10, dualsense.USBMicrophoneMaxPacketSize + }, + makeHID: func() []byte { + report := make([]byte, dualsense.OutputReportSize) + report[0], report[1], report[2] = dualsense.ReportIDOutput, 0x03, 0x14 + report[3], report[4] = 0x39, 0xa7 + report[44], report[45], report[46], report[47] = 0x1f, 0x24, 0x68, 0xb2 + return report + }, + setInputMarker: func(marker int8) { + state := dualsense.NewInputState() + state.LX = marker + state.Buttons = dualsense.ButtonCross | dualsense.ButtonR3 + native.UpdateInputState(state) + }, + inputMarker: func(payload []byte) int8 { return int8(int16(payload[1]) - 128) }, + requireParity: func(t *testing.T) { + requireSynchronizedDualSenseParity(t, legacyCapture.snapshot(), nativeCapture.snapshot()) + }, + requireFinalCounts: func(t *testing.T, mediaFrames, stateReports int) { + for label, snapshot := range map[string]dualSenseCaptureSnapshot{ + "oracle": legacyCapture.snapshot(), "native": nativeCapture.snapshot(), + } { + if len(snapshot.atomic) != mediaFrames || len(snapshot.outputs) != stateReports { + t.Fatalf("%s %s final delivery totals: atomic=%d/%d state=%d/%d", + name, label, len(snapshot.atomic), mediaFrames, + len(snapshot.outputs), stateReports) + } + } + }, + } + soakCase.speakerMeta = nativeSoakEndpointMetadata(t, native, soakCase.speakerEP) + soakCase.microphoneMeta = nativeSoakEndpointMetadata(t, native, soakCase.microphoneEP) + soakCase.hidInMeta = nativeSoakEndpointMetadata(t, native, soakCase.hidInEP) + soakCase.hidOutMeta = nativeSoakEndpointMetadata(t, native, soakCase.hidOutEP) + cases = append(cases, soakCase) + } + + native, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + legacy, err := dualshock4.New(nil) + if err != nil { + t.Fatal(err) + } + nativeCapture, legacyCapture := &synchronizedDualShock4Capture{}, &synchronizedDualShock4Capture{} + nativeCapture.attach(native) + legacyCapture.attach(legacy) + ds4Case := &nativePlayStationSoakCase{ + name: "DualShock 4", identity: udecx.DeviceIdentity{DeviceID: 3}, + native: native, legacy: legacy, + speakerEP: dualshock4.EndpointAudioOut, + microphoneEP: dualshock4.EndpointMicrophoneIn, + hidInEP: dualshock4.EndpointIn, hidOutEP: dualshock4.EndpointOut, + setLegacyAudioActive: func(active bool) { + alt := uint8(0) + if active { + alt = 1 + } + legacy.SetInterfaceAltSetting(dualshock4.InterfaceSpeaker, alt) + legacy.SetInterfaceAltSetting(dualshock4.InterfaceMicrophone, alt) + }, + resetLegacyEndpoint: legacy.ResetEndpoint, + queueMicrophone: func(frame []byte) { + legacy.QueueMicrophonePCMFrame(frame) + native.QueueMicrophonePCMFrame(frame) + }, + oracleMicrophone: func(packets []udecx.IsoPacket) nativeSoakIsoExpectation { + return readOracleIsoPackets(t, legacy, dualshock4.EndpointMicrophoneIn, packets) + }, + legacySpeaker: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualshock4.EndpointAudioOut&0x0f, + usbdevice.DirectionOut, payload) + }, + legacyHID: func(payload []byte) { + legacy.HandleTransfer(context.Background(), dualshock4.EndpointOut&0x0f, + usbdevice.DirectionOut, payload) + }, + makeSpeaker: func(iteration int) ([]byte, int, uint32) { + const packetLength = 128 + return patternedPCM(10*packetLength, byte(0x47+iteration*17)), 10, packetLength + }, + makeMicrophone: func(iteration int) ([]byte, int, uint32) { + return patternedPCM(dualshock4.USBMicrophoneClientFrameSize, + byte(0x19+iteration*11)), 10, dualshock4.USBMicrophoneMaxPacketSize + }, + makeHID: func() []byte { + return []byte{dualshock4.ReportIDOutput, 0, 0, 0, 0x29, 0xc8, 0x12, 0x56, 0x9a, 4, 8} + }, + setInputMarker: func(marker int8) { + state := dualshock4.NewInputState() + state.LX = marker + state.Buttons = dualshock4.ButtonCross | dualshock4.ButtonR3 + native.UpdateInputState(state) + }, + inputMarker: func(payload []byte) int8 { return int8(int16(payload[1]) - 128) }, + requireParity: func(t *testing.T) { + requireSynchronizedDualShock4Parity(t, legacyCapture.snapshot(), nativeCapture.snapshot()) + }, + requireFinalCounts: func(t *testing.T, mediaFrames, stateReports int) { + for label, snapshot := range map[string]dualShock4CaptureSnapshot{ + "oracle": legacyCapture.snapshot(), "native": nativeCapture.snapshot(), + } { + if len(snapshot.speaker) != mediaFrames || len(snapshot.outputs) != stateReports { + t.Fatalf("DualShock 4 %s final delivery totals: speaker=%d/%d state=%d/%d", + label, len(snapshot.speaker), mediaFrames, + len(snapshot.outputs), stateReports) + } + } + }, + } + ds4Case.speakerMeta = nativeSoakEndpointMetadata(t, native, ds4Case.speakerEP) + ds4Case.microphoneMeta = nativeSoakEndpointMetadata(t, native, ds4Case.microphoneEP) + ds4Case.hidInMeta = nativeSoakEndpointMetadata(t, native, ds4Case.hidInEP) + ds4Case.hidOutMeta = nativeSoakEndpointMetadata(t, native, ds4Case.hidOutEP) + return append(cases, ds4Case) +} + +type nativeSoakIsoExpectation struct { + payload []byte + transferLength uint32 + packets []udecx.IsoPacket +} + +func readOracleIsoPackets(t *testing.T, dev usbdevice.Device, endpoint uint8, + packets []udecx.IsoPacket, +) nativeSoakIsoExpectation { + t.Helper() + reader, ok := dev.(usbdevice.IsochronousInputDevice) + if !ok { + t.Fatalf("%T does not expose its immutable caller-buffer ISO-IN contract", dev) + } + expectation := nativeSoakIsoExpectation{packets: make([]udecx.IsoPacket, len(packets))} + for _, packet := range packets { + end := packet.Offset + packet.Length + if uint32(len(expectation.payload)) < end { + expectation.payload = append(expectation.payload, + make([]byte, int(end)-len(expectation.payload))...) + } + } + for index, packet := range packets { + region := expectation.payload[packet.Offset : packet.Offset+packet.Length] + written, err := reader.ReadIsochronousInput( + context.Background(), uint32(endpoint&0x0f), region) + if err != nil { + t.Fatalf("%T oracle ISO-IN packet %d: %v", dev, index, err) + } + if written < 0 || written > len(region) { + t.Fatalf("%T oracle ISO-IN packet %d wrote %d bytes into %d bytes", + dev, index, written, len(region)) + } + expectation.packets[index] = udecx.IsoPacket{ + Offset: packet.Offset, Length: uint32(written), + } + expectation.transferLength += uint32(written) + } + return expectation +} + +func nativeSoakIsoPacketsEqual(got, want []udecx.IsoPacket) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} + +func nativeSoakByteDifference(got, want []byte) string { + limit := min(len(got), len(want)) + index := 0 + for index < limit && got[index] == want[index] { + index++ + } + if index == limit { + if len(got) == len(want) { + return "none" + } + return fmt.Sprintf("length boundary %d (got=%d want=%d)", index, len(got), len(want)) + } + start := max(0, index-8) + end := min(limit, index+9) + return fmt.Sprintf("offset %d got[%d:%d]=%x want[%d:%d]=%x", + index, start, end, got[start:end], start, end, want[start:end]) +} + +func requireSynchronizedDualSenseParity( + t *testing.T, legacy, native dualSenseCaptureSnapshot, +) { + t.Helper() + if legacy.resets != native.resets || len(legacy.outputs) != len(native.outputs) || + len(legacy.atomic) != len(native.atomic) || len(legacy.realtime) != len(native.realtime) { + t.Fatalf("DualSense transport callbacks differ: legacy outputs=%d atomic=%d realtime=%d resets=%d; native outputs=%d atomic=%d realtime=%d resets=%d", + len(legacy.outputs), len(legacy.atomic), len(legacy.realtime), legacy.resets, + len(native.outputs), len(native.atomic), len(native.realtime), native.resets) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualSense HID output %d changed across native transport", index) + } + } + for index := range legacy.atomic { + if legacy.atomic[index].feedback != native.atomic[index].feedback || + !bytes.Equal(legacy.atomic[index].speaker, native.atomic[index].speaker) { + t.Fatalf("DualSense atomic media frame %d changed or was reordered", index) + } + } + for index := range legacy.realtime { + if legacy.realtime[index] != native.realtime[index] { + t.Fatalf("DualSense realtime haptics frame %d changed or was reordered", index) + } + } +} + +func requireSynchronizedDualShock4Parity( + t *testing.T, legacy, native dualShock4CaptureSnapshot, +) { + t.Helper() + if legacy.resets != native.resets || len(legacy.outputs) != len(native.outputs) || + len(legacy.speaker) != len(native.speaker) { + t.Fatalf("DualShock 4 transport callbacks differ: legacy outputs=%d speaker=%d resets=%d; native outputs=%d speaker=%d resets=%d", + len(legacy.outputs), len(legacy.speaker), legacy.resets, + len(native.outputs), len(native.speaker), native.resets) + } + for index := range legacy.outputs { + if legacy.outputs[index] != native.outputs[index] { + t.Fatalf("DualShock 4 HID output %d changed across native transport", index) + } + } + for index := range legacy.speaker { + if !bytes.Equal(legacy.speaker[index], native.speaker[index]) { + t.Fatalf("DualShock 4 speaker frame %d changed or was reordered", index) + } + } +} + +func nativeSoakIsoOperation(meta udecx.Operation, payload []byte, packetCount int, + packetLength uint32, input bool, +) udecx.Operation { + op := udecx.Operation{ + Kind: udecx.OperationTransfer, TransferLength: uint32(packetCount) * packetLength, + TransferFlags: udecx.TransferFlagStartIsoASAP, + IsoPackets: make([]udecx.IsoPacket, packetCount), Payload: append([]byte(nil), payload...), + } + copyNativeEndpointMetadata(&op, meta) + for index := range op.IsoPackets { + op.IsoPackets[index] = udecx.IsoPacket{ + Offset: uint32(index) * packetLength, Length: packetLength, + } + } + if input { + op.Direction = 1 + op.TransferFlags |= udecx.TransferFlagDirectionIn + } + return op +} + +func submitAndWaitNativeSoakOperation(t *testing.T, driver *nativePlayStationSoakDriver, + identity udecx.DeviceIdentity, op udecx.Operation, acknowledged bool, +) udecx.Completion { + t.Helper() + token, waiter := driver.submit(identity, op, acknowledged) + if waiter == nil { + return udecx.Completion{} + } + return driver.wait(t, token, waiter) +} + +func setNativeSoakEndpointState(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, kind udecx.OperationKind, +) { + t.Helper() + for _, meta := range []udecx.Operation{ + soakCase.speakerMeta, soakCase.microphoneMeta, soakCase.hidInMeta, soakCase.hidOutMeta, + } { + op := udecx.Operation{Kind: kind} + copyNativeEndpointMetadata(&op, meta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, true) + if completion.Status != 0 || completion.USBDStatus != 0 { + t.Fatalf("%s endpoint 0x%02x lifecycle %d failed: %+v", + soakCase.name, op.EndpointAddress, kind, completion) + } + } +} + +func primeNativeSoakMicrophone(soakCase *nativePlayStationSoakCase, seed int) { + for frame := 0; frame < 6; frame++ { + payload, _, _ := soakCase.makeMicrophone(seed + frame) + soakCase.queueMicrophone(payload) + } +} + +func runNativePlayStationMediaPhase(t *testing.T, driver *nativePlayStationSoakDriver, + cases []*nativePlayStationSoakCase, phase, cycles int, +) { + t.Helper() + for _, soakCase := range cases { + primeNativeSoakMicrophone(soakCase, phase*1000) + report := soakCase.makeHID() + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, false) + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + t.Fatalf("%s initial HID completion=%+v", soakCase.name, completion) + } + } + + start := make(chan struct{}) + errorsCh := make(chan error, len(cases)*3) + var workers sync.WaitGroup + for caseIndex, soakCase := range cases { + caseIndex, soakCase := caseIndex, soakCase + workers.Add(3) + go func() { + defer workers.Done() + <-start + for iteration := 0; iteration < cycles; iteration++ { + absolute := phase*cycles + iteration + caseIndex*97 + payload, packetCount, packetLength := soakCase.makeSpeaker(absolute) + soakCase.legacySpeaker(payload) + op := nativeSoakIsoOperation(soakCase.speakerMeta, payload, + packetCount, packetLength, false) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s speaker %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != uint32(len(payload)) || + len(completion.Payload) != 0 || len(completion.IsoPackets) != packetCount { + errorsCh <- fmt.Errorf("%s speaker %d malformed completion: %+v", + soakCase.name, iteration, completion) + return + } + } + }() + go func() { + defer workers.Done() + <-start + for iteration := 0; iteration < cycles; iteration++ { + absolute := phase*cycles + iteration + caseIndex*131 + frame, packetCount, packetLength := soakCase.makeMicrophone(absolute + 6) + soakCase.queueMicrophone(frame) + packets := make([]udecx.IsoPacket, packetCount) + for index := range packets { + packets[index] = udecx.IsoPacket{Offset: uint32(index) * packetLength, Length: packetLength} + } + want := soakCase.oracleMicrophone(packets) + op := nativeSoakIsoOperation(soakCase.microphoneMeta, nil, + packetCount, packetLength, true) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s microphone %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != want.transferLength || + !bytes.Equal(completion.Payload, want.payload) || + !nativeSoakIsoPacketsEqual(completion.IsoPackets, want.packets) { + errorsCh <- fmt.Errorf("%s microphone %d changed packet contract: got=%d/%v want=%d/%v; %s", + soakCase.name, iteration, completion.TransferLength, completion.IsoPackets, + want.transferLength, want.packets, + nativeSoakByteDifference(completion.Payload, want.payload)) + return + } + } + }() + go func() { + defer workers.Done() + <-start + report := soakCase.makeHID() + for iteration := 0; iteration < cycles; iteration++ { + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + token, waiter := driver.submit(soakCase.identity, op, false) + completion, err := driver.waitFromWorker(token, waiter) + if err != nil { + errorsCh <- fmt.Errorf("%s HID %d: %w", soakCase.name, iteration, err) + return + } + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + errorsCh <- fmt.Errorf("%s HID %d malformed completion: %+v", + soakCase.name, iteration, completion) + return + } + } + }() + } + close(start) + workers.Wait() + close(errorsCh) + for err := range errorsCh { + t.Error(err) + } + for _, soakCase := range cases { + soakCase.requireParity(t) + } +} + +func requireNativeInputContinuity(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, +) { + t.Helper() + reports := driver.inputSnapshot(soakCase.identity) + if len(reports) < 2 { + t.Fatalf("%s published only %d native HID input reports", soakCase.name, len(reports)) + } + for index, report := range reports { + wantSequence := uint64(index + 1) + if report.DeviceID != soakCase.identity.DeviceID || + report.Generation != soakCase.identity.Generation || + report.EndpointAddress != soakCase.hidInEP || report.Sequence != wantSequence || + len(report.Payload) != 64 { + t.Fatalf("%s native input report %d=%+v len=%d want sequence=%d endpoint=0x%02x", + soakCase.name, index, report, len(report.Payload), wantSequence, soakCase.hidInEP) + } + } +} + +func waitForNativeInputMarker(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, marker int8, +) { + t.Helper() + deadline := time.Now().Add(nativePlayStationSoakTimeout) + for time.Now().Before(deadline) { + reports := driver.inputSnapshot(soakCase.identity) + if len(reports) != 0 && soakCase.inputMarker(reports[len(reports)-1].Payload) == marker { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("%s never published post-lifecycle input marker %d", soakCase.name, marker) +} + +func exerciseNativeD0Boundary(t *testing.T, driver *nativePlayStationSoakDriver, + soakCase *nativePlayStationSoakCase, +) { + t.Helper() + exit := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceD0Exit}, true) + if exit.Status != 0 { + t.Fatalf("%s D0 exit failed: %+v", soakCase.name, exit) + } + before := len(driver.inputSnapshot(soakCase.identity)) + soakCase.setInputMarker(63) + time.Sleep(4 * time.Millisecond) + after := len(driver.inputSnapshot(soakCase.identity)) + if after != before { + t.Fatalf("%s published %d stale input reports after acknowledged D0 exit", + soakCase.name, after-before) + } + entry := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceD0Entry}, true) + if entry.Status != 0 { + t.Fatalf("%s D0 entry failed: %+v", soakCase.name, entry) + } + waitForNativeInputMarker(t, driver, soakCase, 63) +} + +func exerciseNativeCancellationBoundary(t *testing.T, driver *nativePlayStationSoakDriver, + gate *nativePlayStationCancelGate, soakCase *nativePlayStationSoakCase, +) { + t.Helper() + for _, meta := range []udecx.Operation{ + soakCase.speakerMeta, soakCase.microphoneMeta, soakCase.hidOutMeta, + } { + packetCount, packetLength := 10, uint32(0) + var op udecx.Operation + if meta.EndpointAddress == soakCase.speakerEP { + payload, count, length := soakCase.makeSpeaker(0x513) + op = nativeSoakIsoOperation(meta, payload, count, length, false) + } else if meta.EndpointAddress == soakCase.microphoneEP { + _, packetCount, packetLength = soakCase.makeMicrophone(0x517) + op = nativeSoakIsoOperation(meta, nil, packetCount, packetLength, true) + } else { + payload := soakCase.makeHID() + op = udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(payload)), Payload: payload} + copyNativeEndpointMetadata(&op, meta) + } + started, result := gate.arm(soakCase.identity, meta.EndpointAddress) + token := driver.submitCancellable(soakCase.identity, op) + select { + case <-started: + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("%s cancelled endpoint 0x%02x never reached native adapter gate", + soakCase.name, meta.EndpointAddress) + } + driver.cancel(soakCase.identity, token, meta.EndpointAddress) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("%s cancelled endpoint 0x%02x returned %v", + soakCase.name, meta.EndpointAddress, err) + } + case <-time.After(nativePlayStationSoakTimeout): + t.Fatalf("%s cancelled endpoint 0x%02x did not leave native adapter", + soakCase.name, meta.EndpointAddress) + } + } + + // A valid state write on the same device proves both canceled endpoint + // sequences retired and no stale completion or media callback blocked the + // following generation. Callback parity proves the canceled speaker frame + // itself was not published. + report := soakCase.makeHID() + soakCase.legacyHID(report) + op := udecx.Operation{Kind: udecx.OperationTransfer, + TransferLength: uint32(len(report)), Payload: append([]byte(nil), report...)} + copyNativeEndpointMetadata(&op, soakCase.hidOutMeta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, false) + if completion.TransferLength != uint32(len(report)) || len(completion.Payload) != 0 { + t.Fatalf("%s post-cancel HID completion=%+v", soakCase.name, completion) + } + soakCase.requireParity(t) +} + +func runNativePlayStationSoakSession(t *testing.T, session int) { + t.Helper() + driver := newNativePlayStationSoakDriver() + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + cancelGate := &nativePlayStationCancelGate{inner: processor} + host, err := udecx.NewHost(driver, cancelGate, 8) + if err != nil { + t.Fatal(err) + } + cases := makeNativePlayStationSoakCases(t) + for _, soakCase := range cases { + identity, registerErr := host.Register(context.Background(), soakCase.identity.DeviceID, + soakCase.native) + if registerErr != nil { + t.Fatal(registerErr) + } + soakCase.identity = identity + } + serveCtx, stopServe := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + soakCase.setInputMarker(int8(10 + session)) + waitForNativeInputMarker(t, driver, soakCase, int8(10+session)) + } + + const phaseCycles = 12 + mediaStarted := time.Now() + runNativePlayStationMediaPhase(t, driver, cases, 0, phaseCycles) + for _, soakCase := range cases { + exerciseNativeCancellationBoundary(t, driver, cancelGate, soakCase) + } + + // Endpoint reset must retire the previous audio generation without losing + // the selected interfaces or allowing stale PCM across the boundary. + for _, soakCase := range cases { + for _, endpoint := range []uint8{soakCase.speakerEP, soakCase.microphoneEP} { + soakCase.resetLegacyEndpoint(endpoint) + meta := soakCase.speakerMeta + if endpoint == soakCase.microphoneEP { + meta = soakCase.microphoneMeta + } + op := udecx.Operation{Kind: udecx.OperationEndpointReset} + copyNativeEndpointMetadata(&op, meta) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, op, true) + if completion.Status != 0 { + t.Fatalf("%s endpoint reset 0x%02x failed: %+v", soakCase.name, endpoint, completion) + } + } + soakCase.requireParity(t) + exerciseNativeD0Boundary(t, driver, soakCase) + } + runNativePlayStationMediaPhase(t, driver, cases, 1, phaseCycles) + + // A real device reset closes every selected audio interface. Re-open the + // exact descriptors and prove fresh frames cannot inherit the old media or + // microphone generation. + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(false) + completion := submitAndWaitNativeSoakOperation(t, driver, soakCase.identity, + udecx.Operation{Kind: udecx.OperationDeviceReset}, true) + if completion.Status != 0 { + t.Fatalf("%s device reset failed: %+v", soakCase.name, completion) + } + soakCase.requireParity(t) + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + } + runNativePlayStationMediaPhase(t, driver, cases, 2, phaseCycles) + + // Purge completion is the documented UdeCx boundary: all old forwarded I/O + // is terminal before start. Exercise it after sustained duplex traffic and + // require the new generation to continue with no stale or duplicate bytes. + for _, soakCase := range cases { + soakCase.setLegacyAudioActive(false) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointPurge) + soakCase.requireParity(t) + soakCase.setLegacyAudioActive(true) + setNativeSoakEndpointState(t, driver, soakCase, udecx.OperationEndpointStart) + } + runNativePlayStationMediaPhase(t, driver, cases, 3, phaseCycles) + mediaElapsed := time.Since(mediaStarted) + minimumCadence := time.Duration(4*phaseCycles*9) * time.Millisecond + if mediaElapsed < minimumCadence { + t.Fatalf("native media soak collapsed USB service cadence: elapsed=%s minimum=%s", + mediaElapsed, minimumCadence) + } + if mediaElapsed > 3*time.Second { + t.Fatalf("native media soak exceeded continuity deadline: elapsed=%s", mediaElapsed) + } + + for _, soakCase := range cases { + requireNativeInputContinuity(t, driver, soakCase) + soakCase.requireFinalCounts(t, 4*phaseCycles, 4*(phaseCycles+1)+1) + } + driver.requireClean(t) + + for _, soakCase := range cases { + unregisterCtx, cancel := context.WithTimeout(context.Background(), nativePlayStationSoakTimeout) + if err = host.Unregister(unregisterCtx, soakCase.identity); err != nil { + cancel() + t.Fatal(err) + } + cancel() + } + stopServe() + select { + case err = <-serveDone: + if err != nil { + t.Fatalf("native host session shutdown: %v", err) + } + case <-time.After(nativePlayStationSoakTimeout): + t.Fatal("native host session did not stop after broker close") + } + + // No publisher from the retired owner may survive into the next broker + // session. The next outer iteration uses the same stable device IDs and must + // begin again at input sequence one with fresh controller objects. + for _, soakCase := range cases { + before := len(driver.inputSnapshot(soakCase.identity)) + soakCase.setInputMarker(-51) + time.Sleep(2 * time.Millisecond) + if after := len(driver.inputSnapshot(soakCase.identity)); after != before { + t.Fatalf("%s retired broker published %d zombie input reports", + soakCase.name, after-before) + } + } +} + +func TestNativePlayStationTransportZeroDropoutFaultSoak(t *testing.T) { + // Four complete owner sessions cover reconnect and generation teardown while + // carrying 192 ten-millisecond speaker/haptics and microphone intervals per + // controller through resets, D0, purge/start, HID feedback, and fast input. + // The established USB/IP engines are the content oracle throughout. + for session := 0; session < 4; session++ { + t.Run(fmt.Sprintf("broker_session_%d", session+1), func(t *testing.T) { + runNativePlayStationSoakSession(t, session) + }) + } +} + +func TestNativePlayStationCancelledLifecycleDoesNotMutateController(t *testing.T) { + processor, err := serverusb.NewNativeProcessor( + serverusb.New(serverusb.ServerConfig{}, slog.Default(), nil)) + if err != nil { + t.Fatal(err) + } + for _, soakCase := range makeNativePlayStationSoakCases(t) { + t.Run(soakCase.name, func(t *testing.T) { + soakCase.identity.Generation = 1 + soakCase.setLegacyAudioActive(true) + for _, meta := range []udecx.Operation{soakCase.speakerMeta, soakCase.microphoneMeta} { + op := udecx.Operation{Kind: udecx.OperationEndpointStart, + DeviceID: soakCase.identity.DeviceID, Generation: soakCase.identity.Generation} + copyNativeEndpointMetadata(&op, meta) + if lifecycleErr := processor.Lifecycle(context.Background(), soakCase.native, op); lifecycleErr != nil { + t.Fatal(lifecycleErr) + } + } + soakCase.requireParity(t) + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + reset := udecx.Operation{Kind: udecx.OperationEndpointReset, + DeviceID: soakCase.identity.DeviceID, Generation: soakCase.identity.Generation} + copyNativeEndpointMetadata(&reset, soakCase.speakerMeta) + if lifecycleErr := processor.Lifecycle(cancelledCtx, soakCase.native, reset); !errors.Is(lifecycleErr, context.Canceled) { + t.Fatalf("cancelled lifecycle returned %v", lifecycleErr) + } + soakCase.requireParity(t) + + soakCase.resetLegacyEndpoint(soakCase.speakerEP) + if lifecycleErr := processor.Lifecycle(context.Background(), soakCase.native, reset); lifecycleErr != nil { + t.Fatal(lifecycleErr) + } + soakCase.requireParity(t) + }) + } +} From 790cec0e171cf9da170d63012b6864b0076f90b7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 05:38:50 -0500 Subject: [PATCH 163/240] Fence native broker terminal scheduling Reject new broker admissions and publication after lifecycle notification loss faults the owner session, while still waking the terminal fault notification for an already-waiting dequeue. Account for requests canceled while parked on the manual dequeue queue so diagnostics cannot retain phantom workers. Add deterministic mixed-controller control, HID, speaker ISO, and microphone ISO fairness coverage with cancellation pressure. PlayStation payload and cadence semantics are unchanged. --- .../udecx/driver_dispatch_contract_test.go | 166 ++++++++++++++++++ native/udecx/driver/Broker.c | 70 +++++++- native/udecx/driver/Controller.c | 19 ++ native/udecx/driver/ViiperUde.h | 1 + 4 files changed, 248 insertions(+), 8 deletions(-) diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 02c1d0a9..5692e6c2 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -249,3 +249,169 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { t.Fatal("cached input no longer transfers terminal completion to the shared DPC") } } + +func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "ControllerContext->BrokerFaulted", + "pending->Request = Request;") + + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchAvailable")) + requireContractOrder(t, dispatch, + "ViiperDispatchNotificationEvents(Controller);", + "controllerContext->BrokerFaulted", + "controllerContext->NextDispatchSlot + index") + + cancel := normalizedContract(nativeCFunction(t, broker, "ViiperQueueCancelEventLocked")) + requireContractOrder(t, cancel, + "if (!Pending->PublishedToOwner)", + "ControllerContext->BrokerFaulted", + "ControllerContext->NotificationCount") + + for _, function := range []string{ + "ViiperQueueEndpointLifecycleEvent", + "ViiperQueueDeviceLifecycleEvent", + "ViiperQueueInterfaceLifecycleEvent", + } { + lifecycle := normalizedContract(nativeCFunction(t, broker, function)) + requireContractOrder(t, lifecycle, + "active = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "queued = active &&", + "faulted = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (queued || faulted)", + "ViiperDispatchNotificationEvents(deviceContext->Controller);") + } + + acknowledged := normalizedContract(nativeCFunction(t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, acknowledged, + "controllerContext->BrokerFaulted", + "ViiperFaultBrokerLocked(controllerContext)", + "faulted = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (NT_SUCCESS(status) || faulted)", + "ViiperDispatchNotificationEvents(deviceContext->Controller);") +} + +func TestNativeManualDequeueCancellationRetiresAccounting(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + createQueues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, createQueues, + "WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual);", + "queueConfig.EvtIoCanceledOnQueue = ViiperEvtDequeueCanceledOnQueue;", + "WdfIoQueueCreate(Device, &queueConfig") + if !strings.Contains(header, + "EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue;") { + t.Fatal("manual dequeue cancellation callback lost its KMDF declaration") + } + cancel := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDequeueCanceledOnQueue")) + requireContractOrder(t, cancel, + "InterlockedDecrement(&context->WaitingDequeueCount);", + "NT_ASSERT(remaining >= 0);", + "WdfRequestComplete(Request, STATUS_CANCELLED);") +} + +func TestNativeBrokerMixedLaneFairnessModel(t *testing.T) { + // Exercise the exact round-robin slot selection and per-endpoint-head rule + // with control, HID/state, speaker ISO, and microphone ISO traffic from + // several controllers. Deterministic head cancellations model purge/reset + // pressure while proving that an unrelated endpoint is never starved. + const slots = 4096 + type laneKey struct { + device int + endpoint byte + } + type admission struct { + lane laneKey + sequence int + queued bool + linked bool + } + + endpoints := []byte{0x00, 0x01, 0x02, 0x82} + pending := make([]admission, slots) + queues := make(map[laneKey][]int) + allocated := 0 + for round := 1; round <= 8; round++ { + for device := 0; device < 8; device++ { + for _, endpoint := range endpoints { + lane := laneKey{device: device, endpoint: endpoint} + pending[allocated] = admission{ + lane: lane, sequence: round, queued: true, linked: true, + } + queues[lane] = append(queues[lane], allocated) + allocated++ + } + } + } + + // Cancel selected heads before dispatch, exactly like the kernel unlink + // transition: remove the old head and expose its same-endpoint successor. + for lane, queue := range queues { + if (lane.device+int(lane.endpoint))%7 == 0 { + pending[queue[0]].linked = false + pending[queue[0]].queued = false + queues[lane] = queue[1:] + } + } + + cursor := 0 + delivered := make(map[laneKey][]int) + remaining := 0 + for _, queue := range queues { + remaining += len(queue) + } + maxInspections := 0 + totalInspections := 0 + for remaining != 0 { + selected := -1 + inspections := 0 + for offset := 0; offset < slots; offset++ { + inspections++ + candidate := (cursor + offset) % slots + item := pending[candidate] + queue := queues[item.lane] + if item.queued && item.linked && len(queue) != 0 && queue[0] == candidate { + selected = candidate + break + } + } + if selected < 0 { + t.Fatalf("mixed native traffic stranded %d endpoint admissions", remaining) + } + if inspections > maxInspections { + maxInspections = inspections + } + totalInspections += inspections + item := pending[selected] + delivered[item.lane] = append(delivered[item.lane], item.sequence) + queue := queues[item.lane] + queues[item.lane] = queue[1:] + pending[selected].linked = false + pending[selected].queued = false + cursor = (selected + 1) % slots + remaining-- + } + + for lane, sequences := range delivered { + for index := 1; index < len(sequences); index++ { + if sequences[index] != sequences[index-1]+1 { + t.Fatalf("lane %+v lost FIFO order: %v", lane, sequences) + } + } + } + if len(delivered) != 8*len(endpoints) { + t.Fatalf("only %d/%d independent lanes made progress", len(delivered), 8*len(endpoints)) + } + // The independent allocation/dispatch cursors make every healthy admission + // the first inspected slot. Each of the four deliberately canceled heads + // costs one extra inspection, never a controller-table wrap. + if maxInspections != 2 || totalInspections != allocated { + t.Fatalf("mixed native traffic inspected max=%d total=%d, want max=2 total=%d", + maxInspections, totalInspections, allocated) + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index d1589860..24b0c120 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -79,6 +79,14 @@ ViiperQueueCancelEventLocked( if (!Pending->PublishedToOwner) { return FALSE; } + // BrokerFaulted is a terminal owner-session boundary. The one broker + // fault notification already tells user mode to tear down; admitting more + // cancel records after it can only delay that terminal record and consume + // the queue capacity reserved for lifecycle ordering. + if (InterlockedCompareExchange( + &ControllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + return FALSE; + } // Keep one slot reserved for a broker-fault event. Losing cancellation or // lifecycle state is not recoverable within the current owner session. if (ControllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { @@ -785,12 +793,16 @@ ViiperQueueEndpointLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; BOOLEAN active; BOOLEAN queued; + BOOLEAN faulted; WdfSpinLockAcquire(controllerContext->BrokerLock); - active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; queued = active && ViiperQueueLifecycleEventLocked( controllerContext, @@ -800,14 +812,25 @@ ViiperQueueEndpointLifecycleEvent( 0, 0, 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + // Queue overflow can publish the terminal broker-fault record instead + // of this lifecycle event. Dispatch that record even though the + // original insertion failed, otherwise already-waiting dequeue IOCTLs + // can remain parked forever with the fault hidden behind them. + // Lifecycle publication has priority over ordinary URBs. Wake only + // the notification path here so a purge/reset callback cannot also + // publish unrelated media merely because it reported a boundary. + ViiperDispatchNotificationEvents(deviceContext->Controller); + } if (!active) { return STATUS_DEVICE_NOT_READY; } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } - ViiperDispatchNotificationEvents(deviceContext->Controller); return STATUS_SUCCESS; } @@ -820,23 +843,31 @@ ViiperQueueDeviceLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; BOOLEAN active; BOOLEAN queued; + BOOLEAN faulted; WdfSpinLockAcquire(controllerContext->BrokerLock); - active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; queued = active && ViiperQueueLifecycleEventLocked( controllerContext, deviceContext, NULL, Kind, 0, 0, 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } if (!active) { return STATUS_DEVICE_NOT_READY; } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } - ViiperDispatchNotificationEvents(deviceContext->Controller); return STATUS_SUCCESS; } @@ -850,12 +881,16 @@ ViiperQueueInterfaceLifecycleEvent( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN ownerActive; BOOLEAN active; BOOLEAN queued; + BOOLEAN faulted; WdfSpinLockAcquire(controllerContext->BrokerLock); - active = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + ownerActive = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && ViiperLifecycleOwnerSessionActiveLocked(deviceContext); + active = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; queued = active && ViiperQueueLifecycleEventLocked( controllerContext, @@ -865,14 +900,18 @@ ViiperQueueInterfaceLifecycleEvent( InterfaceNumber, InterfaceSetting, 0); + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); + if (queued || faulted) { + ViiperDispatchNotificationEvents(deviceContext->Controller); + } if (!active) { return STATUS_DEVICE_NOT_READY; } if (!queued) { return STATUS_INSUFFICIENT_RESOURCES; } - ViiperDispatchNotificationEvents(deviceContext->Controller); return STATUS_SUCCESS; } @@ -894,15 +933,18 @@ ViiperQueueAcknowledgedLifecycleEvent( ULONG offset; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; BOOLEAN canAllocate = TRUE; + BOOLEAN ownerActive = FALSE; + BOOLEAN faulted = FALSE; if (Endpoint != WDF_NO_HANDLE) { descriptor = &ViiperGetEndpointContext(Endpoint)->Descriptor; } WdfSpinLockAcquire(controllerContext->BrokerLock); + ownerActive = ViiperLifecycleOwnerSessionActiveLocked(deviceContext); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || - !ViiperLifecycleOwnerSessionActiveLocked(deviceContext)) { + !ownerActive) { status = STATUS_DEVICE_NOT_READY; canAllocate = FALSE; } else if (controllerContext->NotificationCount >= @@ -957,12 +999,14 @@ ViiperQueueAcknowledgedLifecycleEvent( status = STATUS_SUCCESS; break; } + faulted = ownerActive && InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); if (status == STATUS_INSUFFICIENT_RESOURCES) { InterlockedIncrement64(&controllerContext->QueueExhaustions); } - if (NT_SUCCESS(status)) { + if (NT_SUCCESS(status) || faulted) { ViiperDispatchNotificationEvents(deviceContext->Controller); } return status; @@ -1024,6 +1068,7 @@ ViiperAllocatePendingSlot( WdfSpinLockAcquire(ControllerContext->BrokerLock); if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&ControllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || @@ -1772,6 +1817,15 @@ ViiperDispatchAvailable( WdfSpinLockRelease(controllerContext->BrokerLock); break; } + // Once lifecycle notification loss faults the owner session, only the + // notification FIFO may drain. Publishing another control/media URB + // would cross a reset or power boundary which user mode can no longer + // reconstruct. + if (InterlockedCompareExchange( + &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE) { + WdfSpinLockRelease(controllerContext->BrokerLock); + break; + } for (index = 0; index < VIIPER_UDE_MAX_PENDING_OPERATIONS; ++index) { ULONG candidate = (controllerContext->NextDispatchSlot + index) % VIIPER_UDE_MAX_PENDING_OPERATIONS; diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index b0698fcb..e7d2035d 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -494,5 +494,24 @@ ViiperCreateQueues( WDF_IO_QUEUE_CONFIG_INIT(&queueConfig, WdfIoQueueDispatchManual); queueConfig.PowerManaged = WdfFalse; + // Overlapped dequeue IOCTLs are routinely cancelled when a host worker is + // retired. KMDF removes those requests from a manual queue without a + // retrieve call, so account for that ownership path explicitly instead of + // leaving WaitingDequeueCount permanently inflated for the owner session. + queueConfig.EvtIoCanceledOnQueue = ViiperEvtDequeueCanceledOnQueue; return WdfIoQueueCreate(Device, &queueConfig, &attributes, &context->WaitingDequeues); } + +VOID +ViiperEvtDequeueCanceledOnQueue( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + LONG remaining = InterlockedDecrement(&context->WaitingDequeueCount); + + NT_ASSERT(remaining >= 0); + WdfRequestComplete(Request, STATUS_CANCELLED); +} diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 24e4374c..b92ad01d 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -246,6 +246,7 @@ EVT_UDECX_USB_ENDPOINT_START ViiperEvtEndpointStart; EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; +EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue; EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; From d9096c7898bfeb00b8e073d099ba79103b0a9906 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 05:49:41 -0500 Subject: [PATCH 164/240] Make native latency benchmark production-authentic --- _testing/e2e/bench_test.go | 41 ++++++++++++++++++++++++++++++++++--- docs/testing/e2e_latency.md | 26 +++++++++++++++-------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index c036e243..e2b65a75 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "strings" "syscall" "testing" @@ -33,6 +34,7 @@ const ( ) const e2eTransportEnvironment = "VIIPER_E2E_TRANSPORT" +const e2eBenchmarkPassword = "testpassword1234" func selectedE2ETransport() (string, error) { transport := strings.ToLower(strings.TrimSpace(os.Getenv(e2eTransportEnvironment))) @@ -46,6 +48,22 @@ func selectedE2ETransport() (string, error) { return transport, nil } +func benchmarkAuthModeSupported(transport string, encrypted bool) bool { + return transport != "native-ude" || encrypted +} + +func TestNativeE2EBenchmarkRequiresProductionAuthentication(t *testing.T) { + if benchmarkAuthModeSupported("native-ude", false) { + t.Fatal("native UDE benchmark accepted an unauthenticated stream") + } + if !benchmarkAuthModeSupported("native-ude", true) { + t.Fatal("native UDE benchmark rejected an authenticated stream") + } + if !benchmarkAuthModeSupported("usbip", false) { + t.Fatal("legacy USB/IP benchmark unexpectedly rejected its plaintext baseline") + } +} + func Benchmark_Xbox360_Delay(b *testing.B) { transport, err := selectedE2ETransport() if err != nil { @@ -168,6 +186,18 @@ func Benchmark_Xbox360_Delay(b *testing.B) { useEncryption: true, }, } + if transport == "native-ude" { + // The native broker owns local kernel topology and therefore requires + // authenticated localhost streams. Do not silently benchmark an + // unsupported plaintext path and label its failure as transport latency. + authenticated := benches[:0] + for _, candidate := range benches { + if benchmarkAuthModeSupported(transport, candidate.useEncryption) { + authenticated = append(authenticated, candidate) + } + } + benches = authenticated + } b.SetParallelism(1) @@ -183,6 +213,10 @@ func Benchmark_Xbox360_Delay(b *testing.B) { existingGamepadSet[id] = true } + credentialPath := filepath.Join(b.TempDir(), "viiper.key.txt") + if err := os.WriteFile(credentialPath, []byte(e2eBenchmarkPassword), 0o600); err != nil { + b.Fatalf("write benchmark credential: %v", err) + } s := cmd.Server{ USBServerConfig: usb.ServerConfig{ Addr: ":3244", @@ -192,13 +226,14 @@ func Benchmark_Xbox360_Delay(b *testing.B) { Addr: ":3245", AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, - Password: "testpassword1234", + Password: e2eBenchmarkPassword, PlatformOpts: api.PlatformOpts{ AutoAttachWindowsNative: true, }, }, ConnectionTimeout: 5 * time.Second, Transport: transport, + KeyFile: credentialPath, } logger := slog.Default() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -208,7 +243,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { serverDone <- s.StartServer(ctx, logger, nil) }() - client := viiperclient.New("localhost:3245") + client := viiperclient.NewWithPassword("localhost:3245", e2eBenchmarkPassword) var busResp *viipertypes.BusCreateResponse var createErr error for range 10 { @@ -259,7 +294,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { for _, bench := range benches { benchClient := viiperclient.New("localhost:3245") if bench.useEncryption { - benchClient = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") + benchClient = viiperclient.NewWithPassword("localhost:3245", e2eBenchmarkPassword) } devStream, openErr := benchClient.OpenStream(ctx, busID, devInfo.DevID) if openErr != nil { diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 4cf722da..47d69836 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -8,8 +8,11 @@ installed, set `VIIPER_E2E_TRANSPORT=native-ude` to run the identical API, controller, SDL, press, and release workload through the native bus. The benchmark never installs or trusts a driver. Invalid transport names, a server that exits during startup, input timeouts, and stream failures fail the run; -they are not reported as latency samples. Plain and encrypted cases open their -own matching API stream, so their labels describe the path actually measured. +they are not reported as latency samples. The harness writes and uses one +private, known benchmark credential rather than accidentally reading a stale +user credential. Plain and encrypted USB/IP cases open their own matching API +stream. Native UDE runs only the authenticated cases because production native +brokers deliberately reject unauthenticated localhost topology and streams. It groups repeated cycles when `-count > 1` and uses the single press E2E measurement (`E2E-InputDelay`) as the 100% baseline. @@ -46,13 +49,20 @@ Runs use a fixed-iteration benchtime (e.g. `-benchtime=1000x`, `-benchtime=10000 ## Running -From repository root: +From repository root: -```bash -cd testing/e2e -# Single run, 1000 fixed iterations per sub benchmark -go run ./scripts/lat_bench.go -benchtime=1000x -count=1 -format markdown -``` +```bash +# Single run, 1000 fixed iterations per sub benchmark +go run ./_testing/e2e/scripts/lat_bench.go -pkg ./_testing/e2e -benchtime=1000x -count=1 -format markdown +``` + +For the production native path, use the same workload and select the encrypted +results: + +```powershell +$env:VIIPER_E2E_TRANSPORT = 'native-ude' +go run ./_testing/e2e/scripts/lat_bench.go -pkg ./_testing/e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown +``` Results (Arch Linux / SteamDeck Kernel / Steam Deck LCD / Go 1.25+, 10k iterations): From 630270d6965989c9a48c081ebbe63050fb006be3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 05:52:59 -0500 Subject: [PATCH 165/240] Bump native driver contract and serialize mutex owners --- internal/cmd/native_mutex_windows.go | 29 +++++++++++++++++++ internal/cmd/native_package_windows.go | 2 +- .../cmd/native_service_install_windows.go | 2 +- internal/transport/udecx/protocol.go | 2 +- native/udecx/driver/Controller.c | 1 + native/udecx/driver/ViiperUde.vcxproj | 4 +-- native/udecx/package/ViiperUde.inf | 2 +- 7 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/native_mutex_windows.go diff --git a/internal/cmd/native_mutex_windows.go b/internal/cmd/native_mutex_windows.go new file mode 100644 index 00000000..a149a916 --- /dev/null +++ b/internal/cmd/native_mutex_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package cmd + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// createNamedNativeMutex normalizes the Win32 CreateMutex contract. A named +// mutex that already exists is a successful open: Windows returns its valid +// handle and ERROR_ALREADY_EXISTS, and WaitForSingleObject decides ownership. +func createNamedNativeMutex( + attributes *windows.SecurityAttributes, + name *uint16, +) (windows.Handle, error) { + handle, err := windows.CreateMutex(attributes, false, name) + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return 0, err + } + if handle == 0 { + if err != nil { + return 0, err + } + return 0, windows.ERROR_INVALID_HANDLE + } + return handle, nil +} diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index 64e98442..e2537e29 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -892,7 +892,7 @@ func acquireNamedNativePackageMutex(name string, timeout time.Duration) (func(), attributes := windows.SecurityAttributes{ Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, } - handle, err := windows.CreateMutex(&attributes, false, pointer) + handle, err := createNamedNativeMutex(&attributes, pointer) if err != nil { runtime.UnlockOSThread() return nil, err diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index d372c80c..f3ae3847 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -1283,7 +1283,7 @@ func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, } - handle, err := windows.CreateMutex(&attributes, false, name) + handle, err := createNamedNativeMutex(&attributes, name) if err != nil { runtime.UnlockOSThread() return nil, fmt.Errorf("create native install mutex: %w", err) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index dc74d5bc..0659b60c 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -18,7 +18,7 @@ const ( // shipped with this service. Runtime negotiation proves the installed // driver speaks the exact ABI below; package installation additionally // verifies this release version and its signed catalog. - DriverPackageVersion = "0.1.0.1" + DriverPackageVersion = "0.1.0.2" HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index e7d2035d..9b5f778e 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -513,5 +513,6 @@ ViiperEvtDequeueCanceledOnQueue( LONG remaining = InterlockedDecrement(&context->WaitingDequeueCount); NT_ASSERT(remaining >= 0); + UNREFERENCED_PARAMETER(remaining); WdfRequestComplete(Request, STATUS_CANCELLED); } diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 13cdb1af..290b4622 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,8 +13,8 @@ ViiperUde 17.0 x64 - 08/10/2026 - 0.1.0.1 + 08/11/2026 + 0.1.0.2 diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index d0079913..b4d595fd 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/10/2026,0.1.0.1 +DriverVer=08/11/2026,0.1.0.2 PnpLockDown=1 [DestinationDirs] From d65d0f25762158002f65fe2d01a59249dee196cb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:09:12 -0500 Subject: [PATCH 166/240] Remove native input and auth hot-path allocations --- device/dualsense/device.go | 30 +- device/dualsense/scheduled_input_test.go | 62 ++ device/dualshock4/device.go | 30 +- device/dualshock4/scheduled_input_test.go | 62 ++ device/keyboard/device.go | 28 + device/keyboard/scheduled_input_test.go | 47 ++ device/mouse/device.go | 28 + device/mouse/scheduled_input_test.go | 55 ++ device/ns2pro/device.go | 32 +- device/ns2pro/scheduled_input_test.go | 57 ++ device/xbox360/device.go | 30 +- device/xbox360/scheduled_input_test.go | 48 ++ docs/architecture/native-udecx.md | 19 + internal/server/api/auth/conn.go | 216 +++++- .../server/api/auth/conn_internal_test.go | 211 +++++ internal/server/api/auth/conn_test.go | 718 +++++++++++++++++- .../transport/udecx/deadline_bench_test.go | 28 + internal/transport/udecx/host.go | 115 ++- internal/transport/udecx/host_test.go | 544 ++++++++++++- usb/device.go | 23 +- 20 files changed, 2314 insertions(+), 69 deletions(-) create mode 100644 device/dualsense/scheduled_input_test.go create mode 100644 device/dualshock4/scheduled_input_test.go create mode 100644 device/keyboard/scheduled_input_test.go create mode 100644 device/mouse/scheduled_input_test.go create mode 100644 device/ns2pro/scheduled_input_test.go create mode 100644 device/xbox360/scheduled_input_test.go create mode 100644 internal/server/api/auth/conn_internal_test.go create mode 100644 internal/transport/udecx/deadline_bench_test.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 0d6f122f..ca3ce069 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -517,19 +517,47 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o // completed, so encoding here removes the per-sample report allocation without // changing USB/IP behavior. func (d *DualSense) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return d.readInterruptInput(ctx, nil, ep, dst) +} + +// ReadScheduledInterruptInput preserves the DualSense report encoder and its +// packet-counter/sensor-timestamp cadence while letting native UDE reuse one +// endpoint timer instead of allocating a context timer for every idle sample. +func (d *DualSense) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *DualSense) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep&0x0f != EndpointIn&0x0f { return 0, fmt.Errorf("DualSense interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } var is InputState select { case <-ctx.Done(): - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + d.mtx.Lock() + is = d.inputState + d.mtx.Unlock() + case <-deadline: + if ctx.Err() != nil { return 0, ctx.Err() } d.mtx.Lock() is = d.inputState d.mtx.Unlock() case is = <-d.inputCh: + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } } d.mtx.Lock() ms := *d.metaState diff --git a/device/dualsense/scheduled_input_test.go b/device/dualsense/scheduled_input_test.go new file mode 100644 index 00000000..8e009d34 --- /dev/null +++ b/device/dualsense/scheduled_input_test.go @@ -0,0 +1,62 @@ +package dualsense + +import ( + "context" + "encoding/binary" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesDualSenseStateAndCadence(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + state.LX, state.R2, state.Buttons = 23, 177, ButtonCross + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, err := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[6] != state.R2 || buffer[7] != 1 { + t.Fatalf("event state/counter=%x", buffer[:11]) + } + firstTimestamp := binary.LittleEndian.Uint32(buffer[28:32]) + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + written, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[6] != state.R2 || buffer[7] != 2 { + t.Fatalf("deadline state/counter=%x", buffer[:11]) + } + secondTimestamp := binary.LittleEndian.Uint32(buffer[28:32]) + if secondTimestamp < firstTimestamp || binary.LittleEndian.Uint32(buffer[49:53]) != secondTimestamp { + t.Fatalf("deadline timestamps first=%d second=%d mirror=%d", firstTimestamp, + secondTimestamp, binary.LittleEndian.Uint32(buffer[49:53])) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 44 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, err = dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer); err != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[7] != 3 { + t.Fatalf("post-cancel state/counter=%x", buffer[:11]) + } + if thirdTimestamp := binary.LittleEndian.Uint32(buffer[28:32]); thirdTimestamp < secondTimestamp { + t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) + } +} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index cdc22c23..1a3d7c5c 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -401,19 +401,47 @@ func (d *DualShock4) publishSpeakerPCM(revision uint64, pcm []byte) bool { // writes the controller's next HID sample into caller-owned storage; USB/IP // continues to use HandleTransfer and its independently owned report slice. func (d *DualShock4) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return d.readInterruptInput(ctx, nil, ep, dst) +} + +// ReadScheduledInterruptInput keeps the exact DualShock 4 packet counter and +// sensor timestamp encoder while native UDE supplies a reusable endpoint +// deadline instead of a fresh timer-backed context for every idle sample. +func (d *DualShock4) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *DualShock4) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep&0x0f != EndpointIn&0x0f { return 0, fmt.Errorf("DualShock 4 interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } var is InputState select { case <-ctx.Done(): - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return 0, ctx.Err() + } + d.mtx.Lock() + is = *d.inputState + d.mtx.Unlock() + case <-deadline: + if ctx.Err() != nil { return 0, ctx.Err() } d.mtx.Lock() is = *d.inputState d.mtx.Unlock() case next := <-d.inputCh: + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } is = *next } d.mtx.Lock() diff --git a/device/dualshock4/scheduled_input_test.go b/device/dualshock4/scheduled_input_test.go new file mode 100644 index 00000000..96747dba --- /dev/null +++ b/device/dualshock4/scheduled_input_test.go @@ -0,0 +1,62 @@ +package dualshock4 + +import ( + "context" + "encoding/binary" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesDualShock4StateAndCadence(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := NewInputState() + state.LX, state.L2, state.Buttons = -31, 166, ButtonCross + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, err := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[8] != state.L2 || buffer[7]>>CounterShift != 1 { + t.Fatalf("event state/counter=%x", buffer[:12]) + } + firstTimestamp := binary.LittleEndian.Uint16(buffer[10:12]) + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + written, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[8] != state.L2 || buffer[7]>>CounterShift != 2 { + t.Fatalf("deadline state/counter=%x", buffer[:12]) + } + secondTimestamp := binary.LittleEndian.Uint16(buffer[10:12]) + if secondTimestamp < firstTimestamp { + t.Fatalf("deadline timestamp=%d before event=%d", secondTimestamp, firstTimestamp) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 45 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + written, err = dev.ReadScheduledInterruptInput(context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, err) + } + if buffer[1] != uint8(int16(state.LX)+128) || buffer[7]>>CounterShift != 3 { + t.Fatalf("post-cancel state/counter=%x", buffer[:12]) + } + if thirdTimestamp := binary.LittleEndian.Uint16(buffer[10:12]); thirdTimestamp < secondTimestamp { + t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) + } +} diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 34f922a3..741fa183 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -119,13 +120,40 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou // ReadInterruptInput implements usb.InterruptInputDevice for native UDE. func (k *Keyboard) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return k.readInterruptInput(ctx, nil, ep, dst) +} + +func (k *Keyboard) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return k.readInterruptInput(ctx, deadline, ep, dst) +} + +func (k *Keyboard) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep != 1 { return 0, fmt.Errorf("keyboard interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } select { case <-ctx.Done(): return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, context.DeadlineExceeded case st := <-k.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case k.inputCh <- st: + default: + } + return 0, ctx.Err() + } return st.BuildReportInto(dst) } } diff --git a/device/keyboard/scheduled_input_test.go b/device/keyboard/scheduled_input_test.go new file mode 100644 index 00000000..63648b8d --- /dev/null +++ b/device/keyboard/scheduled_input_test.go @@ -0,0 +1,47 @@ +package keyboard + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesKeyboardEventContract(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Modifiers = 0x5a + state.KeyBitmap[3] = 0x80 + dev.UpdateInputState(state) + buffer := make([]byte, 34) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 34 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Modifiers || buffer[5] != state.KeyBitmap[3] { + t.Fatalf("event state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); err != context.DeadlineExceeded { + t.Fatalf("idle deadline=%v want %v", err, context.DeadlineExceeded) + } + + state.Modifiers = 0xa5 + dev.UpdateInputState(state) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 34 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Modifiers { + t.Fatalf("post-cancel state=%x", buffer) + } +} diff --git a/device/mouse/device.go b/device/mouse/device.go index f4f60f9d..9439748d 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -74,13 +75,40 @@ func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out [ // ReadInterruptInput implements usb.InterruptInputDevice for native UDE. func (m *Mouse) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return m.readInterruptInput(ctx, nil, ep, dst) +} + +func (m *Mouse) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return m.readInterruptInput(ctx, deadline, ep, dst) +} + +func (m *Mouse) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep != 1 { return 0, fmt.Errorf("mouse interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } select { case <-ctx.Done(): return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, context.DeadlineExceeded case st := <-m.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case m.inputCh <- st: + default: + } + return 0, ctx.Err() + } if st.DX != 0 || st.DY != 0 || st.Wheel != 0 || st.Pan != 0 { zeroed := InputState{Buttons: st.Buttons} select { diff --git a/device/mouse/scheduled_input_test.go b/device/mouse/scheduled_input_test.go new file mode 100644 index 00000000..dde2f64a --- /dev/null +++ b/device/mouse/scheduled_input_test.go @@ -0,0 +1,55 @@ +package mouse + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesMouseEventAndZeroingContract(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Buttons, state.DX, state.DY = 3, 120, -45 + dev.UpdateInputState(state) + buffer := make([]byte, 9) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 9 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != state.Buttons || buffer[1] != byte(state.DX) { + t.Fatalf("event state=%x", buffer) + } + // Relative movement is emitted once, then the device's queued zero-delta + // state is preserved exactly as on the original context-deadline path. + if _, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil { + t.Fatal(readErr) + } + if buffer[0] != state.Buttons || buffer[1] != 0 || buffer[3] != 0 { + t.Fatalf("zeroed relative state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); err != context.DeadlineExceeded { + t.Fatalf("idle deadline=%v want %v", err, context.DeadlineExceeded) + } + + state.DX, state.DY = -321, 123 + dev.UpdateInputState(state) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 9 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[1] != byte(state.DX) || buffer[2] != byte(state.DX>>8) || + buffer[3] != byte(state.DY) || buffer[4] != byte(state.DY>>8) { + t.Fatalf("post-cancel movement=%x", buffer) + } +} diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 739a1049..2c243514 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -166,17 +166,47 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out // ReadInterruptInput implements usb.InterruptInputDevice for the HID input // endpoint. The bulk response endpoint remains on the ordered transfer broker. func (d *NS2Pro) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return d.readInterruptInput(ctx, nil, ep, dst) +} + +func (d *NS2Pro) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return d.readInterruptInput(ctx, deadline, ep, dst) +} + +func (d *NS2Pro) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep != EndpointHIDIn&0x0f { return 0, fmt.Errorf("Switch 2 Pro interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } for { select { case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) && d.reportsEnabled() { + if deadline == nil && errors.Is(ctx.Err(), context.DeadlineExceeded) && d.reportsEnabled() { return d.nextInputReportInto(dst) } return 0, ctx.Err() + case <-deadline: + if ctx.Err() != nil { + return 0, ctx.Err() + } + if d.reportsEnabled() { + return d.nextInputReportInto(dst) + } + return 0, context.DeadlineExceeded case <-d.inputCh: + if deadline != nil && ctx.Err() != nil { + select { + case d.inputCh <- struct{}{}: + default: + } + return 0, ctx.Err() + } if d.reportsEnabled() { return d.nextInputReportInto(dst) } diff --git a/device/ns2pro/scheduled_input_test.go b/device/ns2pro/scheduled_input_test.go new file mode 100644 index 00000000..0f5d4936 --- /dev/null +++ b/device/ns2pro/scheduled_input_test.go @@ -0,0 +1,57 @@ +package ns2pro + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesSwitchStateAndDeadlineReplay(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + dev.protoMu.Lock() + dev.usbReportsEnabled = true + dev.protoMu.Unlock() + state := *NewInputState() + state.Buttons, state.LX = ButtonA|ButtonHome, 0x321 + dev.UpdateInputState(state) + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[0] != ReportIDPro { + t.Fatalf("event report ID=%02x", buffer[0]) + } + if buffer[1] != 1 { + t.Fatalf("event counter=%d want=1", buffer[1]) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), deadline, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("deadline read=(%d, %v)", written, readErr) + } + if buffer[0] != ReportIDPro { + t.Fatalf("deadline report ID=%02x", buffer[0]) + } + if buffer[1] != 2 { + t.Fatalf("deadline counter=%d want=2", buffer[1]) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LX = 0x654 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, EndpointHIDIn&0x0f, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, EndpointHIDIn&0x0f, buffer); readErr != nil || written != InputReportSize { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[1] != 3 { + t.Fatalf("post-cancel counter=%d want=3", buffer[1]) + } +} diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 05906d54..bae0fc39 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -128,15 +129,42 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out // ReadInterruptInput implements usb.InterruptInputDevice for the native UDE // input lane without changing the USB/IP report ownership contract. func (x *Xbox360) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { + return x.readInterruptInput(ctx, nil, ep, dst) +} + +func (x *Xbox360) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { + return x.readInterruptInput(ctx, deadline, ep, dst) +} + +func (x *Xbox360) readInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, error) { if ep != 1 { return 0, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) } + if deadline != nil && ctx.Err() != nil { + return 0, ctx.Err() + } + inputReady := false select { case <-ctx.Done(): - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { return 0, ctx.Err() } + case <-deadline: case <-x.inputSignal: + inputReady = true + } + if deadline != nil && ctx.Err() != nil { + if inputReady { + select { + case x.inputSignal <- struct{}{}: + default: + } + } + return 0, ctx.Err() } x.inputMu.RLock() st := x.inputState diff --git a/device/xbox360/scheduled_input_test.go b/device/xbox360/scheduled_input_test.go new file mode 100644 index 00000000..80eae36c --- /dev/null +++ b/device/xbox360/scheduled_input_test.go @@ -0,0 +1,48 @@ +package xbox360 + +import ( + "context" + "testing" + "time" +) + +func TestScheduledInterruptInputPreservesXboxStateAndDeadlineReplay(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + state := *NewInputState() + state.Buttons, state.LT, state.RX = 0x1234, 199, -4567 + dev.UpdateInputState(state) + buffer := make([]byte, 20) + never := make(chan time.Time) + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("event read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("event state=%x", buffer) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), deadline, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("deadline read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("deadline state=%x", buffer) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state.LT = 211 + dev.UpdateInputState(state) + readyDeadline := make(chan time.Time, 1) + readyDeadline <- time.Now() + if _, err = dev.ReadScheduledInterruptInput(ctx, readyDeadline, 1, buffer); err != context.Canceled { + t.Fatalf("lifecycle cancellation=%v want %v", err, context.Canceled) + } + if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 20 { + t.Fatalf("post-cancel event read=(%d, %v)", written, readErr) + } + if buffer[4] != state.LT { + t.Fatalf("post-cancel state=%x", buffer) + } +} diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 5f26c6ca..9ff6e554 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -95,6 +95,12 @@ The transport is intentionally split by USB semantics: URB because KMDF can invoke it synchronously on UdeCx's submitter thread. The Go publisher allocates one buffer from the endpoint's descriptor at publisher startup and supported controller engines encode directly into it. + Each active publisher also owns one reusable service-deadline timer. The + controller receives that timer's channel separately from the lifecycle + context, so an idle deadline still replays cached state while purge, reset, + D0 exit, and owner shutdown still cancel the read. This preserves the exact + DualSense/DualShock report builders, counters, and sensor timestamps while + removing the timer-backed context allocation from every service interval. The serial overlapped IOCTL copies the report before that buffer is reused, eliminating per-sample Go heap work without shared-memory lifetime hazards. Input timing is therefore host-poll-driven rather than dependent on a second @@ -183,6 +189,12 @@ handle. The handle is therefore always the last object released. This deliberately removes TCP, WSK, USB/IP framing, and attach bookkeeping. The direct input lane removes the highest-frequency HID broker path without mixing report ownership into the proven PlayStation media/state transport. +The authenticated DS4Windows-to-service API which feeds that lane keeps its +wire format but reuses one bounded receive slab per connection and decrypts +records in place before copying into the caller's buffer. Full-duplex access +uses independent read/write locks; concurrent writers are serialized into +whole records with monotonic nonces, and a partially emitted record closes the +now-unrecoverable stream instead of permitting a corrupt retry. Once correctness gates pass, high-rate media payloads may move to a preallocated ring while keeping the same token/generation lifecycle. Control and lifecycle operations remain IOCTL based. @@ -569,6 +581,13 @@ stall an independent pad's registration or removal. client. Continuous control and isochronous traffic no longer allocates a new wire buffer for every URB; an allocation gate protects the caller-buffer encoder while the existing public marshal API remains available for tooling. +- Allocation microbenchmarks cover both authenticated stream directions and + input-deadline scheduling. On the development Windows x64 host, replacing a + timer-backed context per service interval reduced the isolated scheduling + cost from 4 allocations/272 bytes to zero. Authenticated 512-byte record + writes moved from 3 allocations/592 bytes to zero, and reads from 3 + allocations/1092 bytes to zero. These are GC/jitter controls, not substitutes + for the signed HID latency gate. - Product changes to scheduling, thread priority, DPC behavior, or queue depth require a named, bounded-memory WPR capture of the signed live gate. CPU sampled/precise, ready-thread, context-switch, WDF DPC, interrupt, and ISR diff --git a/internal/server/api/auth/conn.go b/internal/server/api/auth/conn.go index cbc8680d..4947bd41 100644 --- a/internal/server/api/auth/conn.go +++ b/internal/server/api/auth/conn.go @@ -1,10 +1,11 @@ package auth import ( - "bytes" "crypto/cipher" "encoding/binary" + "errors" "io" + "math" "net" "sync" @@ -13,14 +14,31 @@ import ( type Conn struct { net.Conn - aead cipher.AEAD - sendCtr uint64 - recvBuf bytes.Buffer - mu sync.Mutex + aead cipher.AEAD + sendCtr uint64 + sendBuf []byte + recvHeader [4]byte + recvHeaderRead int + recvPacket []byte + recvPacketRead int + recvRecordLength int + recvPlain []byte + sendMu sync.Mutex + recvMu sync.Mutex + sendErr error + recvErr error + sendExhausted bool } const maxPacketSize = 2 * 1024 * 1024 // 2 MB +var ( + errPacketTooLarge = errors.New("authenticated stream packet is too large") + errPacketTooShort = errors.New("authenticated stream packet is too short") + errNonceExhausted = errors.New("authenticated stream nonce space is exhausted") + errInvalidWrite = errors.New("authenticated stream transport returned an invalid write count") +) + func WrapConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { aead, err := chacha20poly1305.New(sessionKey) if err != nil { @@ -29,58 +47,178 @@ func WrapConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { return &Conn{Conn: conn, aead: aead}, nil } -func (s *Conn) Write(p []byte) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - - nonce := make([]byte, 12) - binary.BigEndian.PutUint64(nonce[4:], s.sendCtr) - s.sendCtr++ - - ct := s.aead.Seal(nil, nonce, p, nil) - length := uint32(len(nonce) + len(ct)) +func (s *Conn) Close() error { + err := s.Conn.Close() + // Closing the transport first releases any Read or Write currently holding + // its lane lock. Once both lanes join, no cipher or record storage can still + // be in use, so clear it before making subsequent calls fail closed. + s.sendMu.Lock() + s.recvMu.Lock() + clear(s.sendBuf) + clear(s.recvHeader[:]) + clear(s.recvPacket) + s.sendBuf = nil + s.recvPacket = nil + s.recvPlain = nil + s.recvHeaderRead = 0 + s.recvPacketRead = 0 + s.recvRecordLength = 0 + s.sendCtr = 0 + s.sendExhausted = false + s.aead = nil + if s.sendErr == nil { + s.sendErr = net.ErrClosed + } + if s.recvErr == nil { + s.recvErr = net.ErrClosed + } + s.recvMu.Unlock() + s.sendMu.Unlock() + return err +} - var hdr [4]byte - binary.BigEndian.PutUint32(hdr[:], length) +func (s *Conn) Write(p []byte) (int, error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() - if i, err := s.Conn.Write(hdr[:]); err != nil { - return i, err + if s.sendErr != nil { + return 0, s.sendErr + } + if s.sendExhausted { + return 0, errNonceExhausted } - if i, err := s.Conn.Write(nonce); err != nil { - return i, err + nonceSize := s.aead.NonceSize() + recordOverhead := nonceSize + s.aead.Overhead() + if len(p) > maxPacketSize-recordOverhead { + return 0, errPacketTooLarge } - if i, err := s.Conn.Write(ct); err != nil { - return i, err + recordLength := recordOverhead + len(p) + totalLength := 4 + recordLength + if cap(s.sendBuf) < totalLength { + s.sendBuf = make([]byte, totalLength) } + record := s.sendBuf[:4+nonceSize] + nonce := record[4:] + clear(nonce) + binary.BigEndian.PutUint64(nonce[nonceSize-8:], s.sendCtr) + record = s.aead.Seal(record, nonce, p, nil) + binary.BigEndian.PutUint32(record[:4], uint32(len(record)-4)) + + for written := 0; written < len(record); { + remaining := record[written:] + n, err := s.Conn.Write(remaining) + if n < 0 || n > len(remaining) { + s.sendErr = errInvalidWrite + _ = s.Conn.Close() + return 0, errInvalidWrite + } + written += n + if err != nil { + if written == len(record) { + s.advanceSendCounter() + s.sendErr = err + _ = s.Conn.Close() + return len(p), err + } + if written > 0 { + s.sendErr = err + _ = s.Conn.Close() + } + return 0, err + } + if n == 0 { + s.sendErr = io.ErrNoProgress + _ = s.Conn.Close() + return 0, io.ErrNoProgress + } + } + s.advanceSendCounter() return len(p), nil } +func (s *Conn) advanceSendCounter() { + if s.sendCtr == math.MaxUint64 { + s.sendExhausted = true + return + } + s.sendCtr++ +} + func (s *Conn) Read(p []byte) (int, error) { - if s.recvBuf.Len() == 0 { - var hdr [4]byte - if i, err := io.ReadFull(s.Conn, hdr[:]); err != nil { - return i, err + s.recvMu.Lock() + defer s.recvMu.Unlock() + + if len(p) == 0 { + return 0, nil + } + for len(s.recvPlain) == 0 { + if s.recvErr != nil { + return 0, s.recvErr } - length := binary.BigEndian.Uint32(hdr[:]) - if length > maxPacketSize { - return 0, io.ErrUnexpectedEOF + if err := s.readRecord(); err != nil { + return 0, err } + } + n := copy(p, s.recvPlain) + s.recvPlain = s.recvPlain[n:] + return n, nil +} - pkt := make([]byte, length) - if i, err := io.ReadFull(s.Conn, pkt); err != nil { - return i, err +func (s *Conn) readRecord() error { + if s.recvHeaderRead < len(s.recvHeader) { + n, err := io.ReadFull(s.Conn, s.recvHeader[s.recvHeaderRead:]) + s.recvHeaderRead += n + if err != nil { + // The bytes belong to authenticated framing, not to the caller's + // plaintext buffer. Keep the offset so a cleared network deadline can + // resume this record without parsing ciphertext as a new header. + return err } + } - nonce := pkt[:12] - ct := pkt[12:] + if s.recvRecordLength == 0 { + wireLength := binary.BigEndian.Uint32(s.recvHeader[:]) + minimumLength := uint32(s.aead.NonceSize() + s.aead.Overhead()) + switch { + case wireLength < minimumLength: + s.recvErr = errPacketTooShort + return s.recvErr + case wireLength > maxPacketSize: + s.recvErr = errPacketTooLarge + return s.recvErr + } + s.recvRecordLength = int(wireLength) + if cap(s.recvPacket) < s.recvRecordLength { + s.recvPacket = make([]byte, s.recvRecordLength) + } + s.recvPacket = s.recvPacket[:s.recvRecordLength] + } - pt, err := s.aead.Open(nil, nonce, ct, nil) + if s.recvPacketRead < s.recvRecordLength { + n, err := io.ReadFull(s.Conn, s.recvPacket[s.recvPacketRead:]) + s.recvPacketRead += n if err != nil { - return 0, err + return err } + } - s.recvBuf.Write(pt) + nonceSize := s.aead.NonceSize() + nonce := s.recvPacket[:nonceSize] + ct := s.recvPacket[nonceSize:] + + // AEAD permits dst and ciphertext to overlap exactly. Decrypting in place + // keeps one reusable record slab per authenticated stream instead of + // allocating ciphertext and plaintext for every controller/media frame. + // recvPlain is fully consumed before the slab is reused. + pt, err := s.aead.Open(ct[:0], nonce, ct, nil) + if err != nil { + s.recvErr = err + return err } - return s.recvBuf.Read(p) + s.recvPlain = pt + s.recvHeaderRead = 0 + s.recvPacketRead = 0 + s.recvRecordLength = 0 + return nil } diff --git a/internal/server/api/auth/conn_internal_test.go b/internal/server/api/auth/conn_internal_test.go new file mode 100644 index 00000000..3bc67827 --- /dev/null +++ b/internal/server/api/auth/conn_internal_test.go @@ -0,0 +1,211 @@ +package auth + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" + "net" + "sync" + "testing" + "time" +) + +type internalRecordConn struct { + bytes.Buffer +} + +func (*internalRecordConn) Close() error { return nil } +func (*internalRecordConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*internalRecordConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*internalRecordConn) SetDeadline(time.Time) error { return nil } +func (*internalRecordConn) SetReadDeadline(time.Time) error { return nil } +func (*internalRecordConn) SetWriteDeadline(time.Time) error { return nil } + +type internalTestAddr string + +func (a internalTestAddr) Network() string { return string(a) } +func (a internalTestAddr) String() string { return string(a) } + +type blockingInternalConn struct { + readStarted chan struct{} + writeStarted chan struct{} + closed chan struct{} + readOnce sync.Once + writeOnce sync.Once + closeOnce sync.Once +} + +func (c *blockingInternalConn) Read([]byte) (int, error) { + c.readOnce.Do(func() { close(c.readStarted) }) + <-c.closed + return 0, net.ErrClosed +} + +func (c *blockingInternalConn) Write([]byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.closed + return 0, net.ErrClosed +} + +func (c *blockingInternalConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} +func (*blockingInternalConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*blockingInternalConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*blockingInternalConn) SetDeadline(time.Time) error { return nil } +func (*blockingInternalConn) SetReadDeadline(time.Time) error { return nil } +func (*blockingInternalConn) SetWriteDeadline(time.Time) error { return nil } + +func TestConnUsesFinalCounterNonceExactlyOnceBeforeExhaustion(t *testing.T) { + key, err := DeriveKey("nonce-exhaustion") + if err != nil { + t.Fatal(err) + } + raw := &internalRecordConn{} + wrapper, err := WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + conn := wrapper.(*Conn) + conn.sendCtr = math.MaxUint64 + payload := []byte("final nonce") + if written, writeErr := conn.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("final nonce write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + wire := append([]byte(nil), raw.Bytes()...) + if got := binary.BigEndian.Uint64(wire[8:16]); got != math.MaxUint64 { + t.Fatalf("final nonce counter=%d want=%d", got, uint64(math.MaxUint64)) + } + if written, writeErr := conn.Write([]byte("must not wrap")); written != 0 || !errors.Is(writeErr, errNonceExhausted) { + t.Fatalf("exhausted write=(%d, %v), want (0, %v)", written, writeErr, errNonceExhausted) + } + if !bytes.Equal(raw.Bytes(), wire) { + t.Fatal("nonce-exhausted write emitted wire data") + } + + receiverWrapper, err := WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiverWrapper, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("final nonce plaintext=%q want=%q", decoded, payload) + } +} + +func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { + key, err := DeriveKey("clear-connection-state") + if err != nil { + t.Fatal(err) + } + raw := &internalRecordConn{} + senderWrapper, err := WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + sender := senderWrapper.(*Conn) + payload := []byte("sensitive controller state") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), raw.Bytes()...) + sendBacking := sender.sendBuf + if err = sender.Close(); err != nil { + t.Fatal(err) + } + if sender.aead != nil || sender.sendBuf != nil { + t.Fatal("close retained send cipher or record state") + } + for i, value := range sendBacking { + if value != 0 { + t.Fatalf("close retained send byte %d=%02x", i, value) + } + } + if _, writeErr := sender.Write([]byte("closed")); !errors.Is(writeErr, net.ErrClosed) { + t.Fatalf("write after close=%v want %v", writeErr, net.ErrClosed) + } + + receiveRaw := &internalRecordConn{} + _, _ = receiveRaw.Buffer.Write(wire) + receiverWrapper, err := WrapConn(receiveRaw, key) + if err != nil { + t.Fatal(err) + } + receiver := receiverWrapper.(*Conn) + if _, err = receiver.Read(make([]byte, 1)); err != nil { + t.Fatal(err) + } + receiveBacking := receiver.recvPacket + if len(receiver.recvPlain) == 0 { + t.Fatal("test did not leave plaintext buffered before close") + } + if err = receiver.Close(); err != nil { + t.Fatal(err) + } + if receiver.aead != nil || receiver.recvPacket != nil || receiver.recvPlain != nil { + t.Fatal("close retained receive cipher or record state") + } + for i, value := range receiveBacking { + if value != 0 { + t.Fatalf("close retained receive byte %d=%02x", i, value) + } + } + if _, readErr := receiver.Read(make([]byte, 1)); !errors.Is(readErr, net.ErrClosed) { + t.Fatalf("read after close=%v want %v", readErr, net.ErrClosed) + } +} + +func TestConnCloseUnblocksAndJoinsConcurrentReadAndWrite(t *testing.T) { + key, err := DeriveKey("close-concurrent-lanes") + if err != nil { + t.Fatal(err) + } + raw := &blockingInternalConn{ + readStarted: make(chan struct{}), writeStarted: make(chan struct{}), closed: make(chan struct{}), + } + wrapper, err := WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + readDone := make(chan error, 1) + writeDone := make(chan error, 1) + go func() { + _, readErr := wrapper.Read(make([]byte, 1)) + readDone <- readErr + }() + go func() { + _, writeErr := wrapper.Write([]byte("blocked")) + writeDone <- writeErr + }() + select { + case <-raw.readStarted: + case <-time.After(time.Second): + t.Fatal("read lane did not block in transport") + } + select { + case <-raw.writeStarted: + case <-time.After(time.Second): + t.Fatal("write lane did not block in transport") + } + closeDone := make(chan error, 1) + go func() { closeDone <- wrapper.Close() }() + for name, done := range map[string]<-chan error{"read": readDone, "write": writeDone, "close": closeDone} { + select { + case laneErr := <-done: + if name != "close" && !errors.Is(laneErr, net.ErrClosed) { + t.Fatalf("%s lane error=%v want %v", name, laneErr, net.ErrClosed) + } + if name == "close" && laneErr != nil { + t.Fatalf("close error=%v", laneErr) + } + case <-time.After(time.Second): + t.Fatalf("%s lane did not join", name) + } + } +} diff --git a/internal/server/api/auth/conn_test.go b/internal/server/api/auth/conn_test.go index 7c2153c6..840b1c98 100644 --- a/internal/server/api/auth/conn_test.go +++ b/internal/server/api/auth/conn_test.go @@ -1,14 +1,723 @@ package auth_test import ( + "bytes" + "encoding/binary" "errors" + "io" "net" + "sync" "testing" + "time" "github.com/Alia5/VIIPER/internal/server/api/auth" "github.com/stretchr/testify/assert" ) +type recordConn struct { + bytes.Buffer + writeCalls int + maxWrite int +} + +type partialFailureConn struct { + recordConn + remaining int + err error + closed bool +} + +type interruptedReadConn struct { + recordConn + beforeError int + err error + interrupted bool +} + +type fullWriteErrorConn struct { + recordConn + err error + closed bool +} + +type firstWriteErrorConn struct { + recordConn + err error + first bool +} + +type zeroProgressConn struct { + recordConn + closed bool +} + +func (c *partialFailureConn) Write(p []byte) (int, error) { + if c.remaining == 0 { + return 0, c.err + } + if len(p) > c.remaining { + p = p[:c.remaining] + } + n, _ := c.recordConn.Write(p) + c.remaining -= n + return n, nil +} + +func (c *partialFailureConn) Close() error { + c.closed = true + return nil +} + +func (c *interruptedReadConn) Read(p []byte) (int, error) { + if c.interrupted { + return c.recordConn.Read(p) + } + if c.beforeError == 0 { + c.interrupted = true + return 0, c.err + } + if len(p) > c.beforeError { + p = p[:c.beforeError] + } + n, _ := c.recordConn.Read(p) + c.beforeError -= n + if c.beforeError == 0 { + c.interrupted = true + return n, c.err + } + return n, nil +} + +func (c *fullWriteErrorConn) Write(p []byte) (int, error) { + n, _ := c.recordConn.Write(p) + return n, c.err +} + +func (c *fullWriteErrorConn) Close() error { + c.closed = true + return nil +} + +func (c *firstWriteErrorConn) Write(p []byte) (int, error) { + if c.first { + c.first = false + return 0, c.err + } + return c.recordConn.Write(p) +} + +func (c *zeroProgressConn) Write([]byte) (int, error) { return 0, nil } + +func (c *zeroProgressConn) Close() error { + c.closed = true + return nil +} + +type discardConn struct{} + +func (discardConn) Read([]byte) (int, error) { return 0, io.EOF } +func (discardConn) Write(p []byte) (int, error) { return len(p), nil } +func (discardConn) Close() error { return nil } +func (discardConn) LocalAddr() net.Addr { return testAddr("local") } +func (discardConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (discardConn) SetDeadline(time.Time) error { return nil } +func (discardConn) SetReadDeadline(time.Time) error { return nil } +func (discardConn) SetWriteDeadline(time.Time) error { return nil } + +type loopingReadConn struct { + record []byte + offset int +} + +type segmentedReadConn struct { + recordConn + segments [][]byte + index int + offset int +} + +func (c *segmentedReadConn) Read(p []byte) (int, error) { + if c.index == len(c.segments) { + return 0, io.EOF + } + segment := c.segments[c.index] + n := copy(p, segment[c.offset:]) + c.offset += n + if c.offset == len(segment) { + c.index++ + c.offset = 0 + } + return n, nil +} + +func (c *loopingReadConn) Read(p []byte) (int, error) { + if c.offset == len(c.record) { + c.offset = 0 + } + n := copy(p, c.record[c.offset:]) + c.offset += n + return n, nil +} +func (*loopingReadConn) Write(p []byte) (int, error) { return len(p), nil } +func (*loopingReadConn) Close() error { return nil } +func (*loopingReadConn) LocalAddr() net.Addr { return testAddr("local") } +func (*loopingReadConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (*loopingReadConn) SetDeadline(time.Time) error { return nil } +func (*loopingReadConn) SetReadDeadline(time.Time) error { + return nil +} +func (*loopingReadConn) SetWriteDeadline(time.Time) error { + return nil +} + +func (c *recordConn) Write(p []byte) (int, error) { + c.writeCalls++ + if c.maxWrite > 0 && len(p) > c.maxWrite { + p = p[:c.maxWrite] + } + return c.Buffer.Write(p) +} + +func (*recordConn) Close() error { return nil } +func (*recordConn) LocalAddr() net.Addr { return testAddr("local") } +func (*recordConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (*recordConn) SetDeadline(time.Time) error { return nil } +func (*recordConn) SetReadDeadline(time.Time) error { return nil } +func (*recordConn) SetWriteDeadline(time.Time) error { return nil } + +type testAddr string + +func (a testAddr) Network() string { return string(a) } +func (a testAddr) String() string { return string(a) } + +func TestConnCoalescesOneAuthenticatedRecordIntoOneWrite(t *testing.T) { + key, err := auth.DeriveKey("coalesced-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + receiver, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("one input/media frame") + if written, writeErr := sender.Write(payload); writeErr != nil || written != len(payload) { + t.Fatalf("write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if raw.writeCalls != 1 { + t.Fatalf("authenticated frame used %d transport writes, want 1", raw.writeCalls) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnFinishesPartialUnderlyingWritesWithoutSplittingARecord(t *testing.T) { + key, err := auth.DeriveKey("partial-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{maxWrite: 3} + sender, _ := auth.WrapConn(raw, key) + receiver, _ := auth.WrapConn(raw, key) + payload := []byte("partial writes are completed") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + if raw.writeCalls <= 1 { + t.Fatal("partial transport did not exercise the full-write loop") + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnRejectsTruncatedAuthenticatedRecordWithoutPanicking(t *testing.T) { + key, err := auth.DeriveKey("short-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + var header [4]byte + binary.BigEndian.PutUint32(header[:], 1) + _, _ = raw.Buffer.Write(header[:]) + _ = raw.Buffer.WriteByte(0) + receiver, _ := auth.WrapConn(raw, key) + if _, err = receiver.Read(make([]byte, 1)); err == nil { + t.Fatal("truncated authenticated record was accepted") + } +} + +func TestConnRejectsInvalidRecordLengthTerminallyBeforeAllocation(t *testing.T) { + key, err := auth.DeriveKey("invalid-record-length") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + length uint32 + }{ + {name: "below_nonce_and_tag", length: 12 + 16 - 1}, + {name: "above_bound", length: 2*1024*1024 + 1}, + {name: "uint32_max", length: ^uint32(0)}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &recordConn{} + var header [4]byte + binary.BigEndian.PutUint32(header[:], tc.length) + _, _ = raw.Buffer.Write(header[:]) + receiver, wrapErr := auth.WrapConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil { + t.Fatalf("invalid length read=(%d, %v), want (0, error)", n, readErr) + } + remaining := raw.Len() + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil { + t.Fatalf("repeated invalid length read=(%d, %v), want sticky error", n, readErr) + } + if raw.Len() != remaining { + t.Fatal("terminal framing error consumed bytes on retry") + } + }) + } +} + +func TestConnSerializesConcurrentRecordsWithMonotonicNonces(t *testing.T) { + key, err := auth.DeriveKey("concurrent-records") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + const records = 64 + start := make(chan struct{}) + errs := make(chan error, records) + var writers sync.WaitGroup + for id := 0; id < records; id++ { + writers.Add(1) + go func(id int) { + defer writers.Done() + <-start + var payload [4]byte + binary.BigEndian.PutUint32(payload[:], uint32(id)) + _, writeErr := sender.Write(payload[:]) + errs <- writeErr + }(id) + } + close(start) + writers.Wait() + close(errs) + for writeErr := range errs { + if writeErr != nil { + t.Fatal(writeErr) + } + } + + wire := append([]byte(nil), raw.Bytes()...) + for counter := uint64(0); counter < records; counter++ { + if len(wire) < 4 { + t.Fatalf("record %d has no length prefix", counter) + } + length := int(binary.BigEndian.Uint32(wire[:4])) + if length < 12 || len(wire) < 4+length { + t.Fatalf("record %d length=%d remaining=%d", counter, length, len(wire)) + } + nonceCounter := binary.BigEndian.Uint64(wire[8:16]) + if nonceCounter != counter { + t.Fatalf("record %d nonce counter=%d", counter, nonceCounter) + } + wire = wire[4+length:] + } + if len(wire) != 0 { + t.Fatalf("%d trailing authenticated bytes", len(wire)) + } + + receiver, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + seen := make(map[uint32]bool, records) + for range records { + var payload [4]byte + if _, err = io.ReadFull(receiver, payload[:]); err != nil { + t.Fatal(err) + } + seen[binary.BigEndian.Uint32(payload[:])] = true + } + if len(seen) != records { + t.Fatalf("decoded %d unique records, want %d", len(seen), records) + } +} + +func TestConnClosesAfterPartialRecordFailure(t *testing.T) { + key, err := auth.DeriveKey("terminal-partial-record") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("injected transport failure") + raw := &partialFailureConn{remaining: 7, err: wantErr} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write([]byte("frame")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("partial write=(%d, %v), want (0, %v)", written, writeErr, wantErr) + } + if !raw.closed { + t.Fatal("partially emitted authenticated record did not close the stream") + } + wireLength := raw.Len() + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != wireLength { + t.Fatal("terminal authenticated stream emitted bytes after partial failure") + } +} + +func TestConnReturnsCompletePlaintextCountWhenTransportReportsFullWriteAndError(t *testing.T) { + key, err := auth.DeriveKey("full-record-error") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("transport failed after accepting the record") + raw := &fullWriteErrorConn{err: wantErr} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("complete authenticated frame") + if written, writeErr := sender.Write(payload); written != len(payload) || !errors.Is(writeErr, wantErr) { + t.Fatalf("full write=(%d, %v), want (%d, %v)", written, writeErr, len(payload), wantErr) + } + if !raw.closed { + t.Fatal("transport error after a complete record did not make the write side terminal") + } + wireLength := raw.Len() + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != wireLength { + t.Fatal("terminal stream emitted bytes after a full-record transport error") + } + + receiver, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func TestConnRetriesRecordAfterZeroByteTransportErrorWithoutNonceReuseOnWire(t *testing.T) { + key, err := auth.DeriveKey("zero-byte-retry") + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("temporary transport error") + raw := &firstWriteErrorConn{err: wantErr, first: true} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("retry safely") + if written, writeErr := sender.Write(payload); written != 0 || !errors.Is(writeErr, wantErr) { + t.Fatalf("first write=(%d, %v), want (0, %v)", written, writeErr, wantErr) + } + if raw.Len() != 0 { + t.Fatal("zero-byte transport error emitted authenticated wire data") + } + if written, writeErr := sender.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("retry=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if counter := binary.BigEndian.Uint64(raw.Bytes()[8:16]); counter != 0 { + t.Fatalf("retried record nonce counter=%d want=0", counter) + } +} + +func TestConnClosesAfterZeroProgressWrite(t *testing.T) { + key, err := auth.DeriveKey("zero-progress") + if err != nil { + t.Fatal(err) + } + raw := &zeroProgressConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write([]byte("frame")); written != 0 || !errors.Is(writeErr, io.ErrNoProgress) { + t.Fatalf("zero-progress write=(%d, %v), want (0, %v)", written, writeErr, io.ErrNoProgress) + } + if !raw.closed { + t.Fatal("zero-progress transport did not close the unrecoverable stream") + } + if written, writeErr := sender.Write([]byte("retry")); written != 0 || !errors.Is(writeErr, io.ErrNoProgress) { + t.Fatalf("retry=(%d, %v), want terminal (0, %v)", written, writeErr, io.ErrNoProgress) + } +} + +func TestConnRejectsOversizedRecordBeforeTransportWrite(t *testing.T) { + key, err := auth.DeriveKey("oversized-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write(make([]byte, 2*1024*1024)); written != 0 || writeErr == nil { + t.Fatalf("oversized write=(%d, %v), want rejection", written, writeErr) + } + if raw.writeCalls != 0 { + t.Fatalf("oversized record reached transport in %d write(s)", raw.writeCalls) + } +} + +func TestConnAcceptsExactMaximumRecordBound(t *testing.T) { + key, err := auth.DeriveKey("maximum-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + // The 2 MiB bound includes the 12-byte nonce and 16-byte Poly1305 tag. + payload := make([]byte, 2*1024*1024-12-16) + payload[0], payload[len(payload)-1] = 0x5a, 0xa5 + if written, writeErr := sender.Write(payload); written != len(payload) || writeErr != nil { + t.Fatalf("maximum write=(%d, %v), want (%d, nil)", written, writeErr, len(payload)) + } + if got := binary.BigEndian.Uint32(raw.Bytes()[:4]); got != 2*1024*1024 { + t.Fatalf("maximum wire record length=%d", got) + } + + receiver, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatal("maximum authenticated record changed during round trip") + } +} + +func TestConnResumesInterruptedAuthenticatedFramingWithoutExposingWireBytes(t *testing.T) { + key, err := auth.DeriveKey("interrupted-framing") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, err := auth.WrapConn(wireBuffer, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("only authenticated plaintext may reach the caller") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + wantErr := errors.New("injected read deadline") + + for _, tc := range []struct { + name string + beforeError int + }{ + {name: "partial_header", beforeError: 2}, + {name: "partial_record", beforeError: 4 + 7}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &interruptedReadConn{beforeError: tc.beforeError, err: wantErr} + _, _ = raw.Buffer.Write(wire) + receiver, wrapErr := auth.WrapConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + dst := bytes.Repeat([]byte{0xa5}, len(payload)) + if n, readErr := receiver.Read(dst[:1]); n != 0 || !errors.Is(readErr, wantErr) { + t.Fatalf("interrupted read=(%d, %v), want (0, %v)", n, readErr, wantErr) + } + if !bytes.Equal(dst, bytes.Repeat([]byte{0xa5}, len(payload))) { + t.Fatal("unauthenticated framing bytes changed the caller buffer") + } + if _, readErr := io.ReadFull(receiver, dst); readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(dst, payload) { + t.Fatalf("resumed plaintext=%q want=%q", dst, payload) + } + }) + } +} + +func TestConnSkipsAuthenticatedEmptyRecordAndZeroLengthReadDoesNotConsumeWire(t *testing.T) { + key, err := auth.DeriveKey("empty-record") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + if written, writeErr := sender.Write(nil); written != 0 || writeErr != nil { + t.Fatalf("empty write=(%d, %v), want (0, nil)", written, writeErr) + } + payload := []byte("after empty") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wireLength := raw.Len() + receiver, err := auth.WrapConn(raw, key) + if err != nil { + t.Fatal(err) + } + if n, readErr := receiver.Read(nil); n != 0 || readErr != nil { + t.Fatalf("zero-length read=(%d, %v), want (0, nil)", n, readErr) + } + if raw.Len() != wireLength { + t.Fatal("zero-length destination consumed authenticated wire data") + } + dst := make([]byte, len(payload)) + if n, readErr := receiver.Read(dst); n != len(payload) || readErr != nil { + t.Fatalf("read after empty record=(%d, %v), want (%d, nil)", n, readErr, len(payload)) + } + if !bytes.Equal(dst, payload) { + t.Fatalf("read after empty record=%q want=%q", dst, payload) + } +} + +func TestConnReadCopiesPlaintextOutOfReusableRecordSlab(t *testing.T) { + key, err := auth.DeriveKey("retained-read-buffer") + if err != nil { + t.Fatal(err) + } + raw := &recordConn{} + sender, _ := auth.WrapConn(raw, key) + receiver, _ := auth.WrapConn(raw, key) + firstWant := []byte("first-frame") + secondWant := []byte("second-frame") + _, _ = sender.Write(firstWant) + first := make([]byte, len(firstWant)) + if _, err = io.ReadFull(receiver, first); err != nil { + t.Fatal(err) + } + _, _ = sender.Write(secondWant) + second := make([]byte, len(secondWant)) + if _, err = io.ReadFull(receiver, second); err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, firstWant) || !bytes.Equal(second, secondWant) { + t.Fatalf("retained=%q/%q want=%q/%q", first, second, firstWant, secondWant) + } +} + +func TestConnReadPreservesFourSegmentClientWireCompatibility(t *testing.T) { + key, err := auth.DeriveKey("segmented-client-record") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, _ := auth.WrapConn(wireBuffer, key) + payload := []byte("header nonce ciphertext tag remain one protocol record") + if _, err = sender.Write(payload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + tagStart := len(wire) - 16 + raw := &segmentedReadConn{segments: [][]byte{ + wire[:4], wire[4:16], wire[16:tagStart], wire[tagStart:], + }} + receiver, _ := auth.WrapConn(raw, key) + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(receiver, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("decoded=%q want=%q", decoded, payload) + } +} + +func BenchmarkConnWriteAuthenticatedRecord(b *testing.B) { + key, err := auth.DeriveKey("authenticated-write-benchmark") + if err != nil { + b.Fatal(err) + } + wrapped, err := auth.WrapConn(discardConn{}, key) + if err != nil { + b.Fatal(err) + } + payload := make([]byte, 512) + if _, err = wrapped.Write(payload); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err = wrapped.Write(payload); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkConnReadAuthenticatedRecord(b *testing.B) { + key, err := auth.DeriveKey("authenticated-read-benchmark") + if err != nil { + b.Fatal(err) + } + payload := make([]byte, 512) + wire := &recordConn{} + sender, _ := auth.WrapConn(wire, key) + if _, err = sender.Write(payload); err != nil { + b.Fatal(err) + } + raw := &loopingReadConn{record: append([]byte(nil), wire.Bytes()...)} + wrapped, err := auth.WrapConn(raw, key) + if err != nil { + b.Fatal(err) + } + dst := make([]byte, len(payload)) + if _, err = io.ReadFull(wrapped, dst); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err = io.ReadFull(wrapped, dst); err != nil { + b.Fatal(err) + } + } +} + func TestConn(t *testing.T) { type testCase struct { @@ -170,7 +879,11 @@ func TestConn(t *testing.T) { } return } - buf := make([]byte, len(tc.expected)) + readSize := len(tc.expected) + if tc.expectedErr != nil && readSize == 0 { + readSize = 1 + } + buf := make([]byte, readSize) _, err = wrappedServerConn.Read(buf) if err != nil { if tc.expectedErr != nil { @@ -180,6 +893,9 @@ func TestConn(t *testing.T) { } return } + if tc.expectedErr != nil { + t.Fatalf("server read succeeded, want error containing %q", tc.expectedErr) + } assert.Equal(t, tc.expected, buf) }) diff --git a/internal/transport/udecx/deadline_bench_test.go b/internal/transport/udecx/deadline_bench_test.go new file mode 100644 index 00000000..4922c611 --- /dev/null +++ b/internal/transport/udecx/deadline_bench_test.go @@ -0,0 +1,28 @@ +package udecx + +import ( + "context" + "testing" + "time" +) + +func BenchmarkLegacyInputDeadlineContext(b *testing.B) { + parent := context.Background() + b.ReportAllocs() + for b.Loop() { + ctx, cancel := context.WithTimeout(parent, time.Hour) + _ = ctx + cancel() + } +} + +func BenchmarkReusableInputDeadlineTimer(b *testing.B) { + timer := time.NewTimer(time.Hour) + stopInputDeadlineTimer(timer) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + timer.Reset(time.Hour) + stopInputDeadlineTimer(timer) + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 5df927d0..861a254d 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "sync/atomic" "time" @@ -24,6 +25,8 @@ const ( statusUnsuccessful = int32(-1073741823) // STATUS_UNSUCCESSFUL ) +var errInputSequenceExhausted = errors.New("native UDE input report sequence is exhausted") + // Driver is the narrow host-side contract implemented by the overlapped // Windows UdeCx client. Keeping it as an interface makes ordering, teardown, // and stale-generation behavior testable without loading a kernel driver. @@ -43,6 +46,9 @@ type Driver interface { // for interrupt-IN reports. Keeping it separate preserves the ordered broker // contract for control, output, feedback, audio, and lifecycle traffic. type InputReportDriver interface { + // SubmitInputReport must honor ctx. Endpoint lifecycle cancellation stops + // sampling but deliberately lets an already encoded report commit; ctx is + // cancelled when the owning Host session stops or fails. SubmitInputReport(context.Context, InputReport) error } @@ -78,6 +84,7 @@ type inputPublisher struct { reportSize int interval time.Duration sequence *atomic.Uint64 + submitCtx context.Context cancel context.CancelFunc done chan struct{} } @@ -434,7 +441,8 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { publisher := &inputPublisher{ endpoint: endpoint, reportSize: endpointContract.reportSize, interval: endpointContract.interval, sequence: sequence, - cancel: cancel, done: make(chan struct{}), + submitCtx: h.runCtx, + cancel: cancel, done: make(chan struct{}), } entry.publishers[endpoint] = publisher h.mu.Unlock() @@ -491,24 +499,68 @@ func (h *Host) withInputAttemptDeadline( return context.WithTimeout(ctx, interval) } +func stopInputDeadlineTimer(timer *time.Timer) { + if timer.Stop() { + return + } + // Go 1.23+ synchronous timer channels guarantee that Stop prevents a stale + // receive. The nonblocking drain also preserves that invariant if a process + // explicitly restores the legacy buffered timer implementation through + // GODEBUG=asynctimerchan=1. + select { + case <-timer.C: + default: + } +} + +func nextInputReportSequence(previous uint64) (uint64, error) { + // InputReport's wire contract is signed-positive so the kernel can validate + // it with MAXLONGLONG. Never wrap to one: reusing an accepted sequence would + // violate the monotonic endpoint contract. + if previous >= math.MaxInt64 { + return 0, errInputSequenceExhausted + } + return previous + 1, nil +} + func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, publisher *inputPublisher) { defer close(publisher.done) reader, direct := entry.device.(usb.InterruptInputDevice) + scheduledReader, scheduled := entry.device.(usb.ScheduledInterruptInputDevice) var reportBuffer []byte + var deadlineTimer *time.Timer if direct { reportBuffer = make([]byte, publisher.reportSize) + if scheduled && publisher.interval > 0 { + // One endpoint owns one timer for its complete lifetime. Resetting it + // after each submitted sample preserves the established relative + // service-deadline contract while removing a timer/context allocation + // from every idle 1 ms controller report. + deadlineTimer = time.NewTimer(time.Hour) + stopInputDeadlineTimer(deadlineTimer) + defer stopInputDeadlineTimer(deadlineTimer) + } } for { var payload []byte if direct { - attemptCtx := ctx - attemptCancel := context.CancelFunc(func() {}) - if publisher.interval > 0 { - attemptCtx, attemptCancel = h.withInputAttemptDeadline(ctx, publisher.interval) + var written int + var err error + if deadlineTimer != nil { + deadlineTimer.Reset(publisher.interval) + written, err = scheduledReader.ReadScheduledInterruptInput( + ctx, deadlineTimer.C, uint32(publisher.endpoint&0x0f), reportBuffer) + stopInputDeadlineTimer(deadlineTimer) + } else { + attemptCtx := ctx + attemptCancel := context.CancelFunc(func() {}) + if publisher.interval > 0 { + attemptCtx, attemptCancel = h.withInputAttemptDeadline(ctx, publisher.interval) + } + written, err = reader.ReadInterruptInput( + attemptCtx, uint32(publisher.endpoint&0x0f), reportBuffer) + attemptCancel() } - written, err := reader.ReadInterruptInput( - attemptCtx, uint32(publisher.endpoint&0x0f), reportBuffer) - attemptCancel() if err != nil { if ctx.Err() != nil { return @@ -535,30 +587,43 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p payload = entry.device.HandleTransfer( ctx, uint32(publisher.endpoint&0x0f), usb.DirectionIn, nil) } - if ctx.Err() != nil { - return - } if len(payload) == 0 { + // The legacy HandleTransfer contract signals a cancelled wait with + // an empty slice. No report was encoded, so there is nothing to commit. + if ctx.Err() != nil || publisher.submitCtx.Err() != nil { + return + } h.reportFatal(fmt.Errorf( "device %d returned an empty interrupt-IN report for endpoint 0x%02x", entry.identity.DeviceID, publisher.endpoint)) return } - // The sequence is owned by this endpoint generation and survives only a - // purge/start publisher replacement. Keeping it in an atomic endpoint - // counter removes the controller-wide host mutex from the 1 kHz input - // path, so unrelated lifecycle/media work and other pads cannot add input - // tail latency. There is at most one publisher per endpoint, but atomic - // ownership also makes that invariant safe under restart transitions. - sequence := publisher.sequence.Add(1) - if sequence == 0 { - sequence = publisher.sequence.Add(1) + // Once the controller encoder has returned a report, commit that exact + // state before an endpoint lifecycle boundary joins this publisher. + // Only owner-session shutdown may abort the commit. + if publisher.submitCtx.Err() != nil { + return } - if err := h.input.SubmitInputReport(ctx, InputReport{ + // The sequence is owned by this endpoint generation and survives a + // purge/start or reset publisher replacement. There is exactly one live + // publisher per endpoint and stopInputPublisher joins it before a + // replacement starts, so reserve the next value without committing it. + // Owner-session cancellation can land between this point and driver + // acceptance; committing the counter only after a successful submit keeps + // accepted reports contiguous without rolling back device encoder state. + previousSequence := publisher.sequence.Load() + sequence, err := nextInputReportSequence(previousSequence) + if err != nil { + h.reportFatal(fmt.Errorf( + "reserve native UDE input sequence for device %d endpoint 0x%02x: %w", + entry.identity.DeviceID, publisher.endpoint, err)) + return + } + if err := h.input.SubmitInputReport(publisher.submitCtx, InputReport{ DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, EndpointAddress: publisher.endpoint, Sequence: sequence, Payload: payload, }); err != nil { - if ctx.Err() != nil { + if publisher.submitCtx.Err() != nil { return } h.reportFatal(fmt.Errorf( @@ -566,6 +631,12 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p entry.identity.DeviceID, publisher.endpoint, err)) return } + if !publisher.sequence.CompareAndSwap(previousSequence, sequence) { + h.reportFatal(fmt.Errorf( + "commit native UDE input sequence for device %d endpoint 0x%02x: concurrent publisher changed %d", + entry.identity.DeviceID, publisher.endpoint, previousSequence)) + return + } } } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 0f718225..4e437eb5 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "math" "strings" "sync" + "sync/atomic" "testing" "time" @@ -29,6 +31,16 @@ type fastInputDriver struct { submitErr error } +type inputSubmitGate struct { + started chan InputReport + release chan struct{} +} + +type gatedFastInputDriver struct { + *fastInputDriver + gates chan *inputSubmitGate +} + type independentlyBlockingCreateDriver struct { *fakeHostDriver blockedDevice uint64 @@ -63,6 +75,41 @@ func (d *fastInputDriver) SubmitInputReport(ctx context.Context, report InputRep } } +func (d *gatedFastInputDriver) SubmitInputReport(ctx context.Context, report InputReport) error { + select { + case gate := <-d.gates: + report.Payload = append([]byte(nil), report.Payload...) + select { + case gate.started <- report: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-gate.release: + case <-ctx.Done(): + return ctx.Err() + } + default: + } + return d.fastInputDriver.SubmitInputReport(ctx, report) +} + +func newInputSubmitGate() *inputSubmitGate { + return &inputSubmitGate{started: make(chan InputReport, 1), release: make(chan struct{})} +} + +func TestNextInputReportSequenceFailsClosedAtABICeiling(t *testing.T) { + last, err := nextInputReportSequence(math.MaxInt64 - 1) + if err != nil || last != math.MaxInt64 { + t.Fatalf("last valid sequence=(%d, %v), want (%d, nil)", last, err, uint64(math.MaxInt64)) + } + for _, previous := range []uint64{math.MaxInt64, math.MaxUint64} { + if next, nextErr := nextInputReportSequence(previous); next != 0 || !errors.Is(nextErr, errInputSequenceExhausted) { + t.Fatalf("sequence after %d=(%d, %v), want (0, %v)", previous, next, nextErr, errInputSequenceExhausted) + } + } +} + func newFakeHostDriver() *fakeHostDriver { return &fakeHostDriver{ operations: make(chan Operation, 16), completions: make(chan Completion, 16), @@ -683,6 +730,19 @@ type cachedDeadlineInputPublisherTestDevice struct { cached []byte } +type scheduledInputPublisherTestDevice struct { + *inputPublisherTestDevice + deadlines chan (<-chan time.Time) + fallbackRead atomic.Int32 +} + +type staleDeadlineInputPublisherTestDevice struct { + *inputPublisherTestDevice + firstStarted chan struct{} + secondElapsed chan time.Duration + calls atomic.Int32 +} + type controlledInputAttempt struct { context.Context deadline time.Time @@ -743,6 +803,26 @@ func newCachedDeadlineInputPublisherTestDevice(report []byte) *cachedDeadlineInp } } +func newScheduledInputPublisherTestDevice() *scheduledInputPublisherTestDevice { + return &scheduledInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + deadlines: make(chan (<-chan time.Time), 32), + } +} + +func newStaleDeadlineInputPublisherTestDevice() *staleDeadlineInputPublisherTestDevice { + device := &staleDeadlineInputPublisherTestDevice{ + inputPublisherTestDevice: newInputPublisherTestDevice(), + firstStarted: make(chan struct{}), + secondElapsed: make(chan time.Duration, 1), + } + // A high-speed bInterval of 8 is a 16 ms service period. The longer + // interval gives this deterministic stale-tick test enough scheduling + // margin even on a busy Windows runner. + device.descriptor.Interfaces[0].Endpoints[0].BInterval = 8 + return device +} + func (d *directInputPublisherTestDevice) ReadInterruptInput( ctx context.Context, _ uint32, dst []byte, ) (int, error) { @@ -776,6 +856,68 @@ func (d *cachedDeadlineInputPublisherTestDevice) ReadInterruptInput( return len(d.cached), nil } +func (d *scheduledInputPublisherTestDevice) ReadInterruptInput( + context.Context, uint32, []byte, +) (int, error) { + d.fallbackRead.Add(1) + return 0, errors.New("scheduled input used the timer-context fallback") +} + +func (d *scheduledInputPublisherTestDevice) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, _ uint32, dst []byte, +) (int, error) { + select { + case d.deadlines <- deadline: + default: + } + select { + case report := <-d.reports: + if len(report) > len(dst) { + return 0, errors.New("native input buffer is too short") + } + copy(dst, report) + return len(report), nil + case <-deadline: + return 0, context.DeadlineExceeded + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (d *staleDeadlineInputPublisherTestDevice) ReadInterruptInput( + context.Context, uint32, []byte, +) (int, error) { + return 0, errors.New("stale-deadline test used the timer-context fallback") +} + +func (d *staleDeadlineInputPublisherTestDevice) ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, _ uint32, dst []byte, +) (int, error) { + if d.calls.Add(1) == 1 { + close(d.firstStarted) + // Deliberately leave the first deadline unread. This models the hardest + // event/deadline race: a controller event wins after the timer's nominal + // expiry and the host must stop/reset without leaking that old tick into + // the next USB service interval. + select { + case report := <-d.reports: + copy(dst, report) + return len(report), nil + case <-ctx.Done(): + return 0, ctx.Err() + } + } + started := time.Now() + select { + case <-deadline: + d.secondElapsed <- time.Since(started) + dst[0] = 0x7e + return 1, nil + case <-ctx.Done(): + return 0, ctx.Err() + } +} + func (d *inputPublisherTestDevice) HandleTransfer( ctx context.Context, _ uint32, _ uint32, _ []byte, ) []byte { @@ -910,6 +1052,182 @@ func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { } } +func TestHostReusesOneDeadlineTimerForScheduledInterruptInput(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 451, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1, 2, 3} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("first scheduled input report was not submitted") + } + device.reports <- []byte{4, 5, 6} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("second scheduled input report was not submitted") + } + + var first, second <-chan time.Time + select { + case first = <-device.deadlines: + case <-time.After(time.Second): + t.Fatal("scheduled input did not receive a deadline") + } + select { + case second = <-device.deadlines: + case <-time.After(time.Second): + t.Fatal("scheduled input did not receive a second deadline") + } + if first != second { + t.Fatal("scheduled input allocated a replacement endpoint timer") + } + if calls := device.fallbackRead.Load(); calls != 0 { + t.Fatalf("scheduled input used fallback ReadInterruptInput %d time(s)", calls) + } + + // Endpoint reset must synchronously cancel the blocked scheduled read, + // dispose its timer, and start a fresh publisher only after lifecycle ACK. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 2, DeviceSequence: 2, + Kind: OperationEndpointReset, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint reset did not join the scheduled input publisher") + } + device.reports <- []byte{7, 8, 9} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("scheduled input did not resume after endpoint reset") + } + resetDeadline := time.After(time.Second) + for { + select { + case afterReset := <-device.deadlines: + if afterReset != first { + goto resetTimerObserved + } + case <-resetDeadline: + t.Fatal("endpoint reset retained the old publisher timer") + } + } + +resetTimerObserved: + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostTimerResetCannotReplayExpiredDeadlineIntoNextInput(t *testing.T) { + driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newStaleDeadlineInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 452, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + select { + case <-device.firstStarted: + case <-time.After(time.Second): + t.Fatal("first scheduled read did not start") + } + + // Let the first 16 ms timer expire without receiving its tick, then make + // the controller event win. Go 1.23+ Timer.Stop/Reset guarantees that the + // expired value cannot satisfy the next receive. The host also drains a + // buffered tick when the legacy timer implementation is forced by GODEBUG. + time.Sleep(25 * time.Millisecond) + device.reports <- []byte{0x11} + select { + case <-driver.reports: + case <-time.After(time.Second): + t.Fatal("controller event was not submitted") + } + select { + case elapsed := <-device.secondElapsed: + if elapsed < 12*time.Millisecond { + t.Fatalf("expired deadline leaked into next 16 ms interval after %v", elapsed) + } + case <-time.After(time.Second): + t.Fatal("next service deadline did not fire") + } + select { + case report := <-driver.reports: + if string(report.Payload) != string([]byte{0x7e}) { + t.Fatalf("deadline report=%x want=7e", report.Payload) + } + case <-time.After(time.Second): + t.Fatal("deadline report was not submitted") + } + + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop after scheduled deadline race") + } +} + func TestHostInputPublisherDoesNotWaitForGlobalRoutingLock(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ @@ -1055,7 +1373,7 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T resets: make(chan DeviceIdentity, 1), } host, _ := NewHost(driver, processor, 2) - device := newInputPublisherTestDevice() + device := newScheduledInputPublisherTestDevice() identity, err := host.Register(context.Background(), 46, device) if err != nil { t.Fatal(err) @@ -1460,7 +1778,7 @@ func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t resets: make(chan DeviceIdentity, 1), } host, _ := NewHost(driver, processor, 2) - device := newInputPublisherTestDevice() + device := newScheduledInputPublisherTestDevice() identity, err := host.Register(context.Background(), 47, device) if err != nil { t.Fatal(err) @@ -1529,6 +1847,228 @@ func TestHostRestartsInputPublisherAfterEndpointPurgeWithoutResettingSequence(t } } +func TestHostCommitsEncodedInputBeforePurgeAndResetLifecycleBoundaries(t *testing.T) { + baseDriver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 8), + } + driver := &gatedFastInputDriver{ + fastInputDriver: baseDriver, gates: make(chan *inputSubmitGate, 2), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 8), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 472, device) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(context.Background()) + defer cancelServe() + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(serveCtx) }() + + endpointSequence, deviceSequence := uint64(1), uint64(1) + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + device.reports <- []byte{1} + select { + case report := <-driver.reports: + if report.Sequence != 1 || string(report.Payload) != string([]byte{1}) { + t.Fatalf("first accepted report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("first input report was not accepted") + } + + commitAcrossLifecycle := func(kind OperationKind, payload byte, wantSequence uint64) { + t.Helper() + gate := newInputSubmitGate() + driver.gates <- gate + device.reports <- []byte{payload} + select { + case candidate := <-gate.started: + if candidate.Sequence != wantSequence || string(candidate.Payload) != string([]byte{payload}) { + t.Fatalf("gated candidate=%+v want sequence=%d payload=%d", candidate, wantSequence, payload) + } + case <-time.After(time.Second): + t.Fatalf("input %d did not reach the driver commit boundary", payload) + } + + endpointSequence++ + deviceSequence++ + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: kind, + } + select { + case sequence := <-processor.lifecycle: + t.Fatalf("lifecycle sequence %d crossed an uncommitted encoded report", sequence) + case <-time.After(25 * time.Millisecond): + } + select { + case report := <-driver.reports: + t.Fatalf("gated report was accepted before driver release: %+v", report) + default: + } + + close(gate.release) + select { + case report := <-driver.reports: + if report.Sequence != wantSequence || string(report.Payload) != string([]byte{payload}) { + t.Fatalf("committed report=%+v want sequence=%d payload=%d", report, wantSequence, payload) + } + case <-time.After(time.Second): + t.Fatalf("encoded report %d was not committed", payload) + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatalf("lifecycle kind %d did not resume after input commit", kind) + } + } + + commitAcrossLifecycle(OperationEndpointPurge, 2, 2) + endpointSequence++ + deviceSequence++ + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: endpointSequence, + DeviceSequence: deviceSequence, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint restart after purge was not processed") + } + device.reports <- []byte{3} + select { + case report := <-driver.reports: + if report.Sequence != 3 || string(report.Payload) != string([]byte{3}) { + t.Fatalf("post-purge report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after purge/start") + } + + commitAcrossLifecycle(OperationEndpointReset, 4, 4) + device.reports <- []byte{5} + select { + case report := <-driver.reports: + if report.Sequence != 5 || string(report.Payload) != string([]byte{5}) { + t.Fatalf("post-reset report=%+v", report) + } + case <-time.After(time.Second): + t.Fatal("publisher did not resume after endpoint reset") + } + + cancelServe() + select { + case err = <-serveDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + +func TestHostOwnerCancellationBoundsWedgedEncodedInputCommit(t *testing.T) { + baseDriver := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 2), + } + driver := &gatedFastInputDriver{ + fastInputDriver: baseDriver, gates: make(chan *inputSubmitGate, 1), + } + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newScheduledInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 473, device) + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- host.Serve(context.Background()) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + + gate := newInputSubmitGate() + driver.gates <- gate + device.reports <- []byte{0x5a} + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("input did not reach wedged driver boundary") + } + unregisterDone := make(chan error, 1) + go func() { unregisterDone <- host.Unregister(context.Background(), identity) }() + select { + case unregisterErr := <-unregisterDone: + t.Fatalf("unregister crossed an uncommitted report: %v", unregisterErr) + case <-time.After(25 * time.Millisecond): + } + driver.mu.Lock() + destroyedBeforeStop := len(driver.destroyed) + driver.mu.Unlock() + if destroyedBeforeStop != 0 { + t.Fatal("driver removal crossed the pending input commit") + } + + // Endpoint lifecycle intentionally joins the commit. Owner-session + // cancellation is the bounded escape hatch for a driver that never accepts + // it, and must release both Serve and a waiting Unregister. + host.Close() + select { + case err = <-serveDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("owner cancellation did not release the wedged publisher") + } + select { + case err = <-unregisterDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("owner cancellation did not release unregister") + } + select { + case report := <-driver.reports: + t.Fatalf("owner-cancelled input was accepted: %+v", report) + default: + } +} + func TestHostReplaysCachedInputAtServiceDeadlineAcrossPurgeStart(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 8)} processor := &recordingProcessor{ diff --git a/usb/device.go b/usb/device.go index 26ee9b31..0142edef 100644 --- a/usb/device.go +++ b/usb/device.go @@ -1,6 +1,9 @@ package usb -import "context" +import ( + "context" + "time" +) // Transfer directions belong to the USB device contract, not to any concrete // transport. Keep these values aligned with the USB host convention used by @@ -40,6 +43,24 @@ type InterruptInputDevice interface { ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) } +// ScheduledInterruptInputDevice is the allocation-free deadline extension of +// InterruptInputDevice. Native transports keep one reusable timer per active +// endpoint and pass its channel here instead of creating a new timer-backed +// context for every USB service interval. Implementations must preserve the +// same behavior as ReadInterruptInput: ctx closes for lifecycle cancellation, +// while deadline firing represents context.DeadlineExceeded for this one read. +// Stateful devices may encode their current cached state at that boundary; +// event-only devices return context.DeadlineExceeded. +// +// The implementation must consume at most one value from deadline and must not +// retain either deadline or dst after returning. +type ScheduledInterruptInputDevice interface { + InterruptInputDevice + ReadScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, + ) (int, error) +} + // IsochronousInputDevice is the corresponding optional caller-buffer contract // for isochronous IN packets. The transport supplies exactly the packet region // owned by the current URB. The native scheduler invokes this at the packet's From ce51a3ed33b233df24479c930f5cbd740b6a2e1b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:29:26 -0500 Subject: [PATCH 167/240] Separate authenticated stream nonce domains --- docs/api/overview.md | 19 +- docs/architecture/native-udecx.md | 6 +- internal/server/api/auth/conn.go | 73 +++- .../server/api/auth/conn_internal_test.go | 82 +++- internal/server/api/auth/conn_test.go | 413 +++++++++++++----- internal/server/api/server.go | 4 +- viiperclient/stream.go | 12 +- viiperclient/stream_test.go | 20 +- viiperclient/transport.go | 6 +- viiperclient/transport_test.go | 6 +- 10 files changed, 494 insertions(+), 147 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 4a4e67b0..a93f9593 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -85,8 +85,23 @@ The exception to this are the device-control and feedback streams, which are raw (e.g., `bus/list\0` or `bus/create 5\0`) - **Payload**: optional string that can be a JSON object, numeric value, or plain string depending on the endpoint. The payload may contain newlines (e.g., pretty-printed JSON) as only the null byte terminates the request. -- **Success response**: a single line containing a JSON payload (or an empty line for commands that have no payload), terminated by connection close -- **Error response**: a single line JSON object following RFC 7807 Problem Details format with a `status` field (HTTP-style status code) and other error details, terminated by connection close +- **Success response**: a single line containing a JSON payload (or an empty line for commands that have no payload), terminated by connection close +- **Error response**: a single line JSON object following RFC 7807 Problem Details format with a `status` field (HTTP-style status code) and other error details, terminated by connection close + +Authenticated records use a 96-bit nonce split into a fixed 32-bit direction +domain (`0` for client-to-server and `1` for server-to-client) and a 64-bit +monotonic record counter. The Go server and Go client receivers require the +expected direction and exact next counter, rejecting role inversion, replay, +and reordering. This satisfies the [Go `cipher.AEAD` requirement that a nonce +be unique for a given key](https://pkg.go.dev/crypto/cipher#AEAD). The nonce +remains part of every wire record: existing generated clients continue to send +the client domain and decrypt the server domain directly from the record. + +Upgrade authenticated deployments server-first. Existing clients accept the +new server domain because each record carries its nonce, while the new Go +client intentionally rejects records from an older roleless server that still +sends domain `0`. Packaged service and Go client versions should otherwise be +kept matched. !!! tip "Testing the API" For quick testing, you can use tools like `netcat` (Linux/macOS) or PowerShell scripts (Windows) to send requests and read responses. diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 9ff6e554..3554bd68 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -193,8 +193,10 @@ The authenticated DS4Windows-to-service API which feeds that lane keeps its wire format but reuses one bounded receive slab per connection and decrypts records in place before copying into the caller's buffer. Full-duplex access uses independent read/write locks; concurrent writers are serialized into -whole records with monotonic nonces, and a partially emitted record closes the -now-unrecoverable stream instead of permitting a corrupt retry. +whole records. Client and server records use separate 32-bit nonce domains and +monotonic 64-bit counters; receivers enforce both the direction and exact next +counter. A partially emitted record closes the now-unrecoverable stream instead +of permitting a corrupt retry. Once correctness gates pass, high-rate media payloads may move to a preallocated ring while keeping the same token/generation lifecycle. Control and lifecycle operations remain IOCTL based. diff --git a/internal/server/api/auth/conn.go b/internal/server/api/auth/conn.go index 4947bd41..51d9e1fa 100644 --- a/internal/server/api/auth/conn.go +++ b/internal/server/api/auth/conn.go @@ -4,6 +4,7 @@ import ( "crypto/cipher" "encoding/binary" "errors" + "fmt" "io" "math" "net" @@ -15,7 +16,10 @@ import ( type Conn struct { net.Conn aead cipher.AEAD + sendNoncePrefix uint32 sendCtr uint64 + recvNoncePrefix uint32 + recvCtr uint64 sendBuf []byte recvHeader [4]byte recvHeaderRead int @@ -28,23 +32,53 @@ type Conn struct { sendErr error recvErr error sendExhausted bool + recvExhausted bool } -const maxPacketSize = 2 * 1024 * 1024 // 2 MB +const ( + maxPacketSize = 2 * 1024 * 1024 // 2 MB + + // ChaCha20-Poly1305 uses a 96-bit nonce. Split it into a fixed + // direction domain and a monotonically increasing record counter so the + // client and server never use the same nonce with their shared session + // key. cipher.AEAD requires every nonce to be unique for a given key. + clientNoncePrefix uint32 = 0 + serverNoncePrefix uint32 = 1 +) var ( errPacketTooLarge = errors.New("authenticated stream packet is too large") errPacketTooShort = errors.New("authenticated stream packet is too short") errNonceExhausted = errors.New("authenticated stream nonce space is exhausted") + errRecvExhausted = errors.New("authenticated stream receive nonce space is exhausted") errInvalidWrite = errors.New("authenticated stream transport returned an invalid write count") ) -func WrapConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { +// WrapClientConn authenticates conn as the client half of a VIIPER stream. +// A client wrapper must only be paired with a server wrapper using the same +// session key; the roles assign disjoint send-nonce domains. +func WrapClientConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + return wrapConn(conn, sessionKey, clientNoncePrefix, serverNoncePrefix) +} + +// WrapServerConn authenticates conn as the server half of a VIIPER stream. +// A server wrapper must only be paired with a client wrapper using the same +// session key; the roles assign disjoint send-nonce domains. +func WrapServerConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + return wrapConn(conn, sessionKey, serverNoncePrefix, clientNoncePrefix) +} + +func wrapConn(conn net.Conn, sessionKey []byte, sendNoncePrefix, recvNoncePrefix uint32) (net.Conn, error) { aead, err := chacha20poly1305.New(sessionKey) if err != nil { return nil, err } - return &Conn{Conn: conn, aead: aead}, nil + return &Conn{ + Conn: conn, + aead: aead, + sendNoncePrefix: sendNoncePrefix, + recvNoncePrefix: recvNoncePrefix, + }, nil } func (s *Conn) Close() error { @@ -63,8 +97,12 @@ func (s *Conn) Close() error { s.recvHeaderRead = 0 s.recvPacketRead = 0 s.recvRecordLength = 0 + s.sendNoncePrefix = 0 s.sendCtr = 0 + s.recvNoncePrefix = 0 + s.recvCtr = 0 s.sendExhausted = false + s.recvExhausted = false s.aead = nil if s.sendErr == nil { s.sendErr = net.ErrClosed @@ -99,8 +137,8 @@ func (s *Conn) Write(p []byte) (int, error) { } record := s.sendBuf[:4+nonceSize] nonce := record[4:] - clear(nonce) - binary.BigEndian.PutUint64(nonce[nonceSize-8:], s.sendCtr) + binary.BigEndian.PutUint32(nonce[:4], s.sendNoncePrefix) + binary.BigEndian.PutUint64(nonce[4:], s.sendCtr) record = s.aead.Seal(record, nonce, p, nil) binary.BigEndian.PutUint32(record[:4], uint32(len(record)-4)) @@ -206,6 +244,8 @@ func (s *Conn) readRecord() error { nonceSize := s.aead.NonceSize() nonce := s.recvPacket[:nonceSize] ct := s.recvPacket[nonceSize:] + noncePrefix := binary.BigEndian.Uint32(nonce[:4]) + nonceCounter := binary.BigEndian.Uint64(nonce[4:]) // AEAD permits dst and ciphertext to overlap exactly. Decrypting in place // keeps one reusable record slab per authenticated stream instead of @@ -216,9 +256,32 @@ func (s *Conn) readRecord() error { s.recvErr = err return err } + if err = s.validateReceiveNonce(noncePrefix, nonceCounter); err != nil { + clear(pt) + s.recvErr = err + return err + } s.recvPlain = pt s.recvHeaderRead = 0 s.recvPacketRead = 0 s.recvRecordLength = 0 return nil } + +func (s *Conn) validateReceiveNonce(prefix uint32, counter uint64) error { + if prefix != s.recvNoncePrefix { + return fmt.Errorf("authenticated stream nonce direction=%d, want %d", prefix, s.recvNoncePrefix) + } + if s.recvExhausted { + return errRecvExhausted + } + if counter != s.recvCtr { + return fmt.Errorf("authenticated stream nonce counter=%d, want %d", counter, s.recvCtr) + } + if s.recvCtr == math.MaxUint64 { + s.recvExhausted = true + } else { + s.recvCtr++ + } + return nil +} diff --git a/internal/server/api/auth/conn_internal_test.go b/internal/server/api/auth/conn_internal_test.go index 3bc67827..cc34541e 100644 --- a/internal/server/api/auth/conn_internal_test.go +++ b/internal/server/api/auth/conn_internal_test.go @@ -16,6 +16,11 @@ type internalRecordConn struct { bytes.Buffer } +type loopingInternalConn struct { + record []byte + offset int +} + func (*internalRecordConn) Close() error { return nil } func (*internalRecordConn) LocalAddr() net.Addr { return internalTestAddr("local") } func (*internalRecordConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } @@ -23,6 +28,26 @@ func (*internalRecordConn) SetDeadline(time.Time) error { return nil } func (*internalRecordConn) SetReadDeadline(time.Time) error { return nil } func (*internalRecordConn) SetWriteDeadline(time.Time) error { return nil } +func (c *loopingInternalConn) Read(p []byte) (int, error) { + if c.offset == len(c.record) { + c.offset = 0 + } + n := copy(p, c.record[c.offset:]) + c.offset += n + return n, nil +} +func (*loopingInternalConn) Write(p []byte) (int, error) { return len(p), nil } +func (*loopingInternalConn) Close() error { return nil } +func (*loopingInternalConn) LocalAddr() net.Addr { return internalTestAddr("local") } +func (*loopingInternalConn) RemoteAddr() net.Addr { return internalTestAddr("remote") } +func (*loopingInternalConn) SetDeadline(time.Time) error { return nil } +func (*loopingInternalConn) SetReadDeadline(time.Time) error { + return nil +} +func (*loopingInternalConn) SetWriteDeadline(time.Time) error { + return nil +} + type internalTestAddr string func (a internalTestAddr) Network() string { return string(a) } @@ -65,7 +90,7 @@ func TestConnUsesFinalCounterNonceExactlyOnceBeforeExhaustion(t *testing.T) { t.Fatal(err) } raw := &internalRecordConn{} - wrapper, err := WrapConn(raw, key) + wrapper, err := WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -86,10 +111,12 @@ func TestConnUsesFinalCounterNonceExactlyOnceBeforeExhaustion(t *testing.T) { t.Fatal("nonce-exhausted write emitted wire data") } - receiverWrapper, err := WrapConn(raw, key) + receiverWrapper, err := WrapServerConn(raw, key) if err != nil { t.Fatal(err) } + receiver := receiverWrapper.(*Conn) + receiver.recvCtr = math.MaxUint64 decoded := make([]byte, len(payload)) if _, err = io.ReadFull(receiverWrapper, decoded); err != nil { t.Fatal(err) @@ -97,6 +124,46 @@ func TestConnUsesFinalCounterNonceExactlyOnceBeforeExhaustion(t *testing.T) { if !bytes.Equal(decoded, payload) { t.Fatalf("final nonce plaintext=%q want=%q", decoded, payload) } + _, _ = raw.Buffer.Write(wire) + if n, readErr := receiver.Read(decoded[:1]); n != 0 || !errors.Is(readErr, errRecvExhausted) { + t.Fatalf("receive after final nonce=(%d, %v), want (0, %v)", n, readErr, errRecvExhausted) + } +} + +func BenchmarkConnReadAuthenticatedRecord(b *testing.B) { + key, err := DeriveKey("authenticated-read-benchmark") + if err != nil { + b.Fatal(err) + } + payload := make([]byte, 512) + wire := &internalRecordConn{} + sender, err := WrapClientConn(wire, key) + if err != nil { + b.Fatal(err) + } + if _, err = sender.Write(payload); err != nil { + b.Fatal(err) + } + raw := &loopingInternalConn{record: append([]byte(nil), wire.Bytes()...)} + wrapper, err := WrapServerConn(raw, key) + if err != nil { + b.Fatal(err) + } + receiver := wrapper.(*Conn) + dst := make([]byte, len(payload)) + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + // The transport loops one authenticated counter-zero fixture to isolate + // decrypt/copy cost. Reset only the expected test counter; production + // streams reject this replay. + receiver.recvCtr = 0 + receiver.recvExhausted = false + if _, err = io.ReadFull(receiver, dst); err != nil { + b.Fatal(err) + } + } } func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { @@ -105,7 +172,7 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { t.Fatal(err) } raw := &internalRecordConn{} - senderWrapper, err := WrapConn(raw, key) + senderWrapper, err := WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -119,7 +186,7 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { if err = sender.Close(); err != nil { t.Fatal(err) } - if sender.aead != nil || sender.sendBuf != nil { + if sender.aead != nil || sender.sendBuf != nil || sender.sendNoncePrefix != 0 || sender.sendCtr != 0 { t.Fatal("close retained send cipher or record state") } for i, value := range sendBacking { @@ -133,7 +200,7 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { receiveRaw := &internalRecordConn{} _, _ = receiveRaw.Buffer.Write(wire) - receiverWrapper, err := WrapConn(receiveRaw, key) + receiverWrapper, err := WrapServerConn(receiveRaw, key) if err != nil { t.Fatal(err) } @@ -148,7 +215,8 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { if err = receiver.Close(); err != nil { t.Fatal(err) } - if receiver.aead != nil || receiver.recvPacket != nil || receiver.recvPlain != nil { + if receiver.aead != nil || receiver.recvPacket != nil || receiver.recvPlain != nil || + receiver.recvNoncePrefix != 0 || receiver.recvCtr != 0 || receiver.recvExhausted { t.Fatal("close retained receive cipher or record state") } for i, value := range receiveBacking { @@ -169,7 +237,7 @@ func TestConnCloseUnblocksAndJoinsConcurrentReadAndWrite(t *testing.T) { raw := &blockingInternalConn{ readStarted: make(chan struct{}), writeStarted: make(chan struct{}), closed: make(chan struct{}), } - wrapper, err := WrapConn(raw, key) + wrapper, err := WrapClientConn(raw, key) if err != nil { t.Fatal(err) } diff --git a/internal/server/api/auth/conn_test.go b/internal/server/api/auth/conn_test.go index 840b1c98..fdc0078e 100644 --- a/internal/server/api/auth/conn_test.go +++ b/internal/server/api/auth/conn_test.go @@ -6,12 +6,14 @@ import ( "errors" "io" "net" + "strings" "sync" "testing" "time" "github.com/Alia5/VIIPER/internal/server/api/auth" "github.com/stretchr/testify/assert" + "golang.org/x/crypto/chacha20poly1305" ) type recordConn struct { @@ -124,11 +126,6 @@ func (discardConn) SetDeadline(time.Time) error { return nil } func (discardConn) SetReadDeadline(time.Time) error { return nil } func (discardConn) SetWriteDeadline(time.Time) error { return nil } -type loopingReadConn struct { - record []byte - offset int -} - type segmentedReadConn struct { recordConn segments [][]byte @@ -150,26 +147,6 @@ func (c *segmentedReadConn) Read(p []byte) (int, error) { return n, nil } -func (c *loopingReadConn) Read(p []byte) (int, error) { - if c.offset == len(c.record) { - c.offset = 0 - } - n := copy(p, c.record[c.offset:]) - c.offset += n - return n, nil -} -func (*loopingReadConn) Write(p []byte) (int, error) { return len(p), nil } -func (*loopingReadConn) Close() error { return nil } -func (*loopingReadConn) LocalAddr() net.Addr { return testAddr("local") } -func (*loopingReadConn) RemoteAddr() net.Addr { return testAddr("remote") } -func (*loopingReadConn) SetDeadline(time.Time) error { return nil } -func (*loopingReadConn) SetReadDeadline(time.Time) error { - return nil -} -func (*loopingReadConn) SetWriteDeadline(time.Time) error { - return nil -} - func (c *recordConn) Write(p []byte) (int, error) { c.writeCalls++ if c.maxWrite > 0 && len(p) > c.maxWrite { @@ -190,17 +167,270 @@ type testAddr string func (a testAddr) Network() string { return string(a) } func (a testAddr) String() string { return string(a) } +func legacyRolelessRecord(t *testing.T, key, payload []byte, counter uint64) []byte { + t.Helper() + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, aead.NonceSize()) + binary.BigEndian.PutUint64(nonce[4:], counter) + record := make([]byte, 4, 4+len(nonce)+len(payload)+aead.Overhead()) + record = append(record, nonce...) + record = aead.Seal(record, nonce, payload, nil) + binary.BigEndian.PutUint32(record[:4], uint32(len(record)-4)) + return record +} + +func TestConnNewServerAcceptsLegacyClientWireDomain(t *testing.T) { + key, err := auth.DeriveKey("legacy-client-new-server") + if err != nil { + t.Fatal(err) + } + payload := []byte("legacy client request") + raw := &recordConn{} + _, _ = raw.Buffer.Write(legacyRolelessRecord(t, key, payload, 0)) + server, err := auth.WrapServerConn(raw, key) + if err != nil { + t.Fatal(err) + } + decoded := make([]byte, len(payload)) + if _, err = io.ReadFull(server, decoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("legacy client plaintext=%q want=%q", decoded, payload) + } +} + +func TestConnNewClientRejectsLegacyRolelessServerDomain(t *testing.T) { + key, err := auth.DeriveKey("new-client-legacy-server") + if err != nil { + t.Fatal(err) + } + payload := []byte("legacy server response") + raw := &recordConn{} + _, _ = raw.Buffer.Write(legacyRolelessRecord(t, key, payload, 0)) + client, err := auth.WrapClientConn(raw, key) + if err != nil { + t.Fatal(err) + } + dst := bytes.Repeat([]byte{0xa5}, len(payload)) + if n, readErr := client.Read(dst); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce direction=0, want 1") { + t.Fatalf("legacy-server read=(%d, %v), want fail-closed direction error", n, readErr) + } + if !bytes.Equal(dst, bytes.Repeat([]byte{0xa5}, len(payload))) { + t.Fatal("rejected legacy-server record changed the caller plaintext buffer") + } +} + +func TestConnUsesDisjointDirectionalNonceDomains(t *testing.T) { + key, err := auth.DeriveKey("directional-nonce-domains") + if err != nil { + t.Fatal(err) + } + clientWire := &recordConn{} + serverWire := &recordConn{} + client, err := auth.WrapClientConn(clientWire, key) + if err != nil { + t.Fatal(err) + } + server, err := auth.WrapServerConn(serverWire, key) + if err != nil { + t.Fatal(err) + } + payload := []byte("same-session duplex record") + if _, err = client.Write(payload); err != nil { + t.Fatal(err) + } + if _, err = server.Write(payload); err != nil { + t.Fatal(err) + } + + clientNonce := clientWire.Bytes()[4:16] + serverNonce := serverWire.Bytes()[4:16] + if bytes.Equal(clientNonce, serverNonce) { + t.Fatalf("client and server reused nonce %x with one session key", clientNonce) + } + if prefix := binary.BigEndian.Uint32(clientNonce[:4]); prefix != 0 { + t.Fatalf("client nonce prefix=%d want=0", prefix) + } + if prefix := binary.BigEndian.Uint32(serverNonce[:4]); prefix != 1 { + t.Fatalf("server nonce prefix=%d want=1", prefix) + } + if counter := binary.BigEndian.Uint64(clientNonce[4:]); counter != 0 { + t.Fatalf("first client nonce counter=%d want=0", counter) + } + if counter := binary.BigEndian.Uint64(serverNonce[4:]); counter != 0 { + t.Fatalf("first server nonce counter=%d want=0", counter) + } +} + +func TestConnSupportsConcurrentFullDuplexDirectionalTraffic(t *testing.T) { + key, err := auth.DeriveKey("full-duplex-directions") + if err != nil { + t.Fatal(err) + } + clientTransport, serverTransport := net.Pipe() + deadline := time.Now().Add(2 * time.Second) + if err = clientTransport.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + if err = serverTransport.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + client, err := auth.WrapClientConn(clientTransport, key) + if err != nil { + t.Fatal(err) + } + server, err := auth.WrapServerConn(serverTransport, key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + + clientPayload := []byte("client controller state") + serverPayload := []byte("server output media") + type writeResult struct { + name string + n int + err error + } + writes := make(chan writeResult, 2) + go func() { + n, writeErr := client.Write(clientPayload) + writes <- writeResult{name: "client", n: n, err: writeErr} + }() + go func() { + n, writeErr := server.Write(serverPayload) + writes <- writeResult{name: "server", n: n, err: writeErr} + }() + + gotClientPayload := make([]byte, len(clientPayload)) + if _, err = io.ReadFull(server, gotClientPayload); err != nil { + t.Fatal(err) + } + gotServerPayload := make([]byte, len(serverPayload)) + if _, err = io.ReadFull(client, gotServerPayload); err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotClientPayload, clientPayload) || !bytes.Equal(gotServerPayload, serverPayload) { + t.Fatalf("duplex plaintext=%q/%q want=%q/%q", gotClientPayload, gotServerPayload, clientPayload, serverPayload) + } + for range 2 { + result := <-writes + want := len(clientPayload) + if result.name == "server" { + want = len(serverPayload) + } + if result.n != want || result.err != nil { + t.Fatalf("%s write=(%d, %v), want (%d, nil)", result.name, result.n, result.err, want) + } + } +} + +func TestConnRejectsWrongDirectionalRole(t *testing.T) { + key, err := auth.DeriveKey("wrong-directional-role") + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + wrapOut func(net.Conn, []byte) (net.Conn, error) + wrapIn func(net.Conn, []byte) (net.Conn, error) + }{ + {name: "server_wrapper_on_client", wrapOut: auth.WrapServerConn, wrapIn: auth.WrapServerConn}, + {name: "client_wrapper_on_server", wrapOut: auth.WrapClientConn, wrapIn: auth.WrapClientConn}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw := &recordConn{} + sender, wrapErr := tc.wrapOut(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + receiver, wrapErr := tc.wrapIn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if _, writeErr := sender.Write([]byte("valid but wrong-direction record")); writeErr != nil { + t.Fatal(writeErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce direction") { + t.Fatalf("wrong-role read=(%d, %v), want (0, nonce direction error)", n, readErr) + } + }) + } +} + +func TestConnRejectsAuthenticatedReplayAndOutOfOrderRecords(t *testing.T) { + key, err := auth.DeriveKey("record-order") + if err != nil { + t.Fatal(err) + } + wireBuffer := &recordConn{} + sender, err := auth.WrapClientConn(wireBuffer, key) + if err != nil { + t.Fatal(err) + } + firstPayload := []byte("first") + secondPayload := []byte("second") + if _, err = sender.Write(firstPayload); err != nil { + t.Fatal(err) + } + firstLength := 4 + int(binary.BigEndian.Uint32(wireBuffer.Bytes()[:4])) + if _, err = sender.Write(secondPayload); err != nil { + t.Fatal(err) + } + wire := append([]byte(nil), wireBuffer.Bytes()...) + firstRecord := wire[:firstLength] + secondRecord := wire[firstLength:] + + t.Run("replay", func(t *testing.T) { + raw := &recordConn{} + _, _ = raw.Buffer.Write(firstRecord) + _, _ = raw.Buffer.Write(firstRecord) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + decoded := make([]byte, len(firstPayload)) + if _, readErr := io.ReadFull(receiver, decoded); readErr != nil { + t.Fatal(readErr) + } + if n, readErr := receiver.Read(decoded[:1]); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce counter=0, want 1") { + t.Fatalf("replayed read=(%d, %v), want counter rejection", n, readErr) + } + }) + + t.Run("out_of_order", func(t *testing.T) { + raw := &recordConn{} + _, _ = raw.Buffer.Write(secondRecord) + _, _ = raw.Buffer.Write(firstRecord) + receiver, wrapErr := auth.WrapServerConn(raw, key) + if wrapErr != nil { + t.Fatal(wrapErr) + } + if n, readErr := receiver.Read(make([]byte, 1)); n != 0 || readErr == nil || !strings.Contains(readErr.Error(), "nonce counter=1, want 0") { + t.Fatalf("out-of-order read=(%d, %v), want counter rejection", n, readErr) + } + }) +} + func TestConnCoalescesOneAuthenticatedRecordIntoOneWrite(t *testing.T) { key, err := auth.DeriveKey("coalesced-record") if err != nil { t.Fatal(err) } raw := &recordConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } - receiver, err := auth.WrapConn(raw, key) + receiver, err := auth.WrapServerConn(raw, key) if err != nil { t.Fatal(err) } @@ -226,8 +456,8 @@ func TestConnFinishesPartialUnderlyingWritesWithoutSplittingARecord(t *testing.T t.Fatal(err) } raw := &recordConn{maxWrite: 3} - sender, _ := auth.WrapConn(raw, key) - receiver, _ := auth.WrapConn(raw, key) + sender, _ := auth.WrapClientConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) payload := []byte("partial writes are completed") if _, err = sender.Write(payload); err != nil { t.Fatal(err) @@ -254,7 +484,7 @@ func TestConnRejectsTruncatedAuthenticatedRecordWithoutPanicking(t *testing.T) { binary.BigEndian.PutUint32(header[:], 1) _, _ = raw.Buffer.Write(header[:]) _ = raw.Buffer.WriteByte(0) - receiver, _ := auth.WrapConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) if _, err = receiver.Read(make([]byte, 1)); err == nil { t.Fatal("truncated authenticated record was accepted") } @@ -278,7 +508,7 @@ func TestConnRejectsInvalidRecordLengthTerminallyBeforeAllocation(t *testing.T) var header [4]byte binary.BigEndian.PutUint32(header[:], tc.length) _, _ = raw.Buffer.Write(header[:]) - receiver, wrapErr := auth.WrapConn(raw, key) + receiver, wrapErr := auth.WrapServerConn(raw, key) if wrapErr != nil { t.Fatal(wrapErr) } @@ -302,7 +532,7 @@ func TestConnSerializesConcurrentRecordsWithMonotonicNonces(t *testing.T) { t.Fatal(err) } raw := &recordConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -349,7 +579,7 @@ func TestConnSerializesConcurrentRecordsWithMonotonicNonces(t *testing.T) { t.Fatalf("%d trailing authenticated bytes", len(wire)) } - receiver, err := auth.WrapConn(raw, key) + receiver, err := auth.WrapServerConn(raw, key) if err != nil { t.Fatal(err) } @@ -373,7 +603,7 @@ func TestConnClosesAfterPartialRecordFailure(t *testing.T) { } wantErr := errors.New("injected transport failure") raw := &partialFailureConn{remaining: 7, err: wantErr} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -399,7 +629,7 @@ func TestConnReturnsCompletePlaintextCountWhenTransportReportsFullWriteAndError( } wantErr := errors.New("transport failed after accepting the record") raw := &fullWriteErrorConn{err: wantErr} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -418,7 +648,7 @@ func TestConnReturnsCompletePlaintextCountWhenTransportReportsFullWriteAndError( t.Fatal("terminal stream emitted bytes after a full-record transport error") } - receiver, err := auth.WrapConn(raw, key) + receiver, err := auth.WrapServerConn(raw, key) if err != nil { t.Fatal(err) } @@ -438,7 +668,7 @@ func TestConnRetriesRecordAfterZeroByteTransportErrorWithoutNonceReuseOnWire(t * } wantErr := errors.New("temporary transport error") raw := &firstWriteErrorConn{err: wantErr, first: true} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -463,7 +693,7 @@ func TestConnClosesAfterZeroProgressWrite(t *testing.T) { t.Fatal(err) } raw := &zeroProgressConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -484,7 +714,7 @@ func TestConnRejectsOversizedRecordBeforeTransportWrite(t *testing.T) { t.Fatal(err) } raw := &recordConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -502,7 +732,7 @@ func TestConnAcceptsExactMaximumRecordBound(t *testing.T) { t.Fatal(err) } raw := &recordConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -516,7 +746,7 @@ func TestConnAcceptsExactMaximumRecordBound(t *testing.T) { t.Fatalf("maximum wire record length=%d", got) } - receiver, err := auth.WrapConn(raw, key) + receiver, err := auth.WrapServerConn(raw, key) if err != nil { t.Fatal(err) } @@ -535,7 +765,7 @@ func TestConnResumesInterruptedAuthenticatedFramingWithoutExposingWireBytes(t *t t.Fatal(err) } wireBuffer := &recordConn{} - sender, err := auth.WrapConn(wireBuffer, key) + sender, err := auth.WrapClientConn(wireBuffer, key) if err != nil { t.Fatal(err) } @@ -556,7 +786,7 @@ func TestConnResumesInterruptedAuthenticatedFramingWithoutExposingWireBytes(t *t t.Run(tc.name, func(t *testing.T) { raw := &interruptedReadConn{beforeError: tc.beforeError, err: wantErr} _, _ = raw.Buffer.Write(wire) - receiver, wrapErr := auth.WrapConn(raw, key) + receiver, wrapErr := auth.WrapServerConn(raw, key) if wrapErr != nil { t.Fatal(wrapErr) } @@ -583,7 +813,7 @@ func TestConnSkipsAuthenticatedEmptyRecordAndZeroLengthReadDoesNotConsumeWire(t t.Fatal(err) } raw := &recordConn{} - sender, err := auth.WrapConn(raw, key) + sender, err := auth.WrapClientConn(raw, key) if err != nil { t.Fatal(err) } @@ -595,7 +825,7 @@ func TestConnSkipsAuthenticatedEmptyRecordAndZeroLengthReadDoesNotConsumeWire(t t.Fatal(err) } wireLength := raw.Len() - receiver, err := auth.WrapConn(raw, key) + receiver, err := auth.WrapServerConn(raw, key) if err != nil { t.Fatal(err) } @@ -620,8 +850,8 @@ func TestConnReadCopiesPlaintextOutOfReusableRecordSlab(t *testing.T) { t.Fatal(err) } raw := &recordConn{} - sender, _ := auth.WrapConn(raw, key) - receiver, _ := auth.WrapConn(raw, key) + sender, _ := auth.WrapClientConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) firstWant := []byte("first-frame") secondWant := []byte("second-frame") _, _ = sender.Write(firstWant) @@ -645,7 +875,7 @@ func TestConnReadPreservesFourSegmentClientWireCompatibility(t *testing.T) { t.Fatal(err) } wireBuffer := &recordConn{} - sender, _ := auth.WrapConn(wireBuffer, key) + sender, _ := auth.WrapClientConn(wireBuffer, key) payload := []byte("header nonce ciphertext tag remain one protocol record") if _, err = sender.Write(payload); err != nil { t.Fatal(err) @@ -655,7 +885,7 @@ func TestConnReadPreservesFourSegmentClientWireCompatibility(t *testing.T) { raw := &segmentedReadConn{segments: [][]byte{ wire[:4], wire[4:16], wire[16:tagStart], wire[tagStart:], }} - receiver, _ := auth.WrapConn(raw, key) + receiver, _ := auth.WrapServerConn(raw, key) decoded := make([]byte, len(payload)) if _, err = io.ReadFull(receiver, decoded); err != nil { t.Fatal(err) @@ -670,7 +900,7 @@ func BenchmarkConnWriteAuthenticatedRecord(b *testing.B) { if err != nil { b.Fatal(err) } - wrapped, err := auth.WrapConn(discardConn{}, key) + wrapped, err := auth.WrapClientConn(discardConn{}, key) if err != nil { b.Fatal(err) } @@ -688,41 +918,10 @@ func BenchmarkConnWriteAuthenticatedRecord(b *testing.B) { } } -func BenchmarkConnReadAuthenticatedRecord(b *testing.B) { - key, err := auth.DeriveKey("authenticated-read-benchmark") - if err != nil { - b.Fatal(err) - } - payload := make([]byte, 512) - wire := &recordConn{} - sender, _ := auth.WrapConn(wire, key) - if _, err = sender.Write(payload); err != nil { - b.Fatal(err) - } - raw := &loopingReadConn{record: append([]byte(nil), wire.Bytes()...)} - wrapped, err := auth.WrapConn(raw, key) - if err != nil { - b.Fatal(err) - } - dst := make([]byte, len(payload)) - if _, err = io.ReadFull(wrapped, dst); err != nil { - b.Fatal(err) - } - b.SetBytes(int64(len(payload))) - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - if _, err = io.ReadFull(wrapped, dst); err != nil { - b.Fatal(err) - } - } -} - func TestConn(t *testing.T) { type testCase struct { name string - wrapConn func(net.Conn, []byte) (net.Conn, error) setupFn func(clientConn net.Conn, serverConn net.Conn) (clientKey []byte, serverKey []byte) input []byte expected []byte @@ -731,8 +930,7 @@ func TestConn(t *testing.T) { testCases := []testCase{ { - name: "valid read", - wrapConn: auth.WrapConn, + name: "valid read", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { password := "test123" key, err := auth.DeriveKey(password) @@ -745,8 +943,7 @@ func TestConn(t *testing.T) { expected: []byte("Hello, World!"), }, { - name: "Differing Keys", - wrapConn: auth.WrapConn, + name: "Differing Keys", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -763,8 +960,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: message authentication failed"), }, { - name: "bad key length (client)", - wrapConn: auth.WrapConn, + name: "bad key length (client)", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -777,8 +973,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: bad key length"), }, { - name: "bad key length (server)", - wrapConn: auth.WrapConn, + name: "bad key length (server)", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -791,8 +986,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("chacha20poly1305: bad key length"), }, { - name: "client closed before write", - wrapConn: auth.WrapConn, + name: "client closed before write", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -806,8 +1000,7 @@ func TestConn(t *testing.T) { expectedErr: errors.New("use of closed network connection"), }, { - name: "server closed before read", - wrapConn: auth.WrapConn, + name: "server closed before read", setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { key, err := auth.DeriveKey("test123") if err != nil { @@ -847,27 +1040,23 @@ func TestConn(t *testing.T) { clientKey, serverKey = tc.setupFn(clientConn, serverConn) } - var wrappedServerConn net.Conn - var wrappedClientConn net.Conn - if tc.wrapConn != nil { - wrappedServerConn, err = tc.wrapConn(serverConn, serverKey) - if err != nil { - if tc.expectedErr != nil { - assert.ErrorContains(t, err, tc.expectedErr.Error()) - } else { - t.Fatalf("failed to wrap server conn: %v", err) - } - return + wrappedServerConn, err := auth.WrapServerConn(serverConn, serverKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap server conn: %v", err) } - wrappedClientConn, err = tc.wrapConn(clientConn, clientKey) - if err != nil { - if tc.expectedErr != nil { - assert.ErrorContains(t, err, tc.expectedErr.Error()) - } else { - t.Fatalf("failed to wrap client conn: %v", err) - } - return + return + } + wrappedClientConn, err := auth.WrapClientConn(clientConn, clientKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap client conn: %v", err) } + return } _, err = wrappedClientConn.Write(tc.input) diff --git a/internal/server/api/server.go b/internal/server/api/server.go index e66fc73d..d78fe91a 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -200,7 +200,7 @@ func (s *Server) writeOK(w io.Writer, rest string) { } func (s *Server) handleConn(conn net.Conn) { - defer conn.Close() //nolint:errcheck + defer func() { _ = conn.Close() }() connCtx, connCancel := context.WithCancel(context.Background()) defer connCancel() @@ -241,7 +241,7 @@ func (s *Server) handleConn(conn net.Conn) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secConn, err := auth.WrapConn(conn, sessionKey) + secConn, err := auth.WrapServerConn(conn, sessionKey) if err != nil { connLogger.Error("wrap secure conn failed", "error", err) return diff --git a/viiperclient/stream.go b/viiperclient/stream.go index 2e6b6ea0..eff150e6 100644 --- a/viiperclient/stream.go +++ b/viiperclient/stream.go @@ -40,6 +40,12 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D if err != nil { return nil, fmt.Errorf("dial: %w", err) } + keepConn := false + defer func() { + if !keepConn { + _ = conn.Close() + } + }() if tcpConn, ok := conn.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { @@ -58,16 +64,15 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D return nil, err } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - conn, err = auth.WrapConn(conn, sessionKey) + secureConn, err := auth.WrapClientConn(conn, sessionKey) if err != nil { - conn.Close() // nolint return nil, err } + conn = secureConn } streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { - conn.Close() // nolint return nil, fmt.Errorf("write stream path: %w", err) } @@ -76,6 +81,7 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D BusID: busID, DevID: devID, } + keepConn = true return ds, nil } diff --git a/viiperclient/stream_test.go b/viiperclient/stream_test.go index 6af6ef41..8a61a342 100644 --- a/viiperclient/stream_test.go +++ b/viiperclient/stream_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "strings" + "sync/atomic" "testing" "time" @@ -28,6 +29,8 @@ import ( "github.com/stretchr/testify/require" ) +var streamOperationBusID atomic.Uint32 + func TestOpenStream_NotSupportedWithMockTransport(t *testing.T) { c := testClient(map[string]string{}, nil) _, err := c.OpenStream(context.Background(), 1, "1") @@ -105,13 +108,11 @@ func TestDeviceStream_Operations(t *testing.T) { tests := []struct { name string - busID uint32 customRegistration bool op operation }{ { - name: "read deadline timeout", - busID: 201, + name: "read deadline timeout", op: func(t *testing.T, stream *viiperclient.DeviceStream) { // Force immediate timeout by setting deadline in the past. require.NoError(t, stream.SetReadDeadline(time.Now().Add(-10*time.Millisecond))) @@ -128,7 +129,6 @@ func TestDeviceStream_Operations(t *testing.T) { }, { name: "closed stream read/write errors", - busID: 202, customRegistration: true, op: func(t *testing.T, stream *viiperclient.DeviceStream) { require.NoError(t, stream.Close()) @@ -145,6 +145,10 @@ func TestDeviceStream_Operations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // The server deliberately keeps a disconnected device alive for its + // reconnection grace period. A fresh ID makes -count repetitions + // independent without weakening that production lifecycle behavior. + busID := 200_000 + streamOperationBusID.Add(1) usbSrv := usb.New(usb.ServerConfig{Addr: "127.0.0.1:0"}, slog.Default(), log.NewRaw(nil)) ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -168,12 +172,12 @@ func TestDeviceStream_Operations(t *testing.T) { require.NoError(t, apiSrv.Start()) defer apiSrv.Close() //nolint:errcheck - b, err := virtualbus.NewWithBusID(tt.busID) + b, err := virtualbus.NewWithBusID(busID) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(b)) c := viiperclient.New(addr) - stream, devResp, err := c.AddDeviceAndConnect(context.Background(), tt.busID, "xbox360", nil) + stream, devResp, err := c.AddDeviceAndConnect(context.Background(), busID, "xbox360", nil) require.NoError(t, err) require.NotNil(t, devResp) require.NotNil(t, stream) @@ -215,10 +219,10 @@ func TestEncryptedStream(t *testing.T) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secureConn, err := auth.WrapConn(conn, sessionKey) + conn, err = auth.WrapServerConn(conn, sessionKey) assert.NoError(t, err) - rr := bufio.NewReader(secureConn) + rr := bufio.NewReader(conn) line, err := rr.ReadString('\x00') if err != nil { return diff --git a/viiperclient/transport.go b/viiperclient/transport.go index 646bb5d4..a260ce9d 100644 --- a/viiperclient/transport.go +++ b/viiperclient/transport.go @@ -102,7 +102,7 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if err != nil { return "", fmt.Errorf("dial: %w", err) } - defer conn.Close() //nolint:errcheck + defer func() { _ = conn.Close() }() if tcpConn, ok := conn.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { @@ -129,11 +129,11 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar return "", err } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - conn, err = auth.WrapConn(conn, sessionKey) + secureConn, err := auth.WrapClientConn(conn, sessionKey) if err != nil { - conn.Close() // nolint return "", err } + conn = secureConn } if _, err := conn.Write(append(lineBytes, '\x00')); err != nil { diff --git a/viiperclient/transport_test.go b/viiperclient/transport_test.go index 346a0da4..36cfc384 100644 --- a/viiperclient/transport_test.go +++ b/viiperclient/transport_test.go @@ -197,16 +197,16 @@ func TestEncryptedTransport(t *testing.T) { } sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) - secureConn, err := auth.WrapConn(conn, sessionKey) + conn, err = auth.WrapServerConn(conn, sessionKey) assert.NoError(t, err) - rr := bufio.NewReader(secureConn) + rr := bufio.NewReader(conn) line, err := rr.ReadString('\x00') if err != nil { return } - _, err = secureConn.Write([]byte(line)) + _, err = conn.Write([]byte(line)) assert.NoError(t, err) } From 74abace42724bba7a839e95309835498eb432fb1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:36:31 -0500 Subject: [PATCH 168/240] Add source-bound controller latency gate --- _testing/e2e/latency/report.go | 968 ++++++++++++++ _testing/e2e/latency/report_test.go | 525 ++++++++ _testing/e2e/latency_gate_unsupported_test.go | 17 + _testing/e2e/latency_gate_windows_test.go | 1116 +++++++++++++++++ .../scripts/Invoke-ViiperE2ELatencyGate.ps1 | 330 +++++ _testing/e2e/sdl/gamepad.go | 73 ++ _testing/e2e/sdl/sdl_nocgo.go | 43 +- docs/testing/e2e_latency.md | 326 +++-- 8 files changed, 3308 insertions(+), 90 deletions(-) create mode 100644 _testing/e2e/latency/report.go create mode 100644 _testing/e2e/latency/report_test.go create mode 100644 _testing/e2e/latency_gate_unsupported_test.go create mode 100644 _testing/e2e/latency_gate_windows_test.go create mode 100644 _testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 diff --git a/_testing/e2e/latency/report.go b/_testing/e2e/latency/report.go new file mode 100644 index 00000000..af0df05d --- /dev/null +++ b/_testing/e2e/latency/report.go @@ -0,0 +1,968 @@ +package latency + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var productionPhaseSweepOffsetsNS = [...]int64{ + 0, + 125 * int64(time.Microsecond), + 250 * int64(time.Microsecond), + 375 * int64(time.Microsecond), + 500 * int64(time.Microsecond), + 625 * int64(time.Microsecond), + 750 * int64(time.Microsecond), + 875 * int64(time.Microsecond), +} + +const ( + SchemaV1 = "viiper.controller-to-game.latency/v1" + SuiteSchemaV1 = "viiper.controller-to-game.latency-suite/v1" + TransportUSBIP = "usbip" + TransportNativeUDE = "native-ude" + AuthenticationMode = "password-authenticated-encrypted-stream" + MinimumProductionSamplePairs = 256 + MaximumProductionSamplePairs = 10_000 + ProductionWarmupPairs = 16 + ProductionTransportBlocks = 2 + ProductionTransitionTimeoutNS int64 = int64(time.Second) + ProductionInterTransitionDelayNS int64 = 2 * int64(time.Millisecond) + DefaultNativeMaxP95NS int64 = 4 * int64(time.Millisecond) + DefaultNativeMaxP99NS int64 = 8 * int64(time.Millisecond) + DefaultNativeMaxNS int64 = 20 * int64(time.Millisecond) + DefaultNativeMaxP95OverUSBIPNS int64 = 1 * int64(time.Millisecond) + DefaultNativeMaxP99OverUSBIPNS int64 = 2 * int64(time.Millisecond) + DefaultNativeMaxOverUSBIPNS int64 = 5 * int64(time.Millisecond) +) + +type Transition string + +const ( + TransitionPress Transition = "press" + TransitionRelease Transition = "release" +) + +// BlockSpec is one block in the counterbalanced ABBA transport schedule. +// Sample sequence numbers are contiguous within each transport, even though +// the two transports are interleaved in wall-clock order. +type BlockSpec struct { + Order int + Transport string + TransportBlock int + FirstSequence int + SamplePairs int +} + +// ProductionBlockSchedule splits the declared samples as evenly as possible +// across USB/IP, native UDE, native UDE, then USB/IP. The ABBA order controls +// first/last-run drift without discarding the identity proof for either block. +func ProductionBlockSchedule(samplePairs int) []BlockSpec { + firstBlockPairs := samplePairs / ProductionTransportBlocks + secondBlockPairs := samplePairs - firstBlockPairs + secondFirstSequence := firstBlockPairs + 1 + return []BlockSpec{ + {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, + FirstSequence: 1, SamplePairs: firstBlockPairs}, + {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, + FirstSequence: 1, SamplePairs: firstBlockPairs}, + {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, + FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, + {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, + FirstSequence: secondFirstSequence, SamplePairs: secondBlockPairs}, + } +} + +// ProductionPhaseSweepOffsetsNS returns a copy of the deterministic dwell +// offsets. Added to the 2 ms base dwell, the offsets span one 1 ms HID service +// interval without randomizing otherwise reproducible runs. +func ProductionPhaseSweepOffsetsNS() []int64 { + return append([]int64(nil), productionPhaseSweepOffsetsNS[:]...) +} + +// PhaseSweepScheduleSHA256 hashes the comma-separated base-10 nanosecond +// offsets. That canonical representation is recorded in every workload. +func PhaseSweepScheduleSHA256(offsets []int64) string { + var canonical strings.Builder + for index, offset := range offsets { + if index != 0 { + canonical.WriteByte(',') + } + canonical.WriteString(strconv.FormatInt(offset, 10)) + } + digest := sha256.Sum256([]byte(canonical.String())) + return fmt.Sprintf("%x", digest) +} + +// ProductionPhaseOffsetNS returns the source-bound offset for an exact +// press/release sequence. Each transport resumes the same schedule in block 2. +func ProductionPhaseOffsetNS(sequence int, transition Transition) int64 { + edgeIndex := 2 * (sequence - 1) + if transition == TransitionRelease { + edgeIndex++ + } + if edgeIndex < 0 { + return 0 + } + return productionPhaseSweepOffsetsNS[edgeIndex%len(productionPhaseSweepOffsetsNS)] +} + +type Sample struct { + Sequence int `json:"sequence"` + Transition Transition `json:"transition"` + LatencyNS int64 `json:"latency_ns"` + EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` +} + +type Counters struct { + Press int `json:"press"` + Release int `json:"release"` +} + +func (c Counters) Total() int { return c.Press + c.Release } + +type Distribution struct { + Count int `json:"count"` + P50NS int64 `json:"p50_ns"` + P95NS int64 `json:"p95_ns"` + P99NS int64 `json:"p99_ns"` + MaxNS int64 `json:"max_ns"` + JitterNS float64 `json:"jitter_ns"` +} + +type DistributionSet struct { + Press Distribution `json:"press"` + Release Distribution `json:"release"` + Combined Distribution `json:"combined"` +} + +type NativeServerProof struct { + ABIMajor uint16 `json:"abi_major"` + ABIMinor uint16 `json:"abi_minor"` + Capabilities uint32 `json:"capabilities"` + ExpectedDriverPackageVersion string `json:"expected_driver_package_version"` +} + +type ServerProof struct { + Server string `json:"server"` + Version string `json:"version"` + Transport string `json:"transport"` + Ready bool `json:"ready"` + NativeUDE *NativeServerProof `json:"native_ude,omitempty"` +} + +type DeviceProof struct { + BusID uint32 `json:"bus_id"` + DeviceID string `json:"device_id"` + Type string `json:"type"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + USBIPPort int32 `json:"usbip_port,omitempty"` +} + +type ControllerProof struct { + BaselineGamepadIDs []int32 `json:"baseline_gamepad_ids"` + NewGamepadIDs []int32 `json:"new_gamepad_ids"` + SDLInstanceID int32 `json:"sdl_instance_id"` + SDLPath string `json:"sdl_path"` + SDLGUID string `json:"sdl_guid"` + SDLName string `json:"sdl_name"` + SDLType string `json:"sdl_type"` + SDLReportedType int32 `json:"sdl_reported_type"` + SDLRealType int32 `json:"sdl_real_type"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` +} + +type Run struct { + Order int `json:"order"` + TransportBlock int `json:"transport_block"` + FirstSequence int `json:"first_sequence"` + SamplePairs int `json:"sample_pairs"` + Transport string `json:"transport"` + Authentication string `json:"authentication"` + UnauthenticatedRejected bool `json:"unauthenticated_rejected"` + Server ServerProof `json:"server"` + Device DeviceProof `json:"device"` + Controller ControllerProof `json:"controller"` + Samples []Sample `json:"samples"` + Misses Counters `json:"misses"` + Duplicates Counters `json:"duplicates"` + Statistics DistributionSet `json:"statistics"` + Failure string `json:"failure,omitempty"` +} + +type Workload struct { + APIAddress string `json:"api_address"` + USBIPAddress string `json:"usbip_address"` + ControllerType string `json:"controller_type"` + ExpectedVendorID uint16 `json:"expected_vendor_id"` + ExpectedProductID uint16 `json:"expected_product_id"` + ExpectedSDLType string `json:"expected_sdl_type"` + Button string `json:"button"` + WarmupPairs int `json:"warmup_pairs"` + SamplePairs int `json:"sample_pairs"` + PerTransitionTimeoutNS int64 `json:"per_transition_timeout_ns"` + InterTransitionDelayNS int64 `json:"inter_transition_delay_ns"` + PhaseSweepOffsetsNS []int64 `json:"phase_sweep_offsets_ns"` + PhaseSweepSHA256 string `json:"phase_sweep_sha256"` + Authentication string `json:"authentication"` +} + +type Provenance struct { + SourceRevision string `json:"source_revision"` + SDLSourceRevision string `json:"sdl_source_revision"` + SDLBinaryPath string `json:"sdl_binary_path"` + SDLBinarySHA256 string `json:"sdl_binary_sha256"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` +} + +type Policy struct { + MinimumSamplePairs int `json:"minimum_sample_pairs"` + NativeMaxP95NS int64 `json:"native_max_p95_ns"` + NativeMaxP99NS int64 `json:"native_max_p99_ns"` + NativeMaxNS int64 `json:"native_max_ns"` + NativeMaxP95OverUSBIPNS int64 `json:"native_max_p95_over_usbip_ns"` + NativeMaxP99OverUSBIPNS int64 `json:"native_max_p99_over_usbip_ns"` + NativeMaxOverUSBIPNS int64 `json:"native_max_over_usbip_ns"` +} + +type TransportAggregate struct { + Transport string `json:"transport"` + BlockCount int `json:"block_count"` + Misses Counters `json:"misses"` + Duplicates Counters `json:"duplicates"` + Statistics DistributionSet `json:"statistics"` +} + +type MetricComparison struct { + USBIP float64 `json:"usbip"` + NativeUDE float64 `json:"native_ude"` + NativeMinusUSBIP float64 `json:"native_minus_usbip"` + NativeToUSBIPRatio *float64 `json:"native_to_usbip_ratio,omitempty"` +} + +type DistributionComparison struct { + P50 MetricComparison `json:"p50_ns"` + P95 MetricComparison `json:"p95_ns"` + P99 MetricComparison `json:"p99_ns"` + Max MetricComparison `json:"max_ns"` + Jitter MetricComparison `json:"jitter_ns"` +} + +type ComparisonSet struct { + Press DistributionComparison `json:"press"` + Release DistributionComparison `json:"release"` + Combined DistributionComparison `json:"combined"` +} + +type Report struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Provenance Provenance `json:"provenance"` + Workload Workload `json:"workload"` + Policy Policy `json:"policy"` + Runs []Run `json:"runs"` + Transports []TransportAggregate `json:"transports"` + Comparison ComparisonSet `json:"comparison"` + Verdict string `json:"verdict"` + Failures []string `json:"failures"` +} + +type SuiteReport struct { + Schema string `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Provenance Provenance `json:"provenance"` + Cases []Report `json:"cases"` + Verdict string `json:"verdict"` + Failures []string `json:"failures"` +} + +var ( + revisionPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`) + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +// Calculate returns nearest-rank percentiles and population standard deviation. +// The input order is retained so callers can keep the original per-sample record. +func Calculate(values []int64) (Distribution, error) { + if len(values) == 0 { + return Distribution{}, errors.New("cannot summarize zero latency samples") + } + ordered := append([]int64(nil), values...) + for index, value := range ordered { + if value <= 0 { + return Distribution{}, fmt.Errorf("latency sample %d must be positive, got %d", index, value) + } + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) + + mean := 0.0 + m2 := 0.0 + for index, value := range values { + x := float64(value) + delta := x - mean + mean += delta / float64(index+1) + m2 += delta * (x - mean) + } + + return Distribution{ + Count: len(ordered), + P50NS: nearestRank(ordered, 0.50), + P95NS: nearestRank(ordered, 0.95), + P99NS: nearestRank(ordered, 0.99), + MaxNS: ordered[len(ordered)-1], + JitterNS: math.Sqrt(m2 / float64(len(ordered))), + }, nil +} + +func nearestRank(ordered []int64, percentile float64) int64 { + rank := int(math.Ceil(percentile*float64(len(ordered)))) - 1 + if rank < 0 { + rank = 0 + } + if rank >= len(ordered) { + rank = len(ordered) - 1 + } + return ordered[rank] +} + +// Finalize recomputes every derived field and evaluates the fail-closed policy. +func Finalize(report *Report) error { + if report == nil { + return errors.New("nil latency report") + } + if err := validateBase(report); err != nil { + return err + } + + report.Failures = nil + report.Comparison = ComparisonSet{} + report.Transports = nil + for index := range report.Runs { + run := &report.Runs[index] + run.Statistics = summarizeSamples(run.Samples) + if run.Failure != "" { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d failed: %s", run.Transport, run.TransportBlock, run.Failure)) + } + if run.Misses.Total() != 0 { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d observed %d missed transitions (press=%d release=%d)", + run.Transport, run.TransportBlock, run.Misses.Total(), + run.Misses.Press, run.Misses.Release)) + } + if run.Duplicates.Total() != 0 { + report.Failures = append(report.Failures, + fmt.Sprintf("%s block %d observed %d duplicate transitions (press=%d release=%d)", + run.Transport, run.TransportBlock, run.Duplicates.Total(), + run.Duplicates.Press, run.Duplicates.Release)) + } + } + + for _, transport := range []string{TransportUSBIP, TransportNativeUDE} { + aggregate := aggregateTransport(report.Runs, transport) + report.Transports = append(report.Transports, aggregate) + if aggregate.Statistics.Press.Count < report.Policy.MinimumSamplePairs { + report.Failures = append(report.Failures, + fmt.Sprintf("%s has %d/%d required press samples across its counterbalanced blocks", + transport, aggregate.Statistics.Press.Count, report.Policy.MinimumSamplePairs)) + } + if aggregate.Statistics.Release.Count < report.Policy.MinimumSamplePairs { + report.Failures = append(report.Failures, + fmt.Sprintf("%s has %d/%d required release samples across its counterbalanced blocks", + transport, aggregate.Statistics.Release.Count, report.Policy.MinimumSamplePairs)) + } + } + + usbip := aggregateForTransport(report.Transports, TransportUSBIP) + native := aggregateForTransport(report.Transports, TransportNativeUDE) + if usbip != nil && native != nil && + usbip.Statistics.Press.Count != 0 && native.Statistics.Press.Count != 0 && + usbip.Statistics.Release.Count != 0 && native.Statistics.Release.Count != 0 { + report.Comparison = compareSets(usbip.Statistics, native.Statistics) + checkNativeLimits(report, "press", native.Statistics.Press) + checkNativeLimits(report, "release", native.Statistics.Release) + checkNativeLimits(report, "combined", native.Statistics.Combined) + checkNativeNonRegression(report, "press", usbip.Statistics.Press, native.Statistics.Press) + checkNativeNonRegression(report, "release", usbip.Statistics.Release, native.Statistics.Release) + checkNativeNonRegression(report, "combined", usbip.Statistics.Combined, native.Statistics.Combined) + } + + if len(report.Failures) == 0 { + report.Verdict = "pass" + } else { + report.Verdict = "fail" + } + return nil +} + +func aggregateTransport(runs []Run, transport string) TransportAggregate { + aggregate := TransportAggregate{Transport: transport} + var samples []Sample + for index := range runs { + run := &runs[index] + if run.Transport != transport { + continue + } + aggregate.BlockCount++ + aggregate.Misses.Press += run.Misses.Press + aggregate.Misses.Release += run.Misses.Release + aggregate.Duplicates.Press += run.Duplicates.Press + aggregate.Duplicates.Release += run.Duplicates.Release + samples = append(samples, run.Samples...) + } + aggregate.Statistics = summarizeSamples(samples) + return aggregate +} + +func aggregateForTransport(aggregates []TransportAggregate, transport string) *TransportAggregate { + for index := range aggregates { + if aggregates[index].Transport == transport { + return &aggregates[index] + } + } + return nil +} + +func summarizeSamples(samples []Sample) DistributionSet { + press := make([]int64, 0, len(samples)/2) + release := make([]int64, 0, len(samples)/2) + combined := make([]int64, 0, len(samples)) + for _, sample := range samples { + combined = append(combined, sample.LatencyNS) + switch sample.Transition { + case TransitionPress: + press = append(press, sample.LatencyNS) + case TransitionRelease: + release = append(release, sample.LatencyNS) + } + } + var result DistributionSet + if len(press) != 0 { + result.Press, _ = Calculate(press) + } + if len(release) != 0 { + result.Release, _ = Calculate(release) + } + if len(combined) != 0 { + result.Combined, _ = Calculate(combined) + } + return result +} + +func checkNativeLimits(report *Report, name string, distribution Distribution) { + if distribution.Count == 0 { + return + } + if distribution.P95NS > report.Policy.NativeMaxP95NS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p95 %dns exceeds %dns", name, + distribution.P95NS, report.Policy.NativeMaxP95NS)) + } + if distribution.P99NS > report.Policy.NativeMaxP99NS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p99 %dns exceeds %dns", name, + distribution.P99NS, report.Policy.NativeMaxP99NS)) + } + if distribution.MaxNS > report.Policy.NativeMaxNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s max %dns exceeds %dns", name, + distribution.MaxNS, report.Policy.NativeMaxNS)) + } +} + +func checkNativeNonRegression(report *Report, name string, usbip, native Distribution) { + if native.P95NS-usbip.P95NS > report.Policy.NativeMaxP95OverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p95 exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.P95NS-usbip.P95NS, report.Policy.NativeMaxP95OverUSBIPNS)) + } + if native.P99NS-usbip.P99NS > report.Policy.NativeMaxP99OverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s p99 exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.P99NS-usbip.P99NS, report.Policy.NativeMaxP99OverUSBIPNS)) + } + if native.MaxNS-usbip.MaxNS > report.Policy.NativeMaxOverUSBIPNS { + report.Failures = append(report.Failures, fmt.Sprintf( + "native-ude %s max exceeds same-machine USB/IP by %dns (allowed %dns)", + name, native.MaxNS-usbip.MaxNS, report.Policy.NativeMaxOverUSBIPNS)) + } +} + +func compareSets(usbip, native DistributionSet) ComparisonSet { + return ComparisonSet{ + Press: compareDistribution(usbip.Press, native.Press), + Release: compareDistribution(usbip.Release, native.Release), + Combined: compareDistribution(usbip.Combined, native.Combined), + } +} + +func compareDistribution(usbip, native Distribution) DistributionComparison { + return DistributionComparison{ + P50: compareMetric(float64(usbip.P50NS), float64(native.P50NS)), + P95: compareMetric(float64(usbip.P95NS), float64(native.P95NS)), + P99: compareMetric(float64(usbip.P99NS), float64(native.P99NS)), + Max: compareMetric(float64(usbip.MaxNS), float64(native.MaxNS)), + Jitter: compareMetric(usbip.JitterNS, native.JitterNS), + } +} + +func compareMetric(usbip, native float64) MetricComparison { + comparison := MetricComparison{ + USBIP: usbip, + NativeUDE: native, + NativeMinusUSBIP: native - usbip, + } + if usbip != 0 { + ratio := native / usbip + comparison.NativeToUSBIPRatio = &ratio + } + return comparison +} + +func validateBase(report *Report) error { + if report.Schema != SchemaV1 { + return fmt.Errorf("unsupported report schema %q", report.Schema) + } + if report.GeneratedAt.IsZero() { + return errors.New("generated_at is required") + } + if !revisionPattern.MatchString(report.Provenance.SourceRevision) { + return errors.New("source_revision must be a lowercase 40-64 digit Git revision") + } + if !revisionPattern.MatchString(report.Provenance.SDLSourceRevision) { + return errors.New("sdl_source_revision must be a lowercase 40-64 digit Git revision") + } + if report.Provenance.SDLBinaryPath == "" || + !hashPattern.MatchString(report.Provenance.SDLBinarySHA256) { + return errors.New("the loaded SDL binary path and SHA-256 are required") + } + if !hashPattern.MatchString(report.Provenance.NativePackageManifestSHA256) || + !hashPattern.MatchString(report.Provenance.NativeDriverSHA256) { + return errors.New("source-bound native package manifest and installed driver hashes are required") + } + if report.Provenance.GoVersion == "" || report.Provenance.GOOS != "windows" || + report.Provenance.GOARCH == "" { + return errors.New("Windows Go toolchain provenance is incomplete") + } + if report.Workload.APIAddress == "" || report.Workload.USBIPAddress == "" || + report.Workload.Button != "south/A" || + report.Workload.Authentication != AuthenticationMode { + return errors.New("workload identity is incomplete or unsupported") + } + if err := validateControllerWorkload(report.Workload); err != nil { + return err + } + if report.Workload.WarmupPairs != ProductionWarmupPairs || + report.Workload.SamplePairs < MinimumProductionSamplePairs || + report.Workload.SamplePairs > MaximumProductionSamplePairs || + report.Workload.PerTransitionTimeoutNS != ProductionTransitionTimeoutNS || + report.Workload.InterTransitionDelayNS != ProductionInterTransitionDelayNS { + return errors.New("workload warmup, sample count, timeout, or transition delay is invalid") + } + productionOffsets := ProductionPhaseSweepOffsetsNS() + if !reflect.DeepEqual(report.Workload.PhaseSweepOffsetsNS, productionOffsets) || + report.Workload.PhaseSweepSHA256 != PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) { + return errors.New("workload phase-sweep schedule or SHA-256 is not the reviewed production schedule") + } + if report.Policy.MinimumSamplePairs < MinimumProductionSamplePairs || + report.Policy.MinimumSamplePairs > report.Workload.SamplePairs { + return errors.New("minimum sample policy is weaker than the production floor or exceeds the workload") + } + if report.Policy.NativeMaxP95NS <= 0 || + report.Policy.NativeMaxP95NS > DefaultNativeMaxP95NS || + report.Policy.NativeMaxP99NS <= 0 || + report.Policy.NativeMaxP99NS > DefaultNativeMaxP99NS || + report.Policy.NativeMaxNS <= 0 || + report.Policy.NativeMaxNS > DefaultNativeMaxNS || + report.Policy.NativeMaxP95OverUSBIPNS <= 0 || + report.Policy.NativeMaxP95OverUSBIPNS > DefaultNativeMaxP95OverUSBIPNS || + report.Policy.NativeMaxP99OverUSBIPNS <= 0 || + report.Policy.NativeMaxP99OverUSBIPNS > DefaultNativeMaxP99OverUSBIPNS || + report.Policy.NativeMaxOverUSBIPNS <= 0 || + report.Policy.NativeMaxOverUSBIPNS > DefaultNativeMaxOverUSBIPNS { + return errors.New("native latency policy is absent or weaker than the reviewed release limits") + } + schedule := ProductionBlockSchedule(report.Workload.SamplePairs) + if len(report.Runs) != len(schedule) { + return errors.New("report must contain exactly the four ABBA transport blocks") + } + + for index := range report.Runs { + if err := validateRun(&report.Runs[index], report.Workload, schedule[index]); err != nil { + return fmt.Errorf("order %d %s block %d: %w", index+1, + report.Runs[index].Transport, report.Runs[index].TransportBlock, err) + } + } + serverVersion := "" + var priorEventTimestamp uint64 + for index := range report.Runs { + run := &report.Runs[index] + if len(run.Samples) != 0 { + firstTimestamp := run.Samples[0].EventTimestampNS + if priorEventTimestamp != 0 && firstTimestamp < priorEventTimestamp { + return errors.New("SDL event clock regressed between ABBA transport blocks") + } + priorEventTimestamp = run.Samples[len(run.Samples)-1].EventTimestampNS + } + if run.Failure != "" { + continue + } + if serverVersion == "" { + serverVersion = run.Server.Version + } else if run.Server.Version != serverVersion { + return errors.New("transport blocks came from different VIIPER server versions") + } + } + return nil +} + +func validateRun(run *Run, workload Workload, block BlockSpec) error { + if run.Order != block.Order || run.Transport != block.Transport || + run.TransportBlock != block.TransportBlock || + run.FirstSequence != block.FirstSequence || run.SamplePairs != block.SamplePairs { + return fmt.Errorf("block metadata does not match the production ABBA schedule: %+v", block) + } + if run.Authentication != AuthenticationMode { + return errors.New("API/controller stream is not authenticated identically") + } + if run.Misses.Press < 0 || run.Misses.Release < 0 || + run.Duplicates.Press < 0 || run.Duplicates.Release < 0 { + return errors.New("negative integrity counter") + } + if len(run.Samples) > 2*run.SamplePairs { + return errors.New("more samples than the declared transport block") + } + var priorTimestamp uint64 + for index, sample := range run.Samples { + wantSequence := run.FirstSequence + index/2 + wantTransition := TransitionPress + if index%2 != 0 { + wantTransition = TransitionRelease + } + if sample.Sequence != wantSequence || sample.Transition != wantTransition { + return fmt.Errorf("sample %d is %d/%s, want %d/%s", index, + sample.Sequence, sample.Transition, wantSequence, wantTransition) + } + if sample.LatencyNS <= 0 || sample.EventTimestampNS == 0 { + return fmt.Errorf("sample %d has invalid latency or SDL timestamp", index) + } + if priorTimestamp != 0 && sample.EventTimestampNS < priorTimestamp { + return fmt.Errorf("sample %d regressed the SDL event clock", index) + } + priorTimestamp = sample.EventTimestampNS + } + if run.Failure == "" && len(run.Samples) != 2*run.SamplePairs { + return errors.New("successful run does not contain every press/release sample") + } + if run.Failure != "" { + return nil + } + if !run.UnauthenticatedRejected { + return errors.New("unauthenticated API probe was not rejected") + } + if run.Server.Server != "VIIPER" || run.Server.Transport != run.Transport || + !run.Server.Ready || run.Server.Version == "" { + return errors.New("authenticated ping does not prove the requested live transport") + } + if run.Device.BusID != 1 || run.Device.DeviceID != "1" || + run.Device.Type != workload.ControllerType || + run.Device.VendorID != workload.ExpectedVendorID || + run.Device.ProductID != workload.ExpectedProductID { + return errors.New("API device proof does not identify the exact controller workload") + } + if run.Transport == TransportNativeUDE { + if run.Server.NativeUDE == nil || run.Server.NativeUDE.ABIMajor == 0 || + run.Server.NativeUDE.ExpectedDriverPackageVersion == "" || run.Device.USBIPPort != 0 { + return errors.New("native transport proof is absent or contradictory") + } + } else if run.Transport == TransportUSBIP { + if run.Server.NativeUDE != nil || run.Device.USBIPPort <= 0 { + return errors.New("USB/IP transport proof is absent or contradictory") + } + } else { + return fmt.Errorf("unsupported transport %q", run.Transport) + } + if len(run.Controller.NewGamepadIDs) != 1 || + run.Controller.NewGamepadIDs[0] != run.Controller.SDLInstanceID { + return errors.New("SDL observer is not bound to exactly one newly enumerated gamepad") + } + for _, baselineID := range run.Controller.BaselineGamepadIDs { + if baselineID == run.Controller.SDLInstanceID { + return errors.New("SDL observer selected a gamepad that existed before DeviceAdd") + } + } + if run.Controller.SDLInstanceID == 0 || run.Controller.SDLPath == "" || + run.Controller.SDLGUID == "" || run.Controller.SDLName == "" || + run.Controller.SDLType != workload.ExpectedSDLType || + run.Controller.SDLRealType != expectedSDLRealType(workload.ControllerType) || + run.Controller.VendorID != workload.ExpectedVendorID || + run.Controller.ProductID != workload.ExpectedProductID { + return errors.New("new SDL gamepad identity does not match the API-created controller") + } + return nil +} + +func expectedSDLRealType(controllerType string) int32 { + switch controllerType { + case "xbox360": + return 2 // SDL_GAMEPAD_TYPE_XBOX360 + case "dualshock4": + return 5 // SDL_GAMEPAD_TYPE_PS4 + case "dualsensegamepadv5": + return 6 // SDL_GAMEPAD_TYPE_PS5 + default: + return 0 // Rejected by validateControllerWorkload. + } +} + +func validateControllerWorkload(workload Workload) error { + type identity struct { + vendorID, productID uint16 + sdlType string + } + supported := map[string]identity{ + "xbox360": {vendorID: 0x045e, productID: 0x028e, sdlType: "xbox360"}, + "dualshock4": {vendorID: 0x054c, productID: 0x09cc, sdlType: "ps4"}, + "dualsensegamepadv5": {vendorID: 0x054c, productID: 0x0ce6, sdlType: "ps5"}, + } + want, ok := supported[workload.ControllerType] + if !ok || workload.ExpectedVendorID != want.vendorID || + workload.ExpectedProductID != want.productID || workload.ExpectedSDLType != want.sdlType { + return fmt.Errorf("unsupported or contradictory controller workload %q vid=%#04x pid=%#04x SDL=%q", + workload.ControllerType, workload.ExpectedVendorID, + workload.ExpectedProductID, workload.ExpectedSDLType) + } + return nil +} + +// ParseReport strictly parses a finalized report and rejects stale or forged +// derived fields, unknown JSON fields, and trailing input. +func ParseReport(reader io.Reader) (*Report, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var report Report + if err := decoder.Decode(&report); err != nil { + return nil, fmt.Errorf("decode latency report: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("latency report contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing latency report data: %w", err) + } + + reportedStatistics := make([]DistributionSet, len(report.Runs)) + for index := range report.Runs { + reportedStatistics[index] = report.Runs[index].Statistics + } + reportedComparison := report.Comparison + reportedTransports := append([]TransportAggregate(nil), report.Transports...) + reportedFailures := append([]string(nil), report.Failures...) + reportedVerdict := report.Verdict + if err := Finalize(&report); err != nil { + return nil, err + } + for index := range report.Runs { + if !reflect.DeepEqual(reportedStatistics[index], report.Runs[index].Statistics) { + return nil, fmt.Errorf("%s statistics do not match the individual samples", + report.Runs[index].Transport) + } + } + if !reflect.DeepEqual(reportedTransports, report.Transports) || + !reflect.DeepEqual(reportedComparison, report.Comparison) || + !reflect.DeepEqual(reportedFailures, report.Failures) || reportedVerdict != report.Verdict { + return nil, errors.New("latency report aggregates, comparison, or verdict do not match its source samples") + } + return &report, nil +} + +func RequirePass(report *Report) error { + if report == nil { + return errors.New("nil latency report") + } + if report.Verdict == "pass" && len(report.Failures) == 0 { + return nil + } + if len(report.Failures) == 0 { + return fmt.Errorf("latency gate verdict is %q", report.Verdict) + } + return errors.New(strings.Join(report.Failures, "; ")) +} + +// FinalizeSuite validates workload parity across the complete production +// controller set and recomputes each controller report from individual samples. +func FinalizeSuite(suite *SuiteReport) error { + if suite == nil { + return errors.New("nil latency suite") + } + if suite.Schema != SuiteSchemaV1 { + return fmt.Errorf("unsupported latency suite schema %q", suite.Schema) + } + if suite.GeneratedAt.IsZero() { + return errors.New("suite generated_at is required") + } + requiredControllers := []string{"xbox360", "dualshock4", "dualsensegamepadv5"} + if len(suite.Cases) != len(requiredControllers) { + return fmt.Errorf("latency suite must contain exactly %d controller cases", len(requiredControllers)) + } + + suite.Failures = nil + var reference *Report + serverVersion := "" + for index := range suite.Cases { + controllerReport := &suite.Cases[index] + if controllerReport.Workload.ControllerType != requiredControllers[index] { + return fmt.Errorf("controller case %d is %q, want %q", index, + controllerReport.Workload.ControllerType, requiredControllers[index]) + } + if controllerReport.GeneratedAt != suite.GeneratedAt || + !reflect.DeepEqual(controllerReport.Provenance, suite.Provenance) { + return fmt.Errorf("%s case provenance differs from the suite", + controllerReport.Workload.ControllerType) + } + if reference == nil { + reference = controllerReport + } else if !sameWorkloadPolicy(reference, controllerReport) { + return fmt.Errorf("%s does not use the identical authenticated timing workload", + controllerReport.Workload.ControllerType) + } + if err := Finalize(controllerReport); err != nil { + return fmt.Errorf("%s case: %w", controllerReport.Workload.ControllerType, err) + } + for _, failure := range controllerReport.Failures { + suite.Failures = append(suite.Failures, + controllerReport.Workload.ControllerType+": "+failure) + } + for _, run := range controllerReport.Runs { + if run.Failure != "" { + continue + } + if serverVersion == "" { + serverVersion = run.Server.Version + } else if run.Server.Version != serverVersion { + return fmt.Errorf("%s/%s used VIIPER version %q, want %q", + controllerReport.Workload.ControllerType, run.Transport, + run.Server.Version, serverVersion) + } + } + } + if len(suite.Failures) == 0 { + suite.Verdict = "pass" + } else { + suite.Verdict = "fail" + } + return nil +} + +func sameWorkloadPolicy(left, right *Report) bool { + return left.Workload.APIAddress == right.Workload.APIAddress && + left.Workload.USBIPAddress == right.Workload.USBIPAddress && + left.Workload.Button == right.Workload.Button && + left.Workload.WarmupPairs == right.Workload.WarmupPairs && + left.Workload.SamplePairs == right.Workload.SamplePairs && + left.Workload.PerTransitionTimeoutNS == right.Workload.PerTransitionTimeoutNS && + left.Workload.InterTransitionDelayNS == right.Workload.InterTransitionDelayNS && + reflect.DeepEqual(left.Workload.PhaseSweepOffsetsNS, right.Workload.PhaseSweepOffsetsNS) && + left.Workload.PhaseSweepSHA256 == right.Workload.PhaseSweepSHA256 && + left.Workload.Authentication == right.Workload.Authentication && + reflect.DeepEqual(left.Policy, right.Policy) +} + +type suiteCaseDerived struct { + statistics []DistributionSet + transports []TransportAggregate + comparison ComparisonSet + verdict string + failures []string +} + +// ParseSuiteReport is the strict artifact parser used for release evidence. +func ParseSuiteReport(reader io.Reader) (*SuiteReport, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var suite SuiteReport + if err := decoder.Decode(&suite); err != nil { + return nil, fmt.Errorf("decode latency suite: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("latency suite contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing latency suite data: %w", err) + } + + reportedCases := make([]suiteCaseDerived, len(suite.Cases)) + for caseIndex := range suite.Cases { + controllerReport := &suite.Cases[caseIndex] + derived := suiteCaseDerived{ + statistics: make([]DistributionSet, len(controllerReport.Runs)), + transports: append([]TransportAggregate(nil), controllerReport.Transports...), + comparison: controllerReport.Comparison, + verdict: controllerReport.Verdict, + failures: append([]string(nil), controllerReport.Failures...), + } + for runIndex := range controllerReport.Runs { + derived.statistics[runIndex] = controllerReport.Runs[runIndex].Statistics + } + reportedCases[caseIndex] = derived + } + reportedVerdict := suite.Verdict + reportedFailures := append([]string(nil), suite.Failures...) + if err := FinalizeSuite(&suite); err != nil { + return nil, err + } + for caseIndex := range suite.Cases { + controllerReport := &suite.Cases[caseIndex] + derived := reportedCases[caseIndex] + for runIndex := range controllerReport.Runs { + if !reflect.DeepEqual(derived.statistics[runIndex], + controllerReport.Runs[runIndex].Statistics) { + return nil, fmt.Errorf("%s/%s statistics do not match individual samples", + controllerReport.Workload.ControllerType, + controllerReport.Runs[runIndex].Transport) + } + } + if !reflect.DeepEqual(derived.comparison, controllerReport.Comparison) || + !reflect.DeepEqual(derived.transports, controllerReport.Transports) || + derived.verdict != controllerReport.Verdict || + !reflect.DeepEqual(derived.failures, controllerReport.Failures) { + return nil, fmt.Errorf("%s derived case verdict does not match its samples", + controllerReport.Workload.ControllerType) + } + } + if reportedVerdict != suite.Verdict || !reflect.DeepEqual(reportedFailures, suite.Failures) { + return nil, errors.New("latency suite verdict does not match its controller cases") + } + return &suite, nil +} + +func RequireSuitePass(suite *SuiteReport) error { + if suite == nil { + return errors.New("nil latency suite") + } + if suite.Verdict == "pass" && len(suite.Failures) == 0 { + return nil + } + if len(suite.Failures) == 0 { + return fmt.Errorf("latency suite verdict is %q", suite.Verdict) + } + return errors.New(strings.Join(suite.Failures, "; ")) +} diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go new file mode 100644 index 00000000..3f20919b --- /dev/null +++ b/_testing/e2e/latency/report_test.go @@ -0,0 +1,525 @@ +package latency + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "reflect" + "strings" + "testing" + "time" +) + +func TestCalculateNearestRankDistributionAndJitter(t *testing.T) { + values := make([]int64, 100) + for index := range values { + values[index] = int64(100 - index) + } + original := append([]int64(nil), values...) + + got, err := Calculate(values) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(values, original) { + t.Fatal("Calculate reordered the caller's individual samples") + } + if got.Count != 100 || got.P50NS != 50 || got.P95NS != 95 || + got.P99NS != 99 || got.MaxNS != 100 { + t.Fatalf("unexpected distribution: %+v", got) + } + wantJitter := math.Sqrt(833.25) + if math.Abs(got.JitterNS-wantJitter) > 1e-12 { + t.Fatalf("jitter=%0.15f want %0.15f", got.JitterNS, wantJitter) + } +} + +func TestCalculateRejectsMissingAndNonPositiveSamples(t *testing.T) { + if _, err := Calculate(nil); err == nil { + t.Fatal("zero samples were accepted") + } + if _, err := Calculate([]int64{1, 0, 2}); err == nil { + t.Fatal("a zero latency sample was accepted") + } +} + +func TestProductionBlockScheduleIsCounterbalancedAndComplete(t *testing.T) { + want := []BlockSpec{ + {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 2, Transport: TransportNativeUDE, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, + {Order: 3, Transport: TransportNativeUDE, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + {Order: 4, Transport: TransportUSBIP, TransportBlock: 2, FirstSequence: 129, SamplePairs: 129}, + } + if got := ProductionBlockSchedule(257); !reflect.DeepEqual(got, want) { + t.Fatalf("schedule=%+v want %+v", got, want) + } + offsets := ProductionPhaseSweepOffsetsNS() + wantOffsets := []int64{0, 125_000, 250_000, 375_000, 500_000, 625_000, 750_000, 875_000} + if !reflect.DeepEqual(offsets, wantOffsets) { + t.Fatalf("phase sweep=%v want %v", offsets, wantOffsets) + } + if got, wantHash := PhaseSweepScheduleSHA256(offsets), "21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9"; got != wantHash { + t.Fatalf("phase sweep SHA-256=%s want %s", got, wantHash) + } + for sequence, wantPressOffset := range []int64{0, 250_000, 500_000, 750_000} { + if got := ProductionPhaseOffsetNS(sequence+1, TransitionPress); got != wantPressOffset { + t.Fatalf("sequence %d press offset=%d want %d", sequence+1, got, wantPressOffset) + } + } +} + +func TestParseReportRecomputesSamplesStatisticsAndComparison(t *testing.T) { + report := validReport(t) + encoded := encodeReport(t, report) + parsed, err := ParseReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + if err := RequirePass(parsed); err != nil { + t.Fatal(err) + } + if parsed.Transports[0].Statistics.Press.Count != MinimumProductionSamplePairs || + parsed.Transports[1].Statistics.Release.Count != MinimumProductionSamplePairs { + t.Fatalf("individual press/release samples were not retained: %+v", parsed.Transports) + } + if parsed.Comparison.Combined.P99.NativeToUSBIPRatio == nil { + t.Fatal("comparison ratio was not derived") + } +} + +func TestParseReportRejectsUnknownTrailingAndForgedData(t *testing.T) { + report := validReport(t) + + t.Run("unknown field", func(t *testing.T) { + var object map[string]any + if err := json.Unmarshal(encodeReport(t, report), &object); err != nil { + t.Fatal(err) + } + object["not_in_schema"] = true + data, _ := json.Marshal(object) + if _, err := ParseReport(bytes.NewReader(data)); err == nil || + !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown field error=%v", err) + } + }) + + t.Run("trailing JSON", func(t *testing.T) { + data := append(encodeReport(t, report), []byte(` {"extra":true}`)...) + if _, err := ParseReport(bytes.NewReader(data)); err == nil || + !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("trailing data error=%v", err) + } + }) + + t.Run("forged statistic", func(t *testing.T) { + forged := validReport(t) + forged.Runs[1].Statistics.Combined.P99NS++ + if _, err := ParseReport(bytes.NewReader(encodeReport(t, forged))); err == nil || + !strings.Contains(err.Error(), "statistics do not match") { + t.Fatalf("forged statistic error=%v", err) + } + }) + + t.Run("forged transport aggregate", func(t *testing.T) { + forged := validReport(t) + forged.Transports[1].Statistics.Press.P95NS++ + if _, err := ParseReport(bytes.NewReader(encodeReport(t, forged))); err == nil || + !strings.Contains(err.Error(), "aggregates") { + t.Fatalf("forged aggregate error=%v", err) + } + }) + + t.Run("mixed source", func(t *testing.T) { + mixed := validReport(t) + mixed.Runs[1].Server.Transport = TransportUSBIP + if err := Finalize(mixed); err == nil || !strings.Contains(err.Error(), "requested live transport") { + t.Fatalf("mixed source error=%v", err) + } + }) + + t.Run("unauthenticated workload", func(t *testing.T) { + unauthenticated := validReport(t) + unauthenticated.Runs[0].UnauthenticatedRejected = false + if err := Finalize(unauthenticated); err == nil || !strings.Contains(err.Error(), "unauthenticated") { + t.Fatalf("unauthenticated source error=%v", err) + } + }) +} + +func TestParseSuiteRequiresPlayStationCasesAndWorkloadParity(t *testing.T) { + suite := validSuite(t) + encoded := encodeSuite(t, suite) + parsed, err := ParseSuiteReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + if err := RequireSuitePass(parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Cases) != 3 || parsed.Cases[2].Workload.ControllerType != "dualsensegamepadv5" { + t.Fatalf("suite does not contain required production controller cases: %+v", parsed.Cases) + } + + t.Run("Xbox cannot substitute for DualSense", func(t *testing.T) { + missing := validSuite(t) + missing.Cases[2] = cloneReport(t, missing.Cases[0]) + if err := FinalizeSuite(missing); err == nil || !strings.Contains(err.Error(), "dualsensegamepadv5") { + t.Fatalf("missing DualSense error=%v", err) + } + }) + + t.Run("controller workload drift", func(t *testing.T) { + drift := validSuite(t) + drift.Cases[1].Workload.InterTransitionDelayNS++ + if err := FinalizeSuite(drift); err == nil || !strings.Contains(err.Error(), "identical authenticated") { + t.Fatalf("workload drift error=%v", err) + } + }) + + t.Run("DualSense cannot bind as Xbox", func(t *testing.T) { + mismatch := validSuite(t) + mismatch.Cases[2].Runs[1].Controller.SDLRealType = 2 + if err := FinalizeSuite(mismatch); err == nil || !strings.Contains(err.Error(), "SDL gamepad identity") { + t.Fatalf("DualSense SDL substitution error=%v", err) + } + }) + + t.Run("forged controller statistic", func(t *testing.T) { + forged := validSuite(t) + forged.Cases[2].Runs[1].Statistics.Press.P95NS++ + if _, err := ParseSuiteReport(bytes.NewReader(encodeSuite(t, forged))); err == nil || + !strings.Contains(err.Error(), "statistics do not match") { + t.Fatalf("forged suite statistic error=%v", err) + } + }) +} + +func TestFinalizeRejectsTimeoutInsufficientSamplesAndDuplicates(t *testing.T) { + report := validReport(t) + native := &report.Runs[2] + native.Samples = native.Samples[:len(native.Samples)-1] + native.Misses.Release = 1 + native.Duplicates.Press = 2 + native.Failure = "release sample 256 timed out after 1s" + + if err := Finalize(report); err != nil { + t.Fatal(err) + } + if report.Verdict != "fail" { + t.Fatalf("verdict=%q want fail", report.Verdict) + } + joined := strings.Join(report.Failures, "\n") + for _, want := range []string{ + "timed out", "1 missed transitions", "2 duplicate transitions", "255/256 required release samples", + } { + if !strings.Contains(joined, want) { + t.Fatalf("failures did not contain %q:\n%s", want, joined) + } + } + if err := RequirePass(report); err == nil { + t.Fatal("failed report was accepted by RequirePass") + } + + encoded := encodeReport(t, report) + parsed, err := ParseReport(bytes.NewReader(encoded)) + if err != nil { + t.Fatalf("a self-consistent failure artifact must remain parseable: %v", err) + } + if parsed.Verdict != "fail" { + t.Fatalf("parsed verdict=%q", parsed.Verdict) + } +} + +func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { + t.Run("weakened policy", func(t *testing.T) { + report := validReport(t) + report.Policy.NativeMaxP95NS = DefaultNativeMaxP95NS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "weaker") { + t.Fatalf("weakened policy error=%v", err) + } + }) + + t.Run("weakened same-machine policy", func(t *testing.T) { + report := validReport(t) + report.Policy.NativeMaxP95OverUSBIPNS = DefaultNativeMaxP95OverUSBIPNS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "weaker") { + t.Fatalf("weakened comparison policy error=%v", err) + } + }) + + t.Run("non-ABBA block order", func(t *testing.T) { + report := validReport(t) + report.Runs[2].Transport = TransportUSBIP + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ABBA") { + t.Fatalf("block order error=%v", err) + } + }) + + t.Run("phase sweep drift", func(t *testing.T) { + report := validReport(t) + report.Workload.PhaseSweepOffsetsNS[1]++ + report.Workload.PhaseSweepSHA256 = PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "phase-sweep") { + t.Fatalf("phase sweep drift error=%v", err) + } + }) + + t.Run("duplicate sequence", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[1].Transition = TransitionPress + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "want 1/release") { + t.Fatalf("duplicate sequence error=%v", err) + } + }) + + t.Run("event clock regression", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[1].EventTimestampNS = 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "event clock") { + t.Fatalf("event clock error=%v", err) + } + }) +} + +func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { + tests := []struct { + name string + metric string + mutate func(*Report) + }{ + { + name: "p95", metric: "p95", + mutate: func(report *Report) { + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + if run.Transport != TransportNativeUDE { + continue + } + for sampleIndex := range run.Samples { + run.Samples[sampleIndex].LatencyNS = 1_500_000 + } + } + }, + }, + { + name: "p99", metric: "p99", + mutate: func(report *Report) { + nativeSecond := &report.Runs[2] + for index := len(nativeSecond.Samples) - 6; index < len(nativeSecond.Samples); index++ { + nativeSecond.Samples[index].LatencyNS = 2_600_000 + } + }, + }, + { + name: "max", metric: "max", + mutate: func(report *Report) { + nativeSecond := &report.Runs[2] + nativeSecond.Samples[len(nativeSecond.Samples)-1].LatencyNS = 5_600_000 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(report) + if err := Finalize(report); err != nil { + t.Fatal(err) + } + failures := strings.Join(report.Failures, "\n") + if report.Verdict != "fail" || + !strings.Contains(failures, "same-machine USB/IP") || + !strings.Contains(failures, test.metric) { + t.Fatalf("same-machine %s regression was not rejected: verdict=%q failures=%v", + test.metric, report.Verdict, report.Failures) + } + }) + } +} + +func validReport(t *testing.T) *Report { + t.Helper() + report := &Report{ + Schema: SchemaV1, + GeneratedAt: time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC), + Provenance: Provenance{ + SourceRevision: strings.Repeat("a", 40), + SDLSourceRevision: strings.Repeat("b", 40), + SDLBinaryPath: `C:\source\SDL3.dll`, + SDLBinarySHA256: strings.Repeat("c", 64), + NativePackageManifestSHA256: strings.Repeat("d", 64), + NativeDriverSHA256: strings.Repeat("e", 64), + GoVersion: "go1.26.2", + GOOS: "windows", + GOARCH: "amd64", + }, + Workload: Workload{ + APIAddress: "127.0.0.1:33245", + USBIPAddress: "127.0.0.1:33244", + ControllerType: "xbox360", + ExpectedVendorID: 0x045e, + ExpectedProductID: 0x028e, + ExpectedSDLType: "xbox360", + Button: "south/A", + WarmupPairs: ProductionWarmupPairs, + SamplePairs: MinimumProductionSamplePairs, + PerTransitionTimeoutNS: int64(time.Second), + InterTransitionDelayNS: int64(2 * time.Millisecond), + PhaseSweepOffsetsNS: ProductionPhaseSweepOffsetsNS(), + Authentication: AuthenticationMode, + }, + Policy: Policy{ + MinimumSamplePairs: MinimumProductionSamplePairs, + NativeMaxP95NS: DefaultNativeMaxP95NS, + NativeMaxP99NS: DefaultNativeMaxP99NS, + NativeMaxNS: DefaultNativeMaxNS, + NativeMaxP95OverUSBIPNS: DefaultNativeMaxP95OverUSBIPNS, + NativeMaxP99OverUSBIPNS: DefaultNativeMaxP99OverUSBIPNS, + NativeMaxOverUSBIPNS: DefaultNativeMaxOverUSBIPNS, + }, + } + report.Workload.PhaseSweepSHA256 = PhaseSweepScheduleSHA256(report.Workload.PhaseSweepOffsetsNS) + + for runIndex, block := range ProductionBlockSchedule(MinimumProductionSamplePairs) { + run := Run{ + Order: block.Order, + TransportBlock: block.TransportBlock, + FirstSequence: block.FirstSequence, + SamplePairs: block.SamplePairs, + Transport: block.Transport, + Authentication: AuthenticationMode, + UnauthenticatedRejected: true, + Server: ServerProof{ + Server: "VIIPER", Version: "0.1.0", Transport: block.Transport, Ready: true, + }, + Device: DeviceProof{ + BusID: 1, DeviceID: "1", Type: "xbox360", + VendorID: 0x045e, ProductID: 0x028e, + }, + Controller: ControllerProof{ + BaselineGamepadIDs: []int32{10}, + NewGamepadIDs: []int32{int32(20 + runIndex)}, + SDLInstanceID: int32(20 + runIndex), + SDLPath: fmt.Sprintf("source-path-%s-%d", block.Transport, block.TransportBlock), + SDLGUID: "030000005e0400008e02000000000000", + SDLName: "Xbox 360 Controller", + SDLType: "xbox360", + SDLReportedType: 1, + SDLRealType: 2, + VendorID: 0x045e, + ProductID: 0x028e, + }, + } + if block.Transport == TransportUSBIP { + run.Device.USBIPPort = 1 + } else { + run.Server.NativeUDE = &NativeServerProof{ + ABIMajor: 1, ABIMinor: 0, Capabilities: 1, + ExpectedDriverPackageVersion: "0.1.0.2", + } + } + transportOffset := 0 + if block.Transport == TransportNativeUDE { + transportOffset = 50_000 + } + lastSequence := block.FirstSequence + block.SamplePairs - 1 + for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { + base := int64(400_000 + transportOffset + sequence*10) + timestamp := uint64(runIndex+1)*1_000_000_000 + uint64(sequence*2) + run.Samples = append(run.Samples, + Sample{Sequence: sequence, Transition: TransitionPress, + LatencyNS: base, EventTimestampNS: timestamp}, + Sample{Sequence: sequence, Transition: TransitionRelease, + LatencyNS: base + 5, EventTimestampNS: timestamp + 1}) + } + report.Runs = append(report.Runs, run) + } + if err := Finalize(report); err != nil { + t.Fatal(err) + } + if report.Verdict != "pass" { + t.Fatalf("fixture failed: %v", report.Failures) + } + return report +} + +func validSuite(t *testing.T) *SuiteReport { + t.Helper() + xbox := validReport(t) + suite := &SuiteReport{ + Schema: SuiteSchemaV1, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, + } + identities := []struct { + controller string + vendorID uint16 + productID uint16 + sdlType string + realType int32 + }{ + {controller: "xbox360", vendorID: 0x045e, productID: 0x028e, sdlType: "xbox360", realType: 2}, + {controller: "dualshock4", vendorID: 0x054c, productID: 0x09cc, sdlType: "ps4", realType: 5}, + {controller: "dualsensegamepadv5", vendorID: 0x054c, productID: 0x0ce6, sdlType: "ps5", realType: 6}, + } + for caseIndex, identity := range identities { + report := cloneReport(t, *xbox) + report.Workload.ControllerType = identity.controller + report.Workload.ExpectedVendorID = identity.vendorID + report.Workload.ExpectedProductID = identity.productID + report.Workload.ExpectedSDLType = identity.sdlType + for runIndex := range report.Runs { + run := &report.Runs[runIndex] + run.Device.Type = identity.controller + run.Device.VendorID = identity.vendorID + run.Device.ProductID = identity.productID + run.Controller.SDLType = identity.sdlType + run.Controller.SDLRealType = identity.realType + run.Controller.VendorID = identity.vendorID + run.Controller.ProductID = identity.productID + run.Controller.SDLInstanceID = int32(100 + caseIndex*10 + runIndex) + run.Controller.NewGamepadIDs = []int32{run.Controller.SDLInstanceID} + run.Controller.SDLPath = identity.controller + "-" + run.Transport + } + if err := Finalize(&report); err != nil { + t.Fatal(err) + } + suite.Cases = append(suite.Cases, report) + } + if err := FinalizeSuite(suite); err != nil { + t.Fatal(err) + } + if suite.Verdict != "pass" { + t.Fatalf("suite fixture failed: %v", suite.Failures) + } + return suite +} + +func cloneReport(t *testing.T, report Report) Report { + t.Helper() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var clone Report + if err = json.Unmarshal(data, &clone); err != nil { + t.Fatal(err) + } + return clone +} + +func encodeSuite(t *testing.T, suite *SuiteReport) []byte { + t.Helper() + data, err := json.Marshal(suite) + if err != nil { + t.Fatal(err) + } + return data +} + +func encodeReport(t *testing.T, report *Report) []byte { + t.Helper() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/_testing/e2e/latency_gate_unsupported_test.go b/_testing/e2e/latency_gate_unsupported_test.go new file mode 100644 index 00000000..35e59436 --- /dev/null +++ b/_testing/e2e/latency_gate_unsupported_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +package e2e_bench_test + +import ( + "os" + "runtime" + "testing" +) + +func TestLiveControllerToGameLatencyGate(t *testing.T) { + if os.Getenv("VIIPER_E2E_LIVE_LATENCY") == "1" { + t.Fatalf("live controller-to-game latency requires Windows with CGO/SDL3; got %s CGO-disabled build", + runtime.GOOS) + } + t.Skip("live controller-to-game latency is an explicit Windows+CGO production gate") +} diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go new file mode 100644 index 00000000..4f26bcad --- /dev/null +++ b/_testing/e2e/latency_gate_windows_test.go @@ -0,0 +1,1116 @@ +//go:build windows + +package e2e_bench_test + +import ( + "context" + "crypto/sha256" + "encoding" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "github.com/Alia5/VIIPER/_testing/e2e/sdl" + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/cmd" + "github.com/Alia5/VIIPER/internal/server/api" + serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + "golang.org/x/sys/windows" +) + +const ( + liveLatencyEnvironment = "VIIPER_E2E_LIVE_LATENCY" + liveLatencyPreflight = "VIIPER_E2E_PRODUCTION_PREFLIGHT" + liveLatencyOutput = "VIIPER_E2E_LATENCY_OUTPUT" + liveLatencySamples = "VIIPER_E2E_LATENCY_SAMPLES" + liveLatencyExpectedRevision = "VIIPER_E2E_EXPECTED_SOURCE_REVISION" + liveLatencySDLRevision = "VIIPER_E2E_SDL_SOURCE_REVISION" + liveLatencySDLDLL = "VIIPER_E2E_SDL_DLL_PATH" + liveLatencySDLSHA256 = "VIIPER_E2E_SDL_DLL_SHA256" + liveLatencyPackageManifest = "VIIPER_E2E_PACKAGE_MANIFEST_SHA256" + liveLatencyDriverSHA256 = "VIIPER_E2E_NATIVE_DRIVER_SHA256" + liveLatencyAPIAddress = "127.0.0.1:33245" + liveLatencyUSBIPAddress = "127.0.0.1:33244" + liveLatencyPassword = "testpassword1234" + liveLatencyTransitionTimeout = time.Second + liveLatencyTransitionDelay = 2 * time.Millisecond + liveLatencyDiscoveryTimeout = 15 * time.Second + liveLatencyDuplicateQuiet = 25 * time.Millisecond + liveLatencySourceQuiet = 75 * time.Millisecond +) + +type liveLatencyConfig struct { + outputPath string + samplePairs int + expectedRevision string + sdlRevision string + sdlDLLPath string + sdlDLLSHA256 string + packageManifestSHA string + driverSHA256 string +} + +type liveControllerWorkload struct { + apiType string + vendorID uint16 + productID uint16 + sdlType string + sdlRealType sdl.GamepadType + state func(down bool) encoding.BinaryMarshaler +} + +func liveControllerWorkloads() []liveControllerWorkload { + return []liveControllerWorkload{ + { + apiType: "xbox360", vendorID: 0x045e, productID: 0x028e, + sdlType: "xbox360", sdlRealType: sdl.GamepadTypeXbox360, + state: func(down bool) encoding.BinaryMarshaler { + state := &xbox360.InputState{} + if down { + state.Buttons = xbox360.ButtonA + } + return state + }, + }, + { + apiType: "dualshock4", vendorID: dualshock4.DefaultVID, + productID: dualshock4.DefaultPID, + sdlType: "ps4", sdlRealType: sdl.GamepadTypePS4, + state: func(down bool) encoding.BinaryMarshaler { + state := dualshock4.NewInputState() + if down { + state.Buttons = dualshock4.ButtonCross + } + return state + }, + }, + { + apiType: dualsense.DeviceTypeGamepadOnlyV5, vendorID: dualsense.DefaultVID, + productID: dualsense.DefaultPIDDS, + sdlType: "ps5", sdlRealType: sdl.GamepadTypePS5, + state: func(down bool) encoding.BinaryMarshaler { + state := dualsense.NewInputState() + if down { + state.Buttons = dualsense.ButtonCross + } + return state + }, + }, + } +} + +type latencyServerSession struct { + cancel context.CancelFunc + done <-chan error + client *viiperclient.Client + ping *viipertypes.PingResponse +} + +// TestLiveControllerToGameLatencyGate is deliberately inert in ordinary CI. +// The production wrapper performs read-only source, package, loaded-driver, +// and SDL provenance checks before opting in. The test then measures exactly +// the same authenticated API/south-button workload over USB/IP and native UDE +// for Xbox360, DualShock4, and DualSense, then writes every SDL-observed edge. +func TestLiveControllerToGameLatencyGate(t *testing.T) { + if os.Getenv(liveLatencyEnvironment) != "1" { + t.Skipf("run _testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1; direct opt-in requires %s=1", + liveLatencyEnvironment) + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + config, err := loadLiveLatencyConfig() + if err != nil { + t.Fatal(err) + } + if err = validateLiveLatencySource(config); err != nil { + t.Fatal(err) + } + if err = sdl.Init(sdl.InitFlagGamepad | sdl.InitFlagEvents); err != nil { + t.Fatalf("initialize source-bound SDL event observer: %v", err) + } + defer sdl.Quit() + loadedSDL, err := loadedModulePath("SDL3.dll") + if err != nil { + t.Fatal(err) + } + loadedSDL, err = canonicalPath(loadedSDL) + if err != nil { + t.Fatalf("resolve loaded SDL module: %v", err) + } + if !strings.EqualFold(loadedSDL, config.sdlDLLPath) { + t.Fatalf("loaded SDL module %q does not match source-bound module %q", + loadedSDL, config.sdlDLLPath) + } + loadedHash, err := fileSHA256(loadedSDL) + if err != nil { + t.Fatal(err) + } + if loadedHash != config.sdlDLLSHA256 { + t.Fatalf("loaded SDL SHA-256 %s does not match source-bound SHA-256 %s", + loadedHash, config.sdlDLLSHA256) + } + + generatedAt := time.Now().UTC() + provenance := latency.Provenance{ + SourceRevision: config.expectedRevision, + SDLSourceRevision: config.sdlRevision, + SDLBinaryPath: loadedSDL, + SDLBinarySHA256: loadedHash, + NativePackageManifestSHA256: config.packageManifestSHA, + NativeDriverSHA256: config.driverSHA256, + GoVersion: runtime.Version(), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + } + suite := &latency.SuiteReport{ + Schema: latency.SuiteSchemaV1, GeneratedAt: generatedAt, Provenance: provenance, + } + + gateCtx, cancelGate := context.WithTimeout(context.Background(), 18*time.Minute) + defer cancelGate() + for _, controller := range liveControllerWorkloads() { + phaseSweepOffsets := latency.ProductionPhaseSweepOffsetsNS() + report := latency.Report{ + Schema: latency.SchemaV1, GeneratedAt: generatedAt, Provenance: provenance, + Workload: latency.Workload{ + APIAddress: liveLatencyAPIAddress, USBIPAddress: liveLatencyUSBIPAddress, + ControllerType: controller.apiType, + ExpectedVendorID: controller.vendorID, + ExpectedProductID: controller.productID, + ExpectedSDLType: controller.sdlType, + Button: "south/A", WarmupPairs: latency.ProductionWarmupPairs, + SamplePairs: config.samplePairs, + PerTransitionTimeoutNS: int64(liveLatencyTransitionTimeout), + InterTransitionDelayNS: int64(liveLatencyTransitionDelay), + PhaseSweepOffsetsNS: phaseSweepOffsets, + PhaseSweepSHA256: latency.PhaseSweepScheduleSHA256(phaseSweepOffsets), + Authentication: latency.AuthenticationMode, + }, + Policy: latency.Policy{ + MinimumSamplePairs: latency.MinimumProductionSamplePairs, + NativeMaxP95NS: latency.DefaultNativeMaxP95NS, + NativeMaxP99NS: latency.DefaultNativeMaxP99NS, + NativeMaxNS: latency.DefaultNativeMaxNS, + NativeMaxP95OverUSBIPNS: latency.DefaultNativeMaxP95OverUSBIPNS, + NativeMaxP99OverUSBIPNS: latency.DefaultNativeMaxP99OverUSBIPNS, + NativeMaxOverUSBIPNS: latency.DefaultNativeMaxOverUSBIPNS, + }, + } + for _, block := range latency.ProductionBlockSchedule(config.samplePairs) { + report.Runs = append(report.Runs, + runLiveLatencyTransport(gateCtx, block, controller)) + } + if err = latency.Finalize(&report); err != nil { + t.Fatalf("finalize %s source-bound latency report: %v", controller.apiType, err) + } + suite.Cases = append(suite.Cases, report) + } + if err = latency.FinalizeSuite(suite); err != nil { + t.Fatalf("finalize source-bound latency suite: %v", err) + } + if err = writeLatencyReportExclusive(config.outputPath, suite); err != nil { + t.Fatalf("write latency report: %v", err) + } + for _, controllerReport := range suite.Cases { + for _, transport := range controllerReport.Transports { + t.Logf("%s/%s controller-to-SDL: press n=%d p50=%s p95=%s p99=%s max=%s jitter=%s; "+ + "release n=%d p50=%s p95=%s p99=%s max=%s jitter=%s; misses=%d duplicates=%d", + controllerReport.Workload.ControllerType, transport.Transport, + transport.Statistics.Press.Count, + time.Duration(transport.Statistics.Press.P50NS), + time.Duration(transport.Statistics.Press.P95NS), + time.Duration(transport.Statistics.Press.P99NS), + time.Duration(transport.Statistics.Press.MaxNS), + time.Duration(transport.Statistics.Press.JitterNS), + transport.Statistics.Release.Count, + time.Duration(transport.Statistics.Release.P50NS), + time.Duration(transport.Statistics.Release.P95NS), + time.Duration(transport.Statistics.Release.P99NS), + time.Duration(transport.Statistics.Release.MaxNS), + time.Duration(transport.Statistics.Release.JitterNS), + transport.Misses.Total(), transport.Duplicates.Total()) + } + } + t.Logf("source-bound latency artifact: %s", config.outputPath) + if err = latency.RequireSuitePass(suite); err != nil { + t.Errorf("controller-to-game latency gate failed: %v", err) + } +} + +func loadLiveLatencyConfig() (liveLatencyConfig, error) { + if os.Getenv(liveLatencyPreflight) != "1" { + return liveLatencyConfig{}, fmt.Errorf( + "%s=1 is required; use the production preflight wrapper", liveLatencyPreflight) + } + config := liveLatencyConfig{ + outputPath: strings.TrimSpace(os.Getenv(liveLatencyOutput)), + expectedRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedRevision))), + sdlRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLRevision))), + sdlDLLPath: strings.TrimSpace(os.Getenv(liveLatencySDLDLL)), + sdlDLLSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLSHA256))), + packageManifestSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageManifest))), + driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), + } + if config.outputPath == "" || config.expectedRevision == "" || config.sdlRevision == "" || + config.sdlDLLPath == "" || config.sdlDLLSHA256 == "" || + config.packageManifestSHA == "" || config.driverSHA256 == "" { + return liveLatencyConfig{}, errors.New("production latency provenance environment is incomplete") + } + if !filepath.IsAbs(config.outputPath) || !filepath.IsAbs(config.sdlDLLPath) { + return liveLatencyConfig{}, errors.New("latency output and SDL DLL paths must be absolute") + } + if _, err := os.Stat(config.outputPath); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return liveLatencyConfig{}, fmt.Errorf("latency output already exists: %s", config.outputPath) + } + return liveLatencyConfig{}, fmt.Errorf("inspect latency output: %w", err) + } + if info, err := os.Stat(filepath.Dir(config.outputPath)); err != nil || !info.IsDir() { + return liveLatencyConfig{}, fmt.Errorf("latency output parent must already exist: %s", filepath.Dir(config.outputPath)) + } + samples, err := strconv.Atoi(strings.TrimSpace(os.Getenv(liveLatencySamples))) + if err != nil || samples < latency.MinimumProductionSamplePairs || + samples > latency.MaximumProductionSamplePairs { + return liveLatencyConfig{}, fmt.Errorf("%s must be an integer in [%d, %d]", + liveLatencySamples, latency.MinimumProductionSamplePairs, + latency.MaximumProductionSamplePairs) + } + config.samplePairs = samples + canonicalSDL, err := canonicalPath(config.sdlDLLPath) + if err != nil { + return liveLatencyConfig{}, fmt.Errorf("resolve source-bound SDL DLL: %w", err) + } + config.sdlDLLPath = canonicalSDL + return config, nil +} + +func validateLiveLatencySource(config liveLatencyConfig) error { + workingDirectory, err := os.Getwd() + if err != nil { + return err + } + repositoryRoot, err := runGit(workingDirectory, "rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("latency harness is not an exact Git checkout: %w", err) + } + repositoryRoot, err = canonicalPath(strings.TrimSpace(repositoryRoot)) + if err != nil { + return err + } + head, err := runGit(repositoryRoot, "rev-parse", "--verify", "HEAD") + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(head)) != config.expectedRevision { + return fmt.Errorf("latency harness source is %s, expected %s", strings.TrimSpace(head), config.expectedRevision) + } + status, err := runGit(repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return err + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf("latency source tree is not clean; refusing unreviewed code or data:\n%s", status) + } + submodules, err := runGit(repositoryRoot, "submodule", "status", "--recursive") + if err != nil { + return err + } + for _, line := range strings.Split(strings.TrimSpace(submodules), "\n") { + if line != "" && strings.ContainsRune("-+U", rune(line[0])) { + return fmt.Errorf("latency source has an unbound submodule: %s", line) + } + } + sdlRoot := filepath.Join(repositoryRoot, "_testing", "e2e", "deps", "SDL") + sdlRevision, err := runGit(sdlRoot, "rev-parse", "--verify", "HEAD") + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(sdlRevision)) != config.sdlRevision { + return fmt.Errorf("SDL source is %s, expected %s", strings.TrimSpace(sdlRevision), config.sdlRevision) + } + wantSDLPath, err := canonicalPath(filepath.Join(sdlRoot, "build", "Debug", "SDL3.dll")) + if err != nil { + return err + } + if !strings.EqualFold(config.sdlDLLPath, wantSDLPath) { + return fmt.Errorf("SDL DLL must be the wrapper-linked submodule build %s, got %s", + wantSDLPath, config.sdlDLLPath) + } + return nil +} + +func runLiveLatencyTransport( + ctx context.Context, + block latency.BlockSpec, + controller liveControllerWorkload, +) (result latency.Run) { + transport := block.Transport + result.Order = block.Order + result.TransportBlock = block.TransportBlock + result.FirstSequence = block.FirstSequence + result.SamplePairs = block.SamplePairs + result.Transport = transport + result.Authentication = latency.AuthenticationMode + tempDir, err := os.MkdirTemp("", "viiper-e2e-latency-"+controller.apiType+"-"+transport+"-") + if err != nil { + result.Failure = err.Error() + return result + } + defer os.RemoveAll(tempDir) + + baseline, err := snapshotGamepadIDs() + if err != nil { + result.Failure = fmt.Sprintf("snapshot baseline SDL gamepads: %v", err) + return result + } + result.Controller.BaselineGamepadIDs = gamepadIDsAsInt32(baseline) + + server, err := startLatencyServer(ctx, transport, tempDir) + if err != nil { + result.Failure = err.Error() + return result + } + var ( + busCreated bool + deviceID string + gamepadID sdl.GamepadID + gamepad *sdl.Gamepad + stream *viiperclient.DeviceStream + ) + defer func() { + if stream != nil { + if closeErr := stream.Close(); closeErr != nil { + appendLatencyFailure(&result, "close authenticated stream: %v", closeErr) + } + } + if gamepad != nil { + gamepad.Close() + } + if deviceID != "" { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, removeErr := server.client.DeviceRemoveCtx(cleanupCtx, 1, deviceID) + cancel() + if removeErr != nil { + appendLatencyFailure(&result, "remove API device: %v", removeErr) + } + } + if busCreated { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, removeErr := server.client.BusRemoveCtx(cleanupCtx, 1) + cancel() + if removeErr != nil { + appendLatencyFailure(&result, "remove API bus: %v", removeErr) + } + } + if gamepadID != 0 { + if removeErr := waitForGamepadRemoval(gamepadID, 10*time.Second); removeErr != nil { + appendLatencyFailure(&result, "wait for exact SDL gamepad removal: %v", removeErr) + } + } + if closeErr := server.close(); closeErr != nil { + appendLatencyFailure(&result, "stop %s server: %v", transport, closeErr) + } + }() + + result.Server = serverProof(server.ping) + unauthenticated := viiperclient.NewWithConfig(liveLatencyAPIAddress, &viiperclient.Config{ + DialTimeout: 500 * time.Millisecond, ReadTimeout: time.Second, WriteTimeout: time.Second, + }) + probeCtx, cancelProbe := context.WithTimeout(ctx, 2*time.Second) + _, unauthenticatedErr := unauthenticated.PingCtx(probeCtx) + cancelProbe() + var unauthenticatedAPIError *viipertypes.APIError + if !errors.As(unauthenticatedErr, &unauthenticatedAPIError) || + unauthenticatedAPIError.Status != 401 || + unauthenticatedAPIError.Title != "Unauthorized" || + unauthenticatedAPIError.Detail != "authentication required" { + result.Failure = fmt.Sprintf( + "unauthenticated API ping was not explicitly rejected by the live server: %v", + unauthenticatedErr) + return result + } + reprobeCtx, cancelReprobe := context.WithTimeout(ctx, 2*time.Second) + reprobe, reprobeErr := server.client.PingCtx(reprobeCtx) + cancelReprobe() + if reprobeErr != nil { + result.Failure = fmt.Sprintf("authenticated API failed immediately after rejection probe: %v", reprobeErr) + return result + } + if err = validatePing(transport, reprobe); err != nil || reprobe.Version != server.ping.Version { + result.Failure = fmt.Sprintf( + "authenticated API identity changed after rejection probe: response=%+v error=%v", + reprobe, err) + return result + } + result.UnauthenticatedRejected = true + + requestCtx, cancelRequest := context.WithTimeout(ctx, 10*time.Second) + bus, err := server.client.BusCreateCtx(requestCtx, 1) + cancelRequest() + if err != nil || bus == nil || bus.BusID != 1 { + result.Failure = fmt.Sprintf("authenticated BusCreate did not return bus 1: response=%v error=%v", bus, err) + return result + } + busCreated = true + requestCtx, cancelRequest = context.WithTimeout(ctx, 30*time.Second) + device, err := server.client.DeviceAddCtx(requestCtx, 1, controller.apiType, nil) + cancelRequest() + if err != nil { + result.Failure = fmt.Sprintf("authenticated DeviceAdd over %s: %v", transport, err) + return result + } + if device == nil || device.BusID != 1 || device.DevID != "1" || + device.Type != controller.apiType || + !strings.EqualFold(device.Vid, fmt.Sprintf("0x%04x", controller.vendorID)) || + !strings.EqualFold(device.Pid, fmt.Sprintf("0x%04x", controller.productID)) { + result.Failure = fmt.Sprintf("DeviceAdd source proof is not the exact %s workload: %+v", + controller.apiType, device) + return result + } + if transport == latency.TransportUSBIP && device.USBIPPort <= 0 { + result.Failure = "USB/IP DeviceAdd did not return the exact auto-attached import port" + return result + } + if transport == latency.TransportNativeUDE && device.USBIPPort != 0 { + result.Failure = fmt.Sprintf("native DeviceAdd returned contradictory USB/IP port %d", device.USBIPPort) + return result + } + deviceID = device.DevID + result.Device = latency.DeviceProof{ + BusID: 1, DeviceID: device.DevID, Type: device.Type, + VendorID: controller.vendorID, ProductID: controller.productID, USBIPPort: device.USBIPPort, + } + + gamepadID, err = discoverExactNewGamepad(ctx, baseline, liveLatencyDiscoveryTimeout) + if err != nil { + result.Failure = err.Error() + return result + } + result.Controller.NewGamepadIDs = []int32{int32(gamepadID)} + gamepad, err = sdl.OpenGamepad(gamepadID) + if err != nil { + result.Failure = fmt.Sprintf("open exact newly enumerated SDL gamepad %d: %v", gamepadID, err) + return result + } + result.Controller = controllerProof(result.Controller.BaselineGamepadIDs, gamepad) + if err = validateControllerProof(result.Controller, controller); err != nil { + result.Failure = err.Error() + return result + } + + streamCtx, cancelStream := context.WithTimeout(ctx, 10*time.Second) + stream, err = server.client.OpenStream(streamCtx, 1, device.DevID) + cancelStream() + if err != nil { + result.Failure = fmt.Sprintf("open authenticated %s stream over %s: %v", + controller.apiType, transport, err) + return result + } + lastEventTimestamp, err := settleNeutralController(gamepad, stream, controller.state(false)) + if err != nil { + result.Failure = fmt.Sprintf("settle exact SDL source before measurement: %v", err) + return result + } + lastEventTimestamp, err = warmControllerPath(gamepad, stream, controller, lastEventTimestamp) + if err != nil { + result.Failure = fmt.Sprintf("warm exact controller-to-SDL path: %v", err) + return result + } + observedDown := false + lastSequence := block.FirstSequence + block.SamplePairs - 1 + for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { + sleepForProductionPhase(sequence, latency.TransitionPress) + lastEventTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionPress, true, + controller.state(true), &observedDown, lastEventTimestamp, &result) + if err != nil { + result.Failure = err.Error() + return result + } + sleepForProductionPhase(sequence, latency.TransitionRelease) + lastEventTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionRelease, false, + controller.state(false), &observedDown, lastEventTimestamp, &result) + if err != nil { + result.Failure = err.Error() + return result + } + } + if err = observeDuplicateQuietWindow(gamepad, lastEventTimestamp, &observedDown, &result); err != nil { + result.Failure = err.Error() + return result + } + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + result.Failure = "exact SDL source did not finish in the commanded released state" + } + return result +} + +func startLatencyServer(ctx context.Context, transport, tempDir string) (*latencyServerSession, error) { + for _, address := range []string{liveLatencyAPIAddress, liveLatencyUSBIPAddress} { + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, fmt.Errorf("latency endpoint %s is already occupied: %w", address, err) + } + _ = listener.Close() + } + credentialPath := filepath.Join(tempDir, "viiper.key.txt") + if err := os.WriteFile(credentialPath, []byte(liveLatencyPassword), 0o600); err != nil { + return nil, err + } + serverCtx, cancelServer := context.WithCancel(ctx) + serverDone := make(chan error, 1) + server := cmd.Server{ + USBServerConfig: serverusb.ServerConfig{ + Addr: liveLatencyUSBIPAddress, BusCleanupTimeout: 30 * time.Second, + }, + APIServerConfig: api.ServerConfig{ + Addr: liveLatencyAPIAddress, AutoAttachLocalClient: true, + RequireLocalHostAuth: true, DeviceHandlerConnectTimeout: 30 * time.Second, + Password: liveLatencyPassword, + PlatformOpts: api.PlatformOpts{AutoAttachWindowsNative: true}, + }, + ConnectionTimeout: 5 * time.Second, + Transport: transport, + KeyFile: credentialPath, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + go func() { serverDone <- server.StartServer(serverCtx, logger, nil) }() + client := viiperclient.NewWithConfig(liveLatencyAPIAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, + Password: liveLatencyPassword, + }) + startupDeadline := time.Now().Add(20 * time.Second) + var lastError error + for time.Now().Before(startupDeadline) { + select { + case serverErr := <-serverDone: + cancelServer() + return nil, fmt.Errorf("%s server stopped during startup: %w", transport, serverErr) + default: + } + pingCtx, cancelPing := context.WithTimeout(ctx, time.Second) + ping, pingErr := client.PingCtx(pingCtx) + cancelPing() + if pingErr == nil { + if err := validatePing(transport, ping); err != nil { + cancelServer() + <-serverDone + return nil, err + } + return &latencyServerSession{cancel: cancelServer, done: serverDone, client: client, ping: ping}, nil + } + lastError = pingErr + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + cancelServer() + <-serverDone + return nil, ctx.Err() + case <-timer.C: + } + } + cancelServer() + <-serverDone + return nil, fmt.Errorf("authenticated %s API did not become ready: %v", transport, lastError) +} + +func (session *latencyServerSession) close() error { + session.cancel() + select { + case err := <-session.done: + if err == nil || errors.Is(err, context.Canceled) { + return nil + } + return err + case <-time.After(15 * time.Second): + return errors.New("server shutdown timed out") + } +} + +func validatePing(transport string, ping *viipertypes.PingResponse) error { + if ping == nil || ping.Server != "VIIPER" || ping.Transport != transport || + ping.Version == "" || ping.Ready == nil || !*ping.Ready { + return fmt.Errorf("authenticated ping does not prove live %s transport: %+v", transport, ping) + } + if transport == latency.TransportNativeUDE { + if ping.NativeUDE == nil || ping.NativeUDE.ABIMajor == 0 || + ping.NativeUDE.ExpectedDriverPackageVersion == "" { + return fmt.Errorf("authenticated ping lacks native ABI/package proof: %+v", ping) + } + } else if ping.NativeUDE != nil { + return fmt.Errorf("USB/IP ping returned contradictory native proof: %+v", ping.NativeUDE) + } + return nil +} + +func serverProof(ping *viipertypes.PingResponse) latency.ServerProof { + proof := latency.ServerProof{ + Server: ping.Server, Version: ping.Version, Transport: ping.Transport, + Ready: ping.Ready != nil && *ping.Ready, + } + if ping.NativeUDE != nil { + proof.NativeUDE = &latency.NativeServerProof{ + ABIMajor: ping.NativeUDE.ABIMajor, ABIMinor: ping.NativeUDE.ABIMinor, + Capabilities: ping.NativeUDE.Capabilities, + ExpectedDriverPackageVersion: ping.NativeUDE.ExpectedDriverPackageVersion, + } + } + return proof +} + +func snapshotGamepadIDs() ([]sdl.GamepadID, error) { + sdl.UpdateGamepads() + ids, err := sdl.GetGamepads() + if err != nil { + return nil, err + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids, nil +} + +func discoverExactNewGamepad(ctx context.Context, baseline []sdl.GamepadID, timeout time.Duration) (sdl.GamepadID, error) { + baselineSet := make(map[sdl.GamepadID]struct{}, len(baseline)) + for _, id := range baseline { + baselineSet[id] = struct{}{} + } + deadline := time.Now().Add(timeout) + var candidate sdl.GamepadID + var stableSince time.Time + for time.Now().Before(deadline) { + current, err := snapshotGamepadIDs() + if err != nil { + return 0, err + } + currentSet := make(map[sdl.GamepadID]struct{}, len(current)) + for _, id := range current { + currentSet[id] = struct{}{} + } + for id := range baselineSet { + if _, ok := currentSet[id]; !ok { + return 0, fmt.Errorf("baseline SDL gamepad %d disappeared during source discovery", id) + } + } + added := make([]sdl.GamepadID, 0, 2) + for _, id := range current { + if _, exists := baselineSet[id]; !exists { + added = append(added, id) + } + } + if len(added) > 1 { + return 0, fmt.Errorf("source discovery is ambiguous: new SDL gamepads=%v", added) + } + if len(added) == 1 { + if candidate != added[0] { + candidate = added[0] + stableSince = time.Now() + } else if time.Since(stableSince) >= 250*time.Millisecond { + return candidate, nil + } + } else { + candidate = 0 + stableSince = time.Time{} + } + timer := time.NewTimer(25 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return 0, ctx.Err() + case <-timer.C: + } + } + return 0, errors.New("timed out waiting for exactly one stable newly enumerated SDL gamepad") +} + +func controllerProof( + baseline []int32, + gamepad *sdl.Gamepad, +) latency.ControllerProof { + return latency.ControllerProof{ + BaselineGamepadIDs: baseline, + NewGamepadIDs: []int32{int32(gamepad.ID())}, + SDLInstanceID: int32(gamepad.ID()), + SDLPath: gamepad.Path(), + SDLGUID: sdl.GetGamepadGUIDForID(gamepad.ID()).String(), + SDLName: gamepad.Name(), + SDLType: sdlGamepadTypeName(gamepad.RealType()), + SDLReportedType: int32(gamepad.Type()), + SDLRealType: int32(gamepad.RealType()), + VendorID: gamepad.Vendor(), + ProductID: gamepad.Product(), + } +} + +func validateControllerProof(proof latency.ControllerProof, controller liveControllerWorkload) error { + if proof.SDLInstanceID == 0 || proof.SDLPath == "" || proof.SDLGUID == "" || proof.SDLName == "" { + return fmt.Errorf("new SDL gamepad identity is incomplete: %+v", proof) + } + if proof.SDLType != controller.sdlType || + proof.VendorID != controller.vendorID || proof.ProductID != controller.productID || + sdl.GamepadType(proof.SDLRealType) != controller.sdlRealType { + return fmt.Errorf("new SDL gamepad is not the API-created %s: %+v", controller.apiType, proof) + } + return nil +} + +func sdlGamepadTypeName(gamepadType sdl.GamepadType) string { + switch gamepadType { + case sdl.GamepadTypeXbox360: + return "xbox360" + case sdl.GamepadTypePS4: + return "ps4" + case sdl.GamepadTypePS5: + return "ps5" + default: + return fmt.Sprintf("unknown(%d)", gamepadType) + } +} + +func settleNeutralController( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + neutral encoding.BinaryMarshaler, +) (uint64, error) { + if err := stream.SetWriteDeadline(time.Now().Add(liveLatencyTransitionTimeout)); err != nil { + return 0, err + } + if err := stream.WriteBinary(neutral); err != nil { + return 0, err + } + observedDown := gamepad.GetButton(sdl.GamepadButtonSouth) + quietDeadline := time.Now().Add(liveLatencySourceQuiet) + overallDeadline := time.Now().Add(2 * time.Second) + var lastTimestamp uint64 + for time.Now().Before(overallDeadline) { + remaining := time.Until(quietDeadline) + if remaining <= 0 { + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + return 0, errors.New("controller remained pressed after neutral synchronization") + } + return lastTimestamp, nil + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(remaining)) + if err != nil { + return 0, err + } + if !received { + continue + } + if event.TimestampNS == 0 || (lastTimestamp != 0 && event.TimestampNS < lastTimestamp) { + return 0, errors.New("SDL event timestamp was absent or regressed during source synchronization") + } + lastTimestamp = event.TimestampNS + observedDown = event.Down + quietDeadline = time.Now().Add(liveLatencySourceQuiet) + } + return 0, errors.New("SDL source did not reach a quiet released state") +} + +func warmControllerPath( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + controller liveControllerWorkload, + lastTimestamp uint64, +) (uint64, error) { + observedDown := false + warmup := latency.Run{} + var err error + for sequence := 1; sequence <= latency.ProductionWarmupPairs; sequence++ { + sleepForProductionPhase(sequence, latency.TransitionPress) + lastTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionPress, true, + controller.state(true), &observedDown, lastTimestamp, &warmup) + if err != nil { + return lastTimestamp, err + } + sleepForProductionPhase(sequence, latency.TransitionRelease) + lastTimestamp, err = measureTransition( + gamepad, stream, sequence, latency.TransitionRelease, false, + controller.state(false), &observedDown, lastTimestamp, &warmup) + if err != nil { + return lastTimestamp, err + } + } + if err = observeDuplicateQuietWindow(gamepad, lastTimestamp, &observedDown, &warmup); err != nil { + return lastTimestamp, err + } + if warmup.Misses.Total() != 0 || warmup.Duplicates.Total() != 0 { + return lastTimestamp, fmt.Errorf( + "warmup observed misses=%d duplicates=%d", warmup.Misses.Total(), warmup.Duplicates.Total()) + } + if observedDown || gamepad.GetButton(sdl.GamepadButtonSouth) { + return lastTimestamp, errors.New("warmup did not finish in the commanded released state") + } + return lastTimestamp, nil +} + +func sleepForProductionPhase(sequence int, transition latency.Transition) { + delay := liveLatencyTransitionDelay + + time.Duration(latency.ProductionPhaseOffsetNS(sequence, transition)) + time.Sleep(delay) +} + +func measureTransition( + gamepad *sdl.Gamepad, + stream *viiperclient.DeviceStream, + sequence int, + transition latency.Transition, + wantDown bool, + inputState encoding.BinaryMarshaler, + observedDown *bool, + lastTimestamp uint64, + result *latency.Run, +) (uint64, error) { + if *observedDown == wantDown { + return lastTimestamp, fmt.Errorf("%s sample %d started from the wrong observed state", transition, sequence) + } + if err := stream.SetWriteDeadline(time.Now().Add(liveLatencyTransitionTimeout)); err != nil { + return lastTimestamp, err + } + started := time.Now() + if err := stream.WriteBinary(inputState); err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d authenticated WriteBinary: %w", transition, sequence, err) + } + deadline := started.Add(liveLatencyTransitionTimeout) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + incrementTransitionCounter(&result.Misses, transition) + return lastTimestamp, fmt.Errorf("%s sample %d timed out after %s", transition, sequence, + liveLatencyTransitionTimeout) + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(remaining)) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d SDL event wait: %w", transition, sequence, err) + } + if !received { + incrementTransitionCounter(&result.Misses, transition) + return lastTimestamp, fmt.Errorf("%s sample %d timed out after %s", transition, sequence, + liveLatencyTransitionTimeout) + } + if event.TimestampNS == 0 || (lastTimestamp != 0 && event.TimestampNS < lastTimestamp) { + return lastTimestamp, fmt.Errorf("%s sample %d returned an absent or regressed SDL event timestamp", + transition, sequence) + } + lastTimestamp = event.TimestampNS + if event.Down == *observedDown { + incrementEdgeCounter(&result.Duplicates, event.Down) + continue + } + if event.Down != wantDown { + incrementEdgeCounter(&result.Duplicates, event.Down) + *observedDown = event.Down + continue + } + elapsed := time.Since(started) + if elapsed <= 0 { + return lastTimestamp, fmt.Errorf("%s sample %d produced non-positive monotonic latency", transition, sequence) + } + *observedDown = event.Down + result.Samples = append(result.Samples, latency.Sample{ + Sequence: sequence, Transition: transition, LatencyNS: int64(elapsed), + EventTimestampNS: event.TimestampNS, + }) + return lastTimestamp, nil + } +} + +func observeDuplicateQuietWindow( + gamepad *sdl.Gamepad, + lastTimestamp uint64, + observedDown *bool, + result *latency.Run, +) error { + deadline := time.Now().Add(liveLatencyDuplicateQuiet) + for time.Now().Before(deadline) { + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, durationMillisecondsCeiling(time.Until(deadline))) + if err != nil { + return err + } + if !received { + return nil + } + if event.TimestampNS == 0 || event.TimestampNS < lastTimestamp { + return errors.New("SDL event timestamp was absent or regressed in duplicate quiet window") + } + lastTimestamp = event.TimestampNS + incrementEdgeCounter(&result.Duplicates, event.Down) + *observedDown = event.Down + } + return nil +} + +func incrementTransitionCounter(counters *latency.Counters, transition latency.Transition) { + if transition == latency.TransitionPress { + counters.Press++ + } else { + counters.Release++ + } +} + +func incrementEdgeCounter(counters *latency.Counters, down bool) { + if down { + counters.Press++ + } else { + counters.Release++ + } +} + +func durationMillisecondsCeiling(duration time.Duration) int32 { + if duration <= 0 { + return 1 + } + milliseconds := (duration + time.Millisecond - 1) / time.Millisecond + if milliseconds > math.MaxInt32 { + return math.MaxInt32 + } + return int32(milliseconds) +} + +func waitForGamepadRemoval(id sdl.GamepadID, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ids, err := snapshotGamepadIDs() + if err != nil { + return err + } + present := false + for _, candidate := range ids { + if candidate == id { + present = true + break + } + } + if !present { + return nil + } + time.Sleep(25 * time.Millisecond) + } + return fmt.Errorf("gamepad %d still present after %s", id, timeout) +} + +func gamepadIDsAsInt32(ids []sdl.GamepadID) []int32 { + result := make([]int32, len(ids)) + for index, id := range ids { + result[index] = int32(id) + } + return result +} + +func appendLatencyFailure(run *latency.Run, format string, arguments ...any) { + message := fmt.Sprintf(format, arguments...) + if run.Failure == "" { + run.Failure = message + } else { + run.Failure += "; " + message + } +} + +func runGit(directory string, arguments ...string) (string, error) { + command := exec.Command("git", append([]string{"-C", directory}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(arguments, " "), err, + strings.TrimSpace(string(output))) + } + return strings.TrimRight(string(output), "\r\n"), nil +} + +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", err + } + return filepath.Clean(resolved), nil +} + +func loadedModulePath(name string) (string, error) { + moduleName, err := windows.UTF16PtrFromString(name) + if err != nil { + return "", err + } + var module windows.Handle + if err = windows.GetModuleHandleEx( + windows.GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + moduleName, + &module, + ); err != nil { + return "", fmt.Errorf("GetModuleHandleEx(%s): %w", name, err) + } + buffer := make([]uint16, 32768) + length, err := windows.GetModuleFileName(module, &buffer[0], uint32(len(buffer))) + if err != nil { + return "", fmt.Errorf("GetModuleFileName(%s): %w", name, err) + } + if length == 0 || int(length) >= len(buffer) { + return "", fmt.Errorf("GetModuleFileName(%s) returned invalid length %d", name, length) + } + return windows.UTF16ToString(buffer[:length]), nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err = io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func writeLatencyReportExclusive(path string, report *latency.SuiteReport) (err error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + complete := false + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = closeErr + } + if !complete { + _ = os.Remove(path) + } + }() + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err = encoder.Encode(report); err != nil { + return err + } + if err = file.Sync(); err != nil { + return err + } + complete = true + return nil +} diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 new file mode 100644 index 00000000..b86f9e1f --- /dev/null +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -0,0 +1,330 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [string]$ExpectedSourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$SDLBinarySHA256, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $true)] + [string]$WprTracePath, + + [ValidateRange(256, 10000)] + [int]$Samples = 256, + + [string]$RepositoryRoot, + + [string]$GoExecutable = 'go.exe' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Resolve-CanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path +} + +function Resolve-NewEvidencePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Label + ) + + $full = [IO.Path]::GetFullPath($Path) + if (Test-Path -LiteralPath $full) { + throw "$Label already exists; refusing to overwrite source-bound evidence: '$full'." + } + $parent = Split-Path -Parent $full + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { + throw "$Label parent directory must already exist: '$parent'." + } + $repoPrefix = $Repository.TrimEnd('\') + '\' + if ($full.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "$Label must be outside the source checkout so the measured tree remains clean: '$full'." + } + return $full +} + +function Resolve-DriverImagePath { + param([Parameter(Mandatory = $true)][string]$ImagePath) + + $path = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim().Trim('"')) + if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) { + $path = $path.Substring(4) + } + if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length) + } + elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $env:SystemRoot $path + } + if (-not [IO.Path]::IsPathRooted($path)) { + throw "VIIPER UDE has an unsupported relative service image path: '$ImagePath'." + } + return Resolve-CanonicalPath -Path $path +} + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Join-Path $PSScriptRoot '..\..\..' +} +if (-not (Test-IsAdministrator)) { + throw 'The source-bound latency gate and WPR capture require an elevated PowerShell session.' +} +$repository = Resolve-CanonicalPath -Path $RepositoryRoot +$git = Get-Command git.exe -ErrorAction Stop +$headOutput = @(& $git.Source -C $repository rev-parse --verify HEAD 2>&1) +if ($LASTEXITCODE -ne 0 -or $headOutput.Count -eq 0) { + throw "The production latency harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" +} +$headRevision = ([string]$headOutput[0]).Trim().ToLowerInvariant() +if (-not [string]::Equals($headRevision, $ExpectedSourceRevision, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The production latency harness is source '$headRevision', not '$ExpectedSourceRevision'." +} +$treeStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) +if ($LASTEXITCODE -ne 0) { + throw "Could not verify the production latency source tree.`n$($treeStatus -join [Environment]::NewLine)" +} +if ($treeStatus.Count -ne 0) { + throw ("The production latency source tree is not clean; refusing unreviewed test code or data:`n" + + ($treeStatus -join [Environment]::NewLine)) +} +$submoduleStatus = @(& $git.Source -C $repository submodule status --recursive 2>&1) +if ($LASTEXITCODE -ne 0 -or @($submoduleStatus | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) { + throw "The production latency source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" +} +$sdlRoot = Resolve-CanonicalPath -Path (Join-Path $repository '_testing\e2e\deps\SDL') +$sdlRevisionOutput = @(& $git.Source -C $sdlRoot rev-parse --verify HEAD 2>&1) +if ($LASTEXITCODE -ne 0 -or $sdlRevisionOutput.Count -eq 0) { + throw "Could not bind the SDL source revision.`n$($sdlRevisionOutput -join [Environment]::NewLine)" +} +$sdlRevision = ([string]$sdlRevisionOutput[0]).Trim().ToLowerInvariant() +$sdlDLL = Resolve-CanonicalPath -Path (Join-Path $sdlRoot 'build\Debug\SDL3.dll') +$actualSDLHash = (Get-FileHash -LiteralPath $sdlDLL -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not [string]::Equals($actualSDLHash, $SDLBinarySHA256, + [StringComparison]::OrdinalIgnoreCase)) { + throw "The SDL binary hash is '$actualSDLHash', not the source-build hash '$SDLBinarySHA256'." +} + +$signatureGate = Join-Path $repository 'native\udecx\tools\Test-ViiperUdeSignedPackage.ps1' +& $signatureGate ` + -PackageDirectory $SignedPackageDirectory ` + -SubmissionManifestPath $SubmissionManifestPath ` + -ExpectedSourceRevision $ExpectedSourceRevision ` + -ValidationMode Production + +$packageRoot = Resolve-CanonicalPath -Path $SignedPackageDirectory +$packageDriver = Resolve-CanonicalPath -Path (Join-Path $packageRoot 'ViiperUde.sys') +$service = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\ViiperUde' -ErrorAction Stop +if ([string]::IsNullOrWhiteSpace([string]$service.ImagePath)) { + throw 'The installed VIIPER UDE service has no ImagePath.' +} +$installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePath) +$packageDriverHash = (Get-FileHash -LiteralPath $packageDriver -Algorithm SHA256).Hash.ToLowerInvariant() +$installedDriverHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash.ToLowerInvariant() +if ($packageDriverHash -ne $installedDriverHash) { + throw "The loaded VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." +} +$devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { + [string]$_.DeviceID -like 'ROOT\VIIPER\UDE*' +}) +if ($devnodes.Count -ne 1) { + throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." +} +if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { + throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." +} +$manifest = Resolve-CanonicalPath -Path $SubmissionManifestPath +$manifestHash = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() + +$output = Resolve-NewEvidencePath -Path $OutputPath -Repository $repository -Label 'Latency JSON output' +$trace = Resolve-NewEvidencePath -Path $WprTracePath -Repository $repository -Label 'WPR trace output' +if ([string]::Equals($output, $trace, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The latency JSON and WPR trace must use different evidence paths.' +} +$go = Get-Command $GoExecutable -ErrorAction Stop +$wpr = Get-Command wpr.exe -ErrorAction Stop +$wprProfile = 'GeneralProfile.Verbose' +$profileDetailsOutput = @(& $wpr.Source -profiledetails $wprProfile 2>&1) +if ($LASTEXITCODE -ne 0) { + throw "WPR could not describe '$wprProfile'.`n$($profileDetailsOutput -join [Environment]::NewLine)" +} +$profileDetails = $profileDetailsOutput | Out-String +if ($profileDetails -notmatch '(?im)^Profile\s*:\s*GeneralProfile\.Verbose\.Memory\s*$') { + throw "WPR '$wprProfile' is not the required bounded-memory profile.`n$profileDetails" +} +foreach ($eventName in @('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$eventName\s*$").Count -lt 1) { + throw "WPR '$wprProfile' does not capture the required $eventName evidence." + } +} +foreach ($stackName in @('CSwitch', 'ReadyThread', 'SampledProfile')) { + if ([regex]::Matches($profileDetails, "(?im)^\s*$stackName\s*$").Count -lt 2) { + throw "WPR '$wprProfile' does not capture the required $stackName events and stacks." + } +} + +$environmentNames = @( + 'CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK', 'PATH', + 'VIIPER_E2E_LIVE_LATENCY', 'VIIPER_E2E_PRODUCTION_PREFLIGHT', + 'VIIPER_E2E_LATENCY_OUTPUT', 'VIIPER_E2E_LATENCY_SAMPLES', + 'VIIPER_E2E_EXPECTED_SOURCE_REVISION', 'VIIPER_E2E_SDL_SOURCE_REVISION', + 'VIIPER_E2E_SDL_DLL_PATH', 'VIIPER_E2E_SDL_DLL_SHA256', + 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256' +) +$savedEnvironment = @{} +foreach ($name in $environmentNames) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} + +$wprInstance = "ViiperE2ELatency-$PID-$([guid]::NewGuid().ToString('N'))" +$wprStarted = $false +$wprFailure = $null +$testExitCode = -1 +try { + $env:CGO_ENABLED = '1' + $env:GOENV = 'off' + $env:GOFLAGS = '-mod=readonly' + $env:GOTOOLCHAIN = 'local' + $env:GOWORK = 'off' + $env:PATH = "$(Split-Path -Parent $sdlDLL);$($savedEnvironment['PATH'])" + $env:VIIPER_E2E_LIVE_LATENCY = '1' + $env:VIIPER_E2E_PRODUCTION_PREFLIGHT = '1' + $env:VIIPER_E2E_LATENCY_OUTPUT = $output + $env:VIIPER_E2E_LATENCY_SAMPLES = [string]$Samples + $env:VIIPER_E2E_EXPECTED_SOURCE_REVISION = $headRevision + $env:VIIPER_E2E_SDL_SOURCE_REVISION = $sdlRevision + $env:VIIPER_E2E_SDL_DLL_PATH = $sdlDLL + $env:VIIPER_E2E_SDL_DLL_SHA256 = $actualSDLHash + $env:VIIPER_E2E_PACKAGE_MANIFEST_SHA256 = $manifestHash + $env:VIIPER_E2E_NATIVE_DRIVER_SHA256 = $installedDriverHash + + $startOutput = @(& $wpr.Source -start $wprProfile -instancename $wprInstance 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not start the bounded-memory WPR capture (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" + } + $wprStarted = $true + + & $go.Source test -mod=readonly -count=1 -timeout=20m ` + -run '^TestLiveControllerToGameLatencyGate$' -v ./_testing/e2e + $testExitCode = $LASTEXITCODE +} +finally { + if ($wprStarted) { + $statusOutput = @(& $wpr.Source -status -instancename $wprInstance 2>&1) + $statusExitCode = $LASTEXITCODE + $statusText = $statusOutput | Out-String + if ($statusExitCode -ne 0) { + $wprFailure = "WPR status failed with exit $statusExitCode. $($statusOutput -join ' ')" + } + else { + $droppedMatch = [regex]::Match($statusText, '(?im)^\s*Dropped Event\s*:\s*(?\d+)\s*$') + if (-not $droppedMatch.Success) { + $wprFailure = "WPR did not report its dropped-event count. $($statusOutput -join ' ')" + } + elseif ([uint64]$droppedMatch.Groups['count'].Value -ne 0) { + $wprFailure = "WPR dropped $($droppedMatch.Groups['count'].Value) event(s); the trace is incomplete." + } + } + + $stopOutput = @(& $wpr.Source -stop $trace -instancename $wprInstance 2>&1) + $stopExitCode = $LASTEXITCODE + if ($stopExitCode -ne 0) { + $stopFailure = "WPR stop failed with exit $stopExitCode. $($stopOutput -join ' ')" + if ($null -eq $wprFailure) { + $wprFailure = $stopFailure + } + else { + $wprFailure = "$wprFailure $stopFailure" + } + } + elseif (-not (Test-Path -LiteralPath $trace -PathType Leaf) -or + (Get-Item -LiteralPath $trace).Length -le 0) { + $traceFailure = "WPR reported success without a non-empty trace '$trace'." + if ($null -eq $wprFailure) { + $wprFailure = $traceFailure + } + else { + $wprFailure = "$wprFailure $traceFailure" + } + } + } + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } +} + +if ($testExitCode -ne 0) { + $wprSuffix = if ($null -eq $wprFailure) { '' } else { " WPR integrity also failed: $wprFailure" } + throw "The live controller-to-game latency gate failed with exit code $testExitCode. Failure evidence, if emitted, is '$output'; WPR evidence is '$trace'.$wprSuffix" +} +if ($null -ne $wprFailure) { + throw "The live workload passed, but WPR evidence failed closed: $wprFailure" +} +if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw "The latency gate exited successfully without the required JSON artifact '$output'." +} +$report = Get-Content -LiteralPath $output -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v1' -or + [string]$report.provenance.source_revision -cne $headRevision -or + [string]$report.provenance.sdl_source_revision -cne $sdlRevision -or + [string]$report.provenance.sdl_binary_sha256 -cne $actualSDLHash -or + [string]$report.provenance.native_package_manifest_sha256 -cne $manifestHash -or + [string]$report.provenance.native_driver_sha256 -cne $installedDriverHash -or + [string]$report.verdict -cne 'pass' -or + @($report.cases).Count -ne 3) { + throw "The latency JSON artifact is not a passing source-bound production-controller suite." +} +$requiredControllers = @('xbox360', 'dualshock4', 'dualsensegamepadv5') +for ($index = 0; $index -lt $requiredControllers.Count; $index++) { + $case = $report.cases[$index] + if ([string]$case.workload.controller_type -cne $requiredControllers[$index] -or + [int]$case.workload.warmup_pairs -ne 16 -or + [int]$case.workload.sample_pairs -ne $Samples -or + [long]$case.workload.inter_transition_delay_ns -ne 2000000 -or + [string]$case.workload.phase_sweep_sha256 -cne '21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9' -or + @($case.runs).Count -ne 4 -or + [string]$case.runs[0].transport -cne 'usbip' -or + [string]$case.runs[1].transport -cne 'native-ude' -or + [string]$case.runs[2].transport -cne 'native-ude' -or + [string]$case.runs[3].transport -cne 'usbip' -or + [int]$case.runs[0].order -ne 1 -or [int]$case.runs[0].transport_block -ne 1 -or + [int]$case.runs[1].order -ne 2 -or [int]$case.runs[1].transport_block -ne 1 -or + [int]$case.runs[2].order -ne 3 -or [int]$case.runs[2].transport_block -ne 2 -or + [int]$case.runs[3].order -ne 4 -or [int]$case.runs[3].transport_block -ne 2 -or + @($case.transports).Count -ne 2 -or + [int]$case.transports[0].statistics.press.count -ne $Samples -or + [int]$case.transports[0].statistics.release.count -ne $Samples -or + [int]$case.transports[1].statistics.press.count -ne $Samples -or + [int]$case.transports[1].statistics.release.count -ne $Samples) { + throw "The latency JSON artifact is missing the counterbalanced '$($requiredControllers[$index])' workload." + } +} +$postStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) +if ($LASTEXITCODE -ne 0 -or $postStatus.Count -ne 0) { + throw ("The production latency run changed its source checkout:`n" + + ($postStatus -join [Environment]::NewLine)) +} + +Write-Host "Validated source-bound controller-to-game latency evidence: '$output'." +Write-Host "Captured bounded-memory WPR evidence: '$trace'." diff --git a/_testing/e2e/sdl/gamepad.go b/_testing/e2e/sdl/gamepad.go index 39436a43..bc7ff73f 100644 --- a/_testing/e2e/sdl/gamepad.go +++ b/_testing/e2e/sdl/gamepad.go @@ -43,6 +43,43 @@ static inline int wait_gamepad_button_event( } } +static inline int wait_gamepad_button_transition( + SDL_JoystickID which, + SDL_GamepadButton button, + Sint32 timeout_ms, + bool *down, + Uint64 *timestamp_ns) +{ + Uint64 deadline = timeout_ms < 0 ? 0 : SDL_GetTicks() + (Uint64)timeout_ms; + + for (;;) { + Sint32 remaining = timeout_ms; + if (timeout_ms >= 0) { + Uint64 now = SDL_GetTicks(); + if (now >= deadline) { + return 0; + } + Uint64 delta = deadline - now; + remaining = delta > 0x7fffffffULL ? 0x7fffffff : (Sint32)delta; + } + + SDL_ClearError(); + SDL_Event event; + if (!SDL_WaitEventTimeout(&event, remaining)) { + const char *error = SDL_GetError(); + return error != NULL && error[0] != '\0' ? -1 : 0; + } + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button) { + *down = event.gbutton.down; + *timestamp_ns = event.gbutton.timestamp; + return 1; + } + } +} + static inline int gamepad_binding_input_button(const SDL_GamepadBinding *b) { return b->input.button; @@ -111,6 +148,13 @@ type GamepadButton int32 // GamepadButtonLabel the set of gamepad button labels. type GamepadButtonLabel int32 +// GamepadButtonEvent is a single SDL transition from an exact opened gamepad. +// TimestampNS is SDL's monotonic SDL_GetTicksNS timestamp from the event. +type GamepadButtonEvent struct { + Down bool + TimestampNS uint64 +} + // GamepadBindingType describes the type of a gamepad control binding. type GamepadBindingType int32 @@ -397,6 +441,35 @@ func (g *Gamepad) WaitButtonEvent(button GamepadButton, down bool, timeoutMS int ) != 0 } +// WaitButtonTransition blocks on SDL's event queue until this exact gamepad +// and button produces either edge. It returns (event, false, nil) on timeout. +// Unlike WaitButtonEvent, it exposes unexpected same-state edges so a live +// latency gate can count duplicates instead of silently discarding them. +func (g *Gamepad) WaitButtonTransition( + button GamepadButton, + timeoutMS int32, +) (GamepadButtonEvent, bool, error) { + if g == nil || g.cGamepad == nil { + return GamepadButtonEvent{}, false, &SDLError{eStr: "invalid gamepad handle"} + } + var down C.bool + var timestampNS C.Uint64 + result := C.wait_gamepad_button_transition( + C.SDL_GetGamepadID(g.cGamepad), + C.SDL_GamepadButton(button), + C.Sint32(timeoutMS), + &down, + ×tampNS, + ) + if result < 0 { + return GamepadButtonEvent{}, false, GetError() + } + if result == 0 { + return GamepadButtonEvent{}, false, nil + } + return GamepadButtonEvent{Down: bool(down), TimestampNS: uint64(timestampNS)}, true, nil +} + // GetButtonLabel gets the label of a button on a gamepad. func (g *Gamepad) GetButtonLabel(button GamepadButton) GamepadButtonLabel { if g == nil || g.cGamepad == nil { diff --git a/_testing/e2e/sdl/sdl_nocgo.go b/_testing/e2e/sdl/sdl_nocgo.go index 69470fa2..abe8d8fc 100644 --- a/_testing/e2e/sdl/sdl_nocgo.go +++ b/_testing/e2e/sdl/sdl_nocgo.go @@ -16,7 +16,19 @@ const ( InitFlagEvents InitFlags = 0x00004000 ) -type GamepadID uint32 +type GamepadID int32 + +type GamepadType int32 + +const ( + GamepadTypeUnknown GamepadType = iota + GamepadTypeStandard + GamepadTypeXbox360 + GamepadTypeXboxOne + GamepadTypePS3 + GamepadTypePS4 + GamepadTypePS5 +) type GamepadButton int32 @@ -24,6 +36,15 @@ const GamepadButtonSouth GamepadButton = 0 type Gamepad struct{} +type GUID [16]byte + +func (GUID) String() string { return "" } + +type GamepadButtonEvent struct { + Down bool + TimestampNS uint64 +} + func Init(InitFlags) error { return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") } @@ -40,6 +61,26 @@ func OpenGamepad(GamepadID) (*Gamepad, error) { func (*Gamepad) Close() {} +func (*Gamepad) ID() GamepadID { return 0 } + +func (*Gamepad) Path() string { return "" } + +func (*Gamepad) Name() string { return "" } + +func (*Gamepad) Type() GamepadType { return GamepadTypeUnknown } + +func (*Gamepad) RealType() GamepadType { return GamepadTypeUnknown } + +func (*Gamepad) Vendor() uint16 { return 0 } + +func (*Gamepad) Product() uint16 { return 0 } + +func GetGamepadGUIDForID(GamepadID) GUID { return GUID{} } + func (*Gamepad) GetButton(GamepadButton) bool { return false } func (*Gamepad) WaitButtonEvent(GamepadButton, bool, int32) bool { return false } + +func (*Gamepad) WaitButtonTransition(GamepadButton, int32) (GamepadButtonEvent, bool, error) { + return GamepadButtonEvent{}, false, errors.New("SDL3 end-to-end benchmarks require CGO") +} diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 47d69836..37945242 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -1,95 +1,243 @@ -# E2E Latency Benchmarks - -The script `viiper/_testing/e2e/scripts/lat_bench.go` runs (or parses) end‑to‑end input latency benchmarks and produces enriched output (table, markdown, or JSON). - -The benchmark defaults to the supported USB/IP transport. On a disposable -Windows test system with the exact Microsoft-signed native UDE package already -installed, set `VIIPER_E2E_TRANSPORT=native-ude` to run the identical API, -controller, SDL, press, and release workload through the native bus. The -benchmark never installs or trusts a driver. Invalid transport names, a server -that exits during startup, input timeouts, and stream failures fail the run; -they are not reported as latency samples. The harness writes and uses one -private, known benchmark credential rather than accidentally reading a stale -user credential. Plain and encrypted USB/IP cases open their own matching API -stream. Native UDE runs only the authenticated cases because production native -brokers deliberately reject unauthenticated localhost topology and streams. - -It groups repeated cycles when `-count > 1` and uses the single press E2E measurement (`E2E-InputDelay`) as the 100% baseline. - -## Output - -| Column | Meaning | -| --------------- | ----------------------------------------------------------------------------------------- | -| Benchmark | Name of the sub benchmark | -| Count | Iterations performed (from Go bench output; affected by `-benchtime`) | -| ns/op | Nanoseconds per operation (direct Go benchmark figure) | -| % of Full | Relative to `E2E-InputDelay` (single press baseline) | -| Client Share % | Portion attributed to the (go) client write phase (for E2E rows) | -| Latency Share % | Remainder attributed to transport + virtual device/host stack + tight device polling loop | - -`E2E-PressAndRelease` includes both press and release cycles, so it is expected to be ~2× the single press and thus can exceed 100% in `% of Full`. - -## Scope / Methodology - -- All benchmarks included here are executed against a VIIPER server on the same host (localhost). - They therefore measure in-process client emission plus the selected local - transport and emulated-device processing. Remote/network USB/IP attachment - adds network RTT and jitter and is intentionally excluded from these - baseline figures. -- The Windows observer waits on SDL gamepad transition events. It does not - busy-poll controller state, so the harness does not consume a synthetic CPU - core or add that contention to transport tail latency. -- Benchmarks use a single emulated Xbox360 controller device. - Other devices might produce slightly different results depending on USB report size and VIIPER-InputState size. -- Benchmarks use a single button press, which is enough as clients/VIIPER always produce a full report of the devices state. - -## Benchtime Mode - -Runs use a fixed-iteration benchtime (e.g. `-benchtime=1000x`, `-benchtime=10000x`) rather than time-based (e.g. `2s`). - -## Running - -From repository root: - -```bash -# Single run, 1000 fixed iterations per sub benchmark -go run ./_testing/e2e/scripts/lat_bench.go -pkg ./_testing/e2e -benchtime=1000x -count=1 -format markdown +# Controller-to-game latency + +VIIPER has two different latency tools. They answer different questions and +must not be presented as interchangeable evidence. + +- `_testing/e2e/scripts/lat_bench.go` formats Go benchmark averages. It is a + useful developer diagnostic, but `ns/op` does not preserve individual tail + samples, transition loss, or duplication. It is not the native release gate. +- `_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1` is the opt-in Windows + production gate. It records every press and release observed through SDL, + compares authenticated USB/IP and native UDE runs, and emits a strict JSON + evidence artifact plus a bounded-memory WPR trace. + +No live latency result is checked into this document. A passing result exists +only when the production command below succeeds on the stated machine and its +source-bound artifacts are retained. + +### Evidence boundary + +This is an exact-source, production-authentic API-to-consumer path gate. The Go +test starts `cmd.Server` in process at the clean `HEAD` under test and uses the +repository's Go client over real localhost TCP. Beyond that process boundary it +uses the installed USB/IP or native UDE transport, the actual Windows controller +stack, and the source-bound SDL DLL. Authentication, API framing, controller +serialization, transport delivery, HID consumption, SDL event delivery, and +consumer wake-up are therefore live rather than mocked. + +It is not a packaged-executable, service/task-hosted broker, DS4Windows, physical +controller, display, or game-engine-frame test. The signed-package/broker live +gates and any DS4Windows or physical-input qualification remain separate +evidence. A pass here must not be relabeled as a pass for those boundaries. + +## What the production gate measures + +For each controller below, the gate creates the device through the authenticated +VIIPER API, opens an authenticated controller stream, writes alternating south +button states, and waits for the corresponding game-facing SDL transition: + +| API controller | Expected SDL type | VID:PID | South button | +| --- | --- | --- | --- | +| `xbox360` | Xbox 360 | `045e:028e` | A | +| `dualshock4` | PS4 | `054c:09cc` | Cross | +| `dualsensegamepadv5` | PS5 | `054c:0ce6` | Cross | + +Each controller uses a fresh server, bus, device, stream, and exact SDL binding +for four counterbalanced blocks: USB/IP, native UDE, native UDE, USB/IP (ABBA). +Sixteen unrecorded press/release pairs warm the complete path at the start of +every block. The declared sample count is then split as evenly as possible +between the two blocks for each transport; `-Samples 256` therefore records 128 +pairs in each block and aggregates 256 press plus 256 release samples per +transport. ABBA makes both the first/last positions USB/IP and both middle +positions native, reducing one-way warm-up and monotonic-drift bias without +discarding per-block source identity. + +All four blocks use the same API address, credential, bus/device position, +input sequence, warm-up count, one-second event timeout, and deterministic +unmeasured dwell schedule. Xbox success cannot certify either PlayStation path. +Missing, ambiguous, or misidentified DualShock 4 or DualSense enumeration—or a +failure in any ABBA block—fails the whole suite. + +A fixed 2 ms dwell could repeatedly land writes at the same phase of a 1 ms HID +service interval. The gate instead retains a 2 ms minimum state dwell and adds +the source-bound offsets `0, 125000, 250000, 375000, 500000, 625000, 750000, +875000` ns. Press and release edges index that vector deterministically by +sequence number, and block 2 resumes the same per-transport sequence. The +cumulative offsets visit all eight 125 us phases of one millisecond; all +controllers and transports receive the identical pattern. Sleeps occur before +the measured `WriteBinary` interval, never inside it, and do not busy-wait. + +The artifact records the vector and SHA-256 +`21eee9ea71984343ebd21221df8272553d6ab369a5740a1c796380cd468abcd9` of its +comma-separated base-10 nanosecond representation. The parser recomputes that +hash and rejects a changed vector or scheduling workload. This is a reproducible +phase-control policy, not a claim that Windows wakes at each requested +nanosecond; WPR retains the scheduler evidence for investigating overshoot. + +The interval starts immediately before `DeviceStream.WriteBinary` and ends when +the exact SDL gamepad/button event returns to the waiting consumer. It therefore +includes authenticated client framing, localhost TCP delivery, VIIPER device +processing, the selected virtual USB transport, Windows controller input, SDL's +event path, and consumer wake-up. It does not claim display, engine-frame, or +network latency. + +Go's `time.Now`/`time.Since` monotonic readings are used for interval +subtraction. On Windows, the Go runtime sources that clock from QPC; Microsoft +recommends QPC for sub-microsecond interval and latency measurement. SDL's event +timestamp is retained independently to reject absent, stale, or regressing +events. The harness never subtracts the SDL clock from the Go clock. + +The observer uses `SDL_WaitEventTimeout`, not a tight state loop. Unexpected +same-state edges from the exact device are counted as duplicates while the wait +continues. A missing expected edge increments the appropriate miss counter and +terminates that transport/controller run. The final quiet window is also an SDL +event wait, so late release duplicates are not hidden and no measurement-side +busy poll consumes a CPU core. + +## Source and device binding + +The PowerShell entry point fails closed before measurement unless all of the +following are true: + +- `HEAD` equals the caller-supplied 40-64 digit source revision; +- the tracked and untracked source tree is clean and every submodule is at its + recorded revision; +- the native package passes the existing production Microsoft-signature and + submission-manifest gate; +- the installed `ViiperUde.sys` hash matches that verified package and the one + VIIPER root devnode reports a Microsoft signer; +- the SDL DLL hash matches the caller-supplied source-build hash; +- the DLL actually loaded by the Go test is that exact absolute SDL path and + hash; +- the USB/IP prerequisite check accepts the repository's supported runtime; +- both servers reject an unauthenticated ping, while the authenticated ping + reports the requested live transport and a ready backend; +- `DeviceAdd` returns the expected controller type, VID, PID, bus, device ID, + and (for USB/IP) exact auto-attached import port; +- all baseline SDL gamepads remain present and exactly one stable new SDL ID is + created; its path, GUID, real type, VID, and PID must match the API device. + +The gate does not install, update, stop, replace, or remove a driver or service. +Run it on a disposable test machine with the verified production package and +supported USB/IP runtime already installed. No VIIPER process or service may +already own the API ports or native broker handle. + +## Statistics and pass policy + +The artifact retains every sample as +`{sequence, transition, latency_ns, sdl_event_timestamp_ns}`. It reports press, +release, and combined distributions for each controller/transport: + +- p50, p95, and p99 use the nearest-rank definition (`ceil(p * N)`, one based); +- max is the largest individual interval; +- jitter is the population standard deviation of the individual intervals; +- misses and duplicates are separate press/release counters. + +At least 256 complete press samples and 256 complete release samples, aggregated +from both counterbalanced blocks, are required for every controller and +transport. A timeout, write/event error, +insufficient count, non-monotonic SDL event clock, any miss, or any duplicate +fails the artifact. Native press, release, and combined distributions must each +remain at or below the reviewed native limits: 4 ms p95, 8 ms p99, and 20 ms +maximum. + +The JSON also reports native-minus-USB/IP deltas and native/USB-IP ratios for +p50, p95, p99, max, and jitter. A same-machine non-regression policy additionally +requires native p95, p99, and maximum to be no more than 1 ms, 2 ms, and 5 ms +above the corresponding USB/IP values for press, release, and combined samples. +Those absolute deltas are engineering acceptance limits set at one quarter of +the corresponding 4/8/20 ms native ceilings. They avoid unstable ratios when a +USB/IP baseline is very small; they are policy, not a claim about observed +transport performance. The gate does not say native is lower latency unless the +retained live artifact actually shows negative native-minus-USB/IP deltas. + +The parser rejects unknown fields, trailing JSON, weakened absolute or +same-machine limits, a non-ABBA schedule, mixed transport proof, workload drift +between controllers, reordered or missing press/release samples, and block, +aggregate, comparison, or verdict fields that do not exactly recompute from the +individual records. + +## Running the production gate + +Prerequisites are an elevated Windows PowerShell session, an exact clean +checkout, Go 1.26 or newer, CGO with a working C toolchain, CMake, the +source-built SDL submodule, WPR, USB/IP win2 +0.9.7.7, and an already installed Microsoft-signed VIIPER UDE package matching +its submission manifest. + +The SDL wrapper currently links the multi-configuration Debug output. Build and +record that exact binary before running the gate: + +```powershell +cmake -S .\_testing\e2e\deps\SDL -B .\_testing\e2e\deps\SDL\build -A x64 +cmake --build .\_testing\e2e\deps\SDL\build --config Debug +$sdlHash = (Get-FileHash .\_testing\e2e\deps\SDL\build\Debug\SDL3.dll -Algorithm SHA256).Hash ``` -For the production native path, use the same workload and select the encrypted -results: +Choose new evidence paths outside the checkout. Existing files are never +overwritten. `-Samples` is the total pair count per controller/transport and is +bounded to 256–10,000 so the complete three-controller ABBA suite remains +inside its 18-minute fail-closed deadline. ```powershell +$revision = (git rev-parse HEAD).Trim() + +.\_testing\e2e\scripts\Invoke-ViiperE2ELatencyGate.ps1 ` + -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` + -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` + -ExpectedSourceRevision $revision ` + -SDLBinarySHA256 $sdlHash ` + -OutputPath C:\ViiperEvidence\controller-latency.json ` + -WprTracePath C:\ViiperEvidence\controller-latency.etl ` + -Samples 256 +``` + +The wrapper verifies and uses `GeneralProfile.Verbose.Memory`, names the +recording instance, rejects dropped events, and saves the trace on both pass and +test failure. The profile includes context-switch, ready-thread, sampled-profile, +DPC, interrupt, and WDF evidence needed to investigate a tail. The ETL is not +parsed into latency samples and is not a substitute for the SDL consumer +timestamps. + +Directly setting the live-test environment variable is intentionally +insufficient. The Go test also requires the preflight marker, expected source +and SDL revisions, loaded SDL path/hash, verified package-manifest hash, +installed-driver hash, sample count, and a new absolute output path. + +## Aggregate developer diagnostic + +For a non-gating average-only diagnostic, run from the repository root. Use the +encrypted rows when comparing transports so the API/controller stream mode is +the same: + +```powershell +$env:VIIPER_E2E_TRANSPORT = 'usbip' +go run .\_testing\e2e\scripts\lat_bench.go ` + -pkg .\_testing\e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown + $env:VIIPER_E2E_TRANSPORT = 'native-ude' -go run ./_testing/e2e/scripts/lat_bench.go -pkg ./_testing/e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown +go run .\_testing\e2e\scripts\lat_bench.go ` + -pkg .\_testing\e2e -encryption encrypted -benchtime 1000x -count 5 -format markdown ``` - -Results (Arch Linux / SteamDeck Kernel / Steam Deck LCD / Go 1.25+, 10k iterations): - -| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | -| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | -| 1_Go-Client-Write | 10000 | 10668 | 11.98 | 100.00 | 0.00 | -| 2_InputDelay-Without-Client | 10000 | 74154 | 83.25 | 0.00 | 100.00 | -| 3_E2E-InputDelay | 10000 | 89078 | 100.00 | 11.98 | 88.02 | -| 4_E2E-PressAndRelease | 10000 | 184870 | 207.54 | 11.54 | 88.46 | - -Example output (Windows / AMD Ryzen 9 3900X / Go 1.25+, 10k iterations): - -| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | -| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | -| 1_Go-Client-Write | 10000 | 27933 | 16.60 | 100.00 | 0.00 | -| 2_InputDelay-Without-Client | 10000 | 133724 | 79.45 | 0.00 | 100.00 | -| 3_E2E-InputDelay | 10000 | 168307 | 100.00 | 16.60 | 83.40 | -| 4_E2E-PressAndRelease | 10000 | 331439 | 196.93 | 16.86 | 83.14 | - -Variability across repeated measurement runs has been negligible. -Use a larger `-count` if you want to increase the number of runs. - -## Notes - -- Memory statistics from Go benchmarks are intentionally omitted. -- `% of Full` falls back to the largest ns/op if the baseline row is missing. -- All benchmarking must run with parallelism 1 in underlying benches. -- Benchmarks use SDL3 gamepad transition events to detect input changes on the - emulated device without a measurement-side polling loop. -- Benchmarks must be run without an already running VIIPER server instance. + +Go benchmark `ns/op` is an aggregate timing result. Do not infer p95/p99, +misses, duplicates, or a live release pass from it. + +## Method references + +- [Go `testing` benchmarks](https://pkg.go.dev/testing) document `B.Loop`, the + benchmark timer, and aggregate metric semantics. +- [Go monotonic time](https://pkg.go.dev/time#hdr-Monotonic_Clocks) documents why + `time.Since(start)` is robust against wall-clock adjustment. +- [SDL gamepad button events](https://wiki.libsdl.org/SDL3/SDL_GamepadButtonEvent) + define the nanosecond event timestamp, device ID, button, and edge. +- [`SDL_WaitEventTimeout`](https://wiki.libsdl.org/SDL3/SDL_WaitEventTimeout) is + the blocking event-consumer primitive used by the observer. +- [Microsoft high-resolution timestamp guidance](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps) + recommends QPC for interval and latency measurements. +- [Microsoft WPR command-line guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/wpr-command-line-options) + documents named instances, memory/file modes, profiles, start, and stop. +- [ViGEmBus](https://github.com/nefarius/ViGEmBus/tree/d986e1d93708ec9b11049542fa6027272cce716c) + is the virtual-controller lifecycle and replay-method reference. Its design + motivates testing through an unmodified game-consumer API; no ViGEm latency + number is copied or claimed here. From 7519c6b6cd27cbdb8d727c7089330a358e78356e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:36:41 -0500 Subject: [PATCH 169/240] Clear authenticated record slabs on close --- internal/server/api/auth/conn.go | 4 +- .../server/api/auth/conn_internal_test.go | 41 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/internal/server/api/auth/conn.go b/internal/server/api/auth/conn.go index 51d9e1fa..ee90d38e 100644 --- a/internal/server/api/auth/conn.go +++ b/internal/server/api/auth/conn.go @@ -88,9 +88,9 @@ func (s *Conn) Close() error { // be in use, so clear it before making subsequent calls fail closed. s.sendMu.Lock() s.recvMu.Lock() - clear(s.sendBuf) + clear(s.sendBuf[:cap(s.sendBuf)]) clear(s.recvHeader[:]) - clear(s.recvPacket) + clear(s.recvPacket[:cap(s.recvPacket)]) s.sendBuf = nil s.recvPacket = nil s.recvPlain = nil diff --git a/internal/server/api/auth/conn_internal_test.go b/internal/server/api/auth/conn_internal_test.go index cc34541e..118b9144 100644 --- a/internal/server/api/auth/conn_internal_test.go +++ b/internal/server/api/auth/conn_internal_test.go @@ -177,12 +177,15 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { t.Fatal(err) } sender := senderWrapper.(*Conn) - payload := []byte("sensitive controller state") - if _, err = sender.Write(payload); err != nil { + largePayload := bytes.Repeat([]byte{0x5a}, 4096) + smallPayload := []byte("small sensitive controller state") + if _, err = sender.Write(largePayload); err != nil { t.Fatal(err) } - wire := append([]byte(nil), raw.Bytes()...) - sendBacking := sender.sendBuf + if _, err = sender.Write(smallPayload); err != nil { + t.Fatal(err) + } + sendBacking := sender.sendBuf[:cap(sender.sendBuf)] if err = sender.Close(); err != nil { t.Fatal(err) } @@ -199,19 +202,43 @@ func TestConnCloseJoinsLanesAndClearsRecordAndCipherState(t *testing.T) { } receiveRaw := &internalRecordConn{} - _, _ = receiveRaw.Buffer.Write(wire) + _, _ = receiveRaw.Buffer.Write(raw.Bytes()) receiverWrapper, err := WrapServerConn(receiveRaw, key) if err != nil { t.Fatal(err) } receiver := receiverWrapper.(*Conn) - if _, err = receiver.Read(make([]byte, 1)); err != nil { + largeDecoded := make([]byte, len(largePayload)) + if _, err = io.ReadFull(receiver, largeDecoded); err != nil { + t.Fatal(err) + } + if !bytes.Equal(largeDecoded, largePayload) { + t.Fatal("large receive record changed before close") + } + firstSmallByte := make([]byte, 1) + if _, err = receiver.Read(firstSmallByte); err != nil { t.Fatal(err) } - receiveBacking := receiver.recvPacket + if firstSmallByte[0] != smallPayload[0] { + t.Fatalf("small receive prefix=%02x want=%02x", firstSmallByte[0], smallPayload[0]) + } if len(receiver.recvPlain) == 0 { t.Fatal("test did not leave plaintext buffered before close") } + if len(receiver.recvPacket) >= cap(receiver.recvPacket) { + t.Fatalf("test did not shrink receive slab: len=%d cap=%d", len(receiver.recvPacket), cap(receiver.recvPacket)) + } + receiveBacking := receiver.recvPacket[:cap(receiver.recvPacket)] + tailRetainedData := false + for _, value := range receiveBacking[len(receiver.recvPacket):] { + if value != 0 { + tailRetainedData = true + break + } + } + if !tailRetainedData { + t.Fatal("test setup did not retain a large-record tail outside the shrunken receive slice") + } if err = receiver.Close(); err != nil { t.Fatal(err) } From 2c6d940bb6e247e657d18028cce4db0be5782009 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:40:13 -0500 Subject: [PATCH 170/240] Remove native input lookup serialization --- .../udecx/driver_dispatch_contract_test.go | 579 +++++++++++++++++- native/udecx/driver/Broker.c | 75 ++- native/udecx/driver/Controller.c | 3 +- native/udecx/driver/Device.c | 323 +++++++--- native/udecx/driver/ViiperUde.h | 63 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 48 +- 6 files changed, 974 insertions(+), 117 deletions(-) diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 5692e6c2..f353bdcb 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -1,6 +1,7 @@ package udecx import ( + "regexp" "strings" "testing" ) @@ -234,11 +235,12 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { } ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) requireContractOrder(t, ready, - "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", "ViiperEndpointOperationStarted(endpoint);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", "WdfIoQueueRetrieveNextRequest(Queue, &request)", - "ViiperCompleteCachedInputUrb(endpoint, request);", - "WdfWaitLockRelease(endpointContext->InputLock);") + "ViiperPrepareCachedInputUrb(endpoint, request);", + "WdfWaitLockRelease(endpointContext->InputLock);", + "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);") if strings.Contains(ready, "WdfWorkItemEnqueue") || strings.Contains(ready, "UdecxUrbComplete(") { t.Fatal("ReadyNotify either retains a worker hop or completes a UDE URB synchronously") @@ -415,3 +417,574 @@ func TestNativeBrokerMixedLaneFairnessModel(t *testing.T) { maxInspections, totalInspections, allocated) } } + +func TestNativeFastInputUsesSharedIndexedLifetimeAdmission(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + inf := nativeContractSource(t, "native", "udecx", "package", "ViiperUde.inf") + + owner := normalizedContract(nativeCFunction(t, broker, "ViiperValidateBrokerOwner")) + if strings.Contains(owner, "WdfWaitLockAcquire") || + strings.Contains(owner, "controllerContext->OwnerFile") || + strings.Contains(owner, "controllerContext->CleanupInProgress") { + t.Fatalf("fast owner validation still joins controller-wide cleanup state: %s", owner) + } + for _, required := range []string{ + "WdfRequestGetFileObject(Request)", + "fileContext->BrokerOwner", + "fileContext->Negotiated", + "fileContext->Closing", + } { + if !strings.Contains(owner, required) { + t.Fatalf("lock-free owner validation lost %q", required) + } + } + + submit := normalizedContract(nativeCFunction(t, device, "ViiperSubmitInputReport")) + for _, forbidden := range []string{ + "ViiperAcquireDeviceLockExclusive", "WdfObjectReference(endpoint)", + "WdfObjectDereference(endpoint)", + "for (index = 0; index < VIIPER_UDE_MAX_DEVICES", + } { + if strings.Contains(submit, forbidden) { + t.Fatalf("fast input retains hot-path work %q: %s", forbidden, submit) + } + } + requireContractOrder(t, submit, + "ViiperAcquireDeviceLockShared(controllerContext);", + "ViiperFindInputDeviceLocked(controllerContext, input->DeviceId);", + "deviceContext->OwnerFile == ownerFile", + "deviceContext->Generation == input->Generation", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "endpointContext->Purging", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceLockShared(controllerContext);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);") + requireContractOrder(t, submit, + "ViiperPrepareCachedInputUrb(endpoint, urbRequest);", + "WdfWaitLockRelease(endpointContext->InputLock);", + "ViiperCompleteRetrievedInputUrb(endpoint, urbRequest, status);") + requireContractOrder(t, submit, + "endpointContext->LastInputSequence", + "RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength);", + "endpointContext->InputReportLength = input->PayloadLength;", + "InterlockedExchange(&endpointContext->InputReportValid, TRUE);", + "InterlockedIncrement64(&controllerContext->InputReportsSubmitted);", + "WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest);", + "endpointContext->CachedDeliveryPending") + for _, lifecycleGate := range []string{ + "controllerContext->ShuttingDown", + "deviceContext->InD0", + "deviceContext->Purging", + "deviceContext->Resetting", + "endpointContext->Purging", + "endpointContext->Resetting", + } { + if !strings.Contains(submit, lifecycleGate) { + t.Fatalf("fast input admission lost lifecycle gate %q", lifecycleGate) + } + } + + find := normalizedContract(nativeCFunction(t, device, "ViiperFindInputDeviceLocked")) + requireContractOrder(t, find, + "count = ControllerContext->InputDeviceCount;", + "while (count != 0)", + "candidate = first + step;", + "ControllerContext->InputDevices[candidate]", + "return ControllerContext->InputDevices[first];") + if strings.Contains(find, "ControllerContext->Devices[") { + t.Fatalf("input lookup fell back to the physical O(32) table: %s", find) + } + + if !strings.Contains(header, "EX_PUSH_LOCK DeviceLock;") || + !strings.Contains(header, "UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES];") || + !strings.Contains(controller, "ExInitializePushLock(&context->DeviceLock);") { + t.Fatal("controller lost its shared input index or push-lock initialization") + } + sharedAcquire := normalizedContract(nativeCFunction(t, header, "ViiperAcquireDeviceLockShared")) + sharedRelease := normalizedContract(nativeCFunction(t, header, "ViiperReleaseDeviceLockShared")) + exclusiveAcquire := normalizedContract(nativeCFunction(t, header, "ViiperAcquireDeviceLockExclusive")) + exclusiveRelease := normalizedContract(nativeCFunction(t, header, "ViiperReleaseDeviceLockExclusive")) + requireContractOrder(t, sharedAcquire, + "KeEnterCriticalRegion();", "ExAcquirePushLockShared(&ControllerContext->DeviceLock);") + requireContractOrder(t, sharedRelease, + "ExReleasePushLockShared(&ControllerContext->DeviceLock);", "KeLeaveCriticalRegion();") + requireContractOrder(t, exclusiveAcquire, + "KeEnterCriticalRegion();", "ExAcquirePushLockExclusive(&ControllerContext->DeviceLock);") + requireContractOrder(t, exclusiveRelease, + "ExReleasePushLockExclusive(&ControllerContext->DeviceLock);", "KeLeaveCriticalRegion();") + if !strings.Contains(header, "_IRQL_requires_max_(APC_LEVEL)") || + !strings.Contains(header, "Normal shared acquisition waits behind an exclusive") { + t.Fatal("push-lock IRQL/APC or writer-preference contract is undocumented") + } + queues := normalizedContract(nativeCFunction(t, controller, "ViiperCreateQueues")) + requireContractOrder(t, queues, + "attributes.ExecutionLevel = WdfExecutionLevelPassive;", + "attributes.SynchronizationScope = WdfSynchronizationScopeNone;", + "WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);") + if !strings.Contains(inf, "NTamd64.10.0...17763") { + t.Fatal("driver platform floor no longer proves EX_PUSH_LOCK API availability") + } +} + +func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + started := normalizedContract(nativeCFunction(t, broker, "ViiperEndpointOperationStarted")) + requireContractOrder(t, started, + "if (active == 0)", + "KeClearEvent(&endpointContext->OperationsDrained);", + "InterlockedIncrement(&endpointContext->ActiveOperations);") + completedLocked := normalizedContract(nativeCFunction( + t, broker, "ViiperEndpointOperationCompletedLocked")) + requireContractOrder(t, completedLocked, + "InterlockedDecrement(&endpointContext->ActiveOperations);", + "if (remaining == 0)", + "KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE);") + completed := normalizedContract(nativeCFunction(t, broker, "ViiperEndpointOperationCompleted")) + requireContractOrder(t, completed, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationCompletedLocked(Endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + if got := strings.Count(broker+device, + "InterlockedIncrement(&endpointContext->ActiveOperations)"); got != 1 { + t.Fatalf("ActiveOperations has %d increment sites, want one BrokerLock-owned transition", got) + } + if got := strings.Count(broker+device, + "InterlockedDecrement(&endpointContext->ActiveOperations)"); got != 1 { + t.Fatalf("ActiveOperations has %d decrement sites, want one BrokerLock-owned transition", got) + } + startedCalls := regexp.MustCompile(`ViiperEndpointOperationStarted\s*\([^)]*\)\s*;`) + if got := len(startedCalls.FindAllString(broker+device, -1)); got != 4 { + t.Fatalf("endpoint rundown has %d admission call sites, want the four audited BrokerLock callers", got) + } + + for _, name := range []string{ + "ViiperEvtFastInputQueueReady", + "ViiperSubmitInputReport", + } { + admission := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, admission, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfWaitLockAcquire(endpointContext->InputLock, NULL);") + for _, name := range []string{"ViiperEvtUrbCanceledOnQueue", "ViiperQueueUrb"} { + admission := normalizedContract(nativeCFunction(t, broker, name)) + requireContractOrder(t, admission, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + } + + purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) + requireContractOrder(t, purge, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Purging, TRUE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + if strings.Contains(device, "WdfIoQueuePurge(") { + t.Fatal("UdeCx owns the associated endpoint queue; client code must not purge it") + } + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, + "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") || + !strings.Contains(purge, "UdeCx owns and has already stopped the associated queue") { + t.Fatal("endpoint purge lost the UdeCx-owned associated-queue boundary") + } + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "endpointContext->ActiveOperations", + "ViiperInvalidateEndpointInputReport(endpoint);", + "UdecxUsbEndpointPurgeComplete(endpoint);") + resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "endpointContext->ActiveOperations", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) + requireContractOrder(t, start, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Purging, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart);") + if strings.Contains(device, "WdfIoQueueStart(") { + t.Fatal("UdeCx owns the associated endpoint queue; client code must not start it") + } + + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) + requireContractOrder(t, cleanup, + "ViiperAcquireDeviceLockExclusive(controllerContext);", + "endpointContext->ActiveOperations", + "ViiperInvalidateEndpointInputReport(endpoint);", + "deviceContext->Endpoints[address] = WDF_NO_HANDLE;", + "ViiperReleaseDeviceLockExclusive(controllerContext);") + if strings.Contains(cleanup, "KeWaitForSingleObject") { + t.Fatalf("EvtCleanup attempts a late wait after KMDF made the object inaccessible: %s", cleanup) + } + + queueCompletion := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrbCompletion")) + if strings.Contains(queueCompletion, "WdfObjectReference(Endpoint)") { + t.Fatal("terminal DPC lifetime still assumes a WDF reference postpones EvtCleanup") + } + dpc := normalizedContract(nativeCFunction(t, broker, "ViiperEvtCompletionDpc")) + requireContractOrder(t, dpc, + "UdecxUrbCompleteWithNtStatus(request, completionStatus);", + "ViiperEndpointOperationCompletedLocked(endpoint);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfObjectDereference(request);") + if strings.Contains(dpc, "WdfObjectDereference(endpoint)") { + t.Fatal("completion DPC touches the endpoint after releasing its final rundown owner") + } +} + +func TestNativeUdeHandleRevocationPrecedesPlugOutAndCleanup(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "UdecxUsbDevicePlugOutAndDelete(device);") + afterPlugOut := destroy[strings.Index(destroy, "UdecxUsbDevicePlugOutAndDelete(device);")+len("UdecxUsbDevicePlugOutAndDelete(device);"):] + if strings.Contains(afterPlugOut, "ViiperGetDeviceContext(device)") || + strings.Contains(afterPlugOut, "WdfObjectReference(device)") { + t.Fatalf("destroy path accesses a consumed UDE handle after PlugOutAndDelete: %s", afterPlugOut) + } + + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, shutdown, + "ViiperRemoveInputDeviceLocked(controllerContext, device);", + "controllerContext->Devices[index] = WDF_NO_HANDLE;", + "ViiperReleaseDeviceLockExclusive(controllerContext);", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) + requireContractOrder(t, cleanup, + "endpointContext->ActiveOperations", + "deviceContext->Endpoints[address] = WDF_NO_HANDLE;") +} + +func TestNativeDeviceAndBrokerLockOrderNeverReverses(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + functionName := regexp.MustCompile(`(?m)^([A-Za-z_][A-Za-z0-9_]*)\(\r?$`) + + // The only permitted nesting is DeviceLock -> BrokerLock. For every direct + // DeviceLock acquisition, prove there is no unmatched BrokerLock acquisition + // earlier in the same function body. + for _, source := range []string{broker, device} { + for _, match := range functionName.FindAllStringSubmatch(source, -1) { + name := match[1] + body := normalizedContract(nativeCFunction(t, source, name)) + for _, acquire := range []string{ + "ViiperAcquireDeviceLockShared(", + "ViiperAcquireDeviceLockExclusive(", + } { + cursor := 0 + for { + offset := strings.Index(body[cursor:], acquire) + if offset < 0 { + break + } + at := cursor + offset + prefix := body[:at] + brokerAcquire := strings.LastIndex(prefix, "WdfSpinLockAcquire(") + brokerRelease := strings.LastIndex(prefix, "WdfSpinLockRelease(") + if brokerAcquire > brokerRelease { + t.Fatalf("%s reverses global lock order BrokerLock -> DeviceLock: %s", name, body) + } + cursor = at + len(acquire) + } + } + } + } + + for _, name := range []string{ + "ViiperBeginRemoveDevice", + "ViiperBeginControllerShutdown", + "ViiperSubmitInputReport", + } { + body := normalizedContract(nativeCFunction(t, device, name)) + deviceAcquire := "ViiperAcquireDeviceLockExclusive(" + deviceRelease := "ViiperReleaseDeviceLockExclusive(" + if name == "ViiperSubmitInputReport" { + deviceAcquire = "ViiperAcquireDeviceLockShared(" + deviceRelease = "ViiperReleaseDeviceLockShared(" + } + requireContractOrder(t, body, + deviceAcquire, + "WdfSpinLockAcquire(", + "WdfSpinLockRelease(", + deviceRelease) + } + + virtualCleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) + requireContractOrder(t, virtualCleanup, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);") + management := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + firstRelease := strings.Index(management, + "WdfSpinLockRelease(ControllerContext->BrokerLock);") + for _, setter := range []string{ + "ViiperSetDeviceResettingByIdentity(", + "ViiperSetEndpointResettingByIdentity(", + } { + if firstRelease < 0 || strings.Index(management, setter) < firstRelease { + t.Fatalf("management path calls DeviceLock setter before releasing BrokerLock: %s", management) + } + } +} + +func TestNativeEndpointRundownRejectsOldClearIncrementRace(t *testing.T) { + // Old ordering: Start clears first, Completion wins 1 -> 0 and signals, + // then Start increments. A waiter can observe signaled while active == 1. + oldActive := 1 + oldSignaled := false + oldSignaled = false + oldActive-- + if oldActive == 0 { + oldSignaled = true + } + oldActive++ + if oldActive != 1 || !oldSignaled { + t.Fatalf("old adversarial schedule was not reproduced: active=%d signaled=%t", + oldActive, oldSignaled) + } + + type rundown struct { + active int + signaled bool + } + startLocked := func(state *rundown) { + if state.active == 0 { + state.signaled = false + } + state.active++ + } + completeLocked := func(state *rundown) { + state.active-- + if state.active == 0 { + state.signaled = true + } + } + + for _, completionFirst := range []bool{false, true} { + state := rundown{active: 1, signaled: false} + if completionFirst { + completeLocked(&state) + startLocked(&state) + } else { + startLocked(&state) + completeLocked(&state) + } + if state.active != 1 || state.signaled { + t.Fatalf("serialized schedule completionFirst=%t left active=%d signaled=%t", + completionFirst, state.active, state.signaled) + } + } +} + +func TestNativeFastInputIndexIdentityReuseAndComparisonBound(t *testing.T) { + type identity struct { + deviceID uint64 + owner int + generation uint32 + handle int + } + var index []identity + insert := func(value identity) bool { + position := 0 + for position < len(index) && index[position].deviceID < value.deviceID { + position++ + } + if position < len(index) && index[position].deviceID == value.deviceID { + return false + } + index = append(index, identity{}) + copy(index[position+1:], index[position:]) + index[position] = value + return true + } + removeHandle := func(handle int) { + for position := range index { + if index[position].handle == handle { + copy(index[position:], index[position+1:]) + index = index[:len(index)-1] + return + } + } + } + lookup := func(deviceID uint64) (identity, int, bool) { + first, count, comparisons := 0, len(index), 0 + for count != 0 { + step := count / 2 + candidate := first + step + comparisons++ + if index[candidate].deviceID < deviceID { + first = candidate + 1 + count -= step + 1 + } else { + count = step + } + } + if first == len(index) || index[first].deviceID != deviceID { + return identity{}, comparisons, false + } + return index[first], comparisons, true + } + + // 17 is coprime with 32, producing a deterministic hostile insertion order. + for n := 0; n < 32; n++ { + id := uint64((n*17)%32 + 1) + if !insert(identity{deviceID: id, owner: 7, generation: uint32(id + 100), handle: int(id)}) { + t.Fatalf("unexpected duplicate device ID %d", id) + } + } + maxComparisons := 0 + for id := uint64(1); id <= 32; id++ { + got, comparisons, ok := lookup(id) + if !ok || got.owner != 7 || got.generation != uint32(id+100) { + t.Fatalf("identity lookup %d returned %+v ok=%t", id, got, ok) + } + if comparisons > maxComparisons { + maxComparisons = comparisons + } + } + for _, absent := range []uint64{0, 33, 1 << 63} { + if _, comparisons, ok := lookup(absent); ok || comparisons > 6 { + t.Fatalf("absent lookup %d ok=%t comparisons=%d", absent, ok, comparisons) + } + } + if maxComparisons > 6 || maxComparisons >= 32 { + t.Fatalf("binary lookup comparisons=%d, want <=6 and below O(32) scan", maxComparisons) + } + + // A delayed cleanup removes only its exact handle. It cannot revoke a new + // owner/generation which reused the same logical ID after retirement. + removeHandle(13) + if !insert(identity{deviceID: 13, owner: 9, generation: 900, handle: 113}) { + t.Fatal("retired logical ID could not be reused") + } + removeHandle(13) // stale cleanup for the old handle + got, _, ok := lookup(13) + if !ok || got.handle != 113 || got.owner != 9 || got.generation != 900 { + t.Fatalf("stale cleanup revoked successor identity: %+v ok=%t", got, ok) + } +} + +func TestNativeSubmitPurgeResetCancelAndCleanupInterleavings(t *testing.T) { + type endpoint struct { + open bool + active int + purgeWaiting bool + purgeComplete bool + resetWaiting bool + resetQueued bool + terminalDPCs int + cleaned bool + } + admit := func(state *endpoint) bool { + if !state.open { + return false + } + state.active++ + return true + } + closeForPurge := func(state *endpoint) { + state.open = false + state.purgeWaiting = true + } + tryPurgeComplete := func(state *endpoint) bool { + if !state.purgeWaiting || state.active != 0 { + return false + } + state.purgeComplete = true + return true + } + closeForReset := func(state *endpoint) { + state.open = false + state.resetWaiting = true + } + tryQueueReset := func(state *endpoint) bool { + if !state.resetWaiting || state.active != 0 { + return false + } + state.resetQueued = true + return true + } + runTerminalDPC := func(state *endpoint) { + state.terminalDPCs++ + state.active-- + if state.active < 0 { + t.Fatal("terminal DPC released unowned rundown") + } + } + cleanup := func(state *endpoint) bool { + if !state.purgeComplete || state.active != 0 { + return false + } + state.cleaned = true + return true + } + + // Submit wins BrokerLock. Purge closes subsequent admission but cannot pass + // the mandatory completion DPC which owns the admitted request. + submitFirst := endpoint{open: true} + if !admit(&submitFirst) { + t.Fatal("submit failed before lifecycle closure") + } + closeForPurge(&submitFirst) + if admit(&submitFirst) || tryPurgeComplete(&submitFirst) || cleanup(&submitFirst) { + t.Fatal("purge passed an admitted submit") + } + runTerminalDPC(&submitFirst) + if !tryPurgeComplete(&submitFirst) || !cleanup(&submitFirst) || + submitFirst.terminalDPCs != 1 { + t.Fatalf("submit-first path failed to drain through DPC: %+v", submitFirst) + } + + // Purge/remove wins the exclusive lifecycle boundary. A stale report never + // acquires rundown and cleanup can revoke immediately after PurgeComplete. + purgeFirst := endpoint{open: true} + closeForPurge(&purgeFirst) + if admit(&purgeFirst) || !tryPurgeComplete(&purgeFirst) || !cleanup(&purgeFirst) { + t.Fatalf("purge-first path admitted stale input: %+v", purgeFirst) + } + + // Cancellation still crosses the terminal DPC. Endpoint reset waits for the + // same owner but queues an acknowledged reset instead of PurgeComplete. + resetCancel := endpoint{open: true} + if !admit(&resetCancel) { + t.Fatal("cancelled URB was never admitted") + } + closeForReset(&resetCancel) + if tryQueueReset(&resetCancel) { + t.Fatal("reset publication passed a cancelled request before its DPC") + } + runTerminalDPC(&resetCancel) + if !tryQueueReset(&resetCancel) || resetCancel.terminalDPCs != 1 { + t.Fatalf("reset/cancel path failed to drain deterministically: %+v", resetCancel) + } + + // File cleanup publishes Closing before taking OwnerLock. Either validation + // read false first (the submit-first case above) or it observes this permanent + // close and cannot enter a successor owner's device generation. + closing := true + ownerMatches, generationMatches := true, true + if ownerMatches && generationMatches && !closing { + t.Fatal("post-cleanup owner validation admitted a report") + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 24b0c120..21de7e66 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -251,7 +251,7 @@ ViiperSetDeviceResettingByIdentity( { ULONG index; - ExAcquireFastMutex(&ControllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(ControllerContext); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -265,7 +265,7 @@ ViiperSetDeviceResettingByIdentity( break; } } - ExReleaseFastMutex(&ControllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(ControllerContext); } static @@ -280,7 +280,7 @@ ViiperSetEndpointResettingByIdentity( { ULONG index; - ExAcquireFastMutex(&ControllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(ControllerContext); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -299,7 +299,7 @@ ViiperSetEndpointResettingByIdentity( } break; } - ExReleaseFastMutex(&ControllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(ControllerContext); } static @@ -331,6 +331,20 @@ ViiperAdmissionCanPublishLocked( return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry; } +static +VOID +ViiperEndpointOperationCompletedLocked( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + LONG remaining = InterlockedDecrement(&endpointContext->ActiveOperations); + NT_ASSERT(remaining >= 0); + if (remaining == 0) { + KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE); + } +} + static VOID ViiperClearSlotLocked( @@ -367,7 +381,7 @@ ViiperClearSlotLocked( InterlockedDecrement(&deviceContext->PendingOperations); } if (endpoint != WDF_NO_HANDLE) { - ViiperEndpointOperationCompleted(endpoint); + ViiperEndpointOperationCompletedLocked(endpoint); } } @@ -378,11 +392,19 @@ ViiperEndpointOperationStarted( ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - // Callers serialize admission for one endpoint. Clear before publishing - // the increment so a concurrent purge worker can never observe the old - // signaled state between a 0 -> 1 transition and KeClearEvent. - KeClearEvent(&endpointContext->OperationsDrained); - (VOID)InterlockedIncrement(&endpointContext->ActiveOperations); + LONG active = InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0); + + // Every caller holds the controller BrokerLock. The same lock owns the + // final decrement below, so clearing before the 0 -> 1 increment is one + // linearized transaction. Without BrokerLock a concurrent 1 -> 0 + // completion could signal between those steps; incrementing first instead + // would expose active == 1 while the drain event was still signaled. + NT_ASSERT(active >= 0); + if (active == 0) { + KeClearEvent(&endpointContext->OperationsDrained); + } + active = InterlockedIncrement(&endpointContext->ActiveOperations); + NT_ASSERT(active > 0); } _IRQL_requires_max_(DISPATCH_LEVEL) @@ -392,11 +414,16 @@ ViiperEndpointOperationCompleted( ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - LONG remaining = InterlockedDecrement(&endpointContext->ActiveOperations); - NT_ASSERT(remaining >= 0); - if (remaining == 0) { - KeSetEvent(&endpointContext->OperationsDrained, IO_NO_INCREMENT, FALSE); - } + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + // External callers retain an admitted endpoint operation while reaching + // this wrapper. Do not touch Endpoint after the locked decrement can expose + // zero to the purge worker and ultimately allow UdeCx cleanup to begin. + WdfSpinLockAcquire(controllerContext->BrokerLock); + ViiperEndpointOperationCompletedLocked(Endpoint); + WdfSpinLockRelease(controllerContext->BrokerLock); } static @@ -426,14 +453,19 @@ ViiperValidateBrokerOwner( return STATUS_INVALID_HANDLE; } fileContext = ViiperGetFileContext(fileObject); - WdfWaitLockAcquire(controllerContext->OwnerLock, NULL); + // EvtFileCleanup can run with this request outstanding, but KMDF keeps the + // request-associated file object alive. BrokerOwner and Negotiated only + // transition to TRUE, while Closing is set through InterlockedExchange + // before cleanup takes OwnerLock or admits a successor. A request which + // wins before that permanent close boundary may finish against its exact + // device owner/generation; one which loses it must fail without joining + // unrelated input publishers at the controller-wide OwnerLock. if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || - controllerContext->OwnerFile != fileObject || controllerContext->CleanupInProgress || + InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { status = STATUS_INVALID_DEVICE_STATE; } - WdfWaitLockRelease(controllerContext->OwnerLock); return status; } @@ -543,7 +575,6 @@ ViiperQueueUrbCompletion( } WdfObjectReference(Request); - WdfObjectReference(Endpoint); requestContext->CompletionRequest = Request; requestContext->Controller = Controller; requestContext->Endpoint = Endpoint; @@ -641,7 +672,7 @@ ViiperEvtCompletionDpc( ViiperClearSlotLocked(controllerContext, slot); ownershipReleased = TRUE; } else if (slot >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { - ViiperEndpointOperationCompleted(endpoint); + ViiperEndpointOperationCompletedLocked(endpoint); ownershipReleased = TRUE; } if (!ownershipReleased) { @@ -656,7 +687,9 @@ ViiperEvtCompletionDpc( FALSE); } WdfSpinLockRelease(controllerContext->BrokerLock); - WdfObjectDereference(endpoint); + // Endpoint rundown, not a WDF reference, is the lifetime fence. UdeCx + // cannot pass PurgeComplete until the locked decrement above, and this + // DPC performs no endpoint access after that final release. WdfObjectDereference(request); } } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 9b5f778e..8eb63379 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -165,7 +165,7 @@ ViiperEvtDeviceAdd( context = ViiperGetControllerContext(device); RtlZeroMemory(context, sizeof(*context)); - ExInitializeFastMutex(&context->DeviceLock); + ExInitializePushLock(&context->DeviceLock); InitializeListHead(&context->CompletionQueue); KeInitializeEvent(&context->BrokerOperationsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->CompletionOperationsDrained, NotificationEvent, TRUE); @@ -228,6 +228,7 @@ ViiperEvtControllerCleanup( NT_ASSERT(InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->ActiveDevices, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->OwnerReferenced, 0, 0) == 0); + NT_ASSERT(context->InputDeviceCount == 0); } NTSTATUS diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index e2de175b..798ed38f 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -403,6 +403,101 @@ ViiperMapSpeed( } } +static +UDECXUSBDEVICE +ViiperFindInputDeviceLocked( + _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ ULONGLONG DeviceId + ) +{ + ULONG first = 0; + ULONG count = ControllerContext->InputDeviceCount; + + // InputDevices is a cold-lifecycle index: mutations keep it sorted while + // the report producer performs at most log2(32) identity comparisons. + while (count != 0) { + ULONG step = count / 2; + ULONG candidate = first + step; + UDECXUSBDEVICE device = ControllerContext->InputDevices[candidate]; + ULONGLONG candidateId = ViiperGetDeviceContext(device)->DeviceId; + + if (candidateId < DeviceId) { + first = candidate + 1; + count -= step + 1; + } else { + count = step; + } + } + if (first >= ControllerContext->InputDeviceCount || + ViiperGetDeviceContext(ControllerContext->InputDevices[first])->DeviceId != DeviceId) { + return WDF_NO_HANDLE; + } + return ControllerContext->InputDevices[first]; +} + +static +NTSTATUS +ViiperInsertInputDeviceLocked( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + ULONG position = 0; + + if (ControllerContext->InputDeviceCount >= VIIPER_UDE_MAX_DEVICES) { + return STATUS_INSUFFICIENT_RESOURCES; + } + while (position < ControllerContext->InputDeviceCount && + ViiperGetDeviceContext(ControllerContext->InputDevices[position])->DeviceId < + deviceContext->DeviceId) { + ++position; + } + if (position < ControllerContext->InputDeviceCount && + ViiperGetDeviceContext(ControllerContext->InputDevices[position])->DeviceId == + deviceContext->DeviceId) { + return STATUS_OBJECT_NAME_COLLISION; + } + if (position < ControllerContext->InputDeviceCount) { + RtlMoveMemory( + &ControllerContext->InputDevices[position + 1], + &ControllerContext->InputDevices[position], + sizeof(ControllerContext->InputDevices[0]) * + (ControllerContext->InputDeviceCount - position)); + } + ControllerContext->InputDevices[position] = Device; + ++ControllerContext->InputDeviceCount; + return STATUS_SUCCESS; +} + +static +VOID +ViiperRemoveInputDeviceLocked( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, + _In_ UDECXUSBDEVICE Device + ) +{ + ULONG position; + + for (position = 0; position < ControllerContext->InputDeviceCount; ++position) { + if (ControllerContext->InputDevices[position] == Device) { + break; + } + } + if (position == ControllerContext->InputDeviceCount) { + return; + } + --ControllerContext->InputDeviceCount; + if (position < ControllerContext->InputDeviceCount) { + RtlMoveMemory( + &ControllerContext->InputDevices[position], + &ControllerContext->InputDevices[position + 1], + sizeof(ControllerContext->InputDevices[0]) * + (ControllerContext->InputDeviceCount - position)); + } + ControllerContext->InputDevices[ControllerContext->InputDeviceCount] = WDF_NO_HANDLE; +} + static NTSTATUS ViiperClaimDeviceSlot( @@ -416,7 +511,7 @@ ViiperClaimDeviceSlot( ULONG freeSlot = VIIPER_UDE_MAX_DEVICES; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; - ExAcquireFastMutex(&ControllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(ControllerContext); if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0) { status = STATUS_DEVICE_REMOVED; goto Exit; @@ -437,13 +532,15 @@ ViiperClaimDeviceSlot( } } if (freeSlot != VIIPER_UDE_MAX_DEVICES) { - ControllerContext->Devices[freeSlot] = Device; - *Slot = freeSlot; - status = STATUS_SUCCESS; + status = ViiperInsertInputDeviceLocked(ControllerContext, Device); + if (NT_SUCCESS(status)) { + ControllerContext->Devices[freeSlot] = Device; + *Slot = freeSlot; + } } Exit: - ExReleaseFastMutex(&ControllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(ControllerContext); return status; } @@ -455,13 +552,14 @@ ViiperReleaseDeviceSlot( _In_ ULONG Slot ) { - ExAcquireFastMutex(&ControllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(ControllerContext); if (Slot < VIIPER_UDE_MAX_DEVICES) { if (ControllerContext->Devices[Slot] == Device) { + ViiperRemoveInputDeviceLocked(ControllerContext, Device); ControllerContext->Devices[Slot] = WDF_NO_HANDLE; } } - ExReleaseFastMutex(&ControllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(ControllerContext); } static @@ -617,7 +715,7 @@ ViiperBeginRemoveDevice( NTSTATUS status = STATUS_NOT_FOUND; ULONG index; - ExAcquireFastMutex(&ControllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(ControllerContext); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -639,6 +737,7 @@ ViiperBeginRemoveDevice( WdfSpinLockAcquire(ControllerContext->BrokerLock); InterlockedExchange(&deviceContext->Purging, TRUE); WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperRemoveInputDeviceLocked(ControllerContext, current); ControllerContext->Devices[index] = WDF_NO_HANDLE; // Devices[] is the logical ownership table. Retire the slot and its // active count while the UDE handle is still valid; KMDF may defer the @@ -648,7 +747,7 @@ ViiperBeginRemoveDevice( status = STATUS_SUCCESS; break; } - ExReleaseFastMutex(&ControllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(ControllerContext); return status; } @@ -721,7 +820,7 @@ ViiperDestroyOwnedDevices( ULONGLONG deviceId = 0; ULONG index; - ExAcquireFastMutex(&controllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(controllerContext); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { device = controllerContext->Devices[index]; if (device != WDF_NO_HANDLE && @@ -732,7 +831,7 @@ ViiperDestroyOwnedDevices( break; } } - ExReleaseFastMutex(&controllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(controllerContext); if (deviceId == 0) { return TRUE; } @@ -770,7 +869,7 @@ ViiperBeginControllerShutdown( // Revoke all table handles in one transaction. PlugOutAndDelete can invoke // asynchronous UdeCx cleanup, so no controller lock may be held across it. - ExAcquireFastMutex(&controllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(controllerContext); for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE device = controllerContext->Devices[index]; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; @@ -782,11 +881,12 @@ ViiperBeginControllerShutdown( WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&deviceContext->Purging, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperRemoveInputDeviceLocked(controllerContext, device); controllerContext->Devices[index] = WDF_NO_HANDLE; ViiperRetireActiveDevice(controllerContext, deviceContext); devices[deviceCount++] = device; } - ExReleaseFastMutex(&controllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(controllerContext); for (index = 0; index < deviceCount; ++index) { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]); @@ -886,21 +986,17 @@ ViiperInvalidateDeviceInputReports( ULONG index; // Device power/reset admission is already closed before this helper is - // called, so no new report can become valid. Reference each endpoint while - // outside DeviceLock because asynchronous UdeCx cleanup owns its lifetime. + // called, so no new report can become valid. Keep endpoint lookup and the + // final atomic invalidation inside one shared index acquisition; a WDF + // reference would postpone destruction but cannot postpone EvtCleanup. + ViiperAcquireDeviceLockShared(controllerContext); for (index = 0; index < RTL_NUMBER_OF(deviceContext->Endpoints); ++index) { - UDECXUSBENDPOINT endpoint; - ExAcquireFastMutex(&controllerContext->DeviceLock); - endpoint = deviceContext->Endpoints[index]; - if (endpoint != WDF_NO_HANDLE) { - WdfObjectReference(endpoint); - } - ExReleaseFastMutex(&controllerContext->DeviceLock); + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[index]; if (endpoint != WDF_NO_HANDLE) { ViiperInvalidateEndpointInputReport(endpoint); - WdfObjectDereference(endpoint); } } + ViiperReleaseDeviceLockShared(controllerContext); } NTSTATUS @@ -1089,7 +1185,16 @@ ViiperEvtEndpointCleanup( } controllerContext = ViiperGetControllerContext(deviceContext->Controller); address = endpointContext->Descriptor.bEndpointAddress; - ExAcquireFastMutex(&controllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(controllerContext); + // Microsoft permits no ordinary object access after EvtCleanup is called, + // even when a WDF reference postpones destruction. UdeCx therefore owns + // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission, its + // work item drains ActiveOperations, and only then calls PurgeComplete. + // Endpoint creation failure has no published users. Cleanup must never be + // used as a late wait for an operation which can still access this context. + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); + ViiperInvalidateEndpointInputReport(endpoint); if (deviceContext->DefaultEndpoint == endpoint) { deviceContext->DefaultEndpoint = WDF_NO_HANDLE; } @@ -1102,7 +1207,7 @@ ViiperEvtEndpointCleanup( // endpoint that never existed in this device generation. deviceContext->RetiredEndpoints[address] = TRUE; } - ExReleaseFastMutex(&controllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(controllerContext); } NTSTATUS @@ -1209,7 +1314,7 @@ ViiperEvtEndpointAdd( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); - ExAcquireFastMutex(&controllerContext->DeviceLock); + ViiperAcquireDeviceLockExclusive(controllerContext); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0) { if (descriptor.bEndpointAddress == 0) { @@ -1223,7 +1328,7 @@ ViiperEvtEndpointAdd( // endpoint-add callback rejects publication at the removal gate. status = STATUS_DEVICE_REMOVED; } - ExReleaseFastMutex(&controllerContext->DeviceLock); + ViiperReleaseDeviceLockExclusive(controllerContext); } return status; } @@ -1272,7 +1377,7 @@ ViiperCompleteRetrievedInputUrb( static NTSTATUS -ViiperCompleteCachedInputUrb( +ViiperPrepareCachedInputUrb( _In_ UDECXUSBENDPOINT Endpoint, _In_ WDFREQUEST Request ) @@ -1289,12 +1394,10 @@ ViiperCompleteCachedInputUrb( (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || (urb->UrbBulkOrInterruptTransfer.TransferFlags & USBD_TRANSFER_DIRECTION_IN) == 0) { - ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_INVALID_DEVICE_REQUEST); return STATUS_INVALID_DEVICE_REQUEST; } transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; if (endpointContext->InputReportLength > transferLength) { - ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_BUFFER_TOO_SMALL); return STATUS_BUFFER_TOO_SMALL; } status = ViiperCopyTransferBuffer( @@ -1304,7 +1407,6 @@ ViiperCompleteCachedInputUrb( endpointContext->InputReportLength, TRUE); if (!NT_SUCCESS(status)) { - ViiperCompleteRetrievedInputUrb(Endpoint, Request, status); return status; } @@ -1312,7 +1414,6 @@ ViiperCompleteCachedInputUrb( UdecxUrbSetBytesCompleted(Request, endpointContext->InputReportLength); InterlockedAdd64(&controllerContext->BytesFromDevice, endpointContext->InputReportLength); InterlockedIncrement64(&controllerContext->InputReportsCompleted); - ViiperCompleteRetrievedInputUrb(Endpoint, Request, STATUS_SUCCESS); return STATUS_SUCCESS; } @@ -1328,7 +1429,9 @@ ViiperEvtFastInputQueueReady( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request = WDF_NO_HANDLE; + NTSTATUS completionStatus = STATUS_SUCCESS; BOOLEAN admitted = FALSE; + BOOLEAN deliveryReady = FALSE; BOOLEAN completionQueued = FALSE; PAGED_CODE(); @@ -1338,6 +1441,26 @@ ViiperEvtFastInputQueueReady( // completion DPC. The DPC is the separate DISPATCH_LEVEL boundary required // by the UDE/host-controller completion contract; a system work item before // that DPC only adds scheduler latency to the first poll after idle/resume. + // Register endpoint rundown before any endpoint-local wait. A producer can + // own InputLock while UdeCx begins PURGE; entering rundown first prevents + // the passive purge worker from observing zero while this ReadyNotify + // callback is already waiting to inspect the cached report. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0) { + ViiperEndpointOperationStarted(endpoint); + admitted = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!admitted) { + return; + } + WdfWaitLockAcquire(endpointContext->InputLock, NULL); WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && @@ -1349,12 +1472,12 @@ ViiperEvtFastInputQueueReady( InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0 && InterlockedCompareExchange(&endpointContext->CachedDeliveryPending, 0, 0) != 0) { - ViiperEndpointOperationStarted(endpoint); - admitted = TRUE; + deliveryReady = TRUE; } WdfSpinLockRelease(controllerContext->BrokerLock); - if (!admitted) { + if (!deliveryReady) { WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); return; } @@ -1366,15 +1489,22 @@ ViiperEvtFastInputQueueReady( if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); ViiperInvalidateInputIfLifecycleClosed(endpoint); - (VOID)ViiperCompleteCachedInputUrb(endpoint, request); + completionStatus = ViiperPrepareCachedInputUrb(endpoint, request); completionQueued = TRUE; } else { ViiperInvalidateInputIfLifecycleClosed(endpoint); } - if (!completionQueued) { + WdfWaitLockRelease(endpointContext->InputLock); + if (completionQueued) { + // Queue only after the last endpoint-local lock access. The DPC may run + // immediately and performs the final rundown release after UDE's + // mandatory DISPATCH_LEVEL terminal completion. + ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus); + } else { + // This is the final endpoint access: the locked decrement may let the + // purge worker complete and permit UdeCx cleanup immediately after it. ViiperEndpointOperationCompleted(endpoint); } - WdfWaitLockRelease(endpointContext->InputLock); } NTSTATUS @@ -1390,12 +1520,13 @@ ViiperSubmitInputReport( size_t inputLength; size_t payloadLength; WDFFILEOBJECT ownerFile; + UDECXUSBDEVICE device = WDF_NO_HANDLE; UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; VIIPER_UDE_DEVICE_CONTEXT *deviceContext = NULL; - VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = NULL; WDFREQUEST urbRequest = WDF_NO_HANDLE; - ULONG index; NTSTATUS status; + BOOLEAN admitted = FALSE; BOOLEAN lifecycleDrop = FALSE; status = ViiperValidateBrokerOwner(controller, Request); @@ -1440,33 +1571,59 @@ ViiperSubmitInputReport( return STATUS_INVALID_PARAMETER; } - ExAcquireFastMutex(&controllerContext->DeviceLock); - for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { - UDECXUSBDEVICE device = controllerContext->Devices[index]; - if (device == WDF_NO_HANDLE) { - continue; - } + status = STATUS_NOT_FOUND; + ViiperAcquireDeviceLockShared(controllerContext); + device = ViiperFindInputDeviceLocked(controllerContext, input->DeviceId); + if (device != WDF_NO_HANDLE) { deviceContext = ViiperGetDeviceContext(device); - if (deviceContext->OwnerFile != ownerFile || - deviceContext->DeviceId != input->DeviceId || - deviceContext->Generation != input->Generation) { - continue; - } - if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || - InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { - lifecycleDrop = TRUE; - break; - } - endpoint = deviceContext->Endpoints[input->EndpointAddress]; - if (endpoint != WDF_NO_HANDLE) { - WdfObjectReference(endpoint); - } else if (deviceContext->RetiredEndpoints[input->EndpointAddress]) { - lifecycleDrop = TRUE; + if (deviceContext->OwnerFile == ownerFile && + deviceContext->Generation == input->Generation) { + if (InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } else { + endpoint = deviceContext->Endpoints[input->EndpointAddress]; + if (endpoint == WDF_NO_HANDLE) { + if (deviceContext->RetiredEndpoints[input->EndpointAddress]) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } + } else { + endpointContext = ViiperGetEndpointContext(endpoint); + if (!endpointContext->FastInput || + endpointContext->InputLock == WDF_NO_HANDLE) { + status = STATUS_INVALID_DEVICE_STATE; + } else { + // The shared index pins the published endpoint through + // admission. BrokerLock is also the linearization point + // for lifecycle closure and every ActiveOperations + // 0 <-> 1 event transition. Once counted, UdeCx purge + // must drain this operation before cleanup may revoke + // the endpoint context. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } else { + ViiperEndpointOperationStarted(endpoint); + admitted = TRUE; + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } + } + } } - break; } - ExReleaseFastMutex(&controllerContext->DeviceLock); + ViiperReleaseDeviceLockShared(controllerContext); if (lifecycleDrop) { // A report already submitted by the owner may cross the D0/unplug // boundary before the ordered lifecycle notification cancels its @@ -1474,15 +1631,8 @@ ViiperSubmitInputReport( // session. Acknowledge and discard it exactly at that boundary. return STATUS_SUCCESS; } - if (endpoint == WDF_NO_HANDLE) { - return STATUS_NOT_FOUND; - } - - endpointContext = ViiperGetEndpointContext(endpoint); - if (!endpointContext->FastInput || - endpointContext->InputLock == WDF_NO_HANDLE) { - WdfObjectDereference(endpoint); - return STATUS_INVALID_DEVICE_STATE; + if (!admitted) { + return status; } // The default IOCTL queue is parallel so independent controllers never @@ -1498,20 +1648,18 @@ ViiperSubmitInputReport( InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { WdfSpinLockRelease(controllerContext->BrokerLock); WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); + ViiperEndpointOperationCompleted(endpoint); // Endpoint purge/start and endpoint reset preserve the device // generation. A publisher can have one already-built latest-state // report crossing either callback; acknowledge and discard it rather // than faulting the otherwise valid owner session. return STATUS_SUCCESS; } - ViiperEndpointOperationStarted(endpoint); WdfSpinLockRelease(controllerContext->BrokerLock); if (input->Sequence <= (ULONGLONG)InterlockedCompareExchange64( &endpointContext->LastInputSequence, 0, 0)) { - ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); + ViiperEndpointOperationCompleted(endpoint); return STATUS_INVALID_DEVICE_STATE; } // Claim and cache every accepted sequence, including when no Windows poll @@ -1529,9 +1677,8 @@ ViiperSubmitInputReport( &endpointContext->CachedDeliveryPending, status == STATUS_NO_MORE_ENTRIES ? TRUE : FALSE); ViiperInvalidateInputIfLifecycleClosed(endpoint); - ViiperEndpointOperationCompleted(endpoint); WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); + ViiperEndpointOperationCompleted(endpoint); // The cached report now owns this state. Queue-ready delivery services // the next Windows poll even if the physical feeder becomes idle. return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; @@ -1542,9 +1689,12 @@ ViiperSubmitInputReport( // reset/purge/D0 boundary. Revalidate under the same admission lock so // either this path or the lifecycle callback performs the final clear. ViiperInvalidateInputIfLifecycleClosed(endpoint); - status = ViiperCompleteCachedInputUrb(endpoint, urbRequest); + status = ViiperPrepareCachedInputUrb(endpoint, urbRequest); WdfWaitLockRelease(endpointContext->InputLock); - WdfObjectDereference(endpoint); + // This call is the active-operation handoff. It performs every remaining + // endpoint lookup before enqueuing the DPC; the caller performs no endpoint + // access after a concurrently running DPC can release rundown. + ViiperCompleteRetrievedInputUrb(endpoint, urbRequest, status); return status; } @@ -1605,6 +1755,8 @@ ViiperEvtEndpointResetWorkItem( KernelMode, FALSE, NULL); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); // An input publisher admitted immediately before Resetting was raised is // allowed to finish, then this barrier performs the final invalidation. ViiperInvalidateEndpointInputReport(endpoint); @@ -1641,6 +1793,8 @@ ViiperEvtEndpointPurgeWorkItem( KernelMode, FALSE, NULL); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); // The admission barrier is closed and all pre-boundary publishers have // drained, so no cached state can be republished after this clear. ViiperInvalidateEndpointInputReport(endpoint); @@ -1668,9 +1822,10 @@ ViiperEvtEndpointPurge( ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - // UdeCx owns the state of the endpoint queue. We only drain requests that - // were already forwarded to the broker/direct-input paths, then report - // purge completion from the passive work item. + // UdeCx owns and has already stopped the associated queue before PURGE; + // client drivers must not change that queue's state. Only callbacks already + // forwarded to our broker/direct paths remain, and each is covered by the + // ActiveOperations fence before this passive work item may report complete. WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index b92ad01d..445511e7 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -98,11 +98,13 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestC typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFWAITLOCK OwnerLock; - // This lock is embedded in the controller context instead of being a WDF - // child object. UdeCx endpoint/device cleanup can run while the framework - // is deleting sibling controller children, but the parent context remains - // alive until every child cleanup callback has returned. - FAST_MUTEX DeviceLock; + // UdeCx endpoint/device cleanup can run while the framework is deleting + // sibling controller children, but the parent context remains alive until + // every child cleanup callback has returned. A push lock is supported by + // the driver's Windows 10 1809 floor and is optimized for this shared-heavy + // identity index. Normal shared acquisition waits behind an exclusive + // lifecycle writer, so continuous reports cannot starve handle revocation. + EX_PUSH_LOCK DeviceLock; WDFSPINLOCK BrokerLock; WDFMEMORY PendingStorage; VIIPER_UDE_PENDING_SLOT *PendingSlots; @@ -154,11 +156,62 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; + // Sorted by DeviceId and protected by DeviceLock. The input producer uses + // a shared binary lookup while lifecycle mutations retain exclusive access + // to the physical UDE port table below. + ULONG InputDeviceCount; + UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES]; UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; } VIIPER_UDE_CONTROLLER_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_CONTROLLER_CONTEXT, ViiperGetControllerContext) +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperAcquireDeviceLockExclusive( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + // Push-lock callers must suppress normal kernel APC delivery from acquire + // through release and must run at IRQL <= APC_LEVEL. + KeEnterCriticalRegion(); + ExAcquirePushLockExclusive(&ControllerContext->DeviceLock); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperAcquireDeviceLockShared( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + KeEnterCriticalRegion(); + ExAcquirePushLockShared(&ControllerContext->DeviceLock); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperReleaseDeviceLockExclusive( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + ExReleasePushLockExclusive(&ControllerContext->DeviceLock); + KeLeaveCriticalRegion(); +} + +_IRQL_requires_max_(APC_LEVEL) +FORCEINLINE +VOID +ViiperReleaseDeviceLockShared( + _Inout_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext + ) +{ + ExReleasePushLockShared(&ControllerContext->DeviceLock); + KeLeaveCriticalRegion(); +} + typedef struct VIIPER_UDE_FILE_CONTEXT { volatile LONG Negotiated; volatile LONG Closing; diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index c81aeeaf..89b15bf7 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -110,7 +110,9 @@ $allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter ' ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" foreach ($requiredHeaderContract in @( - 'FAST_MUTEX DeviceLock;', + 'EX_PUSH_LOCK DeviceLock;', + 'ULONG InputDeviceCount;', + 'UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES];', 'KEVENT BrokerOperationsDrained;', 'KEVENT CompletionOperationsDrained;', 'KEVENT FileCleanupsDrained;', @@ -152,6 +154,23 @@ if (($controllerSource + $deviceSource + $brokerSource) -match 'WdfWaitLock(?:Acquire|Release)\s*\([^;\r\n]*DeviceLock') { throw 'DeviceLock must remain embedded; a sibling WDF lock is unsafe during UdeCx child cleanup.' } +if ($allDriverCSource -match 'Ex(?:Acquire|Release)FastMutex\s*\([^;\r\n]*DeviceLock') { + throw 'DeviceLock must remain a shared/exclusive push lock; FAST_MUTEX serializes every input producer.' +} +foreach ($pushLockContract in @( + 'KeEnterCriticalRegion();', + 'ExAcquirePushLockShared(&ControllerContext->DeviceLock);', + 'ExAcquirePushLockExclusive(&ControllerContext->DeviceLock);', + 'ExReleasePushLockShared(&ControllerContext->DeviceLock);', + 'ExReleasePushLockExclusive(&ControllerContext->DeviceLock);', + 'KeLeaveCriticalRegion();')) { + if (-not $header.Contains($pushLockContract)) { + throw "DeviceLock lost required push-lock/APC contract: $pushLockContract" + } +} +if ([regex]::Matches($header, '_IRQL_requires_max_\(APC_LEVEL\)').Count -lt 4) { + throw 'Every shared/exclusive DeviceLock acquire/release helper must declare IRQL <= APC_LEVEL.' +} if ($brokerSource -notmatch 'WDF_DPC_CONFIG_INIT\s*\(\s*&dpcConfig\s*,\s*ViiperEvtCompletionDpc\s*\)[\s\S]{0,200}?dpcConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;[\s\S]{0,300}?WdfDpcCreate') { throw 'UdeCx completion must use one preallocated, nonserialized controller DPC.' @@ -180,10 +199,16 @@ $completionQueueMatch = [regex]::Match( if (-not $completionQueueMatch.Success -or $completionQueueMatch.Groups['body'].Value -notmatch 'requestContext->CompletionQueued' -or $completionQueueMatch.Groups['body'].Value -notmatch - 'WdfObjectReference\s*\(\s*Request\s*\)[\s\S]*WdfObjectReference\s*\(\s*Endpoint\s*\)' -or + 'WdfObjectReference\s*\(\s*Request\s*\)' -or + $completionQueueMatch.Groups['body'].Value -match + 'WdfObjectReference\s*\(\s*Endpoint\s*\)' -or $completionQueueMatch.Groups['body'].Value -notmatch 'KeClearEvent\s*\(\s*&controllerContext->CompletionOperationsDrained\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&controllerContext->PendingCompletions\s*\)[\s\S]*InsertTailList\s*\(\s*&controllerContext->CompletionQueue[\s\S]*WdfDpcEnqueue') { - throw 'Completion admission must reject duplicate ownership, reference both WDF objects, account drain, and enqueue the DPC.' + throw 'Completion admission must retain only the request, rely on pre-cleanup endpoint rundown, account drain, and enqueue the DPC.' +} +if ($completionDpcMatch.Groups['body'].Value -match + 'WdfObjectDereference\s*\(\s*endpoint\s*\)') { + throw 'Completion DPC must not treat an endpoint WDF reference as permission to access an object after EvtCleanup.' } $unownedCompletionMatch = [regex]::Match( $brokerSource, @@ -229,6 +254,20 @@ if ($deviceSource -notmatch 'ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*ViiperQueueUrbCompletion') { throw 'Every endpoint queue must override synchronous queued cancellation and transfer it through endpoint rundown to the DPC.' } +$endpointOperationStartMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEndpointOperationStarted\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointOperationCompleteMatch = [regex]::Match( + $brokerSource, + '(?ms)^VOID\s+ViiperEndpointOperationCompletedLocked\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointOperationStartMatch.Success -or + $endpointOperationStartMatch.Groups['body'].Value -notmatch + 'if\s*\(\s*active\s*==\s*0\s*\)[\s\S]*KeClearEvent\s*\(\s*&endpointContext->OperationsDrained\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&endpointContext->ActiveOperations\s*\)' -or + -not $endpointOperationCompleteMatch.Success -or + $endpointOperationCompleteMatch.Groups['body'].Value -notmatch + 'InterlockedDecrement\s*\(\s*&endpointContext->ActiveOperations\s*\)[\s\S]*if\s*\(\s*remaining\s*==\s*0\s*\)[\s\S]*KeSetEvent\s*\(\s*&endpointContext->OperationsDrained') { + throw 'Endpoint ActiveOperations count/event transitions must remain linearized through the BrokerLock-owned helpers.' +} $queueUrbMatch = [regex]::Match( $brokerSource, '(?ms)^NTSTATUS\s+ViiperQueueUrb\s*\([^)]*\)\s*\{(?.*?)^\}') @@ -247,6 +286,9 @@ if (-not $purgeWorkItemMatch.Success -or 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*UdecxUsbEndpointPurgeComplete') { throw 'Endpoint purge-complete must remain behind the forwarded-URB completion drain.' } +if ($deviceSource -match 'WdfIoQueue(?:Purge|Start)\s*\(') { + throw 'UdeCx owns the associated endpoint queue state; the client must only drain its forwarded paths.' +} $completionDrainMatch = [regex]::Match( $brokerSource, '(?ms)^VOID\s+ViiperDrainUrbCompletions\s*\([^)]*\)\s*\{(?.*?)^\}') From cebc88ed8d49bdcdf43e93365049938311c5c6d7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 06:43:07 -0500 Subject: [PATCH 171/240] Bump native driver contract for input hot path --- _testing/e2e/latency/report_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/package/ViiperUde.inf | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go index 3f20919b..6460e534 100644 --- a/_testing/e2e/latency/report_test.go +++ b/_testing/e2e/latency/report_test.go @@ -415,7 +415,7 @@ func validReport(t *testing.T) *Report { } else { run.Server.NativeUDE = &NativeServerProof{ ABIMajor: 1, ABIMinor: 0, Capabilities: 1, - ExpectedDriverPackageVersion: "0.1.0.2", + ExpectedDriverPackageVersion: "0.1.0.3", } } transportOffset := 0 diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 0659b60c..243ce25a 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -18,7 +18,7 @@ const ( // shipped with this service. Runtime negotiation proves the installed // driver speaks the exact ABI below; package installation additionally // verifies this release version and its signed catalog. - DriverPackageVersion = "0.1.0.2" + DriverPackageVersion = "0.1.0.3" HeaderSize = 16 NegotiateRequestSize = 32 diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 290b4622..a1d2502a 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.2 + 0.1.0.3 diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index b4d595fd..c454432c 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.2 +DriverVer=08/11/2026,0.1.0.3 PnpLockDown=1 [DestinationDirs] From c6d2394652f1f8037a05d6b33631c12302d7c3ae Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 07:45:35 -0500 Subject: [PATCH 172/240] fix(udecx): close endpoint lifecycle admission races --- .../udecx/driver_dispatch_contract_test.go | 18 +- ...river_endpoint_quiescence_contract_test.go | 774 ++++++++++++++++++ native/udecx/driver/Broker.c | 412 +++++++--- native/udecx/driver/Controller.c | 87 +- native/udecx/driver/Device.c | 337 +++++++- native/udecx/driver/ViiperUde.h | 38 + .../Test-ViiperUdeTargetCompatibility.ps1 | 95 ++- 7 files changed, 1598 insertions(+), 163 deletions(-) create mode 100644 internal/transport/udecx/driver_endpoint_quiescence_contract_test.go diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index f353bdcb..a955dfe0 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -605,14 +605,14 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. } purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) requireContractOrder(t, purgeWork, - "KeWaitForSingleObject( &endpointContext->OperationsDrained", - "endpointContext->ActiveOperations", + "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", "ViiperInvalidateEndpointInputReport(endpoint);", "UdecxUsbEndpointPurgeComplete(endpoint);") resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) requireContractOrder(t, resetWork, - "KeWaitForSingleObject( &endpointContext->OperationsDrained", - "endpointContext->ActiveOperations", + "resetCurrent = ViiperQuiesceResetByIdentity(", + "if (!resetCurrent)", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", "ViiperInvalidateEndpointInputReport(endpoint);", "ViiperQueueAcknowledgedEndpointLifecycleEvent(") start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) @@ -738,13 +738,9 @@ func TestNativeDeviceAndBrokerLockOrderNeverReverses(t *testing.T) { management := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) firstRelease := strings.Index(management, "WdfSpinLockRelease(ControllerContext->BrokerLock);") - for _, setter := range []string{ - "ViiperSetDeviceResettingByIdentity(", - "ViiperSetEndpointResettingByIdentity(", - } { - if firstRelease < 0 || strings.Index(management, setter) < firstRelease { - t.Fatalf("management path calls DeviceLock setter before releasing BrokerLock: %s", management) - } + resetProof := strings.Index(management, "ViiperQuiesceResetByIdentity(") + if firstRelease < 0 || resetProof < firstRelease { + t.Fatalf("management path acquires the reset identity fence before releasing BrokerLock: %s", management) } } diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go new file mode 100644 index 00000000..dbb0fc07 --- /dev/null +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -0,0 +1,774 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + // UdeCx exclusively owns the associated endpoint queue's START/PURGE + // state. VIIPER may observe that queue, but must never mutate it. + for _, mutation := range []string{ + "WdfIoQueuePurge(", + "WdfIoQueuePurgeSynchronously(", + "WdfIoQueueStart(", + "WdfIoQueueStop(", + "WdfIoQueueStopSynchronously(", + "WdfIoQueueDrain(", + "WdfIoQueueDrainSynchronously(", + } { + if strings.Contains(device, mutation) { + t.Fatalf("UdeCx-associated queue state is client-mutated by %s", mutation) + } + } + createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) + if !strings.Contains(createQueue, + "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") { + t.Fatal("endpoint queue is no longer explicitly associated with UdeCx") + } + + // A WDF callback can be delivered, then preempted before its first + // BrokerLock acquisition. Queue state closes that otherwise invisible + // window; ActiveOperations joins the callback's terminal DPC afterward. + quiesce := normalizedContract(nativeCFunction(t, device, "ViiperWaitForEndpointQuiescence")) + requireContractOrder(t, quiesce, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfIoQueueGetState(endpointContext->Queue, NULL, NULL);", + "WdfIoQueueAcceptRequests | WdfIoQueueDispatchRequests", + "WDF_IO_QUEUE_IDLE(queueState)", + "WdfIoQueueDriverNoRequests", + "endpointContext->ActiveOperations", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (quiescent)", + "return;", + "KeDelayExecutionThread(") + + queueUrb := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrb")) + requireContractOrder(t, queueUrb, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "ViiperEndpointOperationStarted(endpoint);", + "controllerContext->ShuttingDown", + "controllerContext->BrokerFaulted", + "deviceContext->InD0", + "deviceContext->Resetting", + "deviceContext->Purging", + "endpointContext->Resetting", + "endpointContext->Purging", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (!NT_SUCCESS(status))", + "ViiperAllocatePendingSlot(") + allocate := normalizedContract(nativeCFunction(t, broker, "ViiperAllocatePendingSlot")) + requireContractOrder(t, allocate, + "WdfSpinLockAcquire(ControllerContext->BrokerLock);", + "ControllerContext->ShuttingDown", + "ControllerContext->BrokerFaulted", + "deviceContext->InD0", + "endpointContext->Purging", + "endpointContext->Resetting", + "deviceContext->Resetting", + "deviceContext->Purging", + "pending->Request = Request;") + + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "UdecxUsbEndpointPurgeComplete(endpoint);") + resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "resetCurrent = ViiperQuiesceResetByIdentity(", + "deviceContext->DeviceId", + "deviceContext->Generation", + "endpointContext->Descriptor.bEndpointAddress", + "FALSE, FALSE);", + "if (!resetCurrent)", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&endpointContext->Resetting, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + + controllerQuiesce := normalizedContract(nativeCFunction(t, device, "ViiperQuiesceControllerEndpoints")) + requireContractOrder(t, controllerQuiesce, + "ViiperAcquireDeviceLockShared(controllerContext);", + "deviceContext->Endpoints[endpointIndex]", + "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", + "ViiperReleaseDeviceLockShared(controllerContext);") + cleanup := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceSelfManagedIoCleanup")) + requireContractOrder(t, cleanup, + "InterlockedExchange(&context->ShuttingDown, TRUE);", + "ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED);", + "ViiperQuiesceControllerEndpoints(Device);", + "ViiperDrainUrbCompletions(Device);", + "context->PendingOperations", + "context->PendingCompletions", + "IsListEmpty(&context->CompletionQueue)", + "context->CompletionDpcActive", + "ViiperBeginControllerShutdown(Device);") + shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + if strings.Contains(shutdown, "WdfIoQueueGetState") || + strings.Contains(shutdown, "KeWaitForSingleObject") { + t.Fatal("controller shutdown consumes children before, or waits after, the queue proof") + } + if !strings.Contains(shutdown, "UdecxUsbDevicePlugOutAndDelete(devices[index]);") { + t.Fatal("controller shutdown no longer consumes the snapshotted UdeCx children") + } +} + +func TestNativeResetQuiescenceIsExactGenerationAndFailClosed(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + deviceReset := normalizedContract(nativeCFunction(t, device, "ViiperBeginAcknowledgedDeviceReset")) + requireContractOrder(t, deviceReset, + "controllerContext->BrokerFaulted", + "InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE)", + "status = STATUS_DEVICE_BUSY;", + "} else {", + "InterlockedIncrement64(&deviceContext->ResetEpoch)", + "status = STATUS_SUCCESS;", + "if (!ViiperQuiesceResetByIdentity(", + "deviceContext->DeviceId", + "deviceContext->Generation", + "Device", + "resetEpoch", + "TRUE, FALSE))", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->Resetting, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "return STATUS_DEVICE_NOT_READY;", + "ViiperQueueAcknowledgedDeviceLifecycleEvent(") + + identityProof := normalizedContract(nativeCFunction(t, device, "ViiperQuiesceResetByIdentity")) + requireContractOrder(t, identityProof, + "ViiperAcquireDeviceLockShared(controllerContext);", + "deviceContext->DeviceId != DeviceId", + "deviceContext->Generation != Generation", + "ExpectedResetEpoch", + "deviceContext->Endpoints[EndpointAddress]", + "endpoint == ExpectedEndpoint", + "endpointContext->Resetting", + "ViiperWaitForEndpointQuiescence(endpoint, FALSE);", + "endpointContext->Resetting", + "if (ReleaseGate)", + "InterlockedExchange(&endpointContext->Resetting, FALSE);", + "ViiperReleaseDeviceLockShared(controllerContext);", + "return found;") + + ack := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + requireContractOrder(t, ack, + "resetEpoch = ControllerContext->ManagementSlots[slot].ResetEpoch;", + "State = ViiperUdePendingCompleting;", + "resetReleased = ViiperQuiesceResetByIdentity(", + "Completion->DeviceId", + "Completion->Generation", + "device", + "TRUE);", + "if (!resetReleased)", + "WdfRequestComplete(request, STATUS_DEVICE_NOT_READY);", + "ViiperClearManagementSlotLocked(", + "return STATUS_DEVICE_NOT_READY;", + "WdfRequestComplete(request, (NTSTATUS)Completion->Status);") + + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + if !strings.Contains(header, "UDECXUSBDEVICE Device;") || + !strings.Contains(header, "UDECXUSBENDPOINT Endpoint;") || + !strings.Contains(header, "volatile LONG64 ResetDeviceEpoch;") { + t.Fatal("management slots lost their exact WDF-object identity pins") + } + queueLifecycle := normalizedContract(nativeCFunction(t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, queueLifecycle, + "WdfObjectReference(Device);", + "WdfObjectReference(Endpoint);", + "Kind == ViiperUdeOperationEndpointReset", + "deviceContext->ResetEpoch", + "endpointContext->ResetDeviceEpoch", + "pending->Device = Device;", + "pending->Endpoint = Endpoint;", + "pending->ResetEpoch =", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (!NT_SUCCESS(status))", + "ViiperReleaseManagementSlotReferences(Device, Endpoint);") + clearSlot := normalizedContract(nativeCFunction(t, broker, "ViiperClearManagementSlotLocked")) + requireContractOrder(t, clearSlot, + "*DeviceReference = pending->Device;", + "*EndpointReference = pending->Endpoint;", + "pending->Device = WDF_NO_HANDLE;", + "pending->Endpoint = WDF_NO_HANDLE;") + releasePins := normalizedContract(nativeCFunction(t, broker, "ViiperReleaseManagementSlotReferences")) + requireContractOrder(t, releasePins, + "WdfObjectDereference(Endpoint);", + "WdfObjectDereference(Device);") + if strings.Count(broker, "ViiperClearManagementSlotLocked(") != 4 || + strings.Count(broker, "ViiperReleaseManagementSlotReferences(") != 5 { + t.Fatal("a management-slot terminal path can bypass exact-handle release") + } +} + +func TestNativeDeviceResetEpochSupersedesOlderEndpointResets(t *testing.T) { + type endpointReset struct { + capturedEpoch uint64 + gate bool + published bool + } + type device struct { + epoch uint64 + resetGate bool + unavailable bool + } + admitDeviceReset := func(state *device) (uint64, bool) { + if state.unavailable || state.resetGate { + return state.epoch, false + } + state.resetGate = true + state.epoch++ + if state.epoch == 0 { + state.epoch++ + } + return state.epoch, true + } + endpointCurrent := func(state *device, endpoint *endpointReset) bool { + return endpoint.gate && !state.resetGate && + endpoint.capturedEpoch == state.epoch + } + failEndpoint := func(endpoint *endpointReset) { + // This endpoint owns its gate. A device reset/purge/shutdown remains an + // independent blocker and is not touched here. + endpoint.gate = false + } + + // Published endpoint reset, followed by a complete device reset, then a + // delayed endpoint ACK: the logical ID and WDF handle can both still match, + // but the private reset epoch makes the ACK stale. + state := device{} + published := endpointReset{capturedEpoch: state.epoch, gate: true, published: true} + deviceEpoch, admitted := admitDeviceReset(&state) + if !admitted || deviceEpoch != 1 { + t.Fatalf("device reset was not admitted exactly once: %+v", state) + } + state.resetGate = false // acknowledged device reset + if endpointCurrent(&state, &published) { + t.Fatal("stale published endpoint ACK survived a complete device reset") + } + failEndpoint(&published) + if published.gate || state.resetGate { + t.Fatalf("stale endpoint ACK disturbed post-device-reset gates: %+v %+v", state, published) + } + + // Endpoint worker was admitted but not yet published while a full device + // reset starts and completes. Its initial publication proof must fail too. + delayed := endpointReset{capturedEpoch: 4, gate: true} + state = device{epoch: 4} + if _, ok := admitDeviceReset(&state); !ok { + t.Fatal("device reset did not supersede delayed endpoint worker") + } + state.resetGate = false + if endpointCurrent(&state, &delayed) { + t.Fatal("delayed endpoint worker published across a complete device reset") + } + failEndpoint(&delayed) + + // One device reset supersedes every older endpoint transaction, not just + // the endpoint whose worker happened to run first. + state = device{epoch: 9} + left := endpointReset{capturedEpoch: 9, gate: true, published: true} + right := endpointReset{capturedEpoch: 9, gate: true, published: true} + if _, ok := admitDeviceReset(&state); !ok { + t.Fatal("device reset did not supersede two endpoints") + } + state.resetGate = false + for name, endpoint := range map[string]*endpointReset{"left": &left, "right": &right} { + if endpointCurrent(&state, endpoint) { + t.Fatalf("%s endpoint survived superseding device epoch", name) + } + failEndpoint(endpoint) + } + + // Rejected device-reset admission must not invalidate otherwise-current + // endpoint work by consuming an epoch. + state = device{epoch: 15, unavailable: true} + current := endpointReset{capturedEpoch: 15, gate: true} + if epoch, ok := admitDeviceReset(&state); ok || epoch != 15 || state.epoch != 15 { + t.Fatalf("rejected device reset advanced epoch: %+v", state) + } + state.unavailable = false + if !endpointCurrent(&state, ¤t) { + t.Fatal("rejected device reset incorrectly superseded endpoint work") + } +} + +func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + abortMatching := normalizedContract(nativeCFunction( + t, broker, "ViiperAbortManagementOperationsMatching")) + requireContractOrder(t, abortMatching, + "for (;;)", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "Device == WDF_NO_HANDLE || controllerContext->ManagementSlots[index].Device == Device", + "matchingSlot = TRUE;", + "ViiperUdePendingCompleting", + "RetiredToken = token;", + "RetiredDeviceId =", + "RetiredDeviceGeneration =", + "RetiredNotificationPending =", + "ViiperUdePendingQueued;", + "State = ViiperUdePendingCompleting;", + "WdfObjectReference(request);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(request, Status);", + "ViiperClearManagementSlotLocked(", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperReleaseManagementSlotReferences(deviceReference, endpointReference);", + "if (!matchingSlot)", + "return;", + "KeDelayExecutionThread(KernelMode, FALSE, &retryInterval);") + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchNotificationEvents")) + requireContractOrder(t, dispatch, + "event = controllerContext->Notifications[controllerContext->NotificationHead];", + "RetiredNotificationPending", + "RetiredToken != event.Token", + "RetiredDeviceId != event.DeviceId", + "RetiredDeviceGeneration != event.Generation", + "RetiredNotificationPending = FALSE;", + "RetiredToken = 0;", + "event.Kind = ViiperUdeOperationCancel;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfRequestComplete(dequeueRequest, STATUS_SUCCESS);") + complete := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) + requireContractOrder(t, complete, + "!ControllerContext->ManagementSlots[slot].RetiredNotificationPending", + "RetiredToken == Completion->Token", + "RetiredDeviceId == Completion->DeviceId", + "RetiredDeviceGeneration == Completion->Generation", + "RetiredToken = 0;", + "RetiredDeviceId = 0;", + "RetiredDeviceGeneration = 0;", + "RetiredOwnerFile = WDF_NO_HANDLE;", + "retiredCompletion = TRUE;", + "return retiredCompletion ? STATUS_SUCCESS : STATUS_NOT_FOUND;") + clearSlot := normalizedContract(nativeCFunction(t, broker, "ViiperClearManagementSlotLocked")) + if strings.Contains(clearSlot, "RetiredToken") { + t.Fatal("terminal slot clear erases the harmless late-ACK tombstone") + } + queueLifecycle := normalizedContract(nativeCFunction( + t, broker, "ViiperQueueAcknowledgedLifecycleEvent")) + requireContractOrder(t, queueLifecycle, + "pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0", + "continue;", + "pending->OwnerFile = deviceContext->OwnerFile;", + "pending->Token = token;") + retireOwner := normalizedContract(nativeCFunction( + t, broker, "ViiperRetireManagementTombstonesForOwner")) + requireContractOrder(t, retireOwner, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "OwnerFile == WDF_NO_HANDLE || pending->RetiredOwnerFile == OwnerFile", + "pending->RetiredToken = 0;", + "pending->RetiredDeviceId = 0;", + "pending->RetiredDeviceGeneration = 0;", + "pending->RetiredOwnerFile = WDF_NO_HANDLE;", + "pending->RetiredNotificationPending = FALSE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + fileConfig := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceAdd")) + if !strings.Contains(fileConfig, + "WDF_FILEOBJECT_CONFIG_INIT( &fileConfig, ViiperEvtFileCreate, ViiperEvtFileClose, ViiperEvtFileCleanup);") { + t.Fatal("owner-session tombstones are not tied to KMDF's post-I/O file-close boundary") + } + fileClose := normalizedContract(nativeCFunction(t, controller, "ViiperEvtFileClose")) + requireContractOrder(t, fileClose, + "ShuttingDown", + "fileContext->BrokerOwner", + "ViiperRetireManagementTombstonesForOwner(", + "WdfFileObjectGetDevice(FileObject), FileObject);") + cleanup := normalizedContract(nativeCFunction( + t, controller, "ViiperEvtDeviceSelfManagedIoCleanup")) + requireContractOrder(t, cleanup, + "WdfIoQueuePurgeSynchronously(context->ControlQueue);", + "ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED);", + "ViiperRetireManagementTombstonesForOwner(Device, WDF_NO_HANDLE);", + "ViiperBeginControllerShutdown(Device);") + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED);", + "UdecxUsbDevicePlugOutAndDelete(device);") + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "ViiperBeginRemoveDevice(", + "plugged = deviceContext->Plugged;", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "if (plugged)", + "UdecxUsbDevicePlugOutAndDelete(device)") + + // Deterministic no-ACK interleaving: the broker holds an endpoint-reset + // request and both exact WDF-object pins. Removing this device must retire + // only that slot and release both pins before the UDE handle is consumed; + // another device's management request remains live. + type managementSlot struct { + devicePin int + endpointPin int + pending bool + completed bool + } + slots := []managementSlot{ + {devicePin: 7, endpointPin: 71, pending: true}, + {devicePin: 8, endpointPin: 81, pending: true}, + } + abortDevice := func(devicePin int) { + for index := range slots { + slot := &slots[index] + if !slot.pending || slot.devicePin != devicePin { + continue + } + slot.completed = true + slot.pending = false + slot.endpointPin = 0 + slot.devicePin = 0 + } + } + deviceTableContainsSeven := true + purgingSeven := false + udeSevenConsumed := false + purgingSeven = true + deviceTableContainsSeven = false + abortDevice(7) // no owner acknowledgement arrives + if slots[0].pending || !slots[0].completed || + slots[0].devicePin != 0 || slots[0].endpointPin != 0 { + t.Fatalf("destroy stranded exact management references: %+v", slots[0]) + } + if !slots[1].pending || slots[1].completed || + slots[1].devicePin != 8 || slots[1].endpointPin != 81 { + t.Fatalf("exact-device abort disturbed an unrelated child: %+v", slots[1]) + } + if !purgingSeven || deviceTableContainsSeven { + t.Fatal("device removal did not close admission before management abort") + } + udeSevenConsumed = true + if !udeSevenConsumed || slots[0].devicePin != 0 { + t.Fatal("UDE handle was consumed before its management pin drained") + } + + // A queued token is retired in O(1), then dispatch converts that one record + // to a benign cancel and preserves the unrelated child's next event. If + // dispatch already won, the exact slot tombstone accepts one late ACK. + type notification struct { + token uint64 + device int + } + queued := []notification{{token: 101, device: 7}, {token: 202, device: 8}} + queuedRetiredToken := uint64(101) + dispatched := queued[0] + queued = queued[1:] + dispatchedAsCancel := dispatched.token == queuedRetiredToken + queuedRetiredToken = 0 + if !dispatchedAsCancel || queuedRetiredToken != 0 || + len(queued) != 1 || queued[0].token != 202 || queued[0].device != 8 { + t.Fatalf("queued abort corrupted unrelated lifecycle FIFO: %+v", queued) + } + retiredToken := uint64(303) // dispatch crossed BrokerLock before abort + acceptLate := func(token uint64) bool { + if retiredToken != token { + return false + } + retiredToken = 0 + return true + } + if !acceptLate(303) || retiredToken != 0 || acceptLate(303) { + t.Fatal("already-delivered teardown token was not consumed exactly once") + } + + // Slot tombstones are device-bound and non-reusable. B must allocate a + // different empty slot, so removing B cannot overwrite A before A's late + // ACK. A malformed device identity cannot consume A's proof. + type retiredManagement struct { + token uint64 + deviceID uint64 + generation uint32 + owner int + } + retired := []retiredManagement{ + {token: 401, deviceID: 41, generation: 4, owner: 1}, + {}, + } + allocateEmpty := func() int { + for index := range retired { + if retired[index].token == 0 { + return index + } + } + return -1 + } + bSlot := allocateEmpty() + if bSlot != 1 { + t.Fatalf("allocator reused A tombstone: slot=%d state=%+v", bSlot, retired) + } + retired[bSlot] = retiredManagement{token: 502, deviceID: 52, generation: 5, owner: 1} + acceptBoundLate := func(slot int, token, deviceID uint64, generation uint32) bool { + proof := &retired[slot] + if proof.token != token || proof.deviceID != deviceID || + proof.generation != generation { + return false + } + *proof = retiredManagement{} + return true + } + if acceptBoundLate(0, 401, 99, 4) || retired[0].token != 401 { + t.Fatal("malformed completion consumed A's device-bound tombstone") + } + if !acceptBoundLate(0, 401, 41, 4) || retired[1].token != 502 { + t.Fatalf("B removal overwrote A tombstone: %+v", retired) + } + if allocateEmpty() != 0 { + t.Fatal("consuming A did not safely release only A's slot") + } + retired[0] = retiredManagement{token: 603, deviceID: 63, generation: 6, owner: 1} + if allocateEmpty() != -1 { + t.Fatal("allocator did not fail closed when every slot held a late-ACK proof") + } + for index := range retired { + if retired[index].owner == 1 { // KMDF EvtFileClose: old I/O drained + retired[index] = retiredManagement{} + } + } + if allocateEmpty() != 0 { + t.Fatal("post-I/O owner close did not release retired slot capacity") + } +} + +func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { + type endpoint struct { + open bool + queueAccepting bool + queueDispatching bool + queued int + driverOwned int + active int + terminalDPCs int + resetOutstanding bool + } + deliverByWDF := func(state *endpoint) bool { + // The asynchronous reset request is the class-extension fence: UdeCx + // cannot deliver a successor transfer until the client completes it. + if state.resetOutstanding || !state.queueDispatching || state.queued == 0 { + return false + } + state.queued-- + state.driverOwned++ + return true + } + resumeDeliveredCallback := func(state *endpoint) bool { + if state.driverOwned == 0 { + t.Fatal("resumed a callback WDF does not own") + } + state.active++ + // Lifecycle closure is checked in the same BrokerLock transaction as + // rundown entry. A closed request owns only its terminal DPC. + return state.open + } + runTerminalDPC := func(state *endpoint) { + if state.driverOwned == 0 || state.active == 0 { + t.Fatal("terminal DPC released unowned WDF/rundown state") + } + state.terminalDPCs++ + state.active-- + state.driverOwned-- + } + closeForPurgeOrShutdown := func(state *endpoint) { + state.open = false + state.queueAccepting = false + state.queueDispatching = false + state.queued = 0 // UdeCx cancels requests it had not delivered. + } + terminallyQuiescent := func(state *endpoint) bool { + stopped := !state.queueAccepting && !state.queueDispatching + queueIdle := state.queued == 0 && state.driverOwned == 0 + return stopped && queueIdle && state.active == 0 + } + closeForReset := func(state *endpoint) { + state.open = false + state.resetOutstanding = true + } + resetQuiescent := func(state *endpoint) bool { + // A parked interrupt poll may remain queued and the associated queue + // may remain ready. Only driver-owned callbacks plus rundown matter. + return state.driverOwned == 0 && state.active == 0 + } + + for _, lifecycle := range []string{"purge", "shutdown"} { + state := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 1, + } + if !deliverByWDF(&state) { + t.Fatalf("%s: WDF did not deliver the pre-boundary callback", lifecycle) + } + // Preempt here: WDF owns the request but QueueUrb has not yet acquired + // BrokerLock or incremented ActiveOperations. + closeForPurgeOrShutdown(&state) + if terminallyQuiescent(&state) { + t.Fatalf("%s passed a WDF-delivered callback before rundown entry", lifecycle) + } + if resumeDeliveredCallback(&state) { + t.Fatalf("%s callback allocated/published after lifecycle closure", lifecycle) + } + if terminallyQuiescent(&state) { + t.Fatalf("%s passed the callback before terminal DPC completion", lifecycle) + } + runTerminalDPC(&state) + if !terminallyQuiescent(&state) || state.terminalDPCs != 1 { + t.Fatalf("%s failed stable stopped+idle+rundown proof: %+v", lifecycle, state) + } + } + + reset := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + } + if !deliverByWDF(&reset) { + t.Fatal("reset: WDF did not deliver the pre-boundary callback") + } + closeForReset(&reset) + if resetQuiescent(&reset) { + t.Fatal("reset publication passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&reset) { + t.Fatal("reset callback allocated/published after reset closure") + } + if resetQuiescent(&reset) { + t.Fatal("reset publication passed the callback before terminal DPC completion") + } + runTerminalDPC(&reset) + if !resetQuiescent(&reset) || reset.queued != 1 || !reset.queueDispatching { + t.Fatalf("reset failed ready-queue DriverNoRequests proof: %+v", reset) + } + if deliverByWDF(&reset) { + t.Fatal("UdeCx delivered a successor callback before reset acknowledgement") + } + // Owner ACK repeats the proof. Only then may the exact reset gate reopen + // and the asynchronous reset request be completed. + if !resetQuiescent(&reset) { + t.Fatal("reset acknowledgement missed the second quiescence proof") + } + reset.open = true + reset.resetOutstanding = false + if !deliverByWDF(&reset) { + t.Fatal("post-reset queue did not resume after acknowledgement") + } +} + +func TestNativeResetAcknowledgementRejectsRemovalAndIdentityReuse(t *testing.T) { + type identity struct { + deviceID uint64 + generation uint32 + handle uint64 + endpointExists bool + resetting bool + } + ack := func(current *identity, deviceID uint64, generation uint32, handle uint64) bool { + if current == nil || current.deviceID != deviceID || + current.generation != generation || current.handle != handle || + !current.endpointExists || + !current.resetting { + return false + } + current.resetting = false + return true + } + + original := identity{deviceID: 7, generation: 41, handle: 1001, endpointExists: true, resetting: true} + removed := original + removed.endpointExists = false + if ack(&removed, original.deviceID, original.generation, original.handle) || !removed.resetting { + t.Fatal("removed endpoint accepted an acknowledgement or reopened its gate") + } + + // A hostile/raw broker can reuse both logical fields, and generation wraps + // eventually. The pinned old WDF handle cannot be recycled until slot clear, + // so the successor must still reject the delayed acknowledgement. + successor := identity{deviceID: 7, generation: 41, handle: 2002, endpointExists: true, resetting: true} + if ack(&successor, original.deviceID, original.generation, original.handle) || !successor.resetting { + t.Fatal("stale acknowledgement reopened an exact logical identity on a successor handle") + } + if !ack(&successor, successor.deviceID, successor.generation, successor.handle) || successor.resetting { + t.Fatal("exact live generation did not accept its own acknowledgement") + } +} + +func TestNativeOverlappingEndpointAndDeviceResetReleaseOnlyOwnedGate(t *testing.T) { + type gates struct { + deviceReset bool + endpointReset bool + purging bool + shutdown bool + brokerFault bool + } + admissionOpen := func(state gates) bool { + return !state.deviceReset && !state.endpointReset && !state.purging && + !state.shutdown && !state.brokerFault + } + + state := gates{endpointReset: true} + // Device reset wins after endpoint reset admission. The endpoint worker's + // exact proof fails because the device-wide gate is now closed; it must + // release only the endpoint gate before failing its actual reset request. + state.deviceReset = true + state.endpointReset = false + if admissionOpen(state) || !state.deviceReset { + t.Fatalf("endpoint failure disturbed the winning device reset: %+v", state) + } + + // The device reset's own publication proof can then lose to purge. Its + // callback-owned gate is released, while purge remains the independent + // admission blocker. + state.purging = true + state.deviceReset = false + if admissionOpen(state) || !state.purging { + t.Fatalf("device failure disturbed the winning purge: %+v", state) + } +} + +func TestNativeResetPublicationRejectsConcurrentRemoval(t *testing.T) { + type resetBoundary struct { + resetting bool + purging bool + present bool + published bool + } + publish := func(state *resetBoundary) bool { + if !state.present || !state.resetting || state.purging { + return false + } + state.published = true + return true + } + + for _, test := range []struct { + name string + state resetBoundary + }{ + {name: "device removed", state: resetBoundary{resetting: true, present: false}}, + {name: "device purge won", state: resetBoundary{resetting: true, purging: true, present: true}}, + {name: "endpoint retired", state: resetBoundary{resetting: true, present: false}}, + } { + if publish(&test.state) || test.state.published { + t.Fatalf("%s published a reset for a dead lifecycle identity", test.name) + } + } + + live := resetBoundary{resetting: true, present: true} + if !publish(&live) || !live.published { + t.Fatal("live exact reset identity did not publish after quiescence") + } +} diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 21de7e66..1d3a3c33 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -153,10 +153,31 @@ ViiperDispatchNotificationEvents( ULONG managementSlot = ((ULONG)event.Token & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; if (managementSlot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || - controllerContext->ManagementSlots[managementSlot].Token != event.Token || - controllerContext->ManagementSlots[managementSlot].State != - ViiperUdePendingQueued) { + (controllerContext->ManagementSlots[managementSlot].Token != event.Token || + controllerContext->ManagementSlots[managementSlot].State != + ViiperUdePendingQueued) && + (!controllerContext->ManagementSlots[managementSlot].RetiredNotificationPending || + controllerContext->ManagementSlots[managementSlot].RetiredToken != event.Token || + controllerContext->ManagementSlots[managementSlot].RetiredDeviceId != + event.DeviceId || + controllerContext->ManagementSlots[managementSlot].RetiredDeviceGeneration != + event.Generation)) { status = STATUS_INVALID_DEVICE_STATE; + } else if (controllerContext->ManagementSlots[ + managementSlot].RetiredNotificationPending) { + // Teardown retired the held UdeCx request before this + // queued notification crossed to user mode. Consume its + // WDF-free tombstone in O(1) and publish a benign cancel + // record, which the host handles before lane tracking. + controllerContext->ManagementSlots[ + managementSlot].RetiredNotificationPending = FALSE; + controllerContext->ManagementSlots[managementSlot].RetiredToken = 0; + controllerContext->ManagementSlots[managementSlot].RetiredDeviceId = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredDeviceGeneration = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredOwnerFile = WDF_NO_HANDLE; + event.Kind = ViiperUdeOperationCancel; } else { controllerContext->ManagementSlots[managementSlot].State = ViiperUdePendingInFlight; @@ -225,14 +246,22 @@ static VOID ViiperClearManagementSlotLocked( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, - _In_ ULONG Slot + _In_ ULONG Slot, + _Out_ UDECXUSBDEVICE *DeviceReference, + _Out_ UDECXUSBENDPOINT *EndpointReference ) { VIIPER_UDE_MANAGEMENT_SLOT *pending = &ControllerContext->ManagementSlots[Slot]; + *DeviceReference = pending->Device; + *EndpointReference = pending->Endpoint; pending->Request = WDF_NO_HANDLE; + pending->Device = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->OwnerFile = WDF_NO_HANDLE; pending->Token = 0; pending->DeviceId = 0; + pending->ResetEpoch = 0; pending->DeviceGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->Kind = 0; @@ -242,64 +271,21 @@ ViiperClearManagementSlotLocked( static VOID -ViiperSetDeviceResettingByIdentity( - _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, - _In_ ULONGLONG DeviceId, - _In_ ULONG Generation, - _In_ LONG Value +ViiperReleaseManagementSlotReferences( + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint ) { - ULONG index; - - ViiperAcquireDeviceLockExclusive(ControllerContext); - for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { - UDECXUSBDEVICE device = ControllerContext->Devices[index]; - VIIPER_UDE_DEVICE_CONTEXT *deviceContext; - if (device == WDF_NO_HANDLE) { - continue; - } - deviceContext = ViiperGetDeviceContext(device); - if (deviceContext->DeviceId == DeviceId && - deviceContext->Generation == Generation) { - InterlockedExchange(&deviceContext->Resetting, Value); - break; - } + // Dereferencing can make framework cleanup runnable. Never do it under + // BrokerLock: endpoint/device cleanup also uses that lock to close + // admission, and WDF references postpone destruction rather than the + // documented EvtCleanup no-access boundary. + if (Endpoint != WDF_NO_HANDLE) { + WdfObjectDereference(Endpoint); } - ViiperReleaseDeviceLockExclusive(ControllerContext); -} - -static -VOID -ViiperSetEndpointResettingByIdentity( - _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, - _In_ ULONGLONG DeviceId, - _In_ ULONG Generation, - _In_ UCHAR EndpointAddress, - _In_ LONG Value - ) -{ - ULONG index; - - ViiperAcquireDeviceLockExclusive(ControllerContext); - for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { - UDECXUSBDEVICE device = ControllerContext->Devices[index]; - VIIPER_UDE_DEVICE_CONTEXT *deviceContext; - UDECXUSBENDPOINT endpoint; - if (device == WDF_NO_HANDLE) { - continue; - } - deviceContext = ViiperGetDeviceContext(device); - if (deviceContext->DeviceId != DeviceId || - deviceContext->Generation != Generation) { - continue; - } - endpoint = deviceContext->Endpoints[EndpointAddress]; - if (endpoint != WDF_NO_HANDLE) { - InterlockedExchange(&ViiperGetEndpointContext(endpoint)->Resetting, Value); - } - break; + if (Device != WDF_NO_HANDLE) { + WdfObjectDereference(Device); } - ViiperReleaseDeviceLockExclusive(ControllerContext); } static @@ -963,14 +949,24 @@ ViiperQueueAcknowledgedLifecycleEvent( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); const USB_ENDPOINT_DESCRIPTOR *descriptor = NULL; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = NULL; ULONG offset; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; BOOLEAN canAllocate = TRUE; BOOLEAN ownerActive = FALSE; BOOLEAN faulted = FALSE; + // The management slot owns these generic references until every terminal + // clear path snapshots and releases them outside BrokerLock. Besides + // retaining the opaque values, this prevents WDF from recycling either + // handle while a delayed acknowledgement is compared with the live table. + WdfObjectReference(Device); if (Endpoint != WDF_NO_HANDLE) { - descriptor = &ViiperGetEndpointContext(Endpoint)->Descriptor; + WdfObjectReference(Endpoint); + } + if (Endpoint != WDF_NO_HANDLE) { + endpointContext = ViiperGetEndpointContext(Endpoint); + descriptor = &endpointContext->Descriptor; } WdfSpinLockAcquire(controllerContext->BrokerLock); @@ -980,6 +976,23 @@ ViiperQueueAcknowledgedLifecycleEvent( !ownerActive) { status = STATUS_DEVICE_NOT_READY; canAllocate = FALSE; + } else if (Kind == ViiperUdeOperationDeviceReset && + (InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0)) { + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; + } else if (Kind == ViiperUdeOperationEndpointReset && + (endpointContext == NULL || + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 || + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange64(&deviceContext->ResetEpoch, 0, 0) != + InterlockedCompareExchange64(&endpointContext->ResetDeviceEpoch, 0, 0))) { + // A device reset admitted after the endpoint worker's first proof + // supersedes that endpoint transaction before it can be published. + status = STATUS_DEVICE_NOT_READY; + canAllocate = FALSE; } else if (controllerContext->NotificationCount >= VIIPER_UDE_MAX_PENDING_OPERATIONS - 1) { (VOID)ViiperFaultBrokerLocked(controllerContext); @@ -993,7 +1006,7 @@ ViiperQueueAcknowledgedLifecycleEvent( VIIPER_UDE_MANAGEMENT_SLOT *pending = &controllerContext->ManagementSlots[index]; ULONGLONG token; - if (pending->State != ViiperUdePendingEmpty) { + if (pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0) { continue; } ++pending->Generation; @@ -1003,8 +1016,16 @@ ViiperQueueAcknowledgedLifecycleEvent( token = ((ULONGLONG)pending->Generation << 32) | VIIPER_UDE_MANAGEMENT_SLOT_FLAG | (index + 1); pending->Request = Request; + pending->Device = Device; + pending->Endpoint = Endpoint; + pending->OwnerFile = deviceContext->OwnerFile; pending->Token = token; pending->DeviceId = deviceContext->DeviceId; + pending->ResetEpoch = endpointContext != NULL + ? (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->ResetDeviceEpoch, 0, 0) + : (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); pending->DeviceGeneration = deviceContext->Generation; pending->State = ViiperUdePendingQueued; pending->Kind = Kind; @@ -1018,8 +1039,12 @@ ViiperQueueAcknowledgedLifecycleEvent( InterfaceSetting, token)) { pending->Request = WDF_NO_HANDLE; + pending->Device = WDF_NO_HANDLE; + pending->Endpoint = WDF_NO_HANDLE; + pending->OwnerFile = WDF_NO_HANDLE; pending->Token = 0; pending->DeviceId = 0; + pending->ResetEpoch = 0; pending->DeviceGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->Kind = 0; @@ -1042,6 +1067,11 @@ ViiperQueueAcknowledgedLifecycleEvent( if (NT_SUCCESS(status) || faulted) { ViiperDispatchNotificationEvents(deviceContext->Controller); } + if (!NT_SUCCESS(status)) { + // No caller-owned context access is permitted after the last generic + // reference can make deferred WDF destruction runnable. + ViiperReleaseManagementSlotReferences(Device, Endpoint); + } return status; } @@ -1102,6 +1132,7 @@ ViiperAllocatePendingSlot( WdfSpinLockAcquire(ControllerContext->BrokerLock); if (InterlockedCompareExchange(&ControllerContext->ShuttingDown, 0, 0) != 0 || InterlockedCompareExchange(&ControllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || @@ -2066,18 +2097,28 @@ ViiperQueueUrb( requestContext->Controller = deviceContext->Controller; requestContext->Endpoint = endpoint; requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; - // Endpoint purge closes admission under BrokerLock. Enter rundown before - // any admission check so an untracked rejection cannot be completed after - // PurgeComplete has observed a stale zero count. + // KMDF has already delivered this UdeCx request to the driver. Enter + // endpoint rundown and decide whether it may reach the broker in the same + // BrokerLock transaction. A request delivered immediately before PURGE, + // reset, D0 exit, or controller shutdown still owns its mandatory terminal + // DPC, but it must never allocate or publish a broker slot after that + // boundary. WdfSpinLockAcquire(controllerContext->BrokerLock); ViiperEndpointOperationStarted(endpoint); - WdfSpinLockRelease(controllerContext->BrokerLock); - - if (InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { - return STATUS_DEVICE_NOT_READY; + status = STATUS_DEVICE_NOT_READY; + } else { + status = STATUS_SUCCESS; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; } status = ViiperAllocatePendingSlot( controllerContext, Request, endpoint, &slot, &token); @@ -2173,6 +2214,7 @@ ViiperRangeValid( static NTSTATUS ViiperCompleteManagementOperation( + _In_ WDFDEVICE Controller, _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ const VIIPER_UDE_COMPLETION *Completion ) @@ -2180,8 +2222,15 @@ ViiperCompleteManagementOperation( ULONG encodedSlot = (ULONG)Completion->Token; ULONG slot = (encodedSlot & ~VIIPER_UDE_MANAGEMENT_SLOT_FLAG) - 1; WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBDEVICE device = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpoint = WDF_NO_HANDLE; + UDECXUSBDEVICE deviceReference = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpointReference = WDF_NO_HANDLE; ULONG kind = 0; UCHAR endpointAddress = 0; + ULONGLONG resetEpoch = 0; + BOOLEAN resetReleased = TRUE; + BOOLEAN retiredCompletion = FALSE; if ((encodedSlot & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 || slot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || @@ -2198,44 +2247,98 @@ ViiperCompleteManagementOperation( ControllerContext->ManagementSlots[slot].DeviceId == Completion->DeviceId && ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation) { request = ControllerContext->ManagementSlots[slot].Request; + device = ControllerContext->ManagementSlots[slot].Device; + endpoint = ControllerContext->ManagementSlots[slot].Endpoint; + resetEpoch = ControllerContext->ManagementSlots[slot].ResetEpoch; kind = ControllerContext->ManagementSlots[slot].Kind; endpointAddress = ControllerContext->ManagementSlots[slot].EndpointAddress; ControllerContext->ManagementSlots[slot].State = ViiperUdePendingCompleting; WdfObjectReference(request); + } else if (!ControllerContext->ManagementSlots[slot].RetiredNotificationPending && + ControllerContext->ManagementSlots[slot].RetiredToken == + Completion->Token && + ControllerContext->ManagementSlots[slot].RetiredDeviceId == + Completion->DeviceId && + ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration == + Completion->Generation) { + // The corresponding request and WDF-object pins were synchronously + // retired by child teardown after this token crossed to user mode. + // Consume the tombstone as a harmless expected-late ACK. + ControllerContext->ManagementSlots[slot].RetiredToken = 0; + ControllerContext->ManagementSlots[slot].RetiredDeviceId = 0; + ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration = 0; + ControllerContext->ManagementSlots[slot].RetiredOwnerFile = WDF_NO_HANDLE; + retiredCompletion = TRUE; } WdfSpinLockRelease(ControllerContext->BrokerLock); if (request == WDF_NO_HANDLE) { InterlockedIncrement64(&ControllerContext->LateCompletions); - return STATUS_NOT_FOUND; + return retiredCompletion ? STATUS_SUCCESS : STATUS_NOT_FOUND; } if (kind == ViiperUdeOperationDeviceReset) { - // User mode has stopped every direct-input publisher before issuing - // this acknowledgement. Reopen kernel admission immediately before - // completing the UdeCx reset request so any synchronously resumed URB - // sees the post-reset state, while no direct report can cross early. - ViiperSetDeviceResettingByIdentity( - ControllerContext, Completion->DeviceId, Completion->Generation, FALSE); + // Endpoint RESET is asynchronous: UdeCx cannot resume endpoint I/O + // until this reset Request is completed. Repeat the read-only queue / + // rundown proof for the exact device generation at owner ack so even + // a terminal callback admitted after initial reset publication is + // joined. Reopen kernel admission immediately before completing the + // UdeCx request so a synchronously resumed URB sees post-reset state. + resetReleased = ViiperQuiesceResetByIdentity( + Controller, + Completion->DeviceId, + Completion->Generation, + device, + WDF_NO_HANDLE, + resetEpoch, + 0, + TRUE, + TRUE); } else if (kind == ViiperUdeOperationEndpointReset) { // Endpoint reset is a distinct UdeCx boundary, not a purge/start - // cycle. Reopen only this endpoint immediately before completing the - // asynchronous reset request. The host has already stopped and joined - // its direct-input publisher before sending this acknowledgement. - ViiperSetEndpointResettingByIdentity( - ControllerContext, + // cycle. The second exact-generation proof closes the delivered- + // before-rundown window without changing UdeCx-owned queue state. + // Reopen only this endpoint immediately before completing the reset + // request; completion is the boundary at which UdeCx may resume I/O. + resetReleased = ViiperQuiesceResetByIdentity( + Controller, Completion->DeviceId, Completion->Generation, + device, + endpoint, + resetEpoch, endpointAddress, - FALSE); + FALSE, + TRUE); + } + if (!resetReleased) { + // Removal or identity reuse won after this management request was + // published. Never apply an acknowledgement to a different child and + // never reopen a missing endpoint. Fail the held UdeCx reset request, + // then retire this completing slot here so removal cannot strand it. + WdfRequestComplete(request, STATUS_DEVICE_NOT_READY); + WdfSpinLockAcquire(ControllerContext->BrokerLock); + if (ControllerContext->ManagementSlots[slot].Request == request && + ControllerContext->ManagementSlots[slot].Token == Completion->Token && + ControllerContext->ManagementSlots[slot].State == ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked( + ControllerContext, slot, &deviceReference, &endpointReference); + } + WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); + WdfObjectDereference(request); + InterlockedIncrement64(&ControllerContext->OperationsPurged); + return STATUS_DEVICE_NOT_READY; } WdfRequestComplete(request, (NTSTATUS)Completion->Status); WdfSpinLockAcquire(ControllerContext->BrokerLock); if (ControllerContext->ManagementSlots[slot].Request == request && ControllerContext->ManagementSlots[slot].Token == Completion->Token && ControllerContext->ManagementSlots[slot].State == ViiperUdePendingCompleting) { - ViiperClearManagementSlotLocked(ControllerContext, slot); + ViiperClearManagementSlotLocked( + ControllerContext, slot, &deviceReference, &endpointReference); } WdfSpinLockRelease(ControllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); WdfObjectDereference(request); InterlockedIncrement64(&ControllerContext->OperationsCompleted); return STATUS_SUCCESS; @@ -2330,7 +2433,7 @@ ViiperCompleteOperation( payload = tail + completion->PayloadOffset - sizeof(*completion); } if (((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) != 0) { - return ViiperCompleteManagementOperation(controllerContext, completion); + return ViiperCompleteManagementOperation(controller, controllerContext, completion); } slot = (ULONG)(completion->Token & MAXULONG); @@ -2556,46 +2659,143 @@ ViiperAbortMatchingOperations( static VOID -ViiperAbortManagementOperations( +ViiperAbortManagementOperationsMatching( _In_ WDFDEVICE Controller, + _In_opt_ UDECXUSBDEVICE Device, _In_ NTSTATUS Status ) { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); - ULONG index; + LARGE_INTEGER retryInterval; if (controllerContext->BrokerLock == WDF_NO_HANDLE || controllerContext->ManagementSlots == NULL) { return; } - for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { - WDFREQUEST request = WDF_NO_HANDLE; - ULONGLONG token = 0; + retryInterval.QuadPart = -10 * 1000; // one millisecond, relative + for (;;) { + BOOLEAN matchingSlot = FALSE; + ULONG index; - WdfSpinLockAcquire(controllerContext->BrokerLock); - if (controllerContext->ManagementSlots[index].State != ViiperUdePendingEmpty && - controllerContext->ManagementSlots[index].State != ViiperUdePendingCompleting) { - request = controllerContext->ManagementSlots[index].Request; - token = controllerContext->ManagementSlots[index].Token; - controllerContext->ManagementSlots[index].State = ViiperUdePendingCompleting; - WdfObjectReference(request); + for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { + WDFREQUEST request = WDF_NO_HANDLE; + UDECXUSBDEVICE deviceReference = WDF_NO_HANDLE; + UDECXUSBENDPOINT endpointReference = WDF_NO_HANDLE; + ULONGLONG token = 0; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].State != ViiperUdePendingEmpty && + (Device == WDF_NO_HANDLE || + controllerContext->ManagementSlots[index].Device == Device)) { + matchingSlot = TRUE; + if (controllerContext->ManagementSlots[index].State != + ViiperUdePendingCompleting) { + request = controllerContext->ManagementSlots[index].Request; + token = controllerContext->ManagementSlots[index].Token; + controllerContext->ManagementSlots[index].RetiredToken = token; + controllerContext->ManagementSlots[index].RetiredDeviceId = + controllerContext->ManagementSlots[index].DeviceId; + controllerContext->ManagementSlots[index].RetiredDeviceGeneration = + controllerContext->ManagementSlots[index].DeviceGeneration; + controllerContext->ManagementSlots[index].RetiredOwnerFile = + controllerContext->ManagementSlots[index].OwnerFile; + controllerContext->ManagementSlots[index].RetiredNotificationPending = + controllerContext->ManagementSlots[index].State == + ViiperUdePendingQueued; + controllerContext->ManagementSlots[index].State = + ViiperUdePendingCompleting; + WdfObjectReference(request); + } + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (request == WDF_NO_HANDLE) { + continue; + } + + WdfRequestComplete(request, Status); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (controllerContext->ManagementSlots[index].Request == request && + controllerContext->ManagementSlots[index].Token == token && + controllerContext->ManagementSlots[index].State == + ViiperUdePendingCompleting) { + ViiperClearManagementSlotLocked( + controllerContext, index, &deviceReference, &endpointReference); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + ViiperReleaseManagementSlotReferences(deviceReference, endpointReference); + WdfObjectDereference(request); + InterlockedIncrement64(&controllerContext->OperationsPurged); } - WdfSpinLockRelease(controllerContext->BrokerLock); - if (request == WDF_NO_HANDLE) { - continue; + + if (!matchingSlot) { + return; } + // A matching Completing slot is owned by another finite kernel + // callback. Join it before child consumption; no new slot can be + // admitted after ShuttingDown/OwnerFile closing or Device.Purging. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} - WdfRequestComplete(request, Status); - WdfSpinLockAcquire(controllerContext->BrokerLock); - if (controllerContext->ManagementSlots[index].Request == request && - controllerContext->ManagementSlots[index].Token == token && - controllerContext->ManagementSlots[index].State == ViiperUdePendingCompleting) { - ViiperClearManagementSlotLocked(controllerContext, index); +static +VOID +ViiperAbortManagementOperations( + _In_ WDFDEVICE Controller, + _In_ NTSTATUS Status + ) +{ + ViiperAbortManagementOperationsMatching(Controller, WDF_NO_HANDLE, Status); +} + +VOID +ViiperAbortDeviceManagementOperations( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ NTSTATUS Status + ) +{ + // Device removal has already closed Purging and retired the DeviceLock + // table entry. Complete every still-published management request while + // the UDE handle is valid, then release the slot's exact device/endpoint + // pins before PlugOutAndDelete consumes that handle. The shared abort + // helper also stably joins a slot already owned by a completing kernel + // callback, including file cleanup racing the serialized control queue. + ViiperAbortManagementOperationsMatching(Controller, Device, Status); +} + +VOID +ViiperRetireManagementTombstonesForOwner( + _In_ WDFDEVICE Controller, + _In_opt_ WDFFILEOBJECT OwnerFile + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + ULONG index; + + if (controllerContext->BrokerLock == WDF_NO_HANDLE || + controllerContext->ManagementSlots == NULL) { + return; + } + // EvtFileClose runs only after this exact file object's I/O is fully + // drained. Terminal self-managed cleanup passes WDF_NO_HANDLE only after + // synchronously purging the entire control queue. Exact owner identity in + // the ordinary case prevents a delayed close from erasing a successor + // broker's independent late-ACK tombstone. + WdfSpinLockAcquire(controllerContext->BrokerLock); + for (index = 0; index < VIIPER_UDE_MAX_PENDING_MANAGEMENT; ++index) { + VIIPER_UDE_MANAGEMENT_SLOT *pending = + &controllerContext->ManagementSlots[index]; + + if (OwnerFile == WDF_NO_HANDLE || pending->RetiredOwnerFile == OwnerFile) { + pending->RetiredToken = 0; + pending->RetiredDeviceId = 0; + pending->RetiredDeviceGeneration = 0; + pending->RetiredOwnerFile = WDF_NO_HANDLE; + pending->RetiredNotificationPending = FALSE; } - WdfSpinLockRelease(controllerContext->BrokerLock); - WdfObjectDereference(request); - InterlockedIncrement64(&controllerContext->OperationsPurged); } + WdfSpinLockRelease(controllerContext->BrokerLock); } VOID diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 8eb63379..2c873ce9 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -21,6 +21,7 @@ DEFINE_GUID( #pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) #pragma alloc_text(PAGE, ViiperEvtFileCleanup) +#pragma alloc_text(PAGE, ViiperEvtFileClose) #pragma alloc_text(PAGE, ViiperCreateQueues) #endif @@ -137,7 +138,7 @@ ViiperEvtDeviceAdd( WDF_FILEOBJECT_CONFIG_INIT( &fileConfig, ViiperEvtFileCreate, - WDF_NO_EVENT_CALLBACK, + ViiperEvtFileClose, ViiperEvtFileCleanup); WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&fileAttributes, VIIPER_UDE_FILE_CONTEXT); fileAttributes.ExecutionLevel = WdfExecutionLevelPassive; @@ -298,19 +299,42 @@ ViiperEvtDeviceSelfManagedIoCleanup( NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); + // KMDF purges non-power-managed queues before terminal self-managed + // cleanup. Prove each still-valid UdeCx endpoint queue is stopped and idle, + // and that its BrokerLock-owned rundown is zero, before consuming any UDE + // device handle. The shared device index held by this helper also prevents + // endpoint EvtCleanup from invalidating a queue during the observation. + ViiperQuiesceControllerEndpoints(Device); if (context->CompletionDpc != WDF_NO_HANDLE) { - if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { - (VOID)KeWaitForSingleObject( - &context->BrokerOperationsDrained, - Executive, - KernelMode, - FALSE, - NULL); + for (;;) { + BOOLEAN stable; + + if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { + (VOID)KeWaitForSingleObject( + &context->BrokerOperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + } + // BrokerOperationsDrained covers tracked slots. The second join + // also covers rejected and fast-input URBs, then cancels/joins the + // reusable DPC only after its intrusive request list is empty. + ViiperDrainUrbCompletions(Device); + + // Endpoint queue-idle proof precedes this observation, so no UdeCx + // callback can newly enter rundown. Recheck all controller-owned + // terminal state under BrokerLock to join the final DPC handoff. + WdfSpinLockAcquire(context->BrokerLock); + stable = InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0 && + InterlockedCompareExchange(&context->PendingCompletions, 0, 0) == 0 && + IsListEmpty(&context->CompletionQueue) && + !context->CompletionDpcActive; + WdfSpinLockRelease(context->BrokerLock); + if (stable) { + break; + } } - // BrokerOperationsDrained covers tracked slots. The second join also - // covers rejected and fast-input URBs, then cancels/joins the reusable - // DPC only after its intrusive request list is empty. - ViiperDrainUrbCompletions(Device); } if (context->BrokerLock != WDF_NO_HANDLE) { @@ -321,10 +345,16 @@ ViiperEvtDeviceSelfManagedIoCleanup( InterlockedExchange(&context->BrokerFaulted, FALSE); WdfSpinLockRelease(context->BrokerLock); } + // ControlQueue has been synchronously purged and all management slots were + // joined above, so no old owner completion can consume a tombstone now. + // Release any owner generation's retained capacity before a possible PnP + // restart of this same controller object. + ViiperRetireManagementTombstonesForOwner(Device, WDF_NO_HANDLE); - // PlugOutAndDelete owns asynchronous UdeCx cleanup. Do not wait here: a - // synchronous wait can deadlock the same PnP/UdeCx worker that must deliver - // the endpoint and device cleanup callbacks. + // Only after every associated endpoint queue and completion owner is + // quiescent may UdecxUsbDevicePlugOutAndDelete consume the child handles. + // Deletion remains asynchronous; never use a consumed device handle or wait + // for child EvtCleanup on this PnP worker. ViiperBeginControllerShutdown(Device); if (context->OwnerLock != WDF_NO_HANDLE) { @@ -461,6 +491,33 @@ ViiperEvtFileCleanup( } } +VOID +ViiperEvtFileClose( + _In_ WDFFILEOBJECT FileObject + ) +{ + VIIPER_UDE_FILE_CONTEXT *fileContext; + + PAGED_CODE(); + fileContext = ViiperGetFileContext(FileObject); + if (InterlockedCompareExchange( + &ViiperGetControllerContext( + WdfFileObjectGetDevice(FileObject))->ShuttingDown, 0, 0) != 0) { + // Terminal self-managed cleanup drained the whole control queue and + // cleared every tombstone while controller children were valid. + return; + } + if (InterlockedCompareExchange(&fileContext->BrokerOwner, 0, 0) == 0) { + return; + } + // Unlike EvtFileCleanup, KMDF invokes EvtFileClose only after all I/O for + // this file object is complete. That is the safe owner-session boundary + // for freeing unconsumed late-ACK tombstones without racing an old + // completion or erasing a successor broker's slots. + ViiperRetireManagementTombstonesForOwner( + WdfFileObjectGetDevice(FileObject), FileObject); +} + NTSTATUS ViiperCreateQueues( _In_ WDFDEVICE Device diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 798ed38f..5d559b6e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -790,6 +790,7 @@ ViiperDestroyVirtualDevice( if (!NT_SUCCESS(status)) { goto ExitAdmission; } + ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED); status = UdecxUsbDevicePlugOutAndDelete(device); if (!NT_SUCCESS(status)) { // PlugOutAndDelete consumes the UDE handle even when it reports a @@ -817,6 +818,7 @@ ViiperDestroyOwnedDevices( for (;;) { UDECXUSBDEVICE device; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + BOOLEAN plugged; ULONGLONG deviceId = 0; ULONG index; @@ -844,7 +846,12 @@ ViiperDestroyOwnedDevices( continue; } deviceContext = ViiperGetDeviceContext(device); - if (deviceContext->Plugged) { + plugged = deviceContext->Plugged; + ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED); + // Completing a held UdeCx management request can make framework + // cleanup runnable once the slot pins are released. Do not access the + // device context after the exact-device management drain. + if (plugged) { if (!NT_SUCCESS(UdecxUsbDevicePlugOutAndDelete(device))) { WdfDeviceSetFailed(Controller, WdfDeviceFailedAttemptRestart); return FALSE; @@ -1076,6 +1083,7 @@ ViiperBeginAcknowledgedDeviceReset( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); NTSTATUS status; + ULONGLONG resetEpoch = 0; // Post-enumeration reset and device-configuration replacement are both // asynchronous UdeCx reset boundaries. Close every client-owned admission @@ -1084,21 +1092,59 @@ ViiperBeginAcknowledgedDeviceReset( // operation; completion then reopens this exact kernel gate. WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { status = STATUS_DEVICE_BUSY; } else { + resetEpoch = (ULONGLONG)InterlockedIncrement64(&deviceContext->ResetEpoch); + if (resetEpoch == 0) { + resetEpoch = (ULONGLONG)InterlockedIncrement64(&deviceContext->ResetEpoch); + } status = STATUS_SUCCESS; } WdfSpinLockRelease(controllerContext->BrokerLock); if (!NT_SUCCESS(status)) { return STATUS_DEVICE_BUSY; } + // Device callbacks are explicitly passive on the UDECXUSBDEVICE object. + // The unresolved asynchronous reset prevents UdeCx from resuming endpoint + // transfers, so a read-only DriverNoRequests sample plus endpoint rundown + // is stable until Request is completed. Join every endpoint before the + // reset is published to the owner; owner acknowledgement repeats this + // exact-generation proof immediately before completion. + if (!ViiperQuiesceResetByIdentity( + deviceContext->Controller, + deviceContext->DeviceId, + deviceContext->Generation, + Device, + WDF_NO_HANDLE, + resetEpoch, + 0, + TRUE, + FALSE)) { + // Removal/purge/fault won after this callback closed Resetting. Release + // only this callback-owned gate under the admission lock; the winning + // ShuttingDown/Purging/BrokerFaulted predicate remains closed. The + // caller owns the UdeCx request and completes the failed boundary. + WdfSpinLockAcquire(controllerContext->BrokerLock); + if ((ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == resetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + return STATUS_DEVICE_NOT_READY; + } ViiperInvalidateDeviceInputReports(Device); status = ViiperQueueAcknowledgedDeviceLifecycleEvent( Device, Request, ViiperUdeOperationDeviceReset); if (!NT_SUCCESS(status)) { - InterlockedExchange(&deviceContext->Resetting, FALSE); + WdfSpinLockAcquire(controllerContext->BrokerLock); + if ((ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == resetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); } return status; } @@ -1698,6 +1744,239 @@ ViiperSubmitInputReport( return status; } +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperWaitForEndpointQuiescence( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ BOOLEAN RequireStopped + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + LARGE_INTEGER retryInterval; + + PAGED_CODE(); + // One millisecond is used only on the cold purge/reset path. The ordinary + // case returns after one event wait and one read-only queue-state sample. + retryInterval.QuadPart = -10 * 1000; + for (;;) { + WDF_IO_QUEUE_STATE queueState; + BOOLEAN quiescent; + BOOLEAN queueQuiescent; + BOOLEAN stopped; + + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + + // UdeCx exclusively owns START/PURGE state for the associated queue. + // Observe, but never mutate, that state. WDF_IO_QUEUE_IDLE proves both + // that no request remains queued and that every request already + // delivered to a callback has completed or been cancelled. Combined + // with the BrokerLock-owned rundown count, this closes the interval in + // which a delivered callback was preempted before it could increment + // ActiveOperations. + WdfSpinLockAcquire(controllerContext->BrokerLock); + queueState = WdfIoQueueGetState(endpointContext->Queue, NULL, NULL); + stopped = (queueState & + (WdfIoQueueAcceptRequests | WdfIoQueueDispatchRequests)) == 0; + // PURGE/removal owns a stopped queue and therefore requires full idle: + // no queued request and no driver-owned request. Endpoint RESET is a + // distinct asynchronous UdeCx contract; the queue may remain ready + // with an unconsumed interrupt poll, but UdeCx cannot resume endpoint + // I/O until its reset Request is completed. For that case, prove only + // that no request is currently delivered to a driver callback. The + // Resetting gate remains closed through a second proof at owner ack. + queueQuiescent = RequireStopped + ? stopped && WDF_IO_QUEUE_IDLE(queueState) + : (queueState & WdfIoQueueDriverNoRequests) != 0; + quiescent = queueQuiescent && + InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0) == 0 && + (!RequireStopped || stopped); + WdfSpinLockRelease(controllerContext->BrokerLock); + if (quiescent) { + return; + } + + // A callback can be between KMDF delivery and its first BrokerLock + // acquisition. It will either enter rundown and re-arm the event or + // finish its terminal DPC and make the queue idle. Avoid spinning while + // that passive callback is scheduled. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} + +VOID +ViiperQuiesceControllerEndpoints( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + ULONG deviceIndex; + + PAGED_CODE(); + // On terminal removal KMDF purges non-power-managed queues before + // EvtDeviceSelfManagedIoCleanup. Hold the shared device index while + // observing every endpoint so EvtEndpointCleanup cannot invalidate a + // handle between lookup and the final queue/rundown proof. ShuttingDown is + // already set, so no direct-input or broker admission can reopen once the + // framework-owned queue is stopped and idle. + ViiperAcquireDeviceLockShared(controllerContext); + for (deviceIndex = 0; deviceIndex < VIIPER_UDE_MAX_DEVICES; ++deviceIndex) { + UDECXUSBDEVICE device = controllerContext->Devices[deviceIndex]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + ULONG endpointIndex; + + if (device == WDF_NO_HANDLE) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + for (endpointIndex = 0; + endpointIndex < RTL_NUMBER_OF(deviceContext->Endpoints); + ++endpointIndex) { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; + + if (endpoint != WDF_NO_HANDLE) { + ViiperWaitForEndpointQuiescence(endpoint, TRUE); + } + } + } + ViiperReleaseDeviceLockShared(controllerContext); +} + +BOOLEAN +ViiperQuiesceResetByIdentity( + _In_ WDFDEVICE Controller, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ UDECXUSBDEVICE ExpectedDevice, + _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONGLONG ExpectedResetEpoch, + _In_ UCHAR EndpointAddress, + _In_ BOOLEAN WholeDevice, + _In_ BOOLEAN ReleaseGate + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(Controller); + BOOLEAN found = FALSE; + ULONG deviceIndex; + + PAGED_CODE(); + // The asynchronous UdeCx reset request keeps the child alive. Retain the + // shared index as an additional cleanup fence while joining any terminal + // callback admitted after the reset event was first published. + ViiperAcquireDeviceLockShared(controllerContext); + for (deviceIndex = 0; deviceIndex < VIIPER_UDE_MAX_DEVICES; ++deviceIndex) { + UDECXUSBDEVICE device = controllerContext->Devices[deviceIndex]; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext; + + if (device == WDF_NO_HANDLE || device != ExpectedDevice) { + continue; + } + deviceContext = ViiperGetDeviceContext(device); + if (deviceContext->DeviceId != DeviceId || + deviceContext->Generation != Generation) { + continue; + } + + if (WholeDevice) { + ULONG endpointIndex; + ULONGLONG currentResetEpoch; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + currentResetEpoch = (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + currentResetEpoch == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0; + if (!found && ReleaseGate && currentResetEpoch == ExpectedResetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!found) { + break; + } + + for (endpointIndex = 0; + endpointIndex < RTL_NUMBER_OF(deviceContext->Endpoints); + ++endpointIndex) { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; + + if (endpoint != WDF_NO_HANDLE) { + ViiperWaitForEndpointQuiescence(endpoint, FALSE); + } + } + // Revalidate the lifecycle gate after every queue/rundown proof. + // BrokerLock is the admission linearization point; clearing here + // cannot target a reused identity because DeviceLock still pins + // this exact table entry and generation. + WdfSpinLockAcquire(controllerContext->BrokerLock); + currentResetEpoch = (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + currentResetEpoch == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0; + if (ReleaseGate && currentResetEpoch == ExpectedResetEpoch) { + InterlockedExchange(&deviceContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } else { + UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[EndpointAddress]; + + if (endpoint != WDF_NO_HANDLE && endpoint == ExpectedEndpoint) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = + ViiperGetEndpointContext(endpoint); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0; + if (!found && ReleaseGate) { + InterlockedExchange(&endpointContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!found) { + break; + } + ViiperWaitForEndpointQuiescence(endpoint, FALSE); + WdfSpinLockAcquire(controllerContext->BrokerLock); + found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + (ULONGLONG)InterlockedCompareExchange64( + &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0; + if (ReleaseGate) { + InterlockedExchange(&endpointContext->Resetting, FALSE); + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } + } + break; + } + ViiperReleaseDeviceLockShared(controllerContext); + return found; +} + VOID ViiperEvtEndpointReset( _In_ UDECXUSBENDPOINT Endpoint, @@ -1718,6 +1997,9 @@ ViiperEvtEndpointReset( InterlockedCompareExchange(&endpointContext->Resetting, TRUE, FALSE) != FALSE) { status = STATUS_DEVICE_BUSY; } else { + InterlockedExchange64( + &endpointContext->ResetDeviceEpoch, + InterlockedCompareExchange64(&deviceContext->ResetEpoch, 0, 0)); status = STATUS_SUCCESS; } WdfSpinLockRelease(controllerContext->BrokerLock); @@ -1745,32 +2027,52 @@ ViiperEvtEndpointResetWorkItem( { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request; NTSTATUS status; + BOOLEAN resetCurrent; PAGED_CODE(); - (VOID)KeWaitForSingleObject( - &endpointContext->OperationsDrained, - Executive, - KernelMode, + resetCurrent = ViiperQuiesceResetByIdentity( + deviceContext->Controller, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + endpoint, + (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->ResetDeviceEpoch, 0, 0), + endpointContext->Descriptor.bEndpointAddress, FALSE, - NULL); - NT_ASSERT(InterlockedCompareExchange( - &endpointContext->ActiveOperations, 0, 0) == 0); - // An input publisher admitted immediately before Resetting was raised is - // allowed to finish, then this barrier performs the final invalidation. - ViiperInvalidateEndpointInputReport(endpoint); + FALSE); + // The unresolved asynchronous reset Request keeps this endpoint unable to + // process transfers. No successor callback can be delivered between the + // read-only queue/rundown proof and publication; a callback delivered just + // before reset closure is included by DriverNoRequests and the terminal + // DPC-owned rundown. Owner acknowledgement repeats the proof. An input + // publisher admitted immediately before Resetting was raised may finish, + // then this barrier performs the final invalidation. request = endpointContext->ResetRequest; endpointContext->ResetRequest = WDF_NO_HANDLE; - if (InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0) { + if (!resetCurrent) { + // A device reset may have won after this endpoint reset closed its own + // gate. Release only the endpoint-reset gate; the device reset, purge, + // shutdown, or broker-fault predicate independently keeps admission + // closed until its owner finishes recovery. + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); WdfRequestComplete(request, STATUS_DEVICE_NOT_READY); return; } + ViiperInvalidateEndpointInputReport(endpoint); status = ViiperQueueAcknowledgedEndpointLifecycleEvent( endpoint, request, ViiperUdeOperationEndpointReset); if (!NT_SUCCESS(status)) { + WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Resetting, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); WdfRequestComplete(request, status); } } @@ -1787,14 +2089,7 @@ ViiperEvtEndpointPurgeWorkItem( // UdeCx requires every request forwarded out of the endpoint queue to be // completed before PurgeComplete. The shared completion DPC releases both // broker and direct-input ownership only after the terminal UdeCx call. - (VOID)KeWaitForSingleObject( - &endpointContext->OperationsDrained, - Executive, - KernelMode, - FALSE, - NULL); - NT_ASSERT(InterlockedCompareExchange( - &endpointContext->ActiveOperations, 0, 0) == 0); + ViiperWaitForEndpointQuiescence(endpoint, TRUE); // The admission barrier is closed and all pre-boundary publishers have // drained, so no cached state can be republished after this clear. ViiperInvalidateEndpointInputReport(endpoint); diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 445511e7..6103655f 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -66,11 +66,28 @@ typedef struct VIIPER_UDE_NOTIFICATION { typedef struct VIIPER_UDE_MANAGEMENT_SLOT { WDFREQUEST Request; + // Private framework-object pins, never exposed on the broker ABI. They + // prevent a deleted WDF handle value from being recycled while a delayed + // lifecycle acknowledgement still names this slot. Callers compare these + // only with handles in the live DeviceLock-protected table; they never + // access an object's context after its cleanup callback. + UDECXUSBDEVICE Device; + UDECXUSBENDPOINT Endpoint; + WDFFILEOBJECT OwnerFile; ULONGLONG Token; + // One harmless success tombstone for an operation already delivered to + // user mode when kernel teardown completed its held UdeCx request. This + // never retains WDF objects and is consumed by the first late ACK. + ULONGLONG RetiredToken; + ULONGLONG RetiredDeviceId; + WDFFILEOBJECT RetiredOwnerFile; ULONGLONG DeviceId; + ULONGLONG ResetEpoch; ULONG Generation; ULONG DeviceGeneration; + ULONG RetiredDeviceGeneration; VIIPER_UDE_PENDING_STATE State; + BOOLEAN RetiredNotificationPending; ULONG Kind; UCHAR EndpointAddress; } VIIPER_UDE_MANAGEMENT_SLOT; @@ -232,6 +249,7 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { BOOLEAN Plugged; volatile LONG InD0; volatile LONG Resetting; + volatile LONG64 ResetEpoch; volatile LONG Purging; volatile LONG ActiveCounted; volatile LONG OwnerReferenced; @@ -257,6 +275,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; volatile LONG Resetting; + volatile LONG64 ResetDeviceEpoch; volatile LONG ActiveOperations; volatile LONG64 LastInputSequence; volatile LONG64 NextIsoStartFrame; @@ -283,6 +302,7 @@ EVT_WDF_DEVICE_SELF_MANAGED_IO_INIT ViiperEvtDeviceSelfManagedIoInit; EVT_WDF_DEVICE_SELF_MANAGED_IO_CLEANUP ViiperEvtDeviceSelfManagedIoCleanup; EVT_WDF_DEVICE_FILE_CREATE ViiperEvtFileCreate; EVT_WDF_FILE_CLEANUP ViiperEvtFileCleanup; +EVT_WDF_FILE_CLOSE ViiperEvtFileClose; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControlRoute; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; @@ -311,6 +331,17 @@ NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); BOOLEAN ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); +VOID ViiperQuiesceControllerEndpoints(_In_ WDFDEVICE Controller); +BOOLEAN ViiperQuiesceResetByIdentity( + _In_ WDFDEVICE Controller, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_ UDECXUSBDEVICE ExpectedDevice, + _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONGLONG ExpectedResetEpoch, + _In_ UCHAR EndpointAddress, + _In_ BOOLEAN WholeDevice, + _In_ BOOLEAN ReleaseGate); VOID ViiperBeginControllerShutdown(_In_ WDFDEVICE Controller); NTSTATUS ViiperQueueDequeueOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperCompleteOperation(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); @@ -341,6 +372,13 @@ NTSTATUS ViiperCopyTransferBuffer( _In_ ULONG Length, _In_ BOOLEAN ToUrb); VOID ViiperPurgeEndpointOperations(_In_ UDECXUSBENDPOINT Endpoint, _In_ NTSTATUS Status); +VOID ViiperAbortDeviceManagementOperations( + _In_ WDFDEVICE Controller, + _In_ UDECXUSBDEVICE Device, + _In_ NTSTATUS Status); +VOID ViiperRetireManagementTombstonesForOwner( + _In_ WDFDEVICE Controller, + _In_opt_ WDFFILEOBJECT OwnerFile); _IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); _IRQL_requires_max_(DISPATCH_LEVEL) diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 89b15bf7..8fbec08c 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -273,21 +273,96 @@ $queueUrbMatch = [regex]::Match( '(?ms)^NTSTATUS\s+ViiperQueueUrb\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $queueUrbMatch.Success -or $queueUrbMatch.Groups['body'].Value -notmatch - 'ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*ViiperAllocatePendingSlot' -or + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*ViiperEndpointOperationStarted\s*\(\s*endpoint\s*\)[\s\S]*controllerContext->ShuttingDown[\s\S]*controllerContext->BrokerFaulted[\s\S]*deviceContext->InD0[\s\S]*deviceContext->Resetting[\s\S]*deviceContext->Purging[\s\S]*endpointContext->Resetting[\s\S]*endpointContext->Purging[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*ViiperAllocatePendingSlot' -or $queueUrbMatch.Groups['body'].Value -notmatch 'queueCancelledCompletion[\s\S]*ViiperUdePendingDpcCompletion[\s\S]*ViiperQueueUrbCompletion') { - throw 'URB admission must enter endpoint rundown before rejection and route mark-cancel races through the DPC.' + throw 'URB admission must combine rundown and lifecycle closure under BrokerLock, then route every rejection/cancel through the DPC.' +} +$endpointQuiescenceMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperWaitForEndpointQuiescence\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointQuiescenceMatch.Success -or + $endpointQuiescenceMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfIoQueueGetState\s*\(\s*endpointContext->Queue[\s\S]*WDF_IO_QUEUE_IDLE[\s\S]*WdfIoQueueDriverNoRequests[\s\S]*endpointContext->ActiveOperations[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*KeDelayExecutionThread') { + throw 'Endpoint quiescence must pair read-only WDF queue ownership with BrokerLock-owned rundown and a passive retry.' } $purgeWorkItemMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperEvtEndpointPurgeWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $purgeWorkItemMatch.Success -or $purgeWorkItemMatch.Groups['body'].Value -notmatch - 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*UdecxUsbEndpointPurgeComplete') { - throw 'Endpoint purge-complete must remain behind the forwarded-URB completion drain.' + 'ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*,\s*TRUE\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete') { + throw 'Endpoint purge-complete must remain behind stopped+idle WDF queue and forwarded-URB rundown proof.' +} +$resetWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointResetWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $resetWorkItemMatch.Success -or + $resetWorkItemMatch.Groups['body'].Value -notmatch + 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetCurrent\s*\)[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Resetting\s*,\s*FALSE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperQueueAcknowledgedEndpointLifecycleEvent') { + throw 'Endpoint reset publication must prove a live exact identity after DriverNoRequests/rundown and fail closed on removal.' +} +foreach ($associatedQueueMutation in @( + 'WdfIoQueuePurge', + 'WdfIoQueuePurgeSynchronously', + 'WdfIoQueueStart', + 'WdfIoQueueStop', + 'WdfIoQueueStopSynchronously', + 'WdfIoQueueDrain', + 'WdfIoQueueDrainSynchronously')) { + if ($deviceSource -match ([regex]::Escape($associatedQueueMutation) + '\s*\(')) { + throw "UdeCx owns associated endpoint queue state; Device.c must not call $associatedQueueMutation." + } +} +$resetIdentityMatch = [regex]::Match( + $deviceSource, + '(?ms)^BOOLEAN\s+ViiperQuiesceResetByIdentity\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $resetIdentityMatch.Success -or + $resetIdentityMatch.Groups['body'].Value -notmatch + 'ViiperAcquireDeviceLockShared[\s\S]*device\s*!=\s*ExpectedDevice[\s\S]*DeviceId[\s\S]*Generation[\s\S]*ResetEpoch[\s\S]*ExpectedResetEpoch[\s\S]*Endpoints\[EndpointAddress\][\s\S]*endpoint\s*==\s*ExpectedEndpoint[\s\S]*ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*,\s*FALSE\s*\)[\s\S]*if\s*\(\s*ReleaseGate\s*\)[\s\S]*endpointContext->Resetting[\s\S]*ViiperReleaseDeviceLockShared') { + throw 'Reset acknowledgement must prove and release only an exact pinned device/endpoint generation and reset epoch.' } -if ($deviceSource -match 'WdfIoQueue(?:Purge|Start)\s*\(') { - throw 'UdeCx owns the associated endpoint queue state; the client must only drain its forwarded paths.' +$deviceResetAdmissionMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperBeginAcknowledgedDeviceReset\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointResetAdmissionMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointReset\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $deviceResetAdmissionMatch.Success -or + $deviceResetAdmissionMatch.Groups['body'].Value -notmatch + 'BrokerFaulted[\s\S]*InterlockedCompareExchange\s*\(\s*&deviceContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*status\s*=\s*STATUS_DEVICE_BUSY[\s\S]*else[\s\S]*InterlockedIncrement64\s*\(\s*&deviceContext->ResetEpoch\s*\)[\s\S]*ViiperQuiesceResetByIdentity' -or + -not $endpointResetAdmissionMatch.Success -or + $endpointResetAdmissionMatch.Groups['body'].Value -notmatch + 'InterlockedCompareExchange\s*\(\s*&endpointContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*else[\s\S]*ResetDeviceEpoch[\s\S]*deviceContext->ResetEpoch') { + throw 'Device reset must advance its private epoch only after admission, and endpoint reset must capture that epoch atomically.' +} +$managementSlotPinMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperQueueAcknowledgedLifecycleEvent\s*\([^)]*\)\s*\{(?.*?)^\}') +$managementSlotClearMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperClearManagementSlotLocked\s*\([^)]*\)\s*\{(?.*?)^\}') +$managementSlotReleaseMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperReleaseManagementSlotReferences\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $managementSlotPinMatch.Success -or + $managementSlotPinMatch.Groups['body'].Value -notmatch + 'WdfObjectReference\s*\(\s*Device\s*\)[\s\S]*WdfObjectReference\s*\(\s*Endpoint\s*\)[\s\S]*ViiperUdeOperationEndpointReset[\s\S]*deviceContext->ResetEpoch[\s\S]*endpointContext->ResetDeviceEpoch[\s\S]*pending->Device\s*=\s*Device[\s\S]*pending->Endpoint\s*=\s*Endpoint[\s\S]*pending->ResetEpoch[\s\S]*WdfSpinLockRelease[\s\S]*ViiperReleaseManagementSlotReferences' -or + -not $managementSlotClearMatch.Success -or + $managementSlotClearMatch.Groups['body'].Value -notmatch + '\*DeviceReference\s*=\s*pending->Device[\s\S]*\*EndpointReference\s*=\s*pending->Endpoint[\s\S]*pending->Device\s*=\s*WDF_NO_HANDLE[\s\S]*pending->Endpoint\s*=\s*WDF_NO_HANDLE' -or + -not $managementSlotReleaseMatch.Success -or + $managementSlotReleaseMatch.Groups['body'].Value -notmatch + 'WdfObjectDereference\s*\(\s*Endpoint\s*\)[\s\S]*WdfObjectDereference\s*\(\s*Device\s*\)') { + throw 'Management reset identities must pin exact WDF objects and release every pin outside BrokerLock.' +} +$managementCompletionMatch = [regex]::Match( + $brokerSource, + '(?ms)^ViiperCompleteManagementOperation\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $managementCompletionMatch.Success -or + $managementCompletionMatch.Groups['body'].Value -notmatch + 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetReleased\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperClearManagementSlotLocked[\s\S]*return\s+STATUS_DEVICE_NOT_READY[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*\(NTSTATUS\)Completion->Status\s*\)') { + throw 'Reset acknowledgement must fail closed on removal or identity reuse before applying owner status.' } $completionDrainMatch = [regex]::Match( $brokerSource, @@ -324,15 +399,15 @@ $selfManagedCleanupMatch = [regex]::Match( '(?ms)^VOID\s+ViiperEvtDeviceSelfManagedIoCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $selfManagedCleanupMatch.Success -or $selfManagedCleanupMatch.Groups['body'].Value -notmatch - 'ViiperPurgeOwnerOperations[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*ViiperBeginControllerShutdown') { - throw 'Terminal rundown must drain and join all completion-DPC ownership before asynchronous child teardown.' + 'ViiperPurgeOwnerOperations[\s\S]*ViiperQuiesceControllerEndpoints[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*PendingOperations[\s\S]*PendingCompletions[\s\S]*CompletionQueue[\s\S]*CompletionDpcActive[\s\S]*ViiperBeginControllerShutdown') { + throw 'Terminal rundown must prove every UdeCx endpoint queue stopped+idle, join completion-DPC ownership, then consume children.' } $controllerShutdownMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperBeginControllerShutdown\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $controllerShutdownMatch.Success -or - $controllerShutdownMatch.Groups['body'].Value -match 'KeWaitForSingleObject') { - throw 'UdeCx child teardown must remain asynchronous and must not synchronously await child cleanup.' + $controllerShutdownMatch.Groups['body'].Value -match 'KeWaitForSingleObject|WdfIoQueueGetState') { + throw 'UdeCx child teardown must remain asynchronous and must not use endpoint queues after the pre-consumption proof.' } $stampState = if ($RequireStampedInf) { 'stamped output' } else { 'source template' } From 67325921c33f5309c4a5a57ba70ad43aa07c1f3c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 07:47:09 -0500 Subject: [PATCH 173/240] test(udecx): assert pre-drain device snapshot --- internal/transport/udecx/driver_lifecycle_contract_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index 3dfe2d7a..06f136d9 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -240,7 +240,9 @@ func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) requireContractOrder(t, destroyOwned, "deviceContext = ViiperGetDeviceContext(device);", - "if (deviceContext->Plugged)", + "plugged = deviceContext->Plugged;", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "if (plugged)", "UdecxUsbDevicePlugOutAndDelete(device)", "return FALSE;") assertNoConsumedHandleUse(t, destroyOwned, "UdecxUsbDevicePlugOutAndDelete(device)", "} else {") From ef2293860826e6d79e34a711e07130e31e5ea6f3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 07:48:07 -0500 Subject: [PATCH 174/240] feat(native): bind broker to loaded driver identity --- .github/scripts/Test-WorkflowSecurity.ps1 | 16 ++ .github/workflows/build_base.yml | 1 + .github/workflows/native-ude.yml | 7 + docs/api/overview.md | 10 +- .../native-udecx-package-install.md | 6 +- docs/architecture/native-udecx-signing.md | 8 +- internal/cmd/native_package.go | 6 +- .../cmd/native_service_install_windows.go | 41 +++-- .../native_service_install_windows_test.go | 59 ++++++- internal/cmd/native_transport_test.go | 6 +- internal/cmd/native_transport_windows.go | 1 + internal/server/api/handler/ping_test.go | 6 +- .../server/usb/native_live_windows_test.go | 12 +- internal/transport/udecx/client_windows.go | 36 ++++- .../transport/udecx/client_windows_test.go | 37 ++++- .../udecx/live_validation_contract_test.go | 3 + internal/transport/udecx/protocol.go | 85 +++++++++- .../transport/udecx/protocol_contract_test.go | 9 ++ internal/transport/udecx/protocol_test.go | 74 +++++++++ .../transport/udecx/release_contract_test.go | 60 +++++++ justfile | 8 +- native/udecx/README.md | 18 ++- native/udecx/driver/Ioctl.c | 7 +- native/udecx/driver/ViiperUde.vcxproj | 9 +- native/udecx/include/ViiperUdeProtocol.h | 20 ++- native/udecx/package/ViiperUde.inf | 2 +- .../Enable-ViiperUdeVerifierForNextBoot.ps1 | 2 +- .../tools/Get-ViiperUdeBuildIdentity.ps1 | 65 ++++++++ .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 5 +- .../Invoke-ViiperUdePerformanceValidation.ps1 | 2 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 27 +++- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 4 + .../tools/Test-ViiperUdeReleaseBundle.ps1 | 22 ++- .../tools/Test-ViiperUdeSignedPackage.ps1 | 25 ++- native/udecx/tools/ViiperUdeCtl.cpp | 152 +++++++++++++++--- viipertypes/structs.go | 14 +- 36 files changed, 759 insertions(+), 106 deletions(-) create mode 100644 native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index a25dcebb..49199810 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -145,6 +145,9 @@ if ($nativeWorkflow -notmatch '(?m)^\s*if:\s*\$\{\{\s*inputs\.upload_artifacts\s foreach ($requiredNativeGate in @( 'branches: [main, "feature/**"]', 'tags: ["v*.*.*"]', + 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', + 'Get-ViiperUdeBuildIdentity.ps1', + 'efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true')) { @@ -153,6 +156,19 @@ foreach ($requiredNativeGate in @( } } +$baseBuildWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'build_base.yml') -Raw +if (-not $baseBuildWorkflow.Contains('VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}')) { + throw 'Production broker builds must inject the exact workflow source SHA.' +} +$justfile = Get-Content -LiteralPath (Join-Path $repositoryRoot 'justfile') -Raw +foreach ($requiredBuildIdentityGate in @( + 'Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION.', + 'internal/transport/udecx.nativeSourceRevision=')) { + if (-not $justfile.Contains($requiredBuildIdentityGate)) { + throw "The release broker build is missing identity gate '$requiredBuildIdentityGate'." + } +} + $transactionWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'native-package-transaction.yml') -Raw foreach ($requiredTransactionTrigger in @( 'branches: [main, "feature/**"]', diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index c7b75022..a86306f8 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -17,6 +17,7 @@ on: env: GOFLAGS: -mod=readonly GOTOOLCHAIN: local + VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }} jobs: test: diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index ecdb9a5b..0111939e 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -71,6 +71,7 @@ permissions: env: GOFLAGS: -mod=readonly GOTOOLCHAIN: local + VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }} # A driver artifact is meaningful only for the exact current branch head. # Cancel superseded WDK/CodeQL work instead of letting several incompatible @@ -94,6 +95,12 @@ jobs: shell: pwsh run: | ./.github/scripts/Test-WorkflowSecurity.ps1 + $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` + -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -DriverPackageVersion 0.1.0.4 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + if ($identity -cne 'efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7') { + throw "Native build-identity generator drifted: $identity" + } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] $actual = (go env GOVERSION).TrimStart('g', 'o') if ($actual -cne $expected) { throw "Expected Go $expected; runner selected $actual." } diff --git a/docs/api/overview.md b/docs/api/overview.md index a93f9593..df4ed96f 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -178,8 +178,9 @@ kept matched. The packaged server also reports its active transport and readiness. Native UDE mode includes the exact negotiated ABI, capability mask, package - version expected by the service, and negotiated limits. Clients opting in - to the native backend should fail closed unless these fields match their + version expected by the service, the source-bound identity returned by the + currently loaded kernel image, and negotiated limits. Clients opting in to + the native backend should fail closed unless these fields match their required contract: ```json @@ -190,9 +191,10 @@ kept matched. "ready": true, "nativeUde": { "abiMajor": 1, - "abiMinor": 8, + "abiMinor": 9, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.0", + "expectedDriverPackageVersion": "0.1.0.4", + "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, "maxTransferBytes": 1048576, diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index 0c65efbe..c28e0f99 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -19,7 +19,7 @@ The signed bootstrapper supplies all of the following as immutable build data: - the exact VIIPER broker, `ViiperUdeCtl.exe`, and reviewed production-manifest SHA-256 values; -- the reviewed 40-64 hexadecimal source revision; +- the reviewed exact 40- or 64-hexadecimal source revision; - the runtime driver directory containing only the Microsoft-returned INF, SYS, and CAT, plus the source-bound HLK/WHCP manifest; and - the target interactive-user SID whose legacy startup ownership may be @@ -73,7 +73,9 @@ of the source-provenance evidence without becoming a user-machine dependency. broker service transaction. 5. The broker transaction creates or updates the LocalSystem service, rotates its protected credential, starts it, and requires authenticated `ping` - identity, `Ready=true`, ABI 1.8, and the exact negotiated capability mask. + identity, `Ready=true`, ABI 1.9, the exact negotiated capability mask, and + the source-bound build identity returned by the currently loaded kernel + image. A well-formed identity from a stale same-ABI driver fails readiness. Only then does it disable legacy Run/task/process ownership, and it authenticates again before returning success. 6. The helper commits the driver only after that broker proof. A broker failure diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 9b0e7a5c..ad767b5a 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -58,7 +58,10 @@ mode. That mode rejects the attestation EKU and requires a release-eligible `ViiperUde.pdb`, and `ViiperUde.cat` selected by explicit path. - The INF targets only `ROOT\VIIPER\UDE`, copies only `ViiperUde.sys`, and names only `ViiperUde.cat`. -- The build and submission hash manifests identify the exact reviewed bits. +- The schema-2 submission manifest identifies the exact reviewed bits and the + SHA-256 build identity derived from source revision, four-part DriverVer, + ABI 1.9, and the exact capability mask. That same identity is compiled into + the SYS that the signed catalog seals and is returned by the loaded kernel. - Returned packages contain only the canonical INF, SYS, PDB, and CAT in one directory. The unchanged INF/PDB must match the submission manifest, and SignTool must prove INF/SYS membership in the returned Microsoft catalog. @@ -157,6 +160,9 @@ the HLK/DevFund matrix. - [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) - [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) - [Components of a driver package](https://learn.microsoft.com/windows-hardware/drivers/install/components-of-a-driver-package) +- [Catalog files](https://learn.microsoft.com/windows-hardware/drivers/install/catalog-files) +- [Release-signing a driver package catalog](https://learn.microsoft.com/windows-hardware/drivers/install/release-signing-a-driver-package-s-catalog-file) +- [INF Version section](https://learn.microsoft.com/windows-hardware/drivers/install/inf-version-section) - [SignTool command-line reference](https://learn.microsoft.com/windows-hardware/drivers/devtest/signtool) - [Windows Hardware Lab Kit](https://learn.microsoft.com/windows-hardware/test/hlk/) - [Add driver and supplemental content to an HLK package](https://learn.microsoft.com/windows-hardware/test/hlk/user/add-driver-and-supplemental-content-to-your-package) diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index 3e67effe..a089a682 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -11,7 +11,7 @@ import ( "time" ) -var nativePackageHexRevision = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`) +var nativePackageHexRevision = regexp.MustCompile(`^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`) var nativePackageSHA256 = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) const ( @@ -42,7 +42,7 @@ func (e *nativePackageRebootRequiredError) ExitCode() int { type NativePackageInstall struct { PackageDirectory string `help:"Directory containing the four Microsoft-returned VIIPER UDE files." required:""` SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` - SourceRevision string `help:"Reviewed 40-64 character source revision." required:""` + SourceRevision string `help:"Reviewed 40- or 64-character source revision." required:""` DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` @@ -121,7 +121,7 @@ func (r nativePackageRequest) validate() error { } } if !nativePackageHexRevision.MatchString(r.sourceRevision) { - return errors.New("native package source revision must contain 40-64 hexadecimal characters") + return errors.New("native package source revision must contain exactly 40 or 64 hexadecimal characters") } if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index f3ae3847..6d837c76 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -4,7 +4,9 @@ package cmd import ( "context" + "crypto/subtle" "encoding/base64" + "encoding/hex" "encoding/json" "encoding/xml" "errors" @@ -1221,6 +1223,17 @@ func verifyNativeBroker(ctx context.Context, password string) error { } func validateNativeBrokerPing(response *viipertypes.PingResponse) error { + expected, err := udecx.ExpectedBuildIdentity() + if err != nil { + return fmt.Errorf("derive expected native loaded-driver identity: %w", err) + } + return validateNativeBrokerPingAgainstIdentity(response, expected) +} + +func validateNativeBrokerPingAgainstIdentity( + response *viipertypes.PingResponse, + expected [udecx.BuildIdentitySize]byte, +) error { if response == nil { return errors.New("empty ping response") } @@ -1234,11 +1247,7 @@ func validateNativeBrokerPing(response *viipertypes.PingResponse) error { return errors.New("native broker omitted its negotiated driver contract") } native := response.NativeUDE - requiredCapabilities := uint32( - udecx.CapabilityIsochronous | - udecx.CapabilityDeviceLifecycle | - udecx.CapabilityInputReports, - ) + requiredCapabilities := uint32(udecx.AdvertisedCapabilities) if native.ABIMajor != udecx.ABIMajor || native.ABIMinor != udecx.ABIMinor { return fmt.Errorf("native broker ABI=%d.%d expected=%d.%d", native.ABIMajor, native.ABIMinor, udecx.ABIMajor, udecx.ABIMinor) @@ -1246,10 +1255,24 @@ func validateNativeBrokerPing(response *viipertypes.PingResponse) error { if native.Capabilities != requiredCapabilities { return fmt.Errorf("native broker capabilities=%#x expected exact=%#x", native.Capabilities, requiredCapabilities) } - // ExpectedDriverPackageVersion is currently broker compile-time metadata, - // not an attestation read from the installed driver. ABI and negotiated - // capabilities are authoritative here; do not misrepresent that echoed - // constant as installed-package verification. + if native.ExpectedDriverPackageVersion != udecx.DriverPackageVersion { + return fmt.Errorf("native broker package version=%q expected=%q", + native.ExpectedDriverPackageVersion, udecx.DriverPackageVersion) + } + if len(native.LoadedDriverBuildIdentity) != 64 { + return errors.New("native broker omitted the negotiated loaded-driver build identity") + } + loaded, err := hex.DecodeString(native.LoadedDriverBuildIdentity) + if err != nil || + native.LoadedDriverBuildIdentity != strings.ToLower(native.LoadedDriverBuildIdentity) { + return errors.New("native broker returned a malformed loaded-driver build identity") + } + if subtle.ConstantTimeCompare(loaded, expected[:]) != 1 { + return fmt.Errorf( + "native broker loaded-driver build identity=%s expected=%s", + native.LoadedDriverBuildIdentity, udecx.BuildIdentityHex(expected), + ) + } return nil } diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go index 616f659c..308f5d0a 100644 --- a/internal/cmd/native_service_install_windows_test.go +++ b/internal/cmd/native_service_install_windows_test.go @@ -1139,22 +1139,42 @@ func TestNativeUninstallRejectsPausedServiceBeforeMutation(t *testing.T) { } func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { + expected, err := udecx.DeriveBuildIdentity( + strings.Repeat("a", 40), udecx.DriverPackageVersion, + udecx.ABIMajor, udecx.ABIMinor, udecx.AdvertisedCapabilities, + ) + if err != nil { + t.Fatal(err) + } ready := true valid := &viipertypes.PingResponse{ Server: "VIIPER", Transport: "native-ude", Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, - Capabilities: uint32(udecx.CapabilityIsochronous | udecx.CapabilityDeviceLifecycle | udecx.CapabilityInputReports), + Capabilities: uint32(udecx.AdvertisedCapabilities), ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), }, } - if err := validateNativeBrokerPing(valid); err != nil { + if err := validateNativeBrokerPingAgainstIdentity(valid, expected); err != nil { t.Fatal(err) } cases := map[string]func(*viipertypes.PingResponse){ "not ready": func(p *viipertypes.PingResponse) { value := false; p.Ready = &value }, "wrong ABI": func(p *viipertypes.PingResponse) { p.NativeUDE.ABIMinor++ }, "extra caps": func(p *viipertypes.PingResponse) { p.NativeUDE.Capabilities |= uint32(udecx.CapabilityStreams) }, + "wrong package version": func(p *viipertypes.PingResponse) { + p.NativeUDE.ExpectedDriverPackageVersion = "0.1.0.3" + }, + "missing loaded identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = "" + }, + "malformed loaded identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("z", 64) + }, + "stale loaded identity with matching ABI and caps": func(p *viipertypes.PingResponse) { + p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("0", 64) + }, } for name, mutate := range cases { t.Run(name, func(t *testing.T) { @@ -1162,13 +1182,46 @@ func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { copyNative := *valid.NativeUDE copyResponse.NativeUDE = ©Native mutate(©Response) - if err := validateNativeBrokerPing(©Response); err == nil { + if err := validateNativeBrokerPingAgainstIdentity(©Response, expected); err == nil { t.Fatal("expected exact-contract rejection") } }) } } +func TestValidateNativeBrokerPingFailsClosedWithoutBuildInjection(t *testing.T) { + if _, err := udecx.ExpectedBuildIdentity(); err == nil { + t.Skip("test binary has an explicitly injected native source revision") + } + if err := validateNativeBrokerPing(nil); !errors.Is(err, udecx.ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } +} + +func TestValidateNativeBrokerPingUsesInjectedBuildIdentity(t *testing.T) { + expected, err := udecx.ExpectedBuildIdentity() + if err != nil { + t.Skip("test binary has no injected native source revision") + } + ready := true + response := &viipertypes.PingResponse{ + Server: "VIIPER", Transport: "native-ude", Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, + Capabilities: uint32(udecx.AdvertisedCapabilities), + ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), + }, + } + if err := validateNativeBrokerPing(response); err != nil { + t.Fatal(err) + } + response.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("0", 64) + if err := validateNativeBrokerPing(response); err == nil { + t.Fatal("authenticated readiness accepted a stale same-ABI/capability loaded kernel") + } +} + func TestCredentialACLUsesSIDsRatherThanLocalizedAccountNames(t *testing.T) { const userSID = "S-1-5-21-1-2-3-1001" for _, sddl := range []string{nativeCredentialDirectorySDDL(userSID), nativeCredentialFileSDDL(userSID)} { diff --git a/internal/cmd/native_transport_test.go b/internal/cmd/native_transport_test.go index 777a2f7a..3caeb77b 100644 --- a/internal/cmd/native_transport_test.go +++ b/internal/cmd/native_transport_test.go @@ -54,16 +54,16 @@ func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { func TestNativeUDETransportStatusIsSnapshot(t *testing.T) { session := &nativeUDETransportSession{} session.info.ABIMajor = 1 - session.info.ABIMinor = 8 + session.info.ABIMinor = 9 session.ready.Store(true) ready, first := session.Status() - if !ready || first.ABIMajor != 1 || first.ABIMinor != 8 { + if !ready || first.ABIMajor != 1 || first.ABIMinor != 9 { t.Fatalf("unexpected native status: ready=%v info=%+v", ready, first) } first.ABIMinor = 99 _, second := session.Status() - if second.ABIMinor != 8 { + if second.ABIMinor != 9 { t.Fatal("Status exposed mutable session state") } } diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go index 5263a353..13830bae 100644 --- a/internal/cmd/native_transport_windows.go +++ b/internal/cmd/native_transport_windows.go @@ -37,6 +37,7 @@ func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nat ABIMajor: udecx.ABIMajor, ABIMinor: udecx.ABIMinor, Capabilities: uint32(client.Capabilities()), ExpectedDriverPackageVersion: udecx.DriverPackageVersion, + LoadedDriverBuildIdentity: udecx.BuildIdentityHex(client.BuildIdentity()), MaxDevices: limits.MaxDevices, MaxDescriptorBytes: limits.MaxDescriptorBytes, MaxTransferBytes: limits.MaxTransferBytes, MaxIsoPackets: limits.MaxIsoPackets, MaxPendingOperations: limits.MaxPendingOperations, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index a0817639..68ee1306 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -2,6 +2,7 @@ package handler_test import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -36,8 +37,9 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ABIMinor: 8, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.0", + ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, + ExpectedDriverPackageVersion: "0.1.0.4", + LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, MaxPendingOperations: 4096, diff --git a/internal/server/usb/native_live_windows_test.go b/internal/server/usb/native_live_windows_test.go index c6c38858..a7c6a2fc 100644 --- a/internal/server/usb/native_live_windows_test.go +++ b/internal/server/usb/native_live_windows_test.go @@ -639,11 +639,13 @@ func runLiveInputLatencyProbe( // TestNativeUDELiveProductionControllers is deliberately inert in normal CI. // It opens an already-installed native controller and never installs, updates, // enables, or removes a kernel driver. Release validation must first verify the -// package's Microsoft kernel-policy signature, then opt in on a disposable test -// machine with VIIPER_UDE_LIVE=1. +// package's Microsoft kernel-policy signature, then invoke the signed-package +// PowerShell gate on a disposable test machine. That gate sets +// VIIPER_UDE_LIVE=1 and links this test binary to the exact reviewed source +// identity; setting the environment variable alone is intentionally insufficient. func TestNativeUDELiveProductionControllers(t *testing.T) { if os.Getenv(liveNativeTestEnvironment) != "1" { - t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", liveNativeTestEnvironment) } @@ -1059,7 +1061,7 @@ func TestNativeUDELiveProductionControllers(t *testing.T) { // after the driver has removed its children and drained forwarded URBs. func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { if os.Getenv(liveNativeTestEnvironment) != "1" { - t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", liveNativeTestEnvironment) } if os.Getenv(liveNativeCrashChild) == "1" { @@ -1194,7 +1196,7 @@ func TestNativeUDELiveOwnerCrashRecovery(t *testing.T) { // session to enumerate and service input without stale kernel state. func TestNativeUDELiveRootRestartRecovery(t *testing.T) { if os.Getenv(liveNativeTestEnvironment) != "1" { - t.Skipf("set %s=1 after installing a verified Microsoft-signed native UDE package", + t.Skipf("invoke the signed-package validation gate (which sets %s=1 and injects its source identity)", liveNativeTestEnvironment) } instanceID := os.Getenv(liveNativeRestartInstance) diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index ae4d5b51..1af8b2c8 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -5,6 +5,7 @@ package udecx import ( "context" "crypto/rand" + "crypto/subtle" "encoding/binary" "errors" "fmt" @@ -39,7 +40,7 @@ const ( ioctlSubmitInputReport = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 6) << 2) | methodInDirect completionPortCloseKey uintptr = ^uintptr(0) fileSkipCompletionPortOnSuccess byte = 0x1 - requiredCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports + requiredCapabilities = AdvertisedCapabilities // The kernel rechecks asynchronous UdeCx owner cleanup every 100 ms. Match // that cadence for at most 1.9 seconds, rediscovering the interface before // every exclusive CreateFile rather than spinning on a stale symbolic link. @@ -134,6 +135,7 @@ type Client struct { // cancellation or lifecycle I/O. skipCompletionPortOnSuccess bool driverNonce uint64 + buildIdentity [BuildIdentitySize]byte capabilities Capabilities limits NegotiateResponse // pendingObserver is a package-private synchronization seam for the @@ -507,9 +509,22 @@ func (c *Client) Limits() NegotiateResponse { return c.limits } +// BuildIdentity is the identity returned by the currently loaded kernel +// image and accepted during this client's negotiation. It is not inferred +// from an on-disk driver path or copied from broker build metadata. +func (c *Client) BuildIdentity() [BuildIdentitySize]byte { + c.mu.RLock() + defer c.mu.RUnlock() + return c.buildIdentity +} + func (c *Client) negotiate(ctx context.Context) error { + expectedBuildIdentity, err := ExpectedBuildIdentity() + if err != nil { + return fmt.Errorf("prepare native UDE negotiation: %w", err) + } var nonceBytes [8]byte - if _, err := rand.Read(nonceBytes[:]); err != nil { + if _, err = rand.Read(nonceBytes[:]); err != nil { return fmt.Errorf("create native UDE session nonce: %w", err) } nonce := binary.LittleEndian.Uint64(nonceBytes[:]) @@ -535,17 +550,18 @@ func (c *Client) negotiate(ctx context.Context) error { if err != nil { return fmt.Errorf("validate native UDE negotiation: %w", err) } - if err := validateNegotiation(negotiated, nonce); err != nil { + if err := validateNegotiation(negotiated, nonce, expectedBuildIdentity); err != nil { return err } c.driverNonce = negotiated.DriverNonce c.capabilities = negotiated.Capabilities + c.buildIdentity = negotiated.BuildIdentity c.limits = negotiated return nil } func normalizeNegotiationError(err error) error { - // ABI 1.8 is the first driver that reports ERROR_REVISION_MISMATCH. Older + // ABI 1.8 was the first driver that reported ERROR_REVISION_MISMATCH. Older // native previews reject this service's otherwise internally generated, // fixed negotiation request as ERROR_INVALID_PARAMETER. A future fixed // request-size change can surface as either length error before the driver @@ -563,14 +579,20 @@ func normalizeNegotiationError(err error) error { return fmt.Errorf("negotiate native UDE ABI: %w", err) } -func validateNegotiation(negotiated NegotiateResponse, nonce uint64) error { +func validateNegotiation(negotiated NegotiateResponse, nonce uint64, expectedBuildIdentity [BuildIdentitySize]byte) error { if negotiated.ClientNonce != nonce || negotiated.DriverNonce == 0 { return errors.New("validate native UDE negotiation: session nonce mismatch") } - if negotiated.Capabilities&requiredCapabilities != requiredCapabilities { - return fmt.Errorf("validate native UDE negotiation: required capabilities %#x, driver returned %#x", + if negotiated.Capabilities != requiredCapabilities { + return fmt.Errorf("validate native UDE negotiation: exact capabilities %#x required, driver returned %#x", requiredCapabilities, negotiated.Capabilities) } + if subtle.ConstantTimeCompare(negotiated.BuildIdentity[:], expectedBuildIdentity[:]) != 1 { + return fmt.Errorf( + "%w: loaded kernel build identity=%s expected=%s; restart or repair the exact signed native package", + ErrIncompatibleABI, BuildIdentityHex(negotiated.BuildIdentity), BuildIdentityHex(expectedBuildIdentity), + ) + } if negotiated.MaxDevices == 0 || negotiated.MaxDescriptorBytes == 0 || negotiated.MaxTransferBytes == 0 || negotiated.MaxIsoPackets == 0 || negotiated.MaxPendingOperations == 0 { diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index b32a1495..1998721f 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -5,6 +5,7 @@ package udecx import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -23,7 +24,7 @@ func TestNegotiationABIMismatchExplainsPackageRepair(t *testing.T) { if !errors.Is(err, ErrIncompatibleABI) { t.Errorf("negotiation error for %v = %v, want ErrIncompatibleABI", transportErr, err) } - for _, phrase := range []string{"ABI 1.8", "exact native UDE driver", "VIIPER build"} { + for _, phrase := range []string{fmt.Sprintf("ABI %d.%d", ABIMajor, ABIMinor), "exact native UDE driver", "VIIPER build"} { if !strings.Contains(err.Error(), phrase) { t.Errorf("negotiation error %q does not contain %q", err, phrase) } @@ -54,6 +55,11 @@ func TestCompletionAfterCancelPreservesKernelOutcome(t *testing.T) { } func validTestNegotiation() NegotiateResponse { + identity, err := DeriveBuildIdentity(strings.Repeat("a", 40), DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil { + panic(err) + } return NegotiateResponse{ ClientNonce: 7, DriverNonce: 8, @@ -63,28 +69,51 @@ func validTestNegotiation() NegotiateResponse { MaxTransferBytes: MaxTransferBytes, MaxIsoPackets: MaxIsoPackets, MaxPendingOperations: MaxPendingOperations, + BuildIdentity: identity, } } func TestNegotiationRejectsMissingCapabilitiesAndImpossibleLimits(t *testing.T) { valid := validTestNegotiation() - if err := validateNegotiation(valid, valid.ClientNonce); err != nil { + if err := validateNegotiation(valid, valid.ClientNonce, valid.BuildIdentity); err != nil { t.Fatal(err) } missingCapability := valid missingCapability.Capabilities &^= CapabilityIsochronous - if err := validateNegotiation(missingCapability, valid.ClientNonce); err == nil { + if err := validateNegotiation(missingCapability, valid.ClientNonce, valid.BuildIdentity); err == nil { t.Fatal("negotiation accepted a driver without isochronous support") } + extraCapability := valid + extraCapability.Capabilities |= CapabilityStreams + if err := validateNegotiation(extraCapability, valid.ClientNonce, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted capabilities outside the identity-bound exact mask") + } + oversized := valid oversized.MaxTransferBytes++ - if err := validateNegotiation(oversized, valid.ClientNonce); err == nil { + if err := validateNegotiation(oversized, valid.ClientNonce, valid.BuildIdentity); err == nil { t.Fatal("negotiation accepted a driver limit outside the client ABI") } } +func TestNegotiationRejectsStaleLoadedKernelDespiteMatchingOnDiskPackageContract(t *testing.T) { + // acceptedPackageIdentity represents the exact source-bound identity from + // the already validated signed on-disk package and protected manifest. The + // negotiate response is deliberately from an older image still loaded by + // Windows, while ABI, capabilities, nonce, and limits all remain identical. + response := validTestNegotiation() + acceptedPackageIdentity := response.BuildIdentity + response.BuildIdentity[0] ^= 0xff + + if err := validateNegotiation( + response, response.ClientNonce, acceptedPackageIdentity, + ); !errors.Is(err, ErrIncompatibleABI) { + t.Fatalf("same-ABI/capability stale loaded kernel error=%v want ErrIncompatibleABI", err) + } +} + func TestClientRejectsRequestsOutsideNegotiatedLimitsBeforeKernelIO(t *testing.T) { client := &Client{limits: validTestNegotiation()} client.limits.MaxDescriptorBytes = 1 diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index 9d21f895..97815c4e 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -45,6 +45,9 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "$env:GOARCH = 'amd64'", "$env:CGO_ENABLED = '0'", "$go.Source env GOMOD", + "$nativeIdentityLdflags", + "internal/transport/udecx.nativeSourceRevision=", + "$ExpectedSourceRevision.ToLowerInvariant()", "Go reported success without executing required live test", } { if !strings.Contains(contract, required) { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 243ce25a..4911030e 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -4,25 +4,29 @@ package udecx import ( + "crypto/sha256" "encoding/binary" + "encoding/hex" "errors" "fmt" "math" + "strings" ) const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 8 + ABIMinor uint16 = 9 // DriverPackageVersion is the native driver package version built and - // shipped with this service. Runtime negotiation proves the installed - // driver speaks the exact ABI below; package installation additionally - // verifies this release version and its signed catalog. - DriverPackageVersion = "0.1.0.3" + // shipped with this service. Runtime negotiation proves the loaded driver + // carries this version in its source-bound build identity; package + // installation additionally verifies DriverVer and the signed catalog. + DriverPackageVersion = "0.1.0.4" + BuildIdentitySize = sha256.Size HeaderSize = 16 NegotiateRequestSize = 32 - NegotiateResponseSize = 56 + NegotiateResponseSize = 88 DescriptorRecordSize = 16 CreateDeviceSize = 56 DeviceIdentitySize = 32 @@ -62,6 +66,7 @@ var ( ErrInvalidSize = errors.New("native UDE message size is invalid") ErrInvalidRange = errors.New("native UDE message contains an invalid range") ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") + ErrBuildIdentity = errors.New("native UDE build identity is unavailable or invalid") ) type Capabilities uint32 @@ -73,6 +78,67 @@ const ( CapabilityInputReports ) +const AdvertisedCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports + +// nativeSourceRevision must be injected by the production build. Native +// transport startup deliberately has no VCS/on-disk fallback: the broker and +// loaded kernel image must derive their identities from the same explicit +// source-bound build input. +var nativeSourceRevision string + +// DeriveBuildIdentity returns the source/package/ABI/capability identity that +// is embedded in the native driver and compared during negotiation. The exact +// UTF-8 preimage is also implemented by Get-ViiperUdeBuildIdentity.ps1 and the +// package helper; changing it requires another ABI revision. +func DeriveBuildIdentity(sourceRevision, driverPackageVersion string, abiMajor, abiMinor uint16, capabilities Capabilities) ([BuildIdentitySize]byte, error) { + var zero [BuildIdentitySize]byte + if sourceRevision != strings.TrimSpace(sourceRevision) { + return zero, fmt.Errorf("%w: source revision must not contain surrounding whitespace", ErrBuildIdentity) + } + revision := strings.ToLower(sourceRevision) + if len(revision) != 40 && len(revision) != 64 { + return zero, fmt.Errorf("%w: source revision must be exactly 40 or 64 hexadecimal digits", ErrBuildIdentity) + } + if _, err := hex.DecodeString(revision); err != nil { + return zero, fmt.Errorf("%w: source revision: %v", ErrBuildIdentity, err) + } + versionParts := strings.Split(driverPackageVersion, ".") + if len(versionParts) != 4 { + return zero, fmt.Errorf("%w: driver package version must contain four numeric parts", ErrBuildIdentity) + } + for _, part := range versionParts { + if part == "" { + return zero, fmt.Errorf("%w: driver package version contains an empty part", ErrBuildIdentity) + } + for _, character := range part { + if character < '0' || character > '9' { + return zero, fmt.Errorf("%w: driver package version is not numeric", ErrBuildIdentity) + } + } + } + if abiMajor == 0 || capabilities == 0 { + return zero, fmt.Errorf("%w: ABI major and capabilities must be nonzero", ErrBuildIdentity) + } + preimage := fmt.Sprintf( + "VIIPER-UDE-BUILD-IDENTITY/v1\nsourceRevision=%s\ndriverPackageVersion=%s\nabi=%d.%d\ncapabilities=0x%08x\n", + revision, driverPackageVersion, abiMajor, abiMinor, uint32(capabilities), + ) + return sha256.Sum256([]byte(preimage)), nil +} + +func ExpectedBuildIdentity() ([BuildIdentitySize]byte, error) { + if strings.TrimSpace(nativeSourceRevision) == "" { + return [BuildIdentitySize]byte{}, fmt.Errorf( + "%w: production build did not inject VIIPER native source revision", ErrBuildIdentity) + } + return DeriveBuildIdentity(nativeSourceRevision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) +} + +func BuildIdentityHex(identity [BuildIdentitySize]byte) string { + return hex.EncodeToString(identity[:]) +} + type Header struct { Magic uint32 Major uint16 @@ -151,6 +217,7 @@ type NegotiateResponse struct { MaxTransferBytes uint32 MaxIsoPackets uint32 MaxPendingOperations uint32 + BuildIdentity [BuildIdentitySize]byte } func ParseNegotiateResponse(src []byte) (NegotiateResponse, error) { @@ -161,7 +228,7 @@ func ParseNegotiateResponse(src []byte) (NegotiateResponse, error) { if h.Size != NegotiateResponseSize { return NegotiateResponse{}, ErrInvalidSize } - return NegotiateResponse{ + response := NegotiateResponse{ ClientNonce: binary.LittleEndian.Uint64(src[16:24]), DriverNonce: binary.LittleEndian.Uint64(src[24:32]), Capabilities: Capabilities(binary.LittleEndian.Uint32(src[32:36])), @@ -170,7 +237,9 @@ func ParseNegotiateResponse(src []byte) (NegotiateResponse, error) { MaxTransferBytes: binary.LittleEndian.Uint32(src[44:48]), MaxIsoPackets: binary.LittleEndian.Uint32(src[48:52]), MaxPendingOperations: binary.LittleEndian.Uint32(src[52:56]), - }, nil + } + copy(response.BuildIdentity[:], src[56:88]) + return response, nil } type DescriptorKind uint16 diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index 474e93b0..00e9e769 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -44,6 +44,7 @@ type contractNegotiateResponse struct { MaxTransferBytes uint32 MaxIsoPackets uint32 MaxPendingOperations uint32 + BuildIdentity [BuildIdentitySize]uint8 } type contractDescriptorRecord struct { @@ -202,6 +203,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "VIIPER_UDE_MAGIC": uint64(Magic), "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), + "VIIPER_UDE_BUILD_IDENTITY_BYTES": BuildIdentitySize, "VIIPER_UDE_MAX_DEVICES": MaxDevices, "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, @@ -316,6 +318,13 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { } verifyGUIDAndIOCTLContract(t, header) + if !strings.Contains(header, `#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "`+DriverPackageVersion+`"`) { + t.Fatalf("C driver package version does not match Go %q", DriverPackageVersion) + } + advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\)`).MatchString(header) + if !advertised { + t.Fatal("C advertised capability identity tuple does not match Go") + } } func TestKernelMicrosoftOS10StringExceptionMatchesGoContract(t *testing.T) { diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 8a3430e9..426baa3f 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -1,11 +1,85 @@ package udecx import ( + "bytes" "encoding/binary" + "encoding/hex" "errors" + "strings" "testing" ) +func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { + t.Parallel() + + const revision = "0123456789abcdef0123456789abcdef01234567" + const wantHex = "efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7" + identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil { + t.Fatal(err) + } + if got := BuildIdentityHex(identity); got != wantHex { + t.Fatalf("build identity=%s want canonical PowerShell/C++ vector %s", got, wantHex) + } + want, _ := hex.DecodeString(wantHex) + if !bytes.Equal(identity[:], want) { + t.Fatal("build identity bytes do not match their canonical hex encoding") + } + upper, err := DeriveBuildIdentity(strings.ToUpper(revision), DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities) + if err != nil || upper != identity { + t.Fatalf("uppercase source revision did not normalize canonically: identity=%x error=%v", upper, err) + } + + for name, revision := range map[string]string{ + "missing": "", + "short": strings.Repeat("a", 39), + "odd": strings.Repeat("a", 41), + "not hex": strings.Repeat("z", 40), + "spaced": " " + strings.Repeat("a", 40), + } { + t.Run(name, func(t *testing.T) { + if _, err := DeriveBuildIdentity(revision, DriverPackageVersion, + ABIMajor, ABIMinor, AdvertisedCapabilities); !errors.Is(err, ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } + }) + } +} + +func TestExpectedBuildIdentityFailsClosedWithoutBuildInjection(t *testing.T) { + previous := nativeSourceRevision + nativeSourceRevision = "" + t.Cleanup(func() { nativeSourceRevision = previous }) + + if _, err := ExpectedBuildIdentity(); !errors.Is(err, ErrBuildIdentity) { + t.Fatalf("error=%v want ErrBuildIdentity", err) + } +} + +func TestParseNegotiationReturnsLoadedKernelBuildIdentity(t *testing.T) { + raw := make([]byte, NegotiateResponseSize) + header, err := NewHeader(NegotiateResponseSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + for index := 0; index < BuildIdentitySize; index++ { + raw[56+index] = byte(index) + } + + response, err := ParseNegotiateResponse(raw) + if err != nil { + t.Fatal(err) + } + for index, got := range response.BuildIdentity { + if got != byte(index) { + t.Fatalf("build identity byte %d=%#x want %#x", index, got, byte(index)) + } + } +} + func TestABISizes(t *testing.T) { for name, got := range map[string]int{ "header": HeaderSize, "negotiate request": NegotiateRequestSize, diff --git a/internal/transport/udecx/release_contract_test.go b/internal/transport/udecx/release_contract_test.go index 6b1610e3..6c7f590b 100644 --- a/internal/transport/udecx/release_contract_test.go +++ b/internal/transport/udecx/release_contract_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" ) @@ -21,3 +22,62 @@ func TestExpectedDriverPackageVersionMatchesProject(t *testing.T) { t.Fatalf("native package version drift: Go=%q project=%q", DriverPackageVersion, got) } } + +func TestNativeReleaseBuildIdentityIsExplicitAndSourceBound(t *testing.T) { + root := filepath.Join("..", "..", "..") + read := func(parts ...string) string { + t.Helper() + contents, err := os.ReadFile(filepath.Join(append([]string{root}, parts...)...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(parts...), err) + } + return string(contents) + } + + project := read("native", "udecx", "driver", "ViiperUde.vcxproj") + for _, required := range []string{ + "$(VIIPER_NATIVE_SOURCE_REVISION)", + "GenerateViiperUdeBuildIdentity", + `BeforeTargets="ClCompile"`, + "Get-ViiperUdeBuildIdentity.ps1", + `-OutputHeaderPath "$(IntDir)ViiperUdeBuildIdentity.g.h"`, + `$(IntDir);`, + "fails closed without an explicit source revision", + } { + if !strings.Contains(project, required) { + t.Fatalf("driver build omits fail-closed identity contract %q", required) + } + } + + for _, workflow := range []string{"build_base.yml", "native-ude.yml"} { + contents := read(".github", "workflows", workflow) + if !strings.Contains(contents, "VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}") { + t.Fatalf("%s does not inject the exact workflow source SHA", workflow) + } + } + + justfile := read("justfile") + if !strings.Contains(justfile, "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION.") || + !strings.Contains(justfile, "internal/transport/udecx.nativeSourceRevision=") { + t.Fatal("release broker build can silently omit its source-bound native identity") + } + + ioctl := read("native", "udecx", "driver", "Ioctl.c") + if !strings.Contains(ioctl, "ViiperUdeBuildIdentity.g.h") || + !strings.Contains(ioctl, "output->BuildIdentity") { + t.Fatal("kernel negotiation does not return the generated loaded-image identity") + } + + for _, script := range []string{ + "New-ViiperUdeAttestationPackage.ps1", + "Test-ViiperUdeSignedPackage.ps1", + "Test-ViiperUdeReleaseBundle.ps1", + } { + contents := read("native", "udecx", "tools", script) + for _, required := range []string{"Get-ViiperUdeBuildIdentity.ps1", "driverBuildIdentity"} { + if !strings.Contains(contents, required) { + t.Fatalf("%s omits package identity binding %q", script, required) + } + } + } +} diff --git a/justfile b/justfile index 98d4348e..7cf3d85f 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,10 @@ rm_f := if os_family() == "windows" { "Remove-Item -Force -ErrorAction 0" } else version := env_var_or_default("VERSION", `git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always`) commit := `git rev-parse --short HEAD` +native_source_revision_explicit := env_var_or_default("VIIPER_NATIVE_SOURCE_REVISION", "") +# Debug/developer builds may bind the current checkout explicitly. Release +# recipes reject the absence of VIIPER_NATIVE_SOURCE_REVISION before compiling. +native_source_revision := if native_source_revision_explicit != "" { native_source_revision_explicit } else { `git rev-parse HEAD` } build_time := if os_family() == "windows" { `Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'` } else { @@ -29,7 +33,7 @@ licenses_dir := join(dist_dir, "libVIIPER") licenses_out := join(dist_dir, "licenses.txt") lib_licenses_out := join(licenses_dir, "licenses.txt") -ldflags_common := "-X main.Version=" + version + " -X main.Commit=" + commit + " -X main.Date=" + build_time + " -X github.com/Alia5/VIIPER/internal/codegen/common.Version=" + version +ldflags_common := "-X main.Version=" + version + " -X main.Commit=" + commit + " -X main.Date=" + build_time + " -X github.com/Alia5/VIIPER/internal/codegen/common.Version=" + version + " -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=" + native_source_revision ldflags_release := "-s -w " + ldflags_common default: @@ -74,6 +78,7 @@ clean-versioninfo: [arg("type", pattern="Debug|Release")] [windows] build type=build_type: generate-versioninfo + if ("{{ type }}" -eq "Release" -and [string]::IsNullOrWhiteSpace($env:VIIPER_NATIVE_SOURCE_REVISION)) { throw "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION." } {{ mkdir_p }} {{ dist_dir }} $env:CGO_ENABLED='0'; go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} just licenses @@ -81,6 +86,7 @@ build type=build_type: generate-versioninfo [arg("type", pattern="Debug|Release")] [unix] build type=build_type: + if [ "{{ type }}" = "Release" ] && [ -z "${VIIPER_NATIVE_SOURCE_REVISION:-}" ]; then echo "Release builds require explicit VIIPER_NATIVE_SOURCE_REVISION." >&2; exit 1; fi {{ mkdir_p }} {{ dist_dir }} CGO_ENABLED=0 go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} just licenses diff --git a/native/udecx/README.md b/native/udecx/README.md index 5db96c25..2efcf497 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -9,12 +9,22 @@ Directory contract: - `include/` is the stable C ABI shared by the driver and Go broker. - `driver/` is the KMDF/UdeCx controller driver. - `package/` contains INF and installation metadata. + +Release builds must receive `VIIPER_NATIVE_SOURCE_REVISION` from the protected +build job; both driver and broker fail closed when it is absent. `just build +Debug` is the only convenience path that may bind the current checkout HEAD +into a local debug broker when that variable is omitted. Direct driver builds +always require the explicit property/environment input. The debug fallback is +never accepted by a Release recipe or production workflow. + - `tools/ViiperUdeCtl.cpp` installs, verifies, or removes the exact root controller as a driver-store transaction. Installation requires the source-revision submission manifest, verifies the catalog signature and four-part `DriverVer`, rejects same-version replacement and implicit - downgrade, records the prior published INF, negotiates the broker ABI after - start, and restores the prior binding on failure. Removal backs up every + downgrade, records the prior published INF, and negotiates the ABI plus the + source-bound identity embedded in the currently loaded kernel image after + start. A stale same-ABI driver cannot satisfy health. The helper restores the + prior binding on failure. Removal backs up every exact signed VIIPER package before deleting only exact owned devnodes and packages; unrelated driver-store entries are never force-deleted. - `tools/Test-ViiperUdeCtlTransaction.ps1` deterministically guards the @@ -23,7 +33,9 @@ Directory contract: parser/version self-test without changing driver state. - `tools/New-ViiperUdeAttestationPackage.ps1` creates and hash-verifies the exact controlled-test Hardware Dev Center CAB structure and requires an - explicit testing-only acknowledgement. Microsoft currently restricts + explicit testing-only acknowledgement. Its schema-2 manifest binds the + source revision, DriverVer, ABI, exact capability mask, and loaded-image + build identity. Microsoft currently restricts attestation to testing scenarios; production release requires HLK/WHCP. - `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned driver and catalog against kernel signing policy, proves that INF and SYS are diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index bccba360..d70dbd75 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -1,4 +1,5 @@ #include "ViiperUde.h" +#include "ViiperUdeBuildIdentity.g.h" static BOOLEAN @@ -96,14 +97,14 @@ ViiperHandleNegotiate( output->Header.Size = sizeof(*output); output->ClientNonce = fileContext->ClientNonce; output->DriverNonce = fileContext->DriverNonce; - output->Capabilities = VIIPER_UDE_CAP_ISOCHRONOUS | - VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS; + output->Capabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; output->MaxDevices = VIIPER_UDE_MAX_DEVICES; output->MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; output->MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; output->MaxIsoPackets = VIIPER_UDE_MAX_ISO_PACKETS; output->MaxPendingOperations = VIIPER_UDE_MAX_PENDING_OPERATIONS; + RtlCopyMemory(output->BuildIdentity, ViiperUdeBuildIdentity, + sizeof(output->BuildIdentity)); WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index a1d2502a..31b46f9f 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,8 @@ 17.0 x64 08/11/2026 - 0.1.0.3 + 0.1.0.4 + $(VIIPER_NATIVE_SOURCE_REVISION) @@ -55,7 +56,7 @@ Level4 true stdc17 - ..\include;%(AdditionalIncludeDirectories) + $(IntDir);..\include;%(AdditionalIncludeDirectories) POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) true @@ -101,4 +102,8 @@ + + + + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 7c965c60..d0f29fd5 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,7 +35,9 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(8) +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.4" +#define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ #define VIIPER_UDE_INTERFACE_GUID_DATA1 VIIPER_UDE_UINT32_C(0x32d03f48) @@ -66,6 +68,9 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) #define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) #define VIIPER_UDE_CAP_INPUT_REPORTS VIIPER_UDE_UINT32_C(0x00000008) +#define VIIPER_UDE_ADVERTISED_CAPABILITIES \ + (VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | \ + VIIPER_UDE_CAP_INPUT_REPORTS) #if defined(_WIN32) #define VIIPER_UDE_IOCTL_BASE 0x900 @@ -105,6 +110,12 @@ typedef struct VIIPER_UDE_NEGOTIATE_RESPONSE { VIIPER_UDE_UINT32 MaxTransferBytes; VIIPER_UDE_UINT32 MaxIsoPackets; VIIPER_UDE_UINT32 MaxPendingOperations; + /* + * SHA-256 of the source/package/ABI/capability build tuple embedded in + * the currently loaded kernel image. This is intentionally returned by + * the driver rather than inferred from an on-disk SYS path. + */ + VIIPER_UDE_UINT8 BuildIdentity[VIIPER_UDE_BUILD_IDENTITY_BYTES]; } VIIPER_UDE_NEGOTIATE_RESPONSE; typedef enum VIIPER_UDE_DESCRIPTOR_KIND { @@ -243,7 +254,7 @@ typedef struct VIIPER_UDE_STATS { #if defined(__cplusplus) static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); -static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); @@ -255,7 +266,7 @@ static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L _Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); _Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); @@ -275,7 +286,7 @@ _Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); */ typedef char VIIPER_UDE_ABI_HEADER_SIZE[(sizeof(VIIPER_UDE_HEADER) == 16) ? 1 : -1]; typedef char VIIPER_UDE_ABI_NEGOTIATE_REQUEST_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32) ? 1 : -1]; -typedef char VIIPER_UDE_ABI_NEGOTIATE_RESPONSE_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 56) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_NEGOTIATE_RESPONSE_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88) ? 1 : -1]; typedef char VIIPER_UDE_ABI_DESCRIPTOR_RECORD_SIZE[(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16) ? 1 : -1]; typedef char VIIPER_UDE_ABI_CREATE_DEVICE_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56) ? 1 : -1]; typedef char VIIPER_UDE_ABI_DEVICE_IDENTITY_SIZE[(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32) ? 1 : -1]; @@ -312,6 +323,7 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxDescriptorBytes, 40); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxTransferBytes, 44); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxIsoPackets, 48); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, MaxPendingOperations, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_NEGOTIATE_RESPONSE, BuildIdentity, 56); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Kind, 0); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DESCRIPTOR_RECORD, Index, 2); diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index c454432c..e0934837 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.3 +DriverVer=08/11/2026,0.1.0.4 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 index 3b8365c7..9c012c2d 100644 --- a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 +++ b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 @@ -7,7 +7,7 @@ param( [string]$SubmissionManifestPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [ValidateSet('ControlledTest', 'Production')] diff --git a/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 b/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 new file mode 100644 index 00000000..549c982b --- /dev/null +++ b/native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')] + [string]$DriverPackageVersion, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int]$ABIMajor, + + [Parameter(Mandatory = $true)] + [ValidateRange(0, 65535)] + [int]$ABIMinor, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, [uint32]::MaxValue)] + [uint32]$Capabilities, + + [string]$OutputHeaderPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# This exact ASCII/UTF-8 preimage is a cross-language protocol. Keep it in +# lockstep with udecx.DeriveBuildIdentity and ViiperUdeCtl's derivation. +$preimage = "VIIPER-UDE-BUILD-IDENTITY/v1`n" + + "sourceRevision=$($SourceRevision.ToLowerInvariant())`n" + + "driverPackageVersion=$DriverPackageVersion`n" + + "abi=$ABIMajor.$ABIMinor`n" + + ("capabilities=0x{0:x8}`n" -f $Capabilities) +$bytes = [Text.UTF8Encoding]::new($false).GetBytes($preimage) +$sha256 = [Security.Cryptography.SHA256]::Create() +try { + $digest = $sha256.ComputeHash($bytes) +} +finally { + $sha256.Dispose() +} +$hex = ([BitConverter]::ToString($digest)).Replace('-', '').ToLowerInvariant() + +if (-not [string]::IsNullOrWhiteSpace($OutputHeaderPath)) { + $fullPath = [IO.Path]::GetFullPath($OutputHeaderPath) + $directory = [IO.Path]::GetDirectoryName($fullPath) + if ([string]::IsNullOrWhiteSpace($directory)) { + throw 'OutputHeaderPath must include a directory.' + } + [IO.Directory]::CreateDirectory($directory) | Out-Null + $initializer = ($digest | ForEach-Object { '0x{0:x2}' -f $_ }) -join ', ' + $header = @" +#pragma once + +/* Generated from an explicit source/package/ABI/capability tuple. */ +static const VIIPER_UDE_UINT8 ViiperUdeBuildIdentity[VIIPER_UDE_BUILD_IDENTITY_BYTES] = { + $initializer +}; +"@ + [IO.File]::WriteAllText($fullPath, $header, [Text.UTF8Encoding]::new($false)) +} + +$hex diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 7294bfed..295e05d8 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -7,7 +7,7 @@ param( [string]$SubmissionManifestPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [ValidateSet('ControlledTest', 'Production')] @@ -375,7 +375,10 @@ try { [StringComparison]::OrdinalIgnoreCase)) { throw "The live test selected an unexpected Go module '$modulePath'." } + $nativeIdentityLdflags = '-X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=' + + $ExpectedSourceRevision.ToLowerInvariant() $goTestOutput = @(& $go.Source test -v -count=1 -timeout "${timeoutMinutes}m" ` + -ldflags $nativeIdentityLdflags ` -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ./internal/server/usb ) $goTestExitCode = $LASTEXITCODE diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 index 89b5ede2..8ba3d9b5 100644 --- a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -7,7 +7,7 @@ param( [string]$SubmissionManifestPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [ValidateSet('ControlledTest', 'Production')] diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index 965e4bca..a243efe8 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -16,7 +16,7 @@ param( [string]$OutputPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$SourceRevision, [switch]$AcknowledgeTestingOnly, @@ -72,6 +72,24 @@ $pdb = Resolve-RequiredFile -Path $PdbPath -ExpectedExtension '.pdb' $cat = Resolve-RequiredFile -Path $CatalogPath -ExpectedExtension '.cat' Assert-InfContract -Path $inf.FullName +[xml]$driverProject = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj') -Raw +$projectNamespace = [Xml.XmlNamespaceManager]::new($driverProject.NameTable) +$projectNamespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($driverProject.SelectNodes('//msb:ViiperUdeDriverVersion', $projectNamespace)) +if ($versionNodes.Count -ne 1) { + throw 'The driver project must declare one deterministic ViiperUdeDriverVersion.' +} +$driverPackageVersion = $versionNodes[0].InnerText.Trim() +$driverABIMajor = 1 +$driverABIMinor = 9 +$driverCapabilities = [uint32]13 +$driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $SourceRevision ` + -DriverPackageVersion $driverPackageVersion ` + -ABIMajor $driverABIMajor ` + -ABIMinor $driverABIMinor ` + -Capabilities $driverCapabilities + $makeCab = Get-Command makecab.exe -ErrorAction Stop $expand = Get-Command expand.exe -ErrorAction Stop $outputFullPath = [System.IO.Path]::GetFullPath($OutputPath) @@ -165,12 +183,17 @@ try { } $manifest = [ordered]@{ - schema = 1 + schema = 2 purpose = 'Microsoft Hardware Dev Center controlled-test attestation submission; not a retail release package' releaseEligible = $false signingRoute = 'ControlledTestAttestation' requiredProductionRoute = 'HLK/WHCP dashboard signing' sourceRevision = $SourceRevision.ToLowerInvariant() + driverPackageVersion = $driverPackageVersion + driverABIMajor = $driverABIMajor + driverABIMinor = $driverABIMinor + driverCapabilities = ('0x{0:x8}' -f $driverCapabilities) + driverBuildIdentity = $driverBuildIdentity cabinet = [System.IO.Path]::GetFileName($outputFullPath) cabinetSha256 = (Get-FileHash -LiteralPath $outputFullPath -Algorithm SHA256).Hash packageFolder = $packageFolder diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 100d33b9..995bcbdd 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -33,6 +33,10 @@ $requiredContracts = [ordered]@{ 'documented package install' = 'DiInstallDriverW\(' 'documented package removal' = 'DiUninstallDriverW\(' 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' + 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' + 'exact negotiated capability identity' = 'response\.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES' + 'source-bound manifest identity' = 'driverBuildIdentity' + 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' 'install rollback' = 'RollbackInstall\(' 'broker health transaction' = 'RunBrokerInstall\(' 'production broker requirement' = 'broker-required' diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index 34bf0ee4..9ef0f81d 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -4,7 +4,7 @@ param( [string]$BundleDirectory, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-f]{40}$')] + [ValidatePattern('^(?:[0-9a-f]{40}|[0-9a-f]{64})$')] [string]$ExpectedSourceRevision, [string]$ProjectPath, @@ -115,12 +115,6 @@ if ($RequireAuthenticode) { $manifest = Get-Content -LiteralPath $files['submission-manifest.json'].FullName -Raw | ConvertFrom-Json -if ($manifest.schema -ne 1 -or - [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or - -not [bool]$manifest.releaseEligible -or - [string]$manifest.signingRoute -cne 'HLK/WHCP') { - throw 'The runtime bundle requires the exact release-eligible HLK/WHCP source manifest.' -} $submissionNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') $manifestEntries = @($manifest.files) @@ -159,6 +153,20 @@ if ($dateNodes.Count -ne 1 -or $versionNodes.Count -ne 1) { } $driverDate = $dateNodes[0].InnerText.Trim() $driverVersion = $versionNodes[0].InnerText.Trim() +$expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $ExpectedSourceRevision ` + -DriverPackageVersion $driverVersion ` + -ABIMajor 1 -ABIMinor 9 -Capabilities 13 +if ($manifest.schema -ne 2 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or + [string]$manifest.driverPackageVersion -cne $driverVersion -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 9 -or + [string]$manifest.driverCapabilities -cne '0x0000000d' -or + [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or + -not [bool]$manifest.releaseEligible -or + [string]$manifest.signingRoute -cne 'HLK/WHCP') { + throw 'The runtime bundle requires the exact release-eligible HLK/WHCP loaded-driver build identity manifest.' +} $infContents = Get-Content -LiteralPath $runtimeInf.FullName -Raw $driverVerPattern = '(?mi)^DriverVer\s*=\s*' + [regex]::Escape($driverDate) + '\s*,\s*' + diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 7c50e49b..adc5dfdc 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -7,7 +7,7 @@ param( [string]$SubmissionManifestPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [ValidateSet('ControlledTest', 'Production')] @@ -107,9 +107,26 @@ if (@($allFiles | Where-Object { $_.DirectoryName -cne $root.Path }).Count -ne 0 $manifestFile = Resolve-Path -LiteralPath $SubmissionManifestPath -ErrorAction Stop $manifest = Get-Content -LiteralPath $manifestFile.Path -Raw | ConvertFrom-Json -if ($manifest.schema -ne 1 -or - [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant()) { - throw 'The submission manifest schema or source revision does not match the reviewed source.' +$projectPath = Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj' +[xml]$driverProject = Get-Content -LiteralPath $projectPath -Raw +$projectNamespace = [Xml.XmlNamespaceManager]::new($driverProject.NameTable) +$projectNamespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($driverProject.SelectNodes('//msb:ViiperUdeDriverVersion', $projectNamespace)) +if ($versionNodes.Count -ne 1) { + throw 'The driver project must declare one deterministic ViiperUdeDriverVersion.' +} +$driverPackageVersion = $versionNodes[0].InnerText.Trim() +$expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $ExpectedSourceRevision ` + -DriverPackageVersion $driverPackageVersion ` + -ABIMajor 1 -ABIMinor 9 -Capabilities 13 +if ($manifest.schema -ne 2 -or + [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or + [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 9 -or + [string]$manifest.driverCapabilities -cne '0x0000000d' -or + [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { + throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' } if ($ValidationMode -eq 'ControlledTest') { if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'ControlledTestAttestation') { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 37fdc08f..ed1eaab8 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -404,7 +404,7 @@ std::string LowerAscii(std::string value) { } bool IsHexRevision(const std::string& value) { - if (value.size() < 40 || value.size() > 64) { + if (value.size() != 40 && value.size() != 64) { return false; } return std::all_of(value.begin(), value.end(), [](unsigned char character) { @@ -792,6 +792,69 @@ bool Sha256File(const std::filesystem::path& path, std::string* digest, Error* e return Sha256Handle(file.get(), digest, error); } +bool Sha256Data(std::string_view data, std::string* digest, Error* error) { + HCRYPTPROV provider = 0; + HCRYPTHASH hash = 0; + if (!CryptAcquireContextW(&provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { + return SetLastErrorDetail(error, L"sha256-data-provider"); + } + const auto releaseProvider = [&]() { CryptReleaseContext(provider, 0); }; + if (!CryptCreateHash(provider, CALG_SHA_256, 0, 0, &hash)) { + const DWORD code = GetLastError(); + releaseProvider(); + return SetError(error, L"sha256-data-create", code); + } + const bool updated = data.size() <= MAXDWORD && CryptHashData(hash, + reinterpret_cast(data.data()), static_cast(data.size()), 0) != FALSE; + if (!updated) { + const DWORD code = data.size() > MAXDWORD ? ERROR_FILE_TOO_LARGE : GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-update", code); + } + std::array bytes{}; + DWORD length = static_cast(bytes.size()); + if (!CryptGetHashParam(hash, HP_HASHVAL, bytes.data(), &length, 0)) { + const DWORD code = GetLastError(); + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-finish", code); + } + if (length != bytes.size()) { + CryptDestroyHash(hash); + releaseProvider(); + return SetError(error, L"sha256-data-finish", ERROR_INVALID_DATA); + } + CryptDestroyHash(hash); + releaseProvider(); + static constexpr char digits[] = "0123456789abcdef"; + digest->clear(); + digest->reserve(bytes.size() * 2); + for (BYTE byte : bytes) { + digest->push_back(digits[byte >> 4U]); + digest->push_back(digits[byte & 0x0fU]); + } + return true; +} + +bool DeriveDriverBuildIdentity( + const std::string& sourceRevision, + std::string* digest, + Error* error) { + if (!IsHexRevision(sourceRevision)) { + return SetError(error, L"build-identity-source", ERROR_INVALID_DATA, + L"driver build identity requires an exact 40- or 64-digit source revision"); + } + std::ostringstream preimage; + preimage << "VIIPER-UDE-BUILD-IDENTITY/v1\n" + << "sourceRevision=" << LowerAscii(sourceRevision) << "\n" + << "driverPackageVersion=" << VIIPER_UDE_DRIVER_PACKAGE_VERSION << "\n" + << "abi=" << VIIPER_UDE_ABI_MAJOR << "." << VIIPER_UDE_ABI_MINOR << "\n" + << "capabilities=0x" << std::hex << std::nouppercase << std::setw(8) + << std::setfill('0') << VIIPER_UDE_ADVERTISED_CAPABILITIES << "\n"; + return Sha256Data(preimage.str(), digest, error); +} + bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* error) { std::error_code fileError; const uintmax_t size = std::filesystem::file_size(path, fileError); @@ -828,17 +891,39 @@ bool ValidateManifest( const JsonValue* revision = ObjectField(*object, "sourceRevision"); const JsonValue* releaseEligible = ObjectField(*object, "releaseEligible"); const JsonValue* signingRoute = ObjectField(*object, "signingRoute"); + const JsonValue* driverVersion = ObjectField(*object, "driverPackageVersion"); + const JsonValue* driverMajor = ObjectField(*object, "driverABIMajor"); + const JsonValue* driverMinor = ObjectField(*object, "driverABIMinor"); + const JsonValue* driverCapabilities = ObjectField(*object, "driverCapabilities"); + const JsonValue* driverBuildIdentity = ObjectField(*object, "driverBuildIdentity"); const JsonValue* files = ObjectField(*object, "files"); const auto* schemaValue = schema == nullptr ? nullptr : std::get_if(&schema->value); const auto* revisionValue = revision == nullptr ? nullptr : std::get_if(&revision->value); const auto* releaseValue = releaseEligible == nullptr ? nullptr : std::get_if(&releaseEligible->value); const auto* routeValue = signingRoute == nullptr ? nullptr : std::get_if(&signingRoute->value); + const auto* driverVersionValue = driverVersion == nullptr ? nullptr : std::get_if(&driverVersion->value); + const auto* driverMajorValue = driverMajor == nullptr ? nullptr : std::get_if(&driverMajor->value); + const auto* driverMinorValue = driverMinor == nullptr ? nullptr : std::get_if(&driverMinor->value); + const auto* driverCapabilitiesValue = driverCapabilities == nullptr ? nullptr : std::get_if(&driverCapabilities->value); + const auto* driverBuildIdentityValue = driverBuildIdentity == nullptr ? nullptr : std::get_if(&driverBuildIdentity->value); const auto* fileArray = files == nullptr ? nullptr : std::get_if(&files->value); - if (schemaValue == nullptr || *schemaValue != 1 || revisionValue == nullptr || + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity(expectedRevision, &expectedBuildIdentity, error)) { + return false; + } + std::ostringstream expectedCapabilities; + expectedCapabilities << "0x" << std::hex << std::nouppercase << std::setw(8) + << std::setfill('0') << VIIPER_UDE_ADVERTISED_CAPABILITIES; + if (schemaValue == nullptr || *schemaValue != 2 || revisionValue == nullptr || LowerAscii(*revisionValue) != LowerAscii(expectedRevision) || releaseValue == nullptr || - routeValue == nullptr || fileArray == nullptr) { + routeValue == nullptr || fileArray == nullptr || driverVersionValue == nullptr || + *driverVersionValue != VIIPER_UDE_DRIVER_PACKAGE_VERSION || driverMajorValue == nullptr || + *driverMajorValue != VIIPER_UDE_ABI_MAJOR || driverMinorValue == nullptr || + *driverMinorValue != VIIPER_UDE_ABI_MINOR || driverCapabilitiesValue == nullptr || + *driverCapabilitiesValue != expectedCapabilities.str() || driverBuildIdentityValue == nullptr || + *driverBuildIdentityValue != expectedBuildIdentity) { return SetError(error, L"manifest-contract", ERROR_INVALID_DATA, - L"manifest schema, source revision, release route, or file list is invalid"); + L"manifest schema, source revision, loaded-driver identity, release route, or file list is invalid"); } if (production) { if (!*releaseValue || *routeValue != "HLK/WHCP") { @@ -1777,7 +1862,10 @@ bool RegisterRootDeviceExact( return true; } -bool VerifyAbiHealth(uint64_t deadlineUnixMs, Error* error) { +bool VerifyAbiHealth( + uint64_t deadlineUnixMs, + const std::string* expectedBuildIdentity, + Error* error) { DeviceInfoSet set(SetupDiGetClassDevsW( &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); if (!set) { @@ -1841,9 +1929,7 @@ bool VerifyAbiHealth(uint64_t deadlineUnixMs, Error* error) { request.Header.Size = sizeof(request); request.ClientNonce = static_cast(counter.QuadPart) ^ GetTickCount64(); if (request.ClientNonce == 0) request.ClientNonce = 1; - request.RequestedCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | - VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS; + request.RequestedCapabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; VIIPER_UDE_NEGOTIATE_RESPONSE response{}; DWORD returned = 0; WinHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); @@ -1912,21 +1998,28 @@ bool VerifyAbiHealth(uint64_t deadlineUnixMs, Error* error) { return SetLastErrorDetail(error, L"abi-negotiate-result"); } } - const VIIPER_UDE_UINT32 requiredCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | - VIIPER_UDE_CAP_DEVICE_LIFECYCLE | VIIPER_UDE_CAP_INPUT_REPORTS; + std::string loadedBuildIdentity; + loadedBuildIdentity.reserve(VIIPER_UDE_BUILD_IDENTITY_BYTES * 2); + static constexpr char digits[] = "0123456789abcdef"; + for (VIIPER_UDE_UINT8 byte : response.BuildIdentity) { + loadedBuildIdentity.push_back(digits[byte >> 4U]); + loadedBuildIdentity.push_back(digits[byte & 0x0fU]); + } if (returned != sizeof(response) || response.Header.Magic != VIIPER_UDE_MAGIC || response.Header.Major != VIIPER_UDE_ABI_MAJOR || response.Header.Minor != VIIPER_UDE_ABI_MINOR || response.Header.Size != sizeof(response) || response.Header.Flags != 0 || response.ClientNonce != request.ClientNonce || response.DriverNonce == 0 || - (response.Capabilities & requiredCapabilities) != requiredCapabilities || + response.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES || response.MaxDevices != VIIPER_UDE_MAX_DEVICES || response.MaxDescriptorBytes != VIIPER_UDE_MAX_DESCRIPTOR_BYTES || response.MaxTransferBytes != VIIPER_UDE_MAX_TRANSFER_BYTES || response.MaxIsoPackets != VIIPER_UDE_MAX_ISO_PACKETS || - response.MaxPendingOperations != VIIPER_UDE_MAX_PENDING_OPERATIONS) { + response.MaxPendingOperations != VIIPER_UDE_MAX_PENDING_OPERATIONS || + (expectedBuildIdentity != nullptr && + loadedBuildIdentity != *expectedBuildIdentity)) { return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, - L"driver health response does not match the compiled broker ABI"); + L"loaded driver health response does not match the source-bound package identity"); } return true; } @@ -1936,6 +2029,7 @@ bool VerifyInstalled( const std::wstring& publishedName, bool allowStopped, uint64_t healthDeadlineUnixMs, + const std::string* expectedBuildIdentity, Error* error) { Snapshot snapshot; if (!CaptureSnapshot(&snapshot, error)) { @@ -1952,7 +2046,8 @@ bool VerifyInstalled( return SetError(error, L"install-start", ERROR_DEVICE_NOT_AVAILABLE, L"installed driver did not start; problem=" + std::to_wstring(snapshot.devices[0].problem)); } - return allowStopped || VerifyAbiHealth(healthDeadlineUnixMs, error); + return allowStopped || VerifyAbiHealth( + healthDeadlineUnixMs, expectedBuildIdentity, error); } bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* error) { @@ -2096,7 +2191,7 @@ bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) if (!prior.devices.empty() && !*rebootRequired) { return VerifyInstalled( prior.devices[0].package, prior.devices[0].publishedInf, false, - CurrentUnixMilliseconds() + 15000, error); + CurrentUnixMilliseconds() + 15000, nullptr, error); } return true; } @@ -2443,6 +2538,12 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity( + options.sourceRevision, &expectedBuildIdentity, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || !CheckTransactionDeadline(options, L"transaction-deadline-before-driver", &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; @@ -2563,7 +2664,7 @@ Outcome Install(const InstallOptions& options) { } if (outcome.error.code == ERROR_SUCCESS && !VerifyInstalled(candidate, publishedCandidate.publishedName, outcome.rebootRequired, - options.transactionDeadlineUnixMs, &outcome.error)) { + options.transactionDeadlineUnixMs, &expectedBuildIdentity, &outcome.error)) { // Verification recorded the exact failure. } if (outcome.error.code != ERROR_SUCCESS) { @@ -2942,7 +3043,7 @@ bool RollbackRemove( } const uint64_t healthDeadline = std::min( rollbackDeadlineUnixMs, CurrentUnixMilliseconds() + 15000); - if (!VerifyAbiHealth(healthDeadline, error)) { + if (!VerifyAbiHealth(healthDeadline, nullptr, error)) { return false; } } @@ -3114,6 +3215,17 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-version", ERROR_INVALID_DATA); return outcome; } + std::string buildIdentity; + if (!DeriveDriverBuildIdentity( + "0123456789abcdef0123456789abcdef01234567", + &buildIdentity, &outcome.error) || + buildIdentity != + "efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7") { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); + } + return outcome; + } JsonValue value; std::string message; if (!JsonParser(R"({"schema":1,"files":[]})").Parse(&value, &message) || @@ -3209,7 +3321,7 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro } if (!IsHexRevision(options->sourceRevision)) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, - L"source revision must contain 40 to 64 hexadecimal characters"); + L"source revision must contain exactly 40 or 64 hexadecimal characters"); } revisionSeen = true; } else if (_wcsicmp(argument.c_str(), L"--validation-mode") == 0 && @@ -3352,14 +3464,14 @@ void Usage() { std::wcerr << L"usage:\n" << L" ViiperUdeCtl.exe install --manifest --manifest-sha256 <64 hex> " - L"--source-revision <40-64 hex> --validation-mode " + L"--source-revision <40-or-64 hex> --validation-mode " L"--transaction-deadline-unix-ms " L"[--allow-controlled-downgrade ] " L"--broker-executable --broker-sha256 <64 hex> " L"--broker-token --broker-token-sha256 <64 hex> " L"--target-user-sid \n" << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " - L"--source-revision <40-64 hex> --validation-mode " + L"--source-revision <40-or-64 hex> --validation-mode " L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe status\n" diff --git a/viipertypes/structs.go b/viipertypes/structs.go index 55db7be8..4c7a6742 100644 --- a/viipertypes/structs.go +++ b/viipertypes/structs.go @@ -49,11 +49,15 @@ type NativeUDEInfo struct { ABIMinor uint16 `json:"abiMinor"` Capabilities uint32 `json:"capabilities"` ExpectedDriverPackageVersion string `json:"expectedDriverPackageVersion"` - MaxDevices uint32 `json:"maxDevices"` - MaxDescriptorBytes uint32 `json:"maxDescriptorBytes"` - MaxTransferBytes uint32 `json:"maxTransferBytes"` - MaxIsoPackets uint32 `json:"maxIsoPackets"` - MaxPendingOperations uint32 `json:"maxPendingOperations"` + // LoadedDriverBuildIdentity is the lowercase SHA-256 identity returned by + // the currently loaded kernel image during ABI negotiation. It is not an + // on-disk hash or a broker-computed status echo. + LoadedDriverBuildIdentity string `json:"loadedDriverBuildIdentity"` + MaxDevices uint32 `json:"maxDevices"` + MaxDescriptorBytes uint32 `json:"maxDescriptorBytes"` + MaxTransferBytes uint32 `json:"maxTransferBytes"` + MaxIsoPackets uint32 `json:"maxIsoPackets"` + MaxPendingOperations uint32 `json:"maxPendingOperations"` } type BusListResponse struct { From 464844731ca2e23f6596ec2af90e6a32c0421d14 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 07:57:51 -0500 Subject: [PATCH 175/240] Harden source-bound controller latency evidence --- _testing/e2e/cmd/verifylatency/main.go | 81 ++++++ _testing/e2e/latency/ViiperLatency.wprp | 46 +++ _testing/e2e/latency/edge_fence.go | 35 +++ _testing/e2e/latency/edge_fence_test.go | 32 ++ _testing/e2e/latency/profile_contract_test.go | 65 +++++ _testing/e2e/latency/report.go | 200 +++++++++++-- _testing/e2e/latency/report_test.go | 185 +++++++++++- _testing/e2e/latency/trace_markers.go | 100 +++++++ _testing/e2e/latency/trace_markers_test.go | 69 +++++ _testing/e2e/latency_gate_windows_test.go | 275 +++++++++++++++--- _testing/e2e/pnp_path_windows_test.go | 118 ++++++++ _testing/e2e/pnp_windows_test.go | 168 +++++++++++ .../scripts/Invoke-ViiperE2ELatencyGate.ps1 | 200 +++++++++++-- _testing/e2e/sdl/gamepad.go | 62 ++++ _testing/e2e/sdl/sdl_nocgo.go | 10 + docs/testing/e2e_latency.md | 94 ++++-- go.mod | 1 + go.sum | 2 + .../testsupport/latencytrace/trace_windows.go | 104 +++++++ .../latencytrace/trace_windows_test.go | 23 ++ 20 files changed, 1755 insertions(+), 115 deletions(-) create mode 100644 _testing/e2e/cmd/verifylatency/main.go create mode 100644 _testing/e2e/latency/ViiperLatency.wprp create mode 100644 _testing/e2e/latency/edge_fence.go create mode 100644 _testing/e2e/latency/edge_fence_test.go create mode 100644 _testing/e2e/latency/profile_contract_test.go create mode 100644 _testing/e2e/latency/trace_markers.go create mode 100644 _testing/e2e/latency/trace_markers_test.go create mode 100644 _testing/e2e/pnp_path_windows_test.go create mode 100644 _testing/e2e/pnp_windows_test.go create mode 100644 internal/testsupport/latencytrace/trace_windows.go create mode 100644 internal/testsupport/latencytrace/trace_windows_test.go diff --git a/_testing/e2e/cmd/verifylatency/main.go b/_testing/e2e/cmd/verifylatency/main.go new file mode 100644 index 00000000..7c2a916c --- /dev/null +++ b/_testing/e2e/cmd/verifylatency/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" +) + +func main() { + var input, markersPath, source, sdlRevision, sdlHash, manifestHash, driverHash, driverBuildIdentity, profileHash string + var samples int + flag.StringVar(&input, "input", "", "latency suite JSON") + flag.StringVar(&markersPath, "markers", "", "decoded ETL TraceLogging marker JSON") + flag.StringVar(&source, "source", "", "expected repository revision") + flag.StringVar(&sdlRevision, "sdl-revision", "", "expected SDL revision") + flag.StringVar(&sdlHash, "sdl-sha256", "", "expected loaded SDL SHA-256") + flag.StringVar(&manifestHash, "manifest-sha256", "", "expected package manifest SHA-256") + flag.StringVar(&driverHash, "driver-sha256", "", "expected installed driver SHA-256") + flag.StringVar(&driverBuildIdentity, "driver-build-identity", "", "expected negotiated loaded-driver identity") + flag.StringVar(&profileHash, "trace-profile-sha256", "", "expected WPRP SHA-256") + flag.IntVar(&samples, "samples", 0, "expected sample pairs per controller/transport") + flag.Parse() + if input == "" || markersPath == "" || source == "" || sdlRevision == "" || sdlHash == "" || + manifestHash == "" || driverHash == "" || driverBuildIdentity == "" || profileHash == "" || samples == 0 { + fail(errors.New("all verifier flags are required")) + } + file, err := os.Open(input) + if err != nil { + fail(err) + } + defer file.Close() + suite, err := latency.ParseSuiteReport(file) + if err != nil { + fail(err) + } + if err = latency.RequireSuitePass(suite); err != nil { + fail(err) + } + p := suite.Provenance + if p.SourceRevision != strings.ToLower(source) || + p.SDLSourceRevision != strings.ToLower(sdlRevision) || + p.SDLBinarySHA256 != strings.ToLower(sdlHash) || + p.NativePackageManifestSHA256 != strings.ToLower(manifestHash) || + p.NativeDriverSHA256 != strings.ToLower(driverHash) || + p.NativeDriverBuildIdentity != strings.ToLower(driverBuildIdentity) || + p.TraceProfileSHA256 != strings.ToLower(profileHash) || + p.TraceProviderName != latency.TraceProviderName || + p.TraceProviderGUID != latency.TraceProviderGUID || + p.USBIPBaselineMode != latency.USBIPBaselineMode || + p.USBIPBaselineVersion != latency.USBIPBaselineVersion { + fail(errors.New("suite provenance does not match the production invocation")) + } + for _, controllerCase := range suite.Cases { + if controllerCase.Workload.SamplePairs != samples { + fail(fmt.Errorf("%s has %d sample pairs, want %d", + controllerCase.Workload.ControllerType, controllerCase.Workload.SamplePairs, samples)) + } + } + markersFile, err := os.Open(markersPath) + if err != nil { + fail(err) + } + defer markersFile.Close() + markers, err := latency.ParseTraceMarkers(markersFile) + if err != nil { + fail(err) + } + if err = latency.VerifyTraceMarkers(suite, markers); err != nil { + fail(err) + } + fmt.Printf("strictly verified %d controller cases\n", len(suite.Cases)) +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "latency evidence rejected:", err) + os.Exit(1) +} diff --git a/_testing/e2e/latency/ViiperLatency.wprp b/_testing/e2e/latency/ViiperLatency.wprp new file mode 100644 index 00000000..bba70efa --- /dev/null +++ b/_testing/e2e/latency/ViiperLatency.wprp @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_testing/e2e/latency/edge_fence.go b/_testing/e2e/latency/edge_fence.go new file mode 100644 index 00000000..fcf18868 --- /dev/null +++ b/_testing/e2e/latency/edge_fence.go @@ -0,0 +1,35 @@ +package latency + +import "errors" + +// RejectPreWriteEdge accounts for an exact SDL button edge observed during +// dwell or final queue drain and always rejects it as non-causal to the next +// input write. +func RejectPreWriteEdge(lastTimestamp, eventTimestamp uint64, down bool, counters *Counters) (uint64, error) { + if eventTimestamp == 0 || (lastTimestamp != 0 && eventTimestamp < lastTimestamp) { + return lastTimestamp, errors.New("SDL pre-write event timestamp was absent or regressed") + } + if counters != nil { + if down { + counters.Press++ + } else { + counters.Release++ + } + } + return eventTimestamp, errors.New("SDL button edge preceded the input write") +} + +// ValidatePostWriteTimestamp proves an observed SDL event was generated no +// earlier than the SDL clock fence captured before WriteBinary. +func ValidatePostWriteTimestamp(lastTimestamp, fenceTimestamp, eventTimestamp uint64) error { + if fenceTimestamp == 0 || eventTimestamp == 0 { + return errors.New("SDL event or pre-write fence timestamp is absent") + } + if lastTimestamp != 0 && eventTimestamp < lastTimestamp { + return errors.New("SDL event clock regressed") + } + if eventTimestamp <= fenceTimestamp { + return errors.New("SDL event did not follow the input write fence") + } + return nil +} diff --git a/_testing/e2e/latency/edge_fence_test.go b/_testing/e2e/latency/edge_fence_test.go new file mode 100644 index 00000000..c51c01ac --- /dev/null +++ b/_testing/e2e/latency/edge_fence_test.go @@ -0,0 +1,32 @@ +package latency + +import "testing" + +func TestCausalEdgeFenceRejectsDwellDrainAndPreFenceEvents(t *testing.T) { + counters := Counters{} + last, err := RejectPreWriteEdge(100, 101, true, &counters) + if err == nil || last != 101 || counters.Press != 1 { + t.Fatalf("queued press was not rejected/accounted: last=%d counters=%+v error=%v", last, counters, err) + } + last, err = RejectPreWriteEdge(last, 102, false, &counters) + if err == nil || last != 102 || counters.Release != 1 { + t.Fatalf("dwell release was not rejected/accounted: last=%d counters=%+v error=%v", last, counters, err) + } + if err = ValidatePostWriteTimestamp(last, 200, 199); err == nil { + t.Fatal("an event older than the SDL pre-write fence was accepted") + } + if err = ValidatePostWriteTimestamp(last, 200, 200); err == nil { + t.Fatal("an event sharing the pre-write fence tick was accepted") + } + if err = ValidatePostWriteTimestamp(last, 200, 201); err != nil { + t.Fatalf("an event after the causal admission fence was rejected: %v", err) + } +} + +func TestCausalEdgeFenceDoesNotCountInvalidTimestamp(t *testing.T) { + counters := Counters{} + last, err := RejectPreWriteEdge(100, 99, true, &counters) + if err == nil || last != 100 || counters.Total() != 0 { + t.Fatalf("regressed event corrupted counters: last=%d counters=%+v error=%v", last, counters, err) + } +} diff --git a/_testing/e2e/latency/profile_contract_test.go b/_testing/e2e/latency/profile_contract_test.go new file mode 100644 index 00000000..7a59efb4 --- /dev/null +++ b/_testing/e2e/latency/profile_contract_test.go @@ -0,0 +1,65 @@ +package latency + +import ( + "os" + "strings" + "testing" +) + +func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { + profile, err := os.ReadFile("ViiperLatency.wprp") + if err != nil { + t.Fatal(err) + } + profileText := string(profile) + for _, want := range []string{ + `LoggingMode="File"`, TraceProviderGUID[1 : len(TraceProviderGUID)-1], + `Value="CSwitch"`, `Value="ReadyThread"`, `Value="SampledProfile"`, + `Value="DPC"`, `Value="Interrupt"`, `Value="WDFDPC"`, `Value="WDFInterrupt"`, + } { + if !strings.Contains(profileText, want) { + t.Fatalf("source-controlled WPRP is missing %q", want) + } + } + if strings.Contains(profileText, `LoggingMode="Memory"`) { + t.Fatal("production WPRP regressed to circular memory logging") + } + + wrapper, err := os.ReadFile("../scripts/Invoke-ViiperE2ELatencyGate.ps1") + if err != nil { + t.Fatal(err) + } + wrapperText := string(wrapper) + for _, want := range []string{ + "-filemode", "verifylatency", + "-C $repository test", "-C $repository run", + "$env:GOWORK = 'off'", "$env:GOENV = 'off'", "$env:GOTOOLCHAIN = 'local'", + "-ldflags $nativeRevisionLDFlag", + "github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision", + "Get-WinEvent -FilterHashtable", "ProviderName = 'VIIPER-LatencyGate'", + "trace_marker_id", "start_qpc_ticks", "trace_marker_qpc_ticks", + "Dropped\\s+Event", "Buffers?\\s+Lost", + } { + if !strings.Contains(wrapperText, want) { + t.Fatalf("production wrapper is missing fail-closed contract %q", want) + } + } + if strings.Contains(wrapperText, "GeneralProfile.Verbose") { + t.Fatal("production wrapper regressed to an inbox circular profile") + } + liveHarness, err := os.ReadFile("../latency_gate_windows_test.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(liveHarness), "sdl.EnableWindowsRawInput()") { + t.Fatal("production harness no longer enables the SDL backend that supplies exact Xbox PnP paths") + } + verifier, err := os.ReadFile("../cmd/verifylatency/main.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(verifier), "latency.ParseSuiteReport") || + !strings.Contains(string(verifier), "latency.RequireSuitePass") { + t.Fatal("production verifier no longer invokes strict parsing and pass enforcement") + } +} diff --git a/_testing/e2e/latency/report.go b/_testing/e2e/latency/report.go index af0df05d..c1fd84bf 100644 --- a/_testing/e2e/latency/report.go +++ b/_testing/e2e/latency/report.go @@ -32,6 +32,10 @@ const ( TransportUSBIP = "usbip" TransportNativeUDE = "native-ude" AuthenticationMode = "password-authenticated-encrypted-stream" + TraceProviderName = "VIIPER-LatencyGate" + TraceProviderGUID = "{e1726ef8-c2e6-4dad-bbf7-2d871b953ab1}" + USBIPBaselineMode = "version-probed-functional-baseline-not-source-bound" + USBIPBaselineVersion = "0.9.7.7" MinimumProductionSamplePairs = 256 MaximumProductionSamplePairs = 10_000 ProductionWarmupPairs = 16 @@ -118,10 +122,15 @@ func ProductionPhaseOffsetNS(sequence int, transition Transition) int64 { } type Sample struct { - Sequence int `json:"sequence"` - Transition Transition `json:"transition"` - LatencyNS int64 `json:"latency_ns"` - EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` + Sequence int `json:"sequence"` + Transition Transition `json:"transition"` + LatencyNS int64 `json:"latency_ns"` + EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` + SDLFenceTimestampNS uint64 `json:"sdl_prewrite_fence_timestamp_ns"` + StartQPCTicks int64 `json:"start_qpc_ticks"` + EndQPCTicks int64 `json:"end_qpc_ticks"` + MarkerQPCTicks int64 `json:"trace_marker_qpc_ticks"` + MarkerID string `json:"trace_marker_id"` } type Counters struct { @@ -151,6 +160,7 @@ type NativeServerProof struct { ABIMinor uint16 `json:"abi_minor"` Capabilities uint32 `json:"capabilities"` ExpectedDriverPackageVersion string `json:"expected_driver_package_version"` + LoadedDriverBuildIdentity string `json:"loaded_driver_build_identity"` } type ServerProof struct { @@ -171,17 +181,25 @@ type DeviceProof struct { } type ControllerProof struct { - BaselineGamepadIDs []int32 `json:"baseline_gamepad_ids"` - NewGamepadIDs []int32 `json:"new_gamepad_ids"` - SDLInstanceID int32 `json:"sdl_instance_id"` - SDLPath string `json:"sdl_path"` - SDLGUID string `json:"sdl_guid"` - SDLName string `json:"sdl_name"` - SDLType string `json:"sdl_type"` - SDLReportedType int32 `json:"sdl_reported_type"` - SDLRealType int32 `json:"sdl_real_type"` - VendorID uint16 `json:"vendor_id"` - ProductID uint16 `json:"product_id"` + BaselineGamepadIDs []int32 `json:"baseline_gamepad_ids"` + NewGamepadIDs []int32 `json:"new_gamepad_ids"` + SDLInstanceID int32 `json:"sdl_instance_id"` + SDLPath string `json:"sdl_path"` + SDLGUID string `json:"sdl_guid"` + SDLName string `json:"sdl_name"` + SDLType string `json:"sdl_type"` + SDLReportedType int32 `json:"sdl_reported_type"` + SDLRealType int32 `json:"sdl_real_type"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + PNPInstanceID string `json:"pnp_instance_id"` + PNPAncestorIDs []string `json:"pnp_ancestor_ids"` + PNPAncestorServices []string `json:"pnp_ancestor_services"` + PNPAncestorHardwareIDs [][]string `json:"pnp_ancestor_hardware_ids"` + PNPAncestorLocationInfo []string `json:"pnp_ancestor_location_info"` + PNPAncestorLocationPaths [][]string `json:"pnp_ancestor_location_paths"` + TransportAnchorInstanceID string `json:"transport_anchor_instance_id"` + TransportAnchorService string `json:"transport_anchor_service"` } type Run struct { @@ -226,11 +244,43 @@ type Provenance struct { SDLBinarySHA256 string `json:"sdl_binary_sha256"` NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + QPCFrequency int64 `json:"qpc_frequency"` + TraceProviderName string `json:"trace_provider_name"` + TraceProviderGUID string `json:"trace_provider_guid"` + TraceProfileSHA256 string `json:"trace_profile_sha256"` + USBIPBaselineMode string `json:"usbip_baseline_mode"` + USBIPBaselineVersion string `json:"usbip_baseline_version"` GoVersion string `json:"go_version"` GOOS string `json:"goos"` GOARCH string `json:"goarch"` } +// SampleMarkerID is the canonical cross-artifact identity shared by JSON and +// the TraceLogging marker emitted after the SDL edge is observed. +func SampleMarkerID(controller, transport string, block, sequence int, transition Transition) string { + return fmt.Sprintf("%s:%s:%d:%d:%s", controller, transport, block, sequence, transition) +} + +// QPCIntervalNS converts a bounded raw QueryPerformanceCounter interval to +// nanoseconds. Production samples are shorter than the per-transition timeout, +// so rejecting rather than saturating on multiplication overflow is safe and +// prevents a forged or corrupted interval from becoming a plausible latency. +func QPCIntervalNS(start, end, frequency int64) (int64, error) { + if start <= 0 || end <= start || frequency <= 0 { + return 0, errors.New("QPC interval or frequency is invalid") + } + delta := end - start + if delta > math.MaxInt64/int64(time.Second) { + return 0, errors.New("QPC interval overflows nanosecond conversion") + } + nanoseconds := delta * int64(time.Second) / frequency + if nanoseconds <= 0 { + return 0, errors.New("QPC interval has sub-nanosecond or non-positive duration") + } + return nanoseconds, nil +} + type Policy struct { MinimumSamplePairs int `json:"minimum_sample_pairs"` NativeMaxP95NS int64 `json:"native_max_p95_ns"` @@ -293,7 +343,7 @@ type SuiteReport struct { } var ( - revisionPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`) + revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) ) @@ -543,19 +593,30 @@ func validateBase(report *Report) error { return errors.New("generated_at is required") } if !revisionPattern.MatchString(report.Provenance.SourceRevision) { - return errors.New("source_revision must be a lowercase 40-64 digit Git revision") + return errors.New("source_revision must be a lowercase 40- or 64-digit Git revision") } if !revisionPattern.MatchString(report.Provenance.SDLSourceRevision) { - return errors.New("sdl_source_revision must be a lowercase 40-64 digit Git revision") + return errors.New("sdl_source_revision must be a lowercase 40- or 64-digit Git revision") } if report.Provenance.SDLBinaryPath == "" || !hashPattern.MatchString(report.Provenance.SDLBinarySHA256) { return errors.New("the loaded SDL binary path and SHA-256 are required") } if !hashPattern.MatchString(report.Provenance.NativePackageManifestSHA256) || - !hashPattern.MatchString(report.Provenance.NativeDriverSHA256) { + !hashPattern.MatchString(report.Provenance.NativeDriverSHA256) || + !hashPattern.MatchString(report.Provenance.NativeDriverBuildIdentity) { return errors.New("source-bound native package manifest and installed driver hashes are required") } + if report.Provenance.QPCFrequency <= 0 || + report.Provenance.TraceProviderName != TraceProviderName || + report.Provenance.TraceProviderGUID != TraceProviderGUID || + !hashPattern.MatchString(report.Provenance.TraceProfileSHA256) { + return errors.New("QPC and source-controlled TraceLogging provenance are incomplete") + } + if report.Provenance.USBIPBaselineMode != USBIPBaselineMode || + report.Provenance.USBIPBaselineVersion != USBIPBaselineVersion { + return errors.New("USB/IP comparison must be explicitly labeled as the exact version-probed, non-source-bound baseline") + } if report.Provenance.GoVersion == "" || report.Provenance.GOOS != "windows" || report.Provenance.GOARCH == "" { return errors.New("Windows Go toolchain provenance is incomplete") @@ -604,7 +665,7 @@ func validateBase(report *Report) error { } for index := range report.Runs { - if err := validateRun(&report.Runs[index], report.Workload, schedule[index]); err != nil { + if err := validateRun(&report.Runs[index], report.Workload, report.Provenance, schedule[index]); err != nil { return fmt.Errorf("order %d %s block %d: %w", index+1, report.Runs[index].Transport, report.Runs[index].TransportBlock, err) } @@ -632,7 +693,7 @@ func validateBase(report *Report) error { return nil } -func validateRun(run *Run, workload Workload, block BlockSpec) error { +func validateRun(run *Run, workload Workload, provenance Provenance, block BlockSpec) error { if run.Order != block.Order || run.Transport != block.Transport || run.TransportBlock != block.TransportBlock || run.FirstSequence != block.FirstSequence || run.SamplePairs != block.SamplePairs { @@ -649,6 +710,7 @@ func validateRun(run *Run, workload Workload, block BlockSpec) error { return errors.New("more samples than the declared transport block") } var priorTimestamp uint64 + var priorMarkerQPC int64 for index, sample := range run.Samples { wantSequence := run.FirstSequence + index/2 wantTransition := TransitionPress @@ -659,13 +721,23 @@ func validateRun(run *Run, workload Workload, block BlockSpec) error { return fmt.Errorf("sample %d is %d/%s, want %d/%s", index, sample.Sequence, sample.Transition, wantSequence, wantTransition) } - if sample.LatencyNS <= 0 || sample.EventTimestampNS == 0 { - return fmt.Errorf("sample %d has invalid latency or SDL timestamp", index) + wantMarkerID := SampleMarkerID(workload.ControllerType, run.Transport, + run.TransportBlock, sample.Sequence, sample.Transition) + qpcLatencyNS, qpcErr := QPCIntervalNS(sample.StartQPCTicks, + sample.EndQPCTicks, provenance.QPCFrequency) + if qpcErr != nil || sample.LatencyNS != qpcLatencyNS || sample.EventTimestampNS == 0 || + sample.SDLFenceTimestampNS == 0 || + sample.EventTimestampNS <= sample.SDLFenceTimestampNS || + (priorMarkerQPC != 0 && sample.StartQPCTicks < priorMarkerQPC) || + sample.MarkerQPCTicks < sample.EndQPCTicks || + sample.MarkerID != wantMarkerID { + return fmt.Errorf("sample %d has invalid or inconsistent latency, causal fence, QPC interval, or trace marker", index) } if priorTimestamp != 0 && sample.EventTimestampNS < priorTimestamp { return fmt.Errorf("sample %d regressed the SDL event clock", index) } priorTimestamp = sample.EventTimestampNS + priorMarkerQPC = sample.MarkerQPCTicks } if run.Failure == "" && len(run.Samples) != 2*run.SamplePairs { return errors.New("successful run does not contain every press/release sample") @@ -688,9 +760,13 @@ func validateRun(run *Run, workload Workload, block BlockSpec) error { } if run.Transport == TransportNativeUDE { if run.Server.NativeUDE == nil || run.Server.NativeUDE.ABIMajor == 0 || - run.Server.NativeUDE.ExpectedDriverPackageVersion == "" || run.Device.USBIPPort != 0 { + run.Server.NativeUDE.ExpectedDriverPackageVersion == "" || + run.Server.NativeUDE.LoadedDriverBuildIdentity == "" || run.Device.USBIPPort != 0 { return errors.New("native transport proof is absent or contradictory") } + if run.Server.NativeUDE.LoadedDriverBuildIdentity != provenance.NativeDriverBuildIdentity { + return errors.New("loaded native driver build identity does not match the signed package manifest") + } } else if run.Transport == TransportUSBIP { if run.Server.NativeUDE != nil || run.Device.USBIPPort <= 0 { return errors.New("USB/IP transport proof is absent or contradictory") @@ -715,9 +791,85 @@ func validateRun(run *Run, workload Workload, block BlockSpec) error { run.Controller.ProductID != workload.ExpectedProductID { return errors.New("new SDL gamepad identity does not match the API-created controller") } + if err := ValidateTransportAncestry(run.Transport, run.Device.USBIPPort, run.Controller); err != nil { + return err + } return nil } +// ValidateTransportAncestry rejects VID/PID-only substitutions and requires +// exactly one transport-specific root anchor in the SDL interface's PnP chain. +func ValidateTransportAncestry(transport string, usbipPort int32, proof ControllerProof) error { + if proof.PNPInstanceID == "" || len(proof.PNPAncestorIDs) == 0 || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorServices) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorHardwareIDs) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationInfo) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationPaths) || + !strings.EqualFold(proof.PNPAncestorIDs[0], proof.PNPInstanceID) { + return errors.New("SDL observer lacks an exact, internally consistent Windows PnP ancestry proof") + } + anchorCount := 0 + for index, instanceID := range proof.PNPAncestorIDs { + service := proof.PNPAncestorServices[index] + hardwareIDs := proof.PNPAncestorHardwareIDs[index] + isAnchor := false + switch transport { + case TransportNativeUDE: + isAnchor = strings.EqualFold(service, "ViiperUde") && + containsFold(hardwareIDs, `ROOT\VIIPER\UDE`) + case TransportUSBIP: + // Root-enumerated devnode instance IDs are OS-assigned (for example + // ROOT\USB\0002). The stable INF identity is the exact hardware ID. + isAnchor = strings.EqualFold(service, "usbip2_ude") && + containsFold(hardwareIDs, `ROOT\USBIP_WIN2\UDE`) + default: + return fmt.Errorf("unsupported transport %q", transport) + } + if isAnchor { + anchorCount++ + if !strings.EqualFold(proof.TransportAnchorInstanceID, instanceID) || + !strings.EqualFold(proof.TransportAnchorService, service) { + return errors.New("reported transport anchor does not match the exact PnP ancestor") + } + } + } + if anchorCount != 1 { + return fmt.Errorf("PnP ancestry contains %d exact %s transport anchors, want 1", anchorCount, transport) + } + if transport == TransportUSBIP { + if usbipPort <= 0 { + return errors.New("USB/IP transport has no positive import-port identity") + } + portSegment := fmt.Sprintf("USB(%d)", usbipPort) + portMatched := false + for index, instanceID := range proof.PNPAncestorIDs { + if strings.EqualFold(instanceID, proof.TransportAnchorInstanceID) { + break + } + for _, locationPath := range proof.PNPAncestorLocationPaths[index] { + for _, segment := range strings.Split(locationPath, "#") { + if strings.EqualFold(segment, portSegment) { + portMatched = true + } + } + } + } + if !portMatched { + return fmt.Errorf("USB/IP PnP descendants do not prove returned root-hub port %d", usbipPort) + } + } + return nil +} + +func containsFold(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} + func expectedSDLRealType(controllerType string) int32 { switch controllerType { case "xbox360": diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go index 6460e534..5194f1bb 100644 --- a/_testing/e2e/latency/report_test.go +++ b/_testing/e2e/latency/report_test.go @@ -35,6 +35,29 @@ func TestCalculateNearestRankDistributionAndJitter(t *testing.T) { } } +func TestQPCIntervalNSFailsClosed(t *testing.T) { + got, err := QPCIntervalNS(10, 410, 1_000_000) + if err != nil || got != 400_000 { + t.Fatalf("QPCIntervalNS()=(%d, %v), want (400000, nil)", got, err) + } + for _, test := range []struct { + name string + start, end, frequency int64 + }{ + {name: "zero start", start: 0, end: 2, frequency: 1}, + {name: "reversed", start: 2, end: 1, frequency: 1}, + {name: "zero frequency", start: 1, end: 2, frequency: 0}, + {name: "sub nanosecond", start: 1, end: 2, frequency: 2_000_000_000}, + {name: "overflow", start: 1, end: math.MaxInt64, frequency: 1}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := QPCIntervalNS(test.start, test.end, test.frequency); err == nil { + t.Fatal("invalid QPC interval was accepted") + } + }) + } +} + func TestCalculateRejectsMissingAndNonPositiveSamples(t *testing.T) { if _, err := Calculate(nil); err == nil { t.Fatal("zero samples were accepted") @@ -44,6 +67,33 @@ func TestCalculateRejectsMissingAndNonPositiveSamples(t *testing.T) { } } +func TestUSBIPAnchorUsesINFHardwareIDAndOSAssignedInstance(t *testing.T) { + // usbip-win2's INF binds ROOT\USBIP_WIN2\UDE to usbip2_ude, while live + // SetupAPI/pnputil evidence exposes the present OS-assigned instance as + // ROOT\USB\####. Preserve all three identities; none substitutes for another. + proof := ControllerProof{ + PNPInstanceID: `HID\VID_045E&PID_028E\1`, + PNPAncestorIDs: []string{`HID\VID_045E&PID_028E\1`, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`}, + PNPAncestorServices: []string{"HidUsb", "usbccgp", "usbip2_ude"}, + PNPAncestorHardwareIDs: [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}}, + PNPAncestorLocationInfo: []string{"", "Port_#0007.Hub_#0001", ""}, + PNPAncestorLocationPaths: [][]string{{}, {`USBROOT(0)#USB(7)`}, {}}, + TransportAnchorInstanceID: `ROOT\USB\0002`, + TransportAnchorService: "usbip2_ude", + } + if err := ValidateTransportAncestry(TransportUSBIP, 7, proof); err != nil { + t.Fatalf("exact USB/IP INF anchor rejected: %v", err) + } + if err := ValidateTransportAncestry(TransportUSBIP, 8, proof); err == nil || + !strings.Contains(err.Error(), "root-hub port 8") { + t.Fatalf("wrong USB/IP import port was not rejected: %v", err) + } + proof.PNPAncestorHardwareIDs[2] = []string{`ROOT\USB\0002`} + if err := ValidateTransportAncestry(TransportUSBIP, 7, proof); err == nil { + t.Fatal("OS-assigned instance ID was accepted as a substitute for the USB/IP INF hardware ID") + } +} + func TestProductionBlockScheduleIsCounterbalancedAndComplete(t *testing.T) { want := []BlockSpec{ {Order: 1, Transport: TransportUSBIP, TransportBlock: 1, FirstSequence: 1, SamplePairs: 128}, @@ -145,6 +195,14 @@ func TestParseReportRejectsUnknownTrailingAndForgedData(t *testing.T) { t.Fatalf("unauthenticated source error=%v", err) } }) + + t.Run("noncanonical source revision length", func(t *testing.T) { + report := validReport(t) + report.Provenance.SourceRevision = strings.Repeat("a", 41) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "40- or 64") { + t.Fatalf("noncanonical revision error=%v", err) + } + }) } func TestParseSuiteRequiresPlayStationCasesAndWorkloadParity(t *testing.T) { @@ -275,11 +333,78 @@ func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { t.Run("event clock regression", func(t *testing.T) { report := validReport(t) - report.Runs[0].Samples[1].EventTimestampNS = 1 + report.Runs[0].Samples[1].EventTimestampNS = report.Runs[0].Samples[0].EventTimestampNS - 1 + report.Runs[0].Samples[1].SDLFenceTimestampNS = report.Runs[0].Samples[1].EventTimestampNS - 1 if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "event clock") { t.Fatalf("event clock error=%v", err) } }) + + t.Run("pre-write SDL edge", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].SDLFenceTimestampNS = + report.Runs[0].Samples[0].EventTimestampNS + 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "causal fence") { + t.Fatalf("pre-write SDL edge error=%v", err) + } + }) + + t.Run("forged trace marker", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].MarkerID = "another-sample" + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "trace marker") { + t.Fatalf("forged trace marker error=%v", err) + } + }) + + t.Run("trace marker inside measured interval", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].MarkerQPCTicks = + report.Runs[0].Samples[0].EndQPCTicks - 1 + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "QPC interval") { + t.Fatalf("in-interval marker error=%v", err) + } + }) + + t.Run("latency disagrees with raw QPC", func(t *testing.T) { + report := validReport(t) + report.Runs[0].Samples[0].LatencyNS++ + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "inconsistent latency") { + t.Fatalf("forged QPC latency error=%v", err) + } + }) + + t.Run("wrong native ancestry", func(t *testing.T) { + report := validReport(t) + run := &report.Runs[1] + run.Controller.PNPAncestorIDs[len(run.Controller.PNPAncestorIDs)-1] = `ROOT\USB\0002` + run.Controller.PNPAncestorServices[len(run.Controller.PNPAncestorServices)-1] = "usbip2_ude" + run.Controller.PNPAncestorHardwareIDs[len(run.Controller.PNPAncestorHardwareIDs)-1] = []string{`ROOT\USBIP_WIN2\UDE`} + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "anchor") { + t.Fatalf("wrong native ancestry error=%v", err) + } + }) + + t.Run("wrong loaded native build", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Server.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("2", 64) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "signed package manifest") { + t.Fatalf("wrong loaded driver identity error=%v", err) + } + }) + + t.Run("ambiguous USBIP ancestry", func(t *testing.T) { + report := validReport(t) + run := &report.Runs[0] + run.Controller.PNPAncestorIDs = append(run.Controller.PNPAncestorIDs, `ROOT\USB\0003`) + run.Controller.PNPAncestorServices = append(run.Controller.PNPAncestorServices, "usbip2_ude") + run.Controller.PNPAncestorHardwareIDs = append(run.Controller.PNPAncestorHardwareIDs, []string{`ROOT\USBIP_WIN2\UDE`}) + run.Controller.PNPAncestorLocationInfo = append(run.Controller.PNPAncestorLocationInfo, "") + run.Controller.PNPAncestorLocationPaths = append(run.Controller.PNPAncestorLocationPaths, []string{}) + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "anchor") { + t.Fatalf("ambiguous USB/IP ancestry error=%v", err) + } + }) } func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { @@ -297,7 +422,7 @@ func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { continue } for sampleIndex := range run.Samples { - run.Samples[sampleIndex].LatencyNS = 1_500_000 + setSampleLatency(&run.Samples[sampleIndex], 1_500_000, report.Provenance.QPCFrequency) } } }, @@ -307,7 +432,7 @@ func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { mutate: func(report *Report) { nativeSecond := &report.Runs[2] for index := len(nativeSecond.Samples) - 6; index < len(nativeSecond.Samples); index++ { - nativeSecond.Samples[index].LatencyNS = 2_600_000 + setSampleLatency(&nativeSecond.Samples[index], 2_600_000, report.Provenance.QPCFrequency) } }, }, @@ -315,7 +440,8 @@ func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { name: "max", metric: "max", mutate: func(report *Report) { nativeSecond := &report.Runs[2] - nativeSecond.Samples[len(nativeSecond.Samples)-1].LatencyNS = 5_600_000 + setSampleLatency(&nativeSecond.Samples[len(nativeSecond.Samples)-1], 5_600_000, + report.Provenance.QPCFrequency) }, }, } @@ -349,6 +475,13 @@ func validReport(t *testing.T) *Report { SDLBinarySHA256: strings.Repeat("c", 64), NativePackageManifestSHA256: strings.Repeat("d", 64), NativeDriverSHA256: strings.Repeat("e", 64), + NativeDriverBuildIdentity: strings.Repeat("1", 64), + QPCFrequency: 1_000_000_000, + TraceProviderName: TraceProviderName, + TraceProviderGUID: TraceProviderGUID, + TraceProfileSHA256: strings.Repeat("f", 64), + USBIPBaselineMode: USBIPBaselineMode, + USBIPBaselineVersion: USBIPBaselineVersion, GoVersion: "go1.26.2", GOOS: "windows", GOARCH: "amd64", @@ -412,25 +545,54 @@ func validReport(t *testing.T) *Report { } if block.Transport == TransportUSBIP { run.Device.USBIPPort = 1 + run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\1` + run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`} + run.Controller.PNPAncestorServices = []string{"HidUsb", "usbccgp", "usbip2_ude"} + run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}} + run.Controller.PNPAncestorLocationInfo = []string{"", "Port_#0001.Hub_#0001", ""} + run.Controller.PNPAncestorLocationPaths = [][]string{{}, {`USBROOT(0)#USB(1)`}, {}} + run.Controller.TransportAnchorInstanceID = `ROOT\USB\0002` + run.Controller.TransportAnchorService = "usbip2_ude" } else { run.Server.NativeUDE = &NativeServerProof{ ABIMajor: 1, ABIMinor: 0, Capabilities: 1, ExpectedDriverPackageVersion: "0.1.0.3", + LoadedDriverBuildIdentity: report.Provenance.NativeDriverBuildIdentity, } + run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\2` + run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\2`, `ROOT\VIIPER\UDE\0000`} + run.Controller.PNPAncestorServices = []string{"HidUsb", "WUDFRd", "ViiperUde"} + run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\VIIPER\UDE`}} + run.Controller.PNPAncestorLocationInfo = []string{"", "", ""} + run.Controller.PNPAncestorLocationPaths = [][]string{{}, {}, {}} + run.Controller.TransportAnchorInstanceID = `ROOT\VIIPER\UDE\0000` + run.Controller.TransportAnchorService = "ViiperUde" } transportOffset := 0 if block.Transport == TransportNativeUDE { transportOffset = 50_000 } lastSequence := block.FirstSequence + block.SamplePairs - 1 + qpcCursor := int64(runIndex+1) * 1_000_000_000_000 for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { base := int64(400_000 + transportOffset + sequence*10) timestamp := uint64(runIndex+1)*1_000_000_000 + uint64(sequence*2) + pressStartQPC := qpcCursor + pressEndQPC := pressStartQPC + base + releaseStartQPC := pressStartQPC + 10_000_000 + releaseEndQPC := releaseStartQPC + base + 5 run.Samples = append(run.Samples, Sample{Sequence: sequence, Transition: TransitionPress, - LatencyNS: base, EventTimestampNS: timestamp}, + LatencyNS: base, EventTimestampNS: timestamp, SDLFenceTimestampNS: timestamp - 1, + StartQPCTicks: pressStartQPC, EndQPCTicks: pressEndQPC, + MarkerQPCTicks: pressEndQPC + 1, + MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionPress)}, Sample{Sequence: sequence, Transition: TransitionRelease, - LatencyNS: base + 5, EventTimestampNS: timestamp + 1}) + LatencyNS: base + 5, EventTimestampNS: timestamp + 1, SDLFenceTimestampNS: timestamp, + StartQPCTicks: releaseStartQPC, EndQPCTicks: releaseEndQPC, + MarkerQPCTicks: releaseEndQPC + 1, + MarkerID: SampleMarkerID("xbox360", run.Transport, run.TransportBlock, sequence, TransitionRelease)}) + qpcCursor = pressStartQPC + 20_000_000 } report.Runs = append(report.Runs, run) } @@ -443,6 +605,12 @@ func validReport(t *testing.T) *Report { return report } +func setSampleLatency(sample *Sample, latencyNS, qpcFrequency int64) { + sample.LatencyNS = latencyNS + sample.EndQPCTicks = sample.StartQPCTicks + latencyNS*qpcFrequency/int64(time.Second) + sample.MarkerQPCTicks = sample.EndQPCTicks + 1 +} + func validSuite(t *testing.T) *SuiteReport { t.Helper() xbox := validReport(t) @@ -478,6 +646,11 @@ func validSuite(t *testing.T) *SuiteReport { run.Controller.SDLInstanceID = int32(100 + caseIndex*10 + runIndex) run.Controller.NewGamepadIDs = []int32{run.Controller.SDLInstanceID} run.Controller.SDLPath = identity.controller + "-" + run.Transport + for sampleIndex := range run.Samples { + sample := &run.Samples[sampleIndex] + sample.MarkerID = SampleMarkerID(identity.controller, run.Transport, + run.TransportBlock, sample.Sequence, sample.Transition) + } } if err := Finalize(&report); err != nil { t.Fatal(err) diff --git a/_testing/e2e/latency/trace_markers.go b/_testing/e2e/latency/trace_markers.go new file mode 100644 index 00000000..0faad644 --- /dev/null +++ b/_testing/e2e/latency/trace_markers.go @@ -0,0 +1,100 @@ +package latency + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +type TraceMarker struct { + MarkerID string `json:"trace_marker_id"` + Controller string `json:"controller"` + Transport string `json:"transport"` + TransportBlock int `json:"transport_block"` + Sequence int `json:"sequence"` + Transition string `json:"transition"` + StartQPCTicks int64 `json:"start_qpc_ticks"` + EndQPCTicks int64 `json:"end_qpc_ticks"` + MarkerQPCTicks int64 `json:"trace_marker_qpc_ticks"` + LatencyNS int64 `json:"latency_ns"` + EventTimestampNS uint64 `json:"sdl_event_timestamp_ns"` + SDLFenceTimestampNS uint64 `json:"sdl_prewrite_fence_timestamp_ns"` +} + +func ParseTraceMarkers(reader io.Reader) ([]TraceMarker, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var markers []TraceMarker + if err := decoder.Decode(&markers); err != nil { + return nil, fmt.Errorf("decode ETL marker evidence: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("ETL marker evidence contains trailing JSON") + } + return nil, fmt.Errorf("decode trailing ETL marker evidence: %w", err) + } + return markers, nil +} + +// VerifyTraceMarkers requires an exact, chronological, one-to-one copy of every +// finalized JSON sample in the decoded sequential ETL marker stream. The +// decoder supplies events in oldest-first ETL order; accepting set equality +// would hide event reordering and weaken the scheduling evidence. +func VerifyTraceMarkers(suite *SuiteReport, observed []TraceMarker) error { + if suite == nil { + return errors.New("nil latency suite") + } + var expected []TraceMarker + expectedByID := make(map[string]TraceMarker) + for _, controllerCase := range suite.Cases { + for _, run := range controllerCase.Runs { + for _, sample := range run.Samples { + marker := TraceMarker{ + MarkerID: sample.MarkerID, Controller: controllerCase.Workload.ControllerType, + Transport: run.Transport, TransportBlock: run.TransportBlock, + Sequence: sample.Sequence, Transition: string(sample.Transition), + StartQPCTicks: sample.StartQPCTicks, + EndQPCTicks: sample.EndQPCTicks, MarkerQPCTicks: sample.MarkerQPCTicks, + LatencyNS: sample.LatencyNS, + EventTimestampNS: sample.EventTimestampNS, + SDLFenceTimestampNS: sample.SDLFenceTimestampNS, + } + if marker.MarkerID == "" { + return errors.New("latency JSON contains an absent trace marker identity") + } + if _, duplicate := expectedByID[marker.MarkerID]; duplicate { + return fmt.Errorf("latency JSON contains duplicate marker %q", marker.MarkerID) + } + expectedByID[marker.MarkerID] = marker + expected = append(expected, marker) + } + } + } + seen := make(map[string]struct{}, len(observed)) + for index, marker := range observed { + _, exists := expectedByID[marker.MarkerID] + if !exists { + return fmt.Errorf("ETL marker %d has unknown or absent identity %q", index, marker.MarkerID) + } + if _, duplicate := seen[marker.MarkerID]; duplicate { + return fmt.Errorf("ETL contains duplicate marker %q", marker.MarkerID) + } + seen[marker.MarkerID] = struct{}{} + } + if len(seen) != len(expected) { + return fmt.Errorf("ETL contains %d exact markers for %d JSON samples", len(seen), len(expected)) + } + for index, marker := range observed { + want := expected[index] + if marker.MarkerID != want.MarkerID { + return fmt.Errorf("ETL marker order differs at index %d: got %q, want %q", index, marker.MarkerID, want.MarkerID) + } + if marker != want { + return fmt.Errorf("ETL marker %q payload does not match its JSON sample", marker.MarkerID) + } + } + return nil +} diff --git a/_testing/e2e/latency/trace_markers_test.go b/_testing/e2e/latency/trace_markers_test.go new file mode 100644 index 00000000..14f6dcaa --- /dev/null +++ b/_testing/e2e/latency/trace_markers_test.go @@ -0,0 +1,69 @@ +package latency + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestTraceMarkerEvidenceRejectsMissingDuplicateTruncatedAndForged(t *testing.T) { + suite := validSuite(t) + markers := traceMarkersFromSuite(suite) + if err := VerifyTraceMarkers(suite, markers); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutate func([]TraceMarker) []TraceMarker + want string + }{ + {"missing", func(in []TraceMarker) []TraceMarker { return in[:len(in)-1] }, "JSON samples"}, + {"duplicate", func(in []TraceMarker) []TraceMarker { return append(in, in[0]) }, "duplicate marker"}, + {"reordered", func(in []TraceMarker) []TraceMarker { in[0], in[1] = in[1], in[0]; return in }, "order"}, + {"forged payload", func(in []TraceMarker) []TraceMarker { in[0].EndQPCTicks++; return in }, "payload"}, + {"unknown", func(in []TraceMarker) []TraceMarker { in[0].MarkerID = "unknown"; return in }, "unknown"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mutated := test.mutate(append([]TraceMarker(nil), markers...)) + if err := VerifyTraceMarkers(suite, mutated); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("marker error=%v", err) + } + }) + } + + encoded, err := json.Marshal(markers) + if err != nil { + t.Fatal(err) + } + if _, err = ParseTraceMarkers(bytes.NewReader(encoded[:len(encoded)-1])); err == nil { + t.Fatal("truncated marker JSON was accepted") + } + if _, err = ParseTraceMarkers(bytes.NewReader(append(encoded, []byte(` {}`)...))); err == nil || + !strings.Contains(err.Error(), "trailing JSON") { + t.Fatalf("trailing marker JSON error=%v", err) + } +} + +func traceMarkersFromSuite(suite *SuiteReport) []TraceMarker { + var markers []TraceMarker + for _, controllerCase := range suite.Cases { + for _, run := range controllerCase.Runs { + for _, sample := range run.Samples { + markers = append(markers, TraceMarker{ + MarkerID: sample.MarkerID, Controller: controllerCase.Workload.ControllerType, + Transport: run.Transport, TransportBlock: run.TransportBlock, + Sequence: sample.Sequence, Transition: string(sample.Transition), + StartQPCTicks: sample.StartQPCTicks, + EndQPCTicks: sample.EndQPCTicks, MarkerQPCTicks: sample.MarkerQPCTicks, + LatencyNS: sample.LatencyNS, + EventTimestampNS: sample.EventTimestampNS, + SDLFenceTimestampNS: sample.SDLFenceTimestampNS, + }) + } + } + } + return markers +} diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 4f26bcad..e147ee3f 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -32,6 +32,7 @@ import ( "github.com/Alia5/VIIPER/internal/cmd" "github.com/Alia5/VIIPER/internal/server/api" serverusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/testsupport/latencytrace" "github.com/Alia5/VIIPER/viiperclient" "github.com/Alia5/VIIPER/viipertypes" "golang.org/x/sys/windows" @@ -48,6 +49,8 @@ const ( liveLatencySDLSHA256 = "VIIPER_E2E_SDL_DLL_SHA256" liveLatencyPackageManifest = "VIIPER_E2E_PACKAGE_MANIFEST_SHA256" liveLatencyDriverSHA256 = "VIIPER_E2E_NATIVE_DRIVER_SHA256" + liveLatencyTraceProfileSHA = "VIIPER_E2E_TRACE_PROFILE_SHA256" + liveLatencyDriverBuildID = "VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY" liveLatencyAPIAddress = "127.0.0.1:33245" liveLatencyUSBIPAddress = "127.0.0.1:33244" liveLatencyPassword = "testpassword1234" @@ -59,14 +62,16 @@ const ( ) type liveLatencyConfig struct { - outputPath string - samplePairs int - expectedRevision string - sdlRevision string - sdlDLLPath string - sdlDLLSHA256 string - packageManifestSHA string - driverSHA256 string + outputPath string + samplePairs int + expectedRevision string + sdlRevision string + sdlDLLPath string + sdlDLLSHA256 string + packageManifestSHA string + driverSHA256 string + traceProfileSHA256 string + driverBuildIdentity string } type liveControllerWorkload struct { @@ -145,10 +150,29 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { if err = validateLiveLatencySource(config); err != nil { t.Fatal(err) } + if err = sdl.EnableWindowsRawInput(); err != nil { + t.Fatalf("enable SDL RawInput for exact Windows source identity: %v", err) + } if err = sdl.Init(sdl.InitFlagGamepad | sdl.InitFlagEvents); err != nil { t.Fatalf("initialize source-bound SDL event observer: %v", err) } defer sdl.Quit() + traceProvider, err := latencytrace.NewProvider() + if err != nil { + t.Fatalf("initialize source-controlled latency TraceLogging provider: %v", err) + } + defer traceProvider.Close() + traceEnableDeadline := time.Now().Add(time.Second) + for !traceProvider.Enabled() && time.Now().Before(traceEnableDeadline) { + time.Sleep(10 * time.Millisecond) + } + if !traceProvider.Enabled() { + t.Fatal("source-controlled latency TraceLogging provider was not enabled by WPR") + } + qpcFrequency, err := latencytrace.Frequency() + if err != nil { + t.Fatalf("query QPC frequency: %v", err) + } loadedSDL, err := loadedModulePath("SDL3.dll") if err != nil { t.Fatal(err) @@ -178,6 +202,13 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { SDLBinarySHA256: loadedHash, NativePackageManifestSHA256: config.packageManifestSHA, NativeDriverSHA256: config.driverSHA256, + NativeDriverBuildIdentity: config.driverBuildIdentity, + QPCFrequency: qpcFrequency, + TraceProviderName: latency.TraceProviderName, + TraceProviderGUID: latency.TraceProviderGUID, + TraceProfileSHA256: config.traceProfileSHA256, + USBIPBaselineMode: latency.USBIPBaselineMode, + USBIPBaselineVersion: latency.USBIPBaselineVersion, GoVersion: runtime.Version(), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, @@ -218,7 +249,8 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { } for _, block := range latency.ProductionBlockSchedule(config.samplePairs) { report.Runs = append(report.Runs, - runLiveLatencyTransport(gateCtx, block, controller)) + runLiveLatencyTransport(gateCtx, block, controller, traceProvider, + qpcFrequency, config.driverBuildIdentity)) } if err = latency.Finalize(&report); err != nil { t.Fatalf("finalize %s source-bound latency report: %v", controller.apiType, err) @@ -263,17 +295,20 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { "%s=1 is required; use the production preflight wrapper", liveLatencyPreflight) } config := liveLatencyConfig{ - outputPath: strings.TrimSpace(os.Getenv(liveLatencyOutput)), - expectedRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedRevision))), - sdlRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLRevision))), - sdlDLLPath: strings.TrimSpace(os.Getenv(liveLatencySDLDLL)), - sdlDLLSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLSHA256))), - packageManifestSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageManifest))), - driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), + outputPath: strings.TrimSpace(os.Getenv(liveLatencyOutput)), + expectedRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedRevision))), + sdlRevision: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLRevision))), + sdlDLLPath: strings.TrimSpace(os.Getenv(liveLatencySDLDLL)), + sdlDLLSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencySDLSHA256))), + packageManifestSHA: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyPackageManifest))), + driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), + traceProfileSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyTraceProfileSHA))), + driverBuildIdentity: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverBuildID))), } if config.outputPath == "" || config.expectedRevision == "" || config.sdlRevision == "" || config.sdlDLLPath == "" || config.sdlDLLSHA256 == "" || - config.packageManifestSHA == "" || config.driverSHA256 == "" { + config.packageManifestSHA == "" || config.driverSHA256 == "" || + config.traceProfileSHA256 == "" || config.driverBuildIdentity == "" { return liveLatencyConfig{}, errors.New("production latency provenance environment is incomplete") } if !filepath.IsAbs(config.outputPath) || !filepath.IsAbs(config.sdlDLLPath) { @@ -363,6 +398,9 @@ func runLiveLatencyTransport( ctx context.Context, block latency.BlockSpec, controller liveControllerWorkload, + traceProvider *latencytrace.Provider, + qpcFrequency int64, + expectedDriverBuildIdentity string, ) (result latency.Run) { transport := block.Transport result.Order = block.Order @@ -385,7 +423,7 @@ func runLiveLatencyTransport( } result.Controller.BaselineGamepadIDs = gamepadIDsAsInt32(baseline) - server, err := startLatencyServer(ctx, transport, tempDir) + server, err := startLatencyServer(ctx, transport, tempDir, expectedDriverBuildIdentity) if err != nil { result.Failure = err.Error() return result @@ -456,7 +494,8 @@ func runLiveLatencyTransport( result.Failure = fmt.Sprintf("authenticated API failed immediately after rejection probe: %v", reprobeErr) return result } - if err = validatePing(transport, reprobe); err != nil || reprobe.Version != server.ping.Version { + if err = validatePing(transport, reprobe, expectedDriverBuildIdentity); err != nil || + reprobe.Version != server.ping.Version { result.Failure = fmt.Sprintf( "authenticated API identity changed after rejection probe: response=%+v error=%v", reprobe, err) @@ -517,6 +556,10 @@ func runLiveLatencyTransport( result.Failure = err.Error() return result } + if err = bindControllerPnP(&result.Controller, transport, device.USBIPPort); err != nil { + result.Failure = err.Error() + return result + } streamCtx, cancelStream := context.WithTimeout(ctx, 10*time.Second) stream, err = server.client.OpenStream(streamCtx, 1, device.DevID) @@ -531,7 +574,7 @@ func runLiveLatencyTransport( result.Failure = fmt.Sprintf("settle exact SDL source before measurement: %v", err) return result } - lastEventTimestamp, err = warmControllerPath(gamepad, stream, controller, lastEventTimestamp) + lastEventTimestamp, err = warmControllerPath(gamepad, stream, controller, lastEventTimestamp, qpcFrequency) if err != nil { result.Failure = fmt.Sprintf("warm exact controller-to-SDL path: %v", err) return result @@ -539,18 +582,30 @@ func runLiveLatencyTransport( observedDown := false lastSequence := block.FirstSequence + block.SamplePairs - 1 for sequence := block.FirstSequence; sequence <= lastSequence; sequence++ { - sleepForProductionPhase(sequence, latency.TransitionPress) + lastEventTimestamp, err = waitForCausalDwell(gamepad, sequence, + latency.TransitionPress, &observedDown, lastEventTimestamp, &result) + if err != nil { + result.Failure = err.Error() + return result + } lastEventTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionPress, true, - controller.state(true), &observedDown, lastEventTimestamp, &result) + controller.state(true), &observedDown, lastEventTimestamp, &result, + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) + if err != nil { + result.Failure = err.Error() + return result + } + lastEventTimestamp, err = waitForCausalDwell(gamepad, sequence, + latency.TransitionRelease, &observedDown, lastEventTimestamp, &result) if err != nil { result.Failure = err.Error() return result } - sleepForProductionPhase(sequence, latency.TransitionRelease) lastEventTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionRelease, false, - controller.state(false), &observedDown, lastEventTimestamp, &result) + controller.state(false), &observedDown, lastEventTimestamp, &result, + controller.apiType, transport, block.TransportBlock, traceProvider, qpcFrequency) if err != nil { result.Failure = err.Error() return result @@ -566,7 +621,9 @@ func runLiveLatencyTransport( return result } -func startLatencyServer(ctx context.Context, transport, tempDir string) (*latencyServerSession, error) { +func startLatencyServer(ctx context.Context, transport, tempDir, + expectedDriverBuildIdentity string, +) (*latencyServerSession, error) { for _, address := range []string{liveLatencyAPIAddress, liveLatencyUSBIPAddress} { listener, err := net.Listen("tcp", address) if err != nil { @@ -613,7 +670,7 @@ func startLatencyServer(ctx context.Context, transport, tempDir string) (*latenc ping, pingErr := client.PingCtx(pingCtx) cancelPing() if pingErr == nil { - if err := validatePing(transport, ping); err != nil { + if err := validatePing(transport, ping, expectedDriverBuildIdentity); err != nil { cancelServer() <-serverDone return nil, err @@ -649,14 +706,18 @@ func (session *latencyServerSession) close() error { } } -func validatePing(transport string, ping *viipertypes.PingResponse) error { +func validatePing(transport string, ping *viipertypes.PingResponse, + expectedDriverBuildIdentity string, +) error { if ping == nil || ping.Server != "VIIPER" || ping.Transport != transport || ping.Version == "" || ping.Ready == nil || !*ping.Ready { return fmt.Errorf("authenticated ping does not prove live %s transport: %+v", transport, ping) } if transport == latency.TransportNativeUDE { if ping.NativeUDE == nil || ping.NativeUDE.ABIMajor == 0 || - ping.NativeUDE.ExpectedDriverPackageVersion == "" { + ping.NativeUDE.ExpectedDriverPackageVersion == "" || + ping.NativeUDE.LoadedDriverBuildIdentity == "" || + ping.NativeUDE.LoadedDriverBuildIdentity != expectedDriverBuildIdentity { return fmt.Errorf("authenticated ping lacks native ABI/package proof: %+v", ping) } } else if ping.NativeUDE != nil { @@ -665,6 +726,29 @@ func validatePing(transport string, ping *viipertypes.PingResponse) error { return nil } +func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { + ready := true + expected := strings.Repeat("a", 64) + ping := &viipertypes.PingResponse{ + Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, + Ready: &ready, + NativeUDE: &viipertypes.NativeUDEInfo{ + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.4", + LoadedDriverBuildIdentity: expected, + }, + } + if err := validatePing(latency.TransportNativeUDE, ping, expected); err != nil { + t.Fatalf("matching negotiated identity was rejected: %v", err) + } + if err := validatePing(latency.TransportNativeUDE, ping, strings.Repeat("b", 64)); err == nil { + t.Fatal("mismatched negotiated identity was accepted before the native workload") + } + ping.NativeUDE.LoadedDriverBuildIdentity = "" + if err := validatePing(latency.TransportNativeUDE, ping, expected); err == nil { + t.Fatal("absent negotiated identity was accepted before the native workload") + } +} + func serverProof(ping *viipertypes.PingResponse) latency.ServerProof { proof := latency.ServerProof{ Server: ping.Server, Version: ping.Version, Transport: ping.Transport, @@ -675,6 +759,7 @@ func serverProof(ping *viipertypes.PingResponse) latency.ServerProof { ABIMajor: ping.NativeUDE.ABIMajor, ABIMinor: ping.NativeUDE.ABIMinor, Capabilities: ping.NativeUDE.Capabilities, ExpectedDriverPackageVersion: ping.NativeUDE.ExpectedDriverPackageVersion, + LoadedDriverBuildIdentity: ping.NativeUDE.LoadedDriverBuildIdentity, } } return proof @@ -833,22 +918,33 @@ func warmControllerPath( stream *viiperclient.DeviceStream, controller liveControllerWorkload, lastTimestamp uint64, + qpcFrequency int64, ) (uint64, error) { observedDown := false warmup := latency.Run{} var err error for sequence := 1; sequence <= latency.ProductionWarmupPairs; sequence++ { - sleepForProductionPhase(sequence, latency.TransitionPress) + lastTimestamp, err = waitForCausalDwell(gamepad, sequence, latency.TransitionPress, + &observedDown, lastTimestamp, &warmup) + if err != nil { + return lastTimestamp, err + } lastTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionPress, true, - controller.state(true), &observedDown, lastTimestamp, &warmup) + controller.state(true), &observedDown, lastTimestamp, &warmup, + "", "", 0, nil, qpcFrequency) + if err != nil { + return lastTimestamp, err + } + lastTimestamp, err = waitForCausalDwell(gamepad, sequence, latency.TransitionRelease, + &observedDown, lastTimestamp, &warmup) if err != nil { return lastTimestamp, err } - sleepForProductionPhase(sequence, latency.TransitionRelease) lastTimestamp, err = measureTransition( gamepad, stream, sequence, latency.TransitionRelease, false, - controller.state(false), &observedDown, lastTimestamp, &warmup) + controller.state(false), &observedDown, lastTimestamp, &warmup, + "", "", 0, nil, qpcFrequency) if err != nil { return lastTimestamp, err } @@ -866,10 +962,44 @@ func warmControllerPath( return lastTimestamp, nil } -func sleepForProductionPhase(sequence int, transition latency.Transition) { +func waitForCausalDwell( + gamepad *sdl.Gamepad, + sequence int, + transition latency.Transition, + observedDown *bool, + lastTimestamp uint64, + result *latency.Run, +) (uint64, error) { delay := liveLatencyTransitionDelay + time.Duration(latency.ProductionPhaseOffsetNS(sequence, transition)) - time.Sleep(delay) + deadline := time.Now().Add(delay) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + if remaining < time.Millisecond { + time.Sleep(remaining) + continue + } + event, received, err := gamepad.WaitButtonTransition( + sdl.GamepadButtonSouth, int32(remaining/time.Millisecond)) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d SDL causal dwell: %w", transition, sequence, err) + } + if !received { + continue + } + updatedTimestamp, rejection := latency.RejectPreWriteEdge( + lastTimestamp, event.TimestampNS, event.Down, &result.Duplicates) + lastTimestamp = updatedTimestamp + *observedDown = event.Down + return lastTimestamp, fmt.Errorf("%s sample %d pre-write dwell: %w", transition, sequence, rejection) + } + if gamepad.GetButton(sdl.GamepadButtonSouth) != *observedDown { + return lastTimestamp, fmt.Errorf("%s sample %d SDL state changed without an observed edge during pre-write dwell", transition, sequence) + } + return lastTimestamp, nil } func measureTransition( @@ -882,6 +1012,11 @@ func measureTransition( observedDown *bool, lastTimestamp uint64, result *latency.Run, + controllerType string, + transport string, + transportBlock int, + traceProvider *latencytrace.Provider, + qpcFrequency int64, ) (uint64, error) { if *observedDown == wantDown { return lastTimestamp, fmt.Errorf("%s sample %d started from the wrong observed state", transition, sequence) @@ -890,6 +1025,35 @@ func measureTransition( return lastTimestamp, err } started := time.Now() + for { + event, received, err := gamepad.PollButtonTransition(sdl.GamepadButtonSouth) + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d pre-write SDL drain: %w", transition, sequence, err) + } + if !received { + break + } + updatedTimestamp, rejection := latency.RejectPreWriteEdge( + lastTimestamp, event.TimestampNS, event.Down, &result.Duplicates) + lastTimestamp = updatedTimestamp + *observedDown = event.Down + return lastTimestamp, fmt.Errorf("%s sample %d final pre-write queue drain: %w", transition, sequence, rejection) + } + if gamepad.GetButton(sdl.GamepadButtonSouth) != *observedDown { + return lastTimestamp, fmt.Errorf("%s sample %d SDL state changed before its input write", transition, sequence) + } + startQPC, err := latencytrace.Counter() + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query pre-write QPC: %w", transition, sequence, err) + } + // Keep the SDL clock admission fence adjacent to WriteBinary. There is no + // cross-process primitive that can make these two calls atomic; requiring + // the observed event timestamp to be strictly newer closes same-tick stale + // edges and leaves only this irreducible function-call boundary. + sdlFenceTimestamp := sdl.TicksNS() + if sdlFenceTimestamp == 0 { + return lastTimestamp, fmt.Errorf("%s sample %d could not establish its SDL pre-write timestamp fence", transition, sequence) + } if err := stream.WriteBinary(inputState); err != nil { return lastTimestamp, fmt.Errorf("%s sample %d authenticated WriteBinary: %w", transition, sequence, err) } @@ -911,9 +1075,14 @@ func measureTransition( return lastTimestamp, fmt.Errorf("%s sample %d timed out after %s", transition, sequence, liveLatencyTransitionTimeout) } - if event.TimestampNS == 0 || (lastTimestamp != 0 && event.TimestampNS < lastTimestamp) { - return lastTimestamp, fmt.Errorf("%s sample %d returned an absent or regressed SDL event timestamp", - transition, sequence) + if timestampErr := latency.ValidatePostWriteTimestamp( + lastTimestamp, sdlFenceTimestamp, event.TimestampNS); timestampErr != nil { + if event.TimestampNS != 0 && (lastTimestamp == 0 || event.TimestampNS >= lastTimestamp) && + event.TimestampNS <= sdlFenceTimestamp { + incrementEdgeCounter(&result.Duplicates, event.Down) + } + return lastTimestamp, fmt.Errorf("%s sample %d SDL timestamp fence: %w", + transition, sequence, timestampErr) } lastTimestamp = event.TimestampNS if event.Down == *observedDown { @@ -925,15 +1094,31 @@ func measureTransition( *observedDown = event.Down continue } - elapsed := time.Since(started) - if elapsed <= 0 { - return lastTimestamp, fmt.Errorf("%s sample %d produced non-positive monotonic latency", transition, sequence) - } *observedDown = event.Down - result.Samples = append(result.Samples, latency.Sample{ - Sequence: sequence, Transition: transition, LatencyNS: int64(elapsed), - EventTimestampNS: event.TimestampNS, - }) + endQPC, qpcErr := latencytrace.Counter() + if qpcErr != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query observed-edge QPC: %w", transition, sequence, qpcErr) + } + latencyNS, qpcErr := latency.QPCIntervalNS(startQPC, endQPC, qpcFrequency) + if qpcErr != nil { + return lastTimestamp, fmt.Errorf("%s sample %d convert observed QPC interval: %w", transition, sequence, qpcErr) + } + sample := latency.Sample{ + Sequence: sequence, Transition: transition, LatencyNS: latencyNS, + EventTimestampNS: event.TimestampNS, SDLFenceTimestampNS: sdlFenceTimestamp, + StartQPCTicks: startQPC, EndQPCTicks: endQPC, + } + if traceProvider != nil { + sample.MarkerID = latency.SampleMarkerID(controllerType, transport, transportBlock, sequence, transition) + sample.MarkerQPCTicks, err = latencytrace.Counter() + if err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d query pre-marker QPC: %w", transition, sequence, err) + } + if err = traceProvider.WriteSample(controllerType, transport, transportBlock, sample); err != nil { + return lastTimestamp, fmt.Errorf("%s sample %d TraceLogging marker: %w", transition, sequence, err) + } + } + result.Samples = append(result.Samples, sample) return lastTimestamp, nil } } diff --git a/_testing/e2e/pnp_path_windows_test.go b/_testing/e2e/pnp_path_windows_test.go new file mode 100644 index 00000000..dde1387a --- /dev/null +++ b/_testing/e2e/pnp_path_windows_test.go @@ -0,0 +1,118 @@ +//go:build windows + +package e2e_bench_test + +import ( + "os" + "strings" + "testing" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" +) + +func TestPnPInstanceIDFromSDLPathFailsClosed(t *testing.T) { + want := `HID\VID_045E&PID_028E&IG_00\7&ABC&0&0000` + got, err := pnpInstanceIDFromSDLPath( + `\\?\hid#vid_045e&pid_028e&ig_00#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`) + if err != nil || got != want { + t.Fatalf("instance=%q error=%v, want %q", got, err, want) + } + for _, invalid := range []string{"", `HID\VID_045E`, `XInput#0`, `\\?\USB#VID_045E#1#{guid}`} { + if got, err = pnpInstanceIDFromSDLPath(invalid); err == nil { + t.Fatalf("invalid SDL path %q returned %q", invalid, got) + } + } +} + +func TestPinnedSDLXboxPathRequiresRawInputForPnPIdentity(t *testing.T) { + rawInputSource, err := os.ReadFile("deps/SDL/src/joystick/windows/SDL_rawinputjoystick.c") + if err != nil { + t.Fatal(err) + } + xinputSource, err := os.ReadFile("deps/SDL/src/joystick/windows/SDL_xinputjoystick.c") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawInputSource), + "SDL_GetHintBoolean(SDL_HINT_JOYSTICK_RAWINPUT, false)") { + t.Fatal("pinned SDL RawInput default changed; re-audit the exact Xbox PnP binding") + } + if !strings.Contains(string(xinputSource), `"XInput#%u"`) { + t.Fatal("pinned SDL XInput path contract changed; re-audit the exact Xbox PnP binding") + } +} + +func TestAppendPnPAncestryRequiresCompleteUnambiguousRootChain(t *testing.T) { + const ( + hidID = `HID\VID_045E&PID_028E\1` + usbID = `USB\VID_045E&PID_028E\1` + anchorID = `ROOT\USB\0002` + rootID = `HTREE\ROOT\0` + ) + valid := map[string]presentDeviceNode{ + hidID: {instanceID: hidID, parentID: usbID, service: "HidUsb"}, + usbID: {instanceID: usbID, parentID: anchorID, service: "usbccgp", locationPaths: []string{`USBROOT(0)#USB(7)`}}, + anchorID: {instanceID: anchorID, parentID: rootID, service: "usbip2_ude", hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}}, + rootID: {instanceID: rootID}, + } + proof := latency.ControllerProof{PNPInstanceID: hidID} + if err := appendPnPAncestry(valid, hidID, latency.TransportUSBIP, &proof); err != nil { + t.Fatal(err) + } + if err := latency.ValidateTransportAncestry(latency.TransportUSBIP, 7, proof); err != nil { + t.Fatalf("valid full USB/IP ancestry was rejected: %v", err) + } + if got := proof.PNPAncestorIDs[len(proof.PNPAncestorIDs)-1]; got != rootID { + t.Fatalf("ancestry ended at %q, want %q", got, rootID) + } + + t.Run("truncated", func(t *testing.T) { + nodes := clonePresentNodes(valid) + delete(nodes, rootID) + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, + &latency.ControllerProof{PNPInstanceID: hidID}); err == nil { + t.Fatal("truncated PnP ancestry was accepted") + } + }) + + t.Run("cycle", func(t *testing.T) { + nodes := clonePresentNodes(valid) + root := nodes[rootID] + root.parentID = usbID + nodes[rootID] = root + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, + &latency.ControllerProof{PNPInstanceID: hidID}); err == nil || + !strings.Contains(err.Error(), "cycle") { + t.Fatalf("cyclic PnP ancestry error=%v", err) + } + }) + + t.Run("nested spoof anchor", func(t *testing.T) { + const spoofID = `ROOT\USB\0001` + nodes := clonePresentNodes(valid) + usb := nodes[usbID] + usb.parentID = spoofID + nodes[usbID] = usb + nodes[spoofID] = presentDeviceNode{ + instanceID: spoofID, parentID: anchorID, service: "usbip2_ude", + hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}, + } + candidate := latency.ControllerProof{PNPInstanceID: hidID} + if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, &candidate); err != nil { + t.Fatal(err) + } + if err := latency.ValidateTransportAncestry(latency.TransportUSBIP, 7, candidate); err == nil { + t.Fatal("nested spoof transport anchor was accepted") + } + }) +} + +func clonePresentNodes(source map[string]presentDeviceNode) map[string]presentDeviceNode { + result := make(map[string]presentDeviceNode, len(source)) + for key, node := range source { + node.hardwareIDs = append([]string(nil), node.hardwareIDs...) + node.locationPaths = append([]string(nil), node.locationPaths...) + result[key] = node + } + return result +} diff --git a/_testing/e2e/pnp_windows_test.go b/_testing/e2e/pnp_windows_test.go new file mode 100644 index 00000000..e4eba291 --- /dev/null +++ b/_testing/e2e/pnp_windows_test.go @@ -0,0 +1,168 @@ +//go:build windows + +package e2e_bench_test + +import ( + "errors" + "fmt" + "strings" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "golang.org/x/sys/windows" +) + +var devPropKeyDeviceParent = windows.DEVPROPKEY{ + FmtID: windows.DEVPROPGUID(windows.GUID{ + Data1: 0x4340a6c5, Data2: 0x93fa, Data3: 0x4706, + Data4: [8]byte{0x97, 0x2c, 0x7b, 0x64, 0x80, 0x08, 0xa5, 0xa7}, + }), + PID: 8, +} + +type presentDeviceNode struct { + instanceID string + parentID string + service string + hardwareIDs []string + locationInfo string + locationPaths []string +} + +func pnpInstanceIDFromSDLPath(path string) (string, error) { + trimmed := strings.TrimPrefix(path, `\\?\`) + parts := strings.Split(trimmed, "#") + if len(parts) < 4 || !strings.EqualFold(parts[0], "HID") || + parts[1] == "" || parts[2] == "" || !strings.HasPrefix(parts[len(parts)-1], "{") { + return "", fmt.Errorf("SDL HID interface path has no exact PnP instance identity: %q", path) + } + return strings.ToUpper(strings.Join(parts[:3], `\`)), nil +} + +func bindControllerPnP(proof *latency.ControllerProof, transport string, usbipPort int32) error { + if proof == nil { + return errors.New("nil controller proof") + } + instanceID, err := pnpInstanceIDFromSDLPath(proof.SDLPath) + if err != nil { + return err + } + deviceSet, err := windows.SetupDiGetClassDevsEx(nil, "", 0, + windows.DIGCF_PRESENT|windows.DIGCF_ALLCLASSES, 0, "") + if err != nil { + return fmt.Errorf("enumerate present Windows PnP devices: %w", err) + } + defer deviceSet.Close() + + nodes := make(map[string]presentDeviceNode) + for index := 0; ; index++ { + info, enumErr := windows.SetupDiEnumDeviceInfo(deviceSet, index) + if errors.Is(enumErr, windows.ERROR_NO_MORE_ITEMS) { + break + } + if enumErr != nil { + return fmt.Errorf("enumerate present PnP device %d: %w", index, enumErr) + } + id, idErr := windows.SetupDiGetDeviceInstanceId(deviceSet, info) + if idErr != nil { + return fmt.Errorf("read PnP instance ID %d: %w", index, idErr) + } + node := presentDeviceNode{instanceID: strings.ToUpper(id)} + if value, propertyErr := windows.SetupDiGetDeviceProperty(deviceSet, info, + &devPropKeyDeviceParent); propertyErr == nil { + parentID, valid := value.(string) + if !valid { + return fmt.Errorf("PnP parent property for %q is not a string", node.instanceID) + } + node.parentID = strings.ToUpper(parentID) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_SERVICE); propertyErr == nil { + node.service, _ = value.(string) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_HARDWAREID); propertyErr == nil { + switch typed := value.(type) { + case []string: + node.hardwareIDs = append([]string(nil), typed...) + case string: + node.hardwareIDs = []string{typed} + } + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_LOCATION_INFORMATION); propertyErr == nil { + node.locationInfo, _ = value.(string) + } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_LOCATION_PATHS); propertyErr == nil { + switch typed := value.(type) { + case []string: + node.locationPaths = append([]string(nil), typed...) + case string: + node.locationPaths = []string{typed} + } + } + nodes[node.instanceID] = node + } + if _, present := nodes[instanceID]; !present { + return fmt.Errorf("SDL interface instance %q is not a present Windows PnP devnode", instanceID) + } + + proof.PNPInstanceID = instanceID + if err := appendPnPAncestry(nodes, instanceID, transport, proof); err != nil { + return err + } + if err := latency.ValidateTransportAncestry(transport, usbipPort, *proof); err != nil { + return fmt.Errorf("bind SDL path %q to %s transport: %w", proof.SDLPath, transport, err) + } + return nil +} + +func appendPnPAncestry(nodes map[string]presentDeviceNode, startID, transport string, + proof *latency.ControllerProof, +) error { + seen := make(map[string]struct{}) + current := strings.ToUpper(startID) + for depth := 0; depth < 64; depth++ { + if _, duplicate := seen[current]; duplicate { + return errors.New("Windows PnP ancestry contains a cycle") + } + seen[current] = struct{}{} + node, exists := nodes[current] + if !exists { + return fmt.Errorf("PnP ancestor %q is absent from the present-device snapshot", current) + } + proof.PNPAncestorIDs = append(proof.PNPAncestorIDs, node.instanceID) + proof.PNPAncestorServices = append(proof.PNPAncestorServices, node.service) + proof.PNPAncestorHardwareIDs = append(proof.PNPAncestorHardwareIDs, + append([]string(nil), node.hardwareIDs...)) + proof.PNPAncestorLocationInfo = append(proof.PNPAncestorLocationInfo, node.locationInfo) + proof.PNPAncestorLocationPaths = append(proof.PNPAncestorLocationPaths, + append([]string(nil), node.locationPaths...)) + + if transport == latency.TransportNativeUDE && + strings.EqualFold(node.service, "ViiperUde") && + containsFoldE2E(node.hardwareIDs, `ROOT\VIIPER\UDE`) { + proof.TransportAnchorInstanceID = node.instanceID + proof.TransportAnchorService = node.service + } + if transport == latency.TransportUSBIP && + strings.EqualFold(node.service, "usbip2_ude") && + containsFoldE2E(node.hardwareIDs, `ROOT\USBIP_WIN2\UDE`) { + proof.TransportAnchorInstanceID = node.instanceID + proof.TransportAnchorService = node.service + } + if node.parentID == "" { + if !strings.EqualFold(node.instanceID, `HTREE\ROOT\0`) { + return fmt.Errorf("PnP ancestry ended at %q instead of HTREE\\ROOT\\0", node.instanceID) + } + return nil + } + current = strings.ToUpper(node.parentID) + } + return errors.New("Windows PnP ancestry exceeded the 64-node safety bound") +} + +func containsFoldE2E(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 index b86f9e1f..d826ca49 100644 --- a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -7,7 +7,7 @@ param( [string]$SubmissionManifestPath, [Parameter(Mandatory = $true)] - [ValidatePattern('^[0-9a-fA-F]{40,64}$')] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [Parameter(Mandatory = $true)] @@ -127,9 +127,11 @@ if (-not [string]::Equals($actualSDLHash, $SDLBinarySHA256, } $signatureGate = Join-Path $repository 'native\udecx\tools\Test-ViiperUdeSignedPackage.ps1' +$manifest = Resolve-CanonicalPath -Path $SubmissionManifestPath +$manifestHashBeforeGate = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() & $signatureGate ` -PackageDirectory $SignedPackageDirectory ` - -SubmissionManifestPath $SubmissionManifestPath ` + -SubmissionManifestPath $manifest ` -ExpectedSourceRevision $ExpectedSourceRevision ` -ValidationMode Production @@ -143,7 +145,7 @@ $installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePat $packageDriverHash = (Get-FileHash -LiteralPath $packageDriver -Algorithm SHA256).Hash.ToLowerInvariant() $installedDriverHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash.ToLowerInvariant() if ($packageDriverHash -ne $installedDriverHash) { - throw "The loaded VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." + throw "The installed VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." } $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { [string]$_.DeviceID -like 'ROOT\VIIPER\UDE*' @@ -154,24 +156,36 @@ if ($devnodes.Count -ne 1) { if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." } -$manifest = Resolve-CanonicalPath -Path $SubmissionManifestPath $manifestHash = (Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant() +if ($manifestHash -ne $manifestHashBeforeGate) { + throw 'The native submission manifest changed while its signature/package gate was running.' +} +$manifestDocument = Get-Content -LiteralPath $manifest -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +$driverBuildIdentity = ([string]$manifestDocument.driverBuildIdentity).Trim().ToLowerInvariant() +if ($driverBuildIdentity -notmatch '^[0-9a-f]{64}$') { + throw 'The verified submission manifest has no canonical native driver build identity.' +} $output = Resolve-NewEvidencePath -Path $OutputPath -Repository $repository -Label 'Latency JSON output' $trace = Resolve-NewEvidencePath -Path $WprTracePath -Repository $repository -Label 'WPR trace output' -if ([string]::Equals($output, $trace, [StringComparison]::OrdinalIgnoreCase)) { - throw 'The latency JSON and WPR trace must use different evidence paths.' +$markers = Resolve-NewEvidencePath -Path "$output.etl-markers.json" -Repository $repository -Label 'Decoded ETL marker output' +if ([string]::Equals($output, $trace, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($output, $markers, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($trace, $markers, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The latency JSON, WPR trace, and decoded marker evidence must use three different paths.' } $go = Get-Command $GoExecutable -ErrorAction Stop $wpr = Get-Command wpr.exe -ErrorAction Stop -$wprProfile = 'GeneralProfile.Verbose' -$profileDetailsOutput = @(& $wpr.Source -profiledetails $wprProfile 2>&1) +$wprProfilePath = Resolve-CanonicalPath -Path (Join-Path $repository '_testing\e2e\latency\ViiperLatency.wprp') +$wprProfileHash = (Get-FileHash -LiteralPath $wprProfilePath -Algorithm SHA256).Hash.ToLowerInvariant() +$wprProfile = "$wprProfilePath!ViiperLatency" +$profileDetailsOutput = @(& $wpr.Source -profiledetails $wprProfile -filemode 2>&1) if ($LASTEXITCODE -ne 0) { throw "WPR could not describe '$wprProfile'.`n$($profileDetailsOutput -join [Environment]::NewLine)" } $profileDetails = $profileDetailsOutput | Out-String -if ($profileDetails -notmatch '(?im)^Profile\s*:\s*GeneralProfile\.Verbose\.Memory\s*$') { - throw "WPR '$wprProfile' is not the required bounded-memory profile.`n$profileDetails" +if ($profileDetails -notmatch '(?im)^Profile\s*:\s*ViiperLatency\.Verbose\.File\s*$') { + throw "WPR '$wprProfile' is not the required source-controlled sequential-file profile.`n$profileDetails" } foreach ($eventName in @('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')) { if ([regex]::Matches($profileDetails, "(?im)^\s*$eventName\s*$").Count -lt 1) { @@ -190,7 +204,8 @@ $environmentNames = @( 'VIIPER_E2E_LATENCY_OUTPUT', 'VIIPER_E2E_LATENCY_SAMPLES', 'VIIPER_E2E_EXPECTED_SOURCE_REVISION', 'VIIPER_E2E_SDL_SOURCE_REVISION', 'VIIPER_E2E_SDL_DLL_PATH', 'VIIPER_E2E_SDL_DLL_SHA256', - 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256' + 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256', + 'VIIPER_E2E_TRACE_PROFILE_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY' ) $savedEnvironment = @{} foreach ($name in $environmentNames) { @@ -198,6 +213,7 @@ foreach ($name in $environmentNames) { } $wprInstance = "ViiperE2ELatency-$PID-$([guid]::NewGuid().ToString('N'))" +$nativeRevisionLDFlag = "-X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision" $wprStarted = $false $wprFailure = $null $testExitCode = -1 @@ -218,32 +234,38 @@ try { $env:VIIPER_E2E_SDL_DLL_SHA256 = $actualSDLHash $env:VIIPER_E2E_PACKAGE_MANIFEST_SHA256 = $manifestHash $env:VIIPER_E2E_NATIVE_DRIVER_SHA256 = $installedDriverHash + $env:VIIPER_E2E_TRACE_PROFILE_SHA256 = $wprProfileHash + $env:VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY = $driverBuildIdentity - $startOutput = @(& $wpr.Source -start $wprProfile -instancename $wprInstance 2>&1) + $startOutput = @(& $wpr.Source -start $wprProfile -filemode -instancename $wprInstance 2>&1) if ($LASTEXITCODE -ne 0) { - throw "Could not start the bounded-memory WPR capture (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" + throw "Could not start the sequential-file WPR capture (exit $LASTEXITCODE).`n$($startOutput -join [Environment]::NewLine)" } $wprStarted = $true - & $go.Source test -mod=readonly -count=1 -timeout=20m ` + & $go.Source -C $repository test -mod=readonly -count=1 -timeout=20m -ldflags $nativeRevisionLDFlag ` -run '^TestLiveControllerToGameLatencyGate$' -v ./_testing/e2e $testExitCode = $LASTEXITCODE } finally { if ($wprStarted) { - $statusOutput = @(& $wpr.Source -status -instancename $wprInstance 2>&1) + $statusOutput = @(& $wpr.Source -status collectors -details -instancename $wprInstance 2>&1) $statusExitCode = $LASTEXITCODE $statusText = $statusOutput | Out-String if ($statusExitCode -ne 0) { $wprFailure = "WPR status failed with exit $statusExitCode. $($statusOutput -join ' ')" } else { - $droppedMatch = [regex]::Match($statusText, '(?im)^\s*Dropped Event\s*:\s*(?\d+)\s*$') - if (-not $droppedMatch.Success) { - $wprFailure = "WPR did not report its dropped-event count. $($statusOutput -join ' ')" + $lossMatches = [regex]::Matches($statusText, + '(?im)^\s*(?(?:Dropped\s+Events?|Events?\s+Lost|Buffers?\s+Lost))\s*:\s*(?\d+)\s*$') + if ($lossMatches.Count -eq 0) { + $wprFailure = "WPR did not report any event/buffer loss counters. $($statusOutput -join ' ')" } - elseif ([uint64]$droppedMatch.Groups['count'].Value -ne 0) { - $wprFailure = "WPR dropped $($droppedMatch.Groups['count'].Value) event(s); the trace is incomplete." + else { + $nonZeroLoss = @($lossMatches | Where-Object { [uint64]$_.Groups['count'].Value -ne 0 }) + if ($nonZeroLoss.Count -ne 0) { + $wprFailure = "WPR reported event/buffer loss: $($nonZeroLoss.Value -join '; ')." + } } } @@ -291,10 +313,145 @@ if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v1' -or [string]$report.provenance.sdl_binary_sha256 -cne $actualSDLHash -or [string]$report.provenance.native_package_manifest_sha256 -cne $manifestHash -or [string]$report.provenance.native_driver_sha256 -cne $installedDriverHash -or + [string]$report.provenance.native_driver_build_identity -cne $driverBuildIdentity -or [string]$report.verdict -cne 'pass' -or @($report.cases).Count -ne 3) { throw "The latency JSON artifact is not a passing source-bound production-controller suite." } + +$expectedMarkers = @{} +foreach ($case in @($report.cases)) { + foreach ($run in @($case.runs)) { + foreach ($sample in @($run.samples)) { + $markerID = [string]$sample.trace_marker_id + if ([string]::IsNullOrWhiteSpace($markerID) -or $expectedMarkers.ContainsKey($markerID)) { + throw "The strictly parsed JSON contains an absent or duplicate trace marker '$markerID'." + } + $expectedMarkers[$markerID] = @{ + Controller = [string]$case.workload.controller_type + Transport = [string]$run.transport + TransportBlock = [string]$run.transport_block + Sequence = [string]$sample.sequence + Transition = [string]$sample.transition + StartQPCTicks = [string]$sample.start_qpc_ticks + EndQPCTicks = [string]$sample.end_qpc_ticks + MarkerQPCTicks = [string]$sample.trace_marker_qpc_ticks + LatencyNS = [string]$sample.latency_ns + SDLEventTimestampNS = [string]$sample.sdl_event_timestamp_ns + SDLFenceTimestampNS = [string]$sample.sdl_prewrite_fence_timestamp_ns + } + } + } +} +$traceMarkers = @{} +$decodedMarkers = [Collections.Generic.List[object]]::new() +$requiredTraceFields = @( + 'MarkerID', 'Controller', 'Transport', 'TransportBlock', 'Sequence', 'Transition', + 'StartQPCTicks', 'EndQPCTicks', 'MarkerQPCTicks', 'LatencyNS', + 'SDLEventTimestampNS', 'SDLFenceTimestampNS' +) +try { + $traceEvents = @(Get-WinEvent -FilterHashtable @{ + Path = $trace + ProviderName = 'VIIPER-LatencyGate' + } -Oldest -ErrorAction Stop) +} +catch { + throw "The ETL could not be decoded for exact TraceLogging attribution: $($_.Exception.Message)" +} +foreach ($event in $traceEvents) { + [xml]$xml = $event.ToXml() + if (-not [string]::Equals([string]$xml.Event.System.Provider.Name, + 'VIIPER-LatencyGate', [StringComparison]::Ordinal) -or + -not [string]::Equals([string]$xml.Event.System.Provider.Guid, + '{e1726ef8-c2e6-4dad-bbf7-2d871b953ab1}', [StringComparison]::OrdinalIgnoreCase)) { + throw 'A decoded latency event does not have the exact source-controlled provider name and GUID.' + } + $fields = @{} + foreach ($data in @($xml.Event.EventData.Data)) { + $name = [string]$data.Name + if ([string]::IsNullOrWhiteSpace($name) -or $fields.ContainsKey($name)) { + throw 'A latency ETL marker contains absent or duplicate named payload fields.' + } + $fields[$name] = [string]$data.InnerText + } + if ($fields.Count -ne $requiredTraceFields.Count -or + @($requiredTraceFields | Where-Object { -not $fields.ContainsKey($_) }).Count -ne 0) { + throw 'A latency ETL marker does not contain the exact source-controlled payload schema.' + } + $markerID = [string]$fields['MarkerID'] + if ([string]::IsNullOrWhiteSpace($markerID) -or $traceMarkers.ContainsKey($markerID)) { + throw "The ETL contains an absent or duplicate latency marker '$markerID'." + } + if (-not $expectedMarkers.ContainsKey($markerID)) { + throw "The ETL contains an unreported latency marker '$markerID'." + } + $expected = $expectedMarkers[$markerID] + foreach ($fieldName in @('Controller', 'Transport', 'TransportBlock', 'Sequence', 'Transition', + 'StartQPCTicks', 'EndQPCTicks', 'MarkerQPCTicks', 'LatencyNS', + 'SDLEventTimestampNS', 'SDLFenceTimestampNS')) { + if ([string]$fields[$fieldName] -cne [string]$expected[$fieldName]) { + throw "ETL marker '$markerID' field '$fieldName' does not match its JSON sample." + } + } + $traceMarkers[$markerID] = $true + $decodedMarkers.Add([pscustomobject]@{ + trace_marker_id = $markerID + controller = [string]$fields['Controller'] + transport = [string]$fields['Transport'] + transport_block = [int]$fields['TransportBlock'] + sequence = [int]$fields['Sequence'] + transition = [string]$fields['Transition'] + start_qpc_ticks = [long]$fields['StartQPCTicks'] + end_qpc_ticks = [long]$fields['EndQPCTicks'] + trace_marker_qpc_ticks = [long]$fields['MarkerQPCTicks'] + latency_ns = [long]$fields['LatencyNS'] + sdl_event_timestamp_ns = [uint64]$fields['SDLEventTimestampNS'] + sdl_prewrite_fence_timestamp_ns = [uint64]$fields['SDLFenceTimestampNS'] + }) +} +if ($traceMarkers.Count -ne $expectedMarkers.Count) { + $missingMarkers = @($expectedMarkers.Keys | Where-Object { -not $traceMarkers.ContainsKey($_) }) + throw "The ETL has $($traceMarkers.Count) exact sample markers for $($expectedMarkers.Count) JSON samples; missing: $($missingMarkers -join ', ')." +} +$markerJSON = ConvertTo-Json -InputObject @($decodedMarkers) -Depth 3 -Compress +$markerBytes = [Text.UTF8Encoding]::new($false).GetBytes($markerJSON) +$markerStream = [IO.File]::Open($markers, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $markerStream.Write($markerBytes, 0, $markerBytes.Length) + $markerStream.Flush($true) +} +finally { + $markerStream.Dispose() +} +$verifyExitCode = -1 +try { + $env:CGO_ENABLED = '0' + $env:GOENV = 'off' + $env:GOFLAGS = '' + $env:GOTOOLCHAIN = 'local' + $env:GOWORK = 'off' + $verifyOutput = @(& $go.Source -C $repository run -mod=readonly ./_testing/e2e/cmd/verifylatency ` + -input $output ` + -markers $markers ` + -source $headRevision ` + -sdl-revision $sdlRevision ` + -sdl-sha256 $actualSDLHash ` + -manifest-sha256 $manifestHash ` + -driver-sha256 $installedDriverHash ` + -driver-build-identity $driverBuildIdentity ` + -trace-profile-sha256 $wprProfileHash ` + -samples $Samples 2>&1) + $verifyExitCode = $LASTEXITCODE +} +finally { + foreach ($name in @('CGO_ENABLED', 'GOENV', 'GOFLAGS', 'GOTOOLCHAIN', 'GOWORK')) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } +} +if ($verifyExitCode -ne 0) { + throw "The strict Go evidence verifier rejected the JSON/ETL evidence pair.`n$($verifyOutput -join [Environment]::NewLine)" +} $requiredControllers = @('xbox360', 'dualshock4', 'dualsensegamepadv5') for ($index = 0; $index -lt $requiredControllers.Count; $index++) { $case = $report.cases[$index] @@ -327,4 +484,5 @@ if ($LASTEXITCODE -ne 0 -or $postStatus.Count -ne 0) { } Write-Host "Validated source-bound controller-to-game latency evidence: '$output'." -Write-Host "Captured bounded-memory WPR evidence: '$trace'." +Write-Host "Captured source-controlled sequential-file WPR evidence: '$trace'." +Write-Host "Retained the exactly decoded ETL marker evidence: '$markers'." diff --git a/_testing/e2e/sdl/gamepad.go b/_testing/e2e/sdl/gamepad.go index bc7ff73f..9ab31efa 100644 --- a/_testing/e2e/sdl/gamepad.go +++ b/_testing/e2e/sdl/gamepad.go @@ -80,6 +80,28 @@ static inline int wait_gamepad_button_transition( } } +static inline int poll_gamepad_button_transition( + SDL_JoystickID which, + SDL_GamepadButton button, + bool *down, + Uint64 *timestamp_ns) +{ + SDL_ClearError(); + SDL_Event event; + while (SDL_PollEvent(&event)) { + if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_BUTTON_UP) && + event.gbutton.which == which && + event.gbutton.button == (Uint8)button) { + *down = event.gbutton.down; + *timestamp_ns = event.gbutton.timestamp; + return 1; + } + } + const char *error = SDL_GetError(); + return error != NULL && error[0] != '\0' ? -1 : 0; +} + static inline int gamepad_binding_input_button(const SDL_GamepadBinding *b) { return b->input.button; @@ -155,6 +177,22 @@ type GamepadButtonEvent struct { TimestampNS uint64 } +// EnableWindowsRawInput makes SDL expose an actual Windows device-interface +// path for XInput-capable controllers. SDL's default XInput backend reports a +// logical "XInput#N" path, which cannot be causally bound to a PnP devnode. +// The production latency gate calls this before SDL_Init and then still fails +// closed unless the resulting path resolves to the selected transport anchor. +func EnableWindowsRawInput() error { + name := C.CString("SDL_JOYSTICK_RAWINPUT") + defer C.free(unsafe.Pointer(name)) + value := C.CString("1") + defer C.free(unsafe.Pointer(value)) + if !bool(C.SDL_SetHint(name, value)) { + return &SDLError{eStr: "SDL rejected the source-identity RawInput hint"} + } + return nil +} + // GamepadBindingType describes the type of a gamepad control binding. type GamepadBindingType int32 @@ -470,6 +508,30 @@ func (g *Gamepad) WaitButtonTransition( return GamepadButtonEvent{Down: bool(down), TimestampNS: uint64(timestampNS)}, true, nil } +// PollButtonTransition drains SDL's already-queued events and returns an exact +// edge for this gamepad/button without waiting. The latency gate uses it as a +// final causal fence immediately before issuing the input write. +func (g *Gamepad) PollButtonTransition(button GamepadButton) (GamepadButtonEvent, bool, error) { + if g == nil || g.cGamepad == nil { + return GamepadButtonEvent{}, false, &SDLError{eStr: "invalid gamepad handle"} + } + var down C.bool + var timestampNS C.Uint64 + result := C.poll_gamepad_button_transition( + C.SDL_GetGamepadID(g.cGamepad), C.SDL_GamepadButton(button), &down, ×tampNS) + if result < 0 { + return GamepadButtonEvent{}, false, GetError() + } + if result == 0 { + return GamepadButtonEvent{}, false, nil + } + return GamepadButtonEvent{Down: bool(down), TimestampNS: uint64(timestampNS)}, true, nil +} + +// TicksNS returns SDL's monotonically increasing nanosecond clock used by +// SDL_GamepadButtonEvent.timestamp. +func TicksNS() uint64 { return uint64(C.SDL_GetTicksNS()) } + // GetButtonLabel gets the label of a button on a gamepad. func (g *Gamepad) GetButtonLabel(button GamepadButton) GamepadButtonLabel { if g == nil || g.cGamepad == nil { diff --git a/_testing/e2e/sdl/sdl_nocgo.go b/_testing/e2e/sdl/sdl_nocgo.go index abe8d8fc..29bcbc16 100644 --- a/_testing/e2e/sdl/sdl_nocgo.go +++ b/_testing/e2e/sdl/sdl_nocgo.go @@ -49,6 +49,10 @@ func Init(InitFlags) error { return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") } +func EnableWindowsRawInput() error { + return errors.New("SDL3 end-to-end benchmarks require CGO and the vendored SDL3 development files") +} + func Quit() {} func UpdateGamepads() {} @@ -84,3 +88,9 @@ func (*Gamepad) WaitButtonEvent(GamepadButton, bool, int32) bool { return false func (*Gamepad) WaitButtonTransition(GamepadButton, int32) (GamepadButtonEvent, bool, error) { return GamepadButtonEvent{}, false, errors.New("SDL3 end-to-end benchmarks require CGO") } + +func (*Gamepad) PollButtonTransition(GamepadButton) (GamepadButtonEvent, bool, error) { + return GamepadButtonEvent{}, false, errors.New("SDL3 end-to-end benchmarks require CGO") +} + +func TicksNS() uint64 { return 0 } diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 37945242..65ab5c27 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -9,7 +9,7 @@ must not be presented as interchangeable evidence. - `_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1` is the opt-in Windows production gate. It records every press and release observed through SDL, compares authenticated USB/IP and native UDE runs, and emits a strict JSON - evidence artifact plus a bounded-memory WPR trace. + evidence artifact plus a source-controlled sequential-file WPR trace. No live latency result is checked into this document. A passing result exists only when the production command below succeeds on the stated machine and its @@ -17,7 +17,11 @@ source-bound artifacts are retained. ### Evidence boundary -This is an exact-source, production-authentic API-to-consumer path gate. The Go +This is an exact-source native-path, production-authentic API-to-consumer gate. +The USB/IP comparator is deliberately labeled +`version-probed-functional-baseline-not-source-bound`: the wrapper proves the +supported 0.9.7.7 command and functional port contract, not the source revision +of that third-party installed driver. The Go test starts `cmd.Server` in process at the clean `HEAD` under test and uses the repository's Go client over real localhost TCP. Beyond that process boundary it uses the installed USB/IP or native UDE transport, the actual Windows controller @@ -81,31 +85,54 @@ processing, the selected virtual USB transport, Windows controller input, SDL's event path, and consumer wake-up. It does not claim display, engine-frame, or network latency. -Go's `time.Now`/`time.Since` monotonic readings are used for interval -subtraction. On Windows, the Go runtime sources that clock from QPC; Microsoft -recommends QPC for sub-microsecond interval and latency measurement. SDL's event -timestamp is retained independently to reject absent, stale, or regressing -events. The harness never subtracts the SDL clock from the Go clock. - -The observer uses `SDL_WaitEventTimeout`, not a tight state loop. Unexpected +Raw `QueryPerformanceCounter` ticks bracket every interval and are converted +with the once-recorded `QueryPerformanceFrequency`; that conversion is the +canonical `latency_ns`. The strict parser recomputes it exactly and rejects an +overflow, clock regression, cross-sample QPC regression, or JSON latency that +does not match its retained ticks. Go's monotonic clock is used only for wait +deadlines. Microsoft recommends QPC for sub-microsecond interval and latency +measurement. SDL's event timestamp is retained independently to reject absent, +stale, or regressing events. The harness never subtracts the SDL clock from the +QPC clock. + +The observer uses `SDL_WaitEventTimeout`, not a tight state loop. It observes +the complete unmeasured dwell, then drains exact queued button events, checks +the current state, captures QPC, and places an `SDL_GetTicksNS` fence directly +beside the input write. An event must be strictly newer than that fence; an +older or same-tick event is rejected rather than misattributed to the write. +SDL and the authenticated TCP write expose no shared atomic operation, so this +is a stale-edge exclusion/admission proof, not a claim of cryptographic causal +identity across the irreducible final function-call boundary. Unexpected same-state edges from the exact device are counted as duplicates while the wait continues. A missing expected edge increments the appropriate miss counter and terminates that transport/controller run. The final quiet window is also an SDL event wait, so late release duplicates are not hidden and no measurement-side busy poll consumes a CPU core. +The source-bound SDL build enables its Windows RawInput backend before +initialization. SDL's default Xbox backend exposes only a logical `XInput#N` +path; RawInput retains the exact HID device-interface path needed to bind the +observed controller to Windows PnP ancestry. This makes the Xbox arm an SDL +RawInput consumer-path measurement, not an XInput API polling measurement. A +logical XInput path or failure to enable RawInput fails closed rather than +falling back to VID/PID-only identity. + ## Source and device binding The PowerShell entry point fails closed before measurement unless all of the following are true: -- `HEAD` equals the caller-supplied 40-64 digit source revision; +- `HEAD` equals the caller-supplied 40- or 64-digit source revision; - the tracked and untracked source tree is clean and every submodule is at its recorded revision; - the native package passes the existing production Microsoft-signature and submission-manifest gate; - the installed `ViiperUde.sys` hash matches that verified package and the one VIIPER root devnode reports a Microsoft signer; +- the live Go harness is linked with the clean `HEAD` as + `nativeSourceRevision`, and the build identity negotiated from the loaded + kernel image exactly matches the verified manifest identity (the installed + file hash alone is not presented as loaded-image proof); - the SDL DLL hash matches the caller-supplied source-build hash; - the DLL actually loaded by the Go test is that exact absolute SDL path and hash; @@ -115,7 +142,14 @@ following are true: - `DeviceAdd` returns the expected controller type, VID, PID, bus, device ID, and (for USB/IP) exact auto-attached import port; - all baseline SDL gamepads remain present and exactly one stable new SDL ID is - created; its path, GUID, real type, VID, and PID must match the API device. + created; its path, GUID, real type, VID, and PID must match the API device; +- the SDL HID interface resolves to an exact present Windows PnP ancestry. A + native run must terminate at service `ViiperUde`/hardware ID + `ROOT\VIIPER\UDE`; USB/IP must terminate at service `usbip2_ude`/INF hardware + ID `ROOT\USBIP_WIN2\UDE` (the OS-assigned devnode instance is commonly + `ROOT\USB\####` and is recorded separately). The gate follows the unified + `DEVPKEY_Device_Parent` relation through that anchor to `HTREE\ROOT\0`; a + truncated/cyclic chain or a second matching anchor is rejected. The gate does not install, update, stop, replace, or remove a driver or service. Run it on a disposable test machine with the verified production package and @@ -124,8 +158,9 @@ already own the API ports or native broker handle. ## Statistics and pass policy -The artifact retains every sample as -`{sequence, transition, latency_ns, sdl_event_timestamp_ns}`. It reports press, +The artifact retains every sample's sequence, transition, monotonic latency, +SDL event/pre-write-fence timestamps, raw QPC start/end/pre-marker ticks, and canonical +TraceLogging marker ID. It reports press, release, and combined distributions for each controller/transport: - p50, p95, and p99 use the nearest-rank definition (`ceil(p * N)`, one based); @@ -192,12 +227,21 @@ $revision = (git rev-parse HEAD).Trim() -Samples 256 ``` -The wrapper verifies and uses `GeneralProfile.Verbose.Memory`, names the -recording instance, rejects dropped events, and saves the trace on both pass and -test failure. The profile includes context-switch, ready-thread, sampled-profile, -DPC, interrupt, and WDF evidence needed to investigate a tail. The ETL is not -parsed into latency samples and is not a substitute for the SDL consumer -timestamps. +The wrapper verifies and uses the checked-in `ViiperLatency.wprp` in sequential +file mode, names the recording instance, rejects any reported event/buffer +loss, and saves the trace on both pass and test failure. The profile includes +context-switch, ready-thread, sampled-profile, DPC, interrupt, and WDF evidence +needed to investigate a tail. A fixed TraceLogging provider captures another +QPC value after the measured end and then emits each marker. The wrapper decodes +the ETL oldest-first and requires exact chronological, one-to-one marker and +QPC/timestamp/latency payload equality with the strictly parsed JSON; missing, +duplicate, reordered, extra, or undecodable markers fail closed. +The exact decoded marker set is retained beside the JSON as +`.etl-markers.json`, and the production wrapper invokes the same Go +strict parser/recomputation verifier used by deterministic tests on the JSON, +decoded-marker, and ETL evidence pair. +The ETL remains corroborating scheduler evidence, not a substitute for SDL's +consumer timestamp. Directly setting the live-test environment variable is intentionally insufficient. The Go test also requires the preflight marker, expected source @@ -233,10 +277,22 @@ misses, duplicates, or a live release pass from it. define the nanosecond event timestamp, device ID, button, and edge. - [`SDL_WaitEventTimeout`](https://wiki.libsdl.org/SDL3/SDL_WaitEventTimeout) is the blocking event-consumer primitive used by the observer. +- [`SDL_HINT_JOYSTICK_RAWINPUT`](https://wiki.libsdl.org/SDL3/SDL_HINT_JOYSTICK_RAWINPUT) + documents that RawInput is disabled by default, handles XInput-capable + devices, and must be enabled before SDL initialization. - [Microsoft high-resolution timestamp guidance](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps) recommends QPC for interval and latency measurements. +- [Microsoft `CM_Get_Parent` and unified-parent guidance](https://learn.microsoft.com/en-us/windows/win32/api/cfgmgr32/nf-cfgmgr32-cm_get_parent) + identifies `DEVPKEY_Device_Parent` as the Windows Vista-and-later device-tree + parent relation used by the identity proof. - [Microsoft WPR command-line guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/wpr-command-line-options) documents named instances, memory/file modes, profiles, start, and stop. +- [Microsoft WPR logging-mode guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/logging-mode) + distinguishes sequential file logging from bounded circular memory logging. +- [Microsoft `Get-WinEvent` guidance](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent) + documents ETL `Path`, `ProviderName` filtering, and oldest-first decoding. +- [Microsoft TraceLogging capture guidance](https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/capture-and-view-tracelogging-data) + documents collecting self-describing providers with WPR/WPA. - [ViGEmBus](https://github.com/nefarius/ViGEmBus/tree/d986e1d93708ec9b11049542fa6027272cce716c) is the virtual-controller lifecycle and replay-method reference. Its design motivates testing through an unmodified game-consumer API; no ViGEm latency diff --git a/go.mod b/go.mod index a83535ea..d54d635a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.2 require ( fyne.io/systray v1.12.1 + github.com/Microsoft/go-winio v0.6.2 github.com/alecthomas/kong v1.15.0 github.com/alecthomas/kong-toml v0.4.0 github.com/alecthomas/kong-yaml v0.2.0 diff --git a/go.sum b/go.sum index 2eb865f8..482e3537 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= diff --git a/internal/testsupport/latencytrace/trace_windows.go b/internal/testsupport/latencytrace/trace_windows.go new file mode 100644 index 00000000..a3ea158c --- /dev/null +++ b/internal/testsupport/latencytrace/trace_windows.go @@ -0,0 +1,104 @@ +//go:build windows + +// Package latencytrace supplies the Windows clocks and source-controlled +// TraceLogging markers used to correlate each latency JSON sample with ETL. +package latencytrace + +import ( + "errors" + "fmt" + "strings" + "sync/atomic" + "unsafe" + + "github.com/Alia5/VIIPER/_testing/e2e/latency" + "github.com/Microsoft/go-winio/pkg/etw" + "github.com/Microsoft/go-winio/pkg/guid" + "golang.org/x/sys/windows" +) + +var ( + kernel32QPC = windows.NewLazySystemDLL("kernel32.dll") + queryPerformanceCounter = kernel32QPC.NewProc("QueryPerformanceCounter") + queryPerformanceFrequency = kernel32QPC.NewProc("QueryPerformanceFrequency") +) + +func query(proc *windows.LazyProc) (int64, error) { + var value int64 + ok, _, callErr := proc.Call(uintptr(unsafe.Pointer(&value))) + if ok == 0 { + return 0, fmt.Errorf("%s: %w", proc.Name, callErr) + } + if value <= 0 { + return 0, fmt.Errorf("%s returned %d", proc.Name, value) + } + return value, nil +} + +func Counter() (int64, error) { return query(queryPerformanceCounter) } +func Frequency() (int64, error) { return query(queryPerformanceFrequency) } + +type Provider struct { + provider *etw.Provider + enabled atomic.Bool +} + +func applyProviderState(enabled *atomic.Bool, state etw.ProviderState) { + switch state { + case etw.ProviderStateEnable: + enabled.Store(true) + case etw.ProviderStateDisable: + enabled.Store(false) + case etw.ProviderStateCaptureState: + // A capture-state request asks an already enabled provider to emit + // rundown state; it does not disable the session. + } +} + +func NewProvider() (*Provider, error) { + id, err := guid.FromString(strings.Trim(latency.TraceProviderGUID, "{}")) + if err != nil { + return nil, fmt.Errorf("parse latency provider GUID: %w", err) + } + result := &Provider{} + provider, err := etw.NewProviderWithID(latency.TraceProviderName, id, + func(_ guid.GUID, state etw.ProviderState, _ etw.Level, _, _ uint64, _ uintptr) { + applyProviderState(&result.enabled, state) + }) + if err != nil { + return nil, fmt.Errorf("register latency TraceLogging provider: %w", err) + } + result.provider = provider + return result, nil +} + +func (p *Provider) Close() error { + if p == nil || p.provider == nil { + return nil + } + return p.provider.Close() +} + +func (p *Provider) Enabled() bool { return p != nil && p.enabled.Load() } + +func (p *Provider) WriteSample(controller, transport string, block int, sample latency.Sample) error { + if !p.Enabled() { + return errors.New("latency TraceLogging provider is not enabled by the active WPR profile") + } + return p.provider.WriteEvent("TransitionObserved", + []etw.EventOpt{etw.WithLevel(etw.LevelInfo)}, + etw.WithFields( + etw.StringField("MarkerID", sample.MarkerID), + etw.StringField("Controller", controller), + etw.StringField("Transport", transport), + etw.IntField("TransportBlock", block), + etw.IntField("Sequence", sample.Sequence), + etw.StringField("Transition", string(sample.Transition)), + etw.Int64Field("StartQPCTicks", sample.StartQPCTicks), + etw.Int64Field("EndQPCTicks", sample.EndQPCTicks), + etw.Int64Field("MarkerQPCTicks", sample.MarkerQPCTicks), + etw.Int64Field("LatencyNS", sample.LatencyNS), + etw.Uint64Field("SDLEventTimestampNS", sample.EventTimestampNS), + etw.Uint64Field("SDLFenceTimestampNS", sample.SDLFenceTimestampNS), + )) +} diff --git a/internal/testsupport/latencytrace/trace_windows_test.go b/internal/testsupport/latencytrace/trace_windows_test.go new file mode 100644 index 00000000..e9b9ff11 --- /dev/null +++ b/internal/testsupport/latencytrace/trace_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package latencytrace + +import ( + "sync/atomic" + "testing" + + "github.com/Microsoft/go-winio/pkg/etw" +) + +func TestCaptureStateDoesNotDisableActiveProvider(t *testing.T) { + var enabled atomic.Bool + applyProviderState(&enabled, etw.ProviderStateEnable) + applyProviderState(&enabled, etw.ProviderStateCaptureState) + if !enabled.Load() { + t.Fatal("ETW capture-state request disabled the active marker provider") + } + applyProviderState(&enabled, etw.ProviderStateDisable) + if enabled.Load() { + t.Fatal("ETW disable request left the marker provider enabled") + } +} From e1deabca21ce8e0e7a0e2f26d060ca357baf79f0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 07:58:39 -0500 Subject: [PATCH 176/240] docs: record native UDE lifecycle invariants --- docs/architecture/native-udecx.md | 98 +++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index 3554bd68..bd5f51fc 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -76,6 +76,13 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. installer to the exact matching native-driver package. The service also recognizes the parameter/length errors returned by native previews from before that distinct status existed, so an upgrade cannot strand ABI 1.7. + ABI 1.9 additionally returns the 32-byte identity compiled into the loaded + kernel image: SHA-256 over the canonical source revision, driver-package + version, ABI, and exact-capability tuple. The broker binary and schema-2 + accepted-package manifest derive the same value from their protected build + inputs. A stale + loaded image is rejected even when its on-disk replacement, ABI, and + capability mask otherwise look correct. 11. Every packed wire structure has a compiler-independent size guard. The 72-byte completion header carries two explicit reserved words; its size never depends on compiler tail padding. Every field offset is guarded too, @@ -373,14 +380,19 @@ a wedged provider cannot retain the installer mutex indefinitely. completion DPC, locks, and broker storage are still valid. Cleanup first joins any file cleanup that crossed the owner lock before the gate, then purges user-mode queues, aborts every admitted broker operation, - waits for tracked and untracked completion counts to reach zero, and joins - the DPC before revoking device-table handles. The final controller - `EvtCleanupCallback` performs only invariant checks because KMDF has already - cleaned up child objects by then. + and uses KMDF's preceding non-power-managed queue purge as its terminal + endpoint fence. While a shared device-index lock still pins every endpoint, + it observes each UdeCx-owned queue as nonaccepting, nondispatching, and + `WDF_IO_QUEUE_IDLE`, with `ActiveOperations == 0` under the broker lock. This + includes a callback already delivered by WDF but preempted before its first + driver instruction. Only after that proof does cleanup join tracked and + untracked completion counts and the final DPC, then revoke and consume UDE + handles. The final controller `EvtCleanupCallback` performs only invariant + checks because KMDF has already cleaned up child objects by then. - UdeCx USB-device deletion remains asynchronous. Shutdown snapshots and - revokes each device under the embedded `FAST_MUTEX`, invokes + revokes each device under the embedded shared/exclusive push lock, invokes `UdecxUsbDevicePlugOutAndDelete` after dropping the lock, and never waits for - child cleanup from the PnP cleanup callback. Embedding the mutex in the + child cleanup from the PnP cleanup callback. Embedding the push lock in the controller context keeps endpoint/device cleanup independent of sibling WDF child deletion order. - Removal atomically revokes the UDE handle from the device table and retires @@ -393,6 +405,14 @@ a wedged provider cannot retain the installer mutex indefinitely. and releasing the exclusive controller owner. Each retired child keeps its own reference on the old file object until `EvtCleanupCallback`; that late physical rundown cannot block a successor owner or clear a reused slot. +- Child teardown aborts every exact-device reset/configuration request before + consuming its UdeCx handle. A reset notification still queued for user mode + is retired in O(1) and emitted as a benign cancel; an already delivered reset + keeps only a `(token, device ID, generation, owner)` tombstone, so its late + acknowledgement cannot reopen or mutate a replacement child. Management-slot + reuse remains closed until that acknowledgement arrives or KMDF invokes the + old owner's `EvtFileClose`, the documented post-I/O boundary. No teardown + performs a global notification-ring scan or blocks unrelated controllers. - A post-transfer UdeCx removal failure is terminal for the controller, not retryable for the child. The kernel accepts the broker's removal request and requests a PnP controller restart; user mode can retry only failures returned @@ -408,28 +428,48 @@ a wedged provider cannot retain the installer mutex indefinitely. direct interrupt-IN fast path. UdeCx itself owns and purges the framework endpoint queue; VIIPER never starts or purges that queue. The purge callback closes admission and cancels only the requests already forwarded into - VIIPER-owned paths; a passive work item calls - `UdecxUsbEndpointPurgeComplete` only after the last forwarded URB has actually - completed. A pipe can therefore never restart or disappear across a live - request. + VIIPER-owned paths. A passive work item only observes the associated queue: + `WDF_IO_QUEUE_IDLE` proves both that no request remains queued and that every + WDF-delivered request has completed or been canceled, while the broker-lock + rundown proves its terminal DPC has released the endpoint. Only then may the + work item call `UdecxUsbEndpointPurgeComplete`. A pipe can therefore never + restart or disappear across a live or pre-callback-delivery request, and the + client never mutates UdeCx-owned queue state. - Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx - management requests, not notifications. ABI 1.8 gives only those lifecycle - operations a generation-bound management token. Windows receives the request - completion only after the Go controller engine has applied the reset or - alternate-setting transition. Start, purge, and power notifications remain - unacknowledged and cannot add a media round trip. + management requests, not notifications. ABI 1.9 preserves the + generation-bound management tokens introduced in ABI 1.8 and adds the + source-bound loaded-kernel identity to negotiation. Windows receives the + request completion only after the Go controller engine has applied the reset + or alternate-setting transition. Start, purge, and power notifications + remain unacknowledged and cannot add a media round trip. - Endpoint reset owns a gate separate from endpoint purge. The UdeCx reset callback closes both broker and direct-input admission under the broker lock, - cancels forwarded work, and defers its acknowledged lifecycle event until the - last already-admitted endpoint operation drains. User mode stops and joins - that endpoint's direct-input publisher before applying recovery, acknowledges - the reset, then resumes the same sequence. Reset never calls purge-complete or - waits for a later start callback, matching UdeCx's distinct reset and purge - contracts. + cancels forwarded work, and defers its acknowledged lifecycle event until a + read-only queue sample reports `WdfIoQueueDriverNoRequests` and the endpoint + rundown reaches zero. Unlike purge, reset may leave a parked interrupt poll + queued and the queue ready. This weaker queue predicate is stable because the + UdeCx reset is asynchronous: the endpoint cannot process successor transfers + until VIIPER completes the reset request. User mode stops and joins that + endpoint's direct-input publisher before applying recovery. At owner + acknowledgement the kernel repeats the exact `(device ID, generation, + pinned WDF device/endpoint, reset epoch)` proof, clears only that live reset + gate under the admission lock, and immediately completes the UdeCx request. + Generic framework references prevent opaque handle recycling until every + management-slot terminal path has cleared the slot outside the broker lock. + Missing, purged, removed, or reused identities fail closed and receive no + stale reset publication or successor gate change. Reset never calls + purge-complete or waits for a later start callback, matching UdeCx's distinct + reset and purge contracts. - Device reset closes direct input admission in the kernel callback and pauses - every user-mode publisher before controller state is cleared. Admission and - the active publishers reopen only after the generation-bound reset request - has been acknowledged, so no HID snapshot can cross the reset boundary. + every user-mode publisher before controller state is cleared. Every endpoint + first passes the same reset-specific driver-owned-request/rundown proof; if + purge or removal wins, the actual reset request fails without publishing a + dead generation. Successful device-reset admission also advances a private + 64-bit epoch. Endpoint reset admission captures that epoch, so a later device + reset deterministically supersedes every older endpoint reset even when user + mode acknowledges them out of order. Admission and active publishers reopen + only after a second exact-generation/object/epoch proof at acknowledgement, + so no HID snapshot or late terminal callback can cross the reset boundary. Post-enumeration reset, device initialization, and configuration replacement share this one-child-at-a-time gate; concurrent reset transactions are rejected instead of interleaving two controller resets. @@ -628,7 +668,17 @@ authenticated commit order are documented in - Microsoft, *Handling I/O Requests in a USB Host Controller Driver* +- Microsoft, `WdfRequestRetrieveOutputBuffer` (the buffered negotiation + response remains framework-owned until request completion) + - Microsoft, `EVT_UDECX_USB_ENDPOINT_PURGE` +- Microsoft, `EVT_UDECX_USB_ENDPOINT_RESET` (asynchronous reset request) + +- Microsoft, `WdfIoQueueGetState`, `WDF_IO_QUEUE_STATE`, and + `WDF_IO_QUEUE_IDLE` (idle includes requests delivered to the driver) + + + - Microsoft, `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` From 8f45acdf7f267d886d99206495bd7ad9677739b5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 08:20:18 -0500 Subject: [PATCH 177/240] fix(udecx): satisfy warning-clean driver build --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 ++-- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Device.c | 1 - native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 12 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 49199810..b9ee83af 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - 'efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7', + '285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true')) { diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 0111939e..220a71d7 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -97,8 +97,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.4 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 - if ($identity -cne 'efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7') { + -DriverPackageVersion 0.1.0.5 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + if ($identity -cne '285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index e147ee3f..3b9b9411 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.4", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.5", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index df4ed96f..cff1ed10 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 9, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.4", + "expectedDriverPackageVersion": "0.1.0.5", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 68ee1306..c69c717d 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.4", + ExpectedDriverPackageVersion: "0.1.0.5", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 4911030e..623d6f8f 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.4" + DriverPackageVersion = "0.1.0.5" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 426baa3f..96e9f664 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7" + const wantHex = "285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 5d559b6e..4e5f232f 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -2083,7 +2083,6 @@ ViiperEvtEndpointPurgeWorkItem( ) { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); - VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); PAGED_CODE(); // UdeCx requires every request forwarded out of the endpoint queue to be diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 31b46f9f..3536eebe 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.4 + 0.1.0.5 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index d0f29fd5..cfe71cb5 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.4" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.5" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index e0934837..ec980da9 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.4 +DriverVer=08/11/2026,0.1.0.5 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index ed1eaab8..f335426d 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -3220,7 +3220,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "efb6c64ffa47eb72492406dcc8add19451c24f203fdc8706082a2c6bb91e9eb7") { + "285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From d777a6a2100a7361ee83f16dc3ae5fe30abefd0d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 13:52:28 -0500 Subject: [PATCH 178/240] Harden native package transaction recovery --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency/profile_contract_test.go | 5 + _testing/e2e/latency/report_test.go | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- .../scripts/Invoke-ViiperE2ELatencyGate.ps1 | 9 +- docs/api/overview.md | 2 +- .../native-udecx-package-install.md | 128 +- internal/cmd/native_mutex_windows.go | 273 ++ internal/cmd/native_mutex_windows_test.go | 143 ++ internal/cmd/native_package.go | 142 +- internal/cmd/native_package_contract_test.go | 70 +- internal/cmd/native_package_other.go | 8 +- .../cmd/native_package_process_windows.go | 156 ++ .../native_package_process_windows_test.go | 160 ++ internal/cmd/native_package_test.go | 130 +- .../cmd/native_package_uninstall_windows.go | 70 +- .../native_package_uninstall_windows_test.go | 44 +- internal/cmd/native_package_windows.go | 489 ++-- internal/cmd/native_package_windows_test.go | 34 + .../cmd/native_service_install_windows.go | 182 +- .../native_service_install_windows_test.go | 24 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/live_validation_contract_test.go | 6 + internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 9 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 94 +- native/udecx/tools/ViiperUdeCtl.cpp | 2200 +++++++++++++++-- 32 files changed, 3918 insertions(+), 484 deletions(-) create mode 100644 internal/cmd/native_mutex_windows_test.go create mode 100644 internal/cmd/native_package_process_windows.go create mode 100644 internal/cmd/native_package_process_windows_test.go diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index b9ee83af..9b100bc6 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed', + '5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true')) { diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 220a71d7..720d3500 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -97,8 +97,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.5 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 - if ($identity -cne '285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed') { + -DriverPackageVersion 0.1.0.6 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + if ($identity -cne '5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency/profile_contract_test.go b/_testing/e2e/latency/profile_contract_test.go index 7a59efb4..a09715cc 100644 --- a/_testing/e2e/latency/profile_contract_test.go +++ b/_testing/e2e/latency/profile_contract_test.go @@ -38,6 +38,8 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { "github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$headRevision", "Get-WinEvent -FilterHashtable", "ProviderName = 'VIIPER-LatencyGate'", "trace_marker_id", "start_qpc_ticks", "trace_marker_qpc_ticks", + "Win32_PnPEntity", "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", + "$ownedRootDevices[0].PNPDeviceID", "Dropped\\s+Event", "Buffers?\\s+Lost", } { if !strings.Contains(wrapperText, want) { @@ -47,6 +49,9 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { if strings.Contains(wrapperText, "GeneralProfile.Verbose") { t.Fatal("production wrapper regressed to an inbox circular profile") } + if strings.Contains(wrapperText, "DeviceID -like 'ROOT\\VIIPER\\UDE*'") { + t.Fatal("production wrapper confuses the INF hardware ID with the generated PnP instance ID") + } liveHarness, err := os.ReadFile("../latency_gate_windows_test.go") if err != nil { t.Fatal(err) diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go index 5194f1bb..401f0775 100644 --- a/_testing/e2e/latency/report_test.go +++ b/_testing/e2e/latency/report_test.go @@ -560,12 +560,12 @@ func validReport(t *testing.T) *Report { LoadedDriverBuildIdentity: report.Provenance.NativeDriverBuildIdentity, } run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\2` - run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\2`, `ROOT\VIIPER\UDE\0000`} + run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\2`, `ROOT\VIIPERUDE\0000`} run.Controller.PNPAncestorServices = []string{"HidUsb", "WUDFRd", "ViiperUde"} run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\VIIPER\UDE`}} run.Controller.PNPAncestorLocationInfo = []string{"", "", ""} run.Controller.PNPAncestorLocationPaths = [][]string{{}, {}, {}} - run.Controller.TransportAnchorInstanceID = `ROOT\VIIPER\UDE\0000` + run.Controller.TransportAnchorInstanceID = `ROOT\VIIPERUDE\0000` run.Controller.TransportAnchorService = "ViiperUde" } transportOffset := 0 diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 3b9b9411..60293fd0 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.5", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.6", LoadedDriverBuildIdentity: expected, }, } diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 index d826ca49..83c76338 100644 --- a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -147,8 +147,15 @@ $installedDriverHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SH if ($packageDriverHash -ne $installedDriverHash) { throw "The installed VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." } +$ownedRootDevices = @(Get-CimInstance -ClassName Win32_PnPEntity | Where-Object { + @($_.HardwareID) -contains 'ROOT\VIIPER\UDE' +}) +if ($ownedRootDevices.Count -ne 1) { + throw "Expected exactly one VIIPER UDE hardware-ID owner; found $($ownedRootDevices.Count)." +} +$ownedRootInstance = [string]$ownedRootDevices[0].PNPDeviceID $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { - [string]$_.DeviceID -like 'ROOT\VIIPER\UDE*' + [string]$_.DeviceID -ieq $ownedRootInstance }) if ($devnodes.Count -ne 1) { throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." diff --git a/docs/api/overview.md b/docs/api/overview.md index cff1ed10..4650f33e 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 9, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.5", + "expectedDriverPackageVersion": "0.1.0.6", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index c28e0f99..0e376972 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -17,8 +17,8 @@ the broker and leaving the devnode or Driver Store package behind. The signed bootstrapper supplies all of the following as immutable build data: -- the exact VIIPER broker, `ViiperUdeCtl.exe`, and reviewed production-manifest - SHA-256 values; +- the exact VIIPER broker, `ViiperUdeCtl.exe`, production manifest, INF, SYS, + and CAT SHA-256 values; - the reviewed exact 40- or 64-hexadecimal source revision; - the runtime driver directory containing only the Microsoft-returned INF, SYS, and CAT, plus the source-bound HLK/WHCP manifest; and @@ -51,50 +51,60 @@ of the source-provenance evidence without becoming a user-machine dependency. 1. Acquire the administrator-only machine package mutex and validate every immutable input. -2. Inspect `VIIPERNativeBroker` without changing it. A protected canonical - service and executable become an exact rollback source. An exact service - name whose executable is restricted to one of VIIPER's managed Program - Files layouts, but whose service/image ACL is weak or stale, is stopped and - deleted; its unsafe ACL is never repaired in place or restored. -3. Create `%ProgramFiles%\VIIPER` with the canonical protected ACL, or require - an existing directory to already have that exact ACL. Write a random sibling - staging file with the exact executable ACL, flush it, verify its SHA-256 and - single-link identity, retain the previous broker under a random protected - rollback name, and publish with `MoveFileExW(..., WRITE_THROUGH)`. The outer - transaction also creates and holds a random one-time token with an - administrator/SYSTEM-only DACL and passes its installer-bound SHA-256 to the - helper. -4. `ViiperUdeCtl verify` repeats source/package verification without mutation. - `ViiperUdeCtl install` then retains its in-memory pre-install DriverStore and - devnode snapshot while launching the staged broker's hidden - `native-package-broker-commit` command. That command reopens the immutable - token, requires its exact DACL/hash/path, and proves the package mutex is - still owned by the separate outer process before it may enter the normal - broker service transaction. -5. The broker transaction creates or updates the LocalSystem service, rotates - its protected credential, starts it, and requires authenticated `ping` - identity, `Ready=true`, ABI 1.9, the exact negotiated capability mask, and - the source-bound build identity returned by the currently loaded kernel - image. A well-formed identity from a stale same-ABI driver fails readiness. - Only then does it disable legacy Run/task/process ownership, and it - authenticates again before returning success. -6. The helper commits the driver only after that broker proof. A broker failure - first rolls back SCM, credential, and legacy state inside the broker, then - restores the prior driver packages/devnode inside the still-running helper. - The outer command finally restores the old broker image and prior service - run-state. It never removes USB/IP directly. +2. Create and hold a random one-time token below `%ProgramFiles%\VIIPER` with + an administrator/SYSTEM-only DACL, and pass its installer-bound SHA-256 to + `ViiperUdeCtl install`. The helper independently reopens and verifies the + source manifest and all three runtime driver hashes, acquires its private + driver mutex, and snapshots the exact Driver Store/devnode topology. +3. Classify the driver under that mutex. Exact package bytes plus an exact + started binding cause no SetupAPI mutation. Exact bytes with missing, + stopped, or stale topology select the already-published driver for the exact + devnode and call `DiInstallDevice`; they never replace same-version Driver + Store content. An absent or newer candidate uses `DiInstallDriverW` under the + monotonic version policy. Same-version INF/SYS/CAT conflicts and implicit + downgrades fail before mutation. +4. After the exact binding is verified, the helper launches the immutable + package broker's hidden `native-package-broker-commit` command while still + holding the driver mutex and snapshot. That command reopens the token, + requires its exact DACL/hash/path, proves the separate outer process still + owns the package mutex, then acquires the broker-service mutex. +5. The nested command first checks for a true no-op: canonical protected + service/image/credential state, no live legacy owner, stable service PID, and + authenticated `ping` with `Ready=true`, ABI 1.9, the exact capability mask, + package version, and loaded-kernel build identity. If any part is unhealthy, + it transactionally publishes the exact broker through a flushed protected + sibling, creates or repairs the LocalSystem service, rotates its credential, + and repeats authenticated health before and after removing legacy + Run/task/process ownership. A weak pre-existing service is deleted and + recreated; its unsafe ACL is never repaired in place or restored. +6. The child emits one newline-terminated canonical result. A broker failure + first rolls back SCM, credential, and legacy state, then restores the prior + broker image and run-state inside the child. Only a pre-mutation proof or a + fully settled child rollback authorizes the still-running helper to restore + its captured driver packages/devnode. Crash, malformed/missing proof, exit 3, + pipe/wait ambiguity, or an over-budget child leaves driver rollback + unauthorized and reports external reconciliation. USB/IP itself is never + directly removed by this transaction. The mutating broker process is never hard-terminated. The outer absolute four-minute deadline is passed through the helper into the nested broker, so it does not receive a fresh budget after driver mutation. The broker owns a separately bounded rollback and unwinds cooperatively; the helper retains the -driver snapshot and polls only through that explicit rollback ceiling. If the -child violates both bounds, the helper reports an indeterminate rollback and -does not race the still-owning child with a second driver rollback. The outer rollback -uses its own non-canceled two-minute context. Synchronous SetupAPI work is +driver snapshot through a three-minute post-deadline ceiling that covers the +45-second inner service rollback plus the outer non-canceled two-minute image +rollback. Even after that ceiling it retains the driver mutex until the child +actually exits, then reports an indeterminate result rather than racing the +child with a second driver rollback. Synchronous SetupAPI work is checked immediately before and after each mutating boundary; no new phase may start after expiry, and no process is killed mid-rollback. +Before calling Go's `Cmd.Wait`, the outer transaction duplicates the exact +helper process handle with `SYNCHRONIZE`. It independently waits for that +process object to become signaled before releasing the package mutex or any +immutable input handle. A non-exit `Cmd.Wait` error is therefore still +indeterminate, but it can no longer let a live mutating helper escape the +transaction scope. + ## Exact package removal Removal is a separate fail-closed composition transaction; it does not reuse @@ -118,7 +128,9 @@ the historical broker-only uninstall routine. one-link state, non-reparse identity, and a stable hash. Launch `ViiperUdeCtl remove` with the outer absolute deadline. The Go parent never uses a context-killed process or hard - termination after launch. The helper checks the cooperative deadline before + termination after launch, and it applies the same retained-process join as + install before releasing its service/package scope. The helper checks the + cooperative deadline before and after each SetupAPI boundary and owns a separate two-minute cooperative rollback ceiling. If the live-log identity cannot be upgraded exactly, the helper is not launched and the broker stays stopped rather than reopening an @@ -134,11 +146,26 @@ the historical broker-only uninstall routine. Exit 3, a crash, a missing/malformed proof, or any ambiguous wait cannot prove a safe binding, so the broker remains stopped and the command reports that external reconciliation is required. - Before mutation, rollback copies are placed below the non-reparse Windows - temporary directory in a cryptographically unpredictable, protected - Administrators/LocalSystem-only directory. The parent and backup root remain - locked against rename, and the exact INF/SYS/CAT handles deny write/delete - sharing until rollback is no longer possible. + Before mutation, an exact three-file INF/SYS/CAT rollback tree is placed + below the non-reparse Windows temporary directory in a cryptographically + unpredictable location. Every directory and file is created with, and then + verified against, an explicit protected Administrators/LocalSystem-only ACL; + payload writes are write-through, explicitly flushed, signature/hash + revalidated, and locked against write/delete sharing. A canonical journal + binds every captured devnode to one package index and every package to its + relative backup paths and exact hashes. It is written to a private temporary + name, flushed, atomically published with a write-through rename, reopened, + ACL/byte verified, and flushed again before the first SetupAPI mutation. + Journal presence means manual reconciliation may be required; it never + authorizes automatic restoration. Preservation is armed before mutation and + therefore survives C++ exceptions or process failure. It is disarmed only + after explicit, verified deletion following either committed removal or a + verified rollback; cleanup failure is surfaced and retains the journal and + backup tree. A backup-preparation failure whose tree cannot be deleted emits + the retained root and planned journal path with `recoveryRecordWritten=0`. + The allocation-free exception outcome separately tracks whether transaction + mutation actually started: pre-mutation exceptions remain exit 4 with + `changed=0`, while post-mutation exceptions require exit 3 reconciliation. 5. Only after exit 0 or 3010 does cleanup revalidate and delete the exact service, credential, broker log, and installer-owned broker images. Deletion uses the retained file identities rather than a second untrusted path lookup. It does @@ -187,11 +214,14 @@ registration cleanup is treated as VIIPER ownership authority. lifetime rule. - SetupAPI rollback preserves the captured root device instance ID. Per [`SetupDiCreateDeviceInfoW`](https://learn.microsoft.com/windows/win32/api/setupapi/nf-setupapi-setupdicreatedeviceinfow), - omitting `DICD_GENERATE_ID` makes `DeviceName` the complete instance ID; - generated IDs are used only for a first-time forward install. Rollback - reconciles and verifies the captured identity, topology, and signed package - hash rather than deleting every matching devnode and manufacturing a - replacement. + forward creation passes the VIIPER-owned `VIIPERUDE` device name with + `DICD_GENERATE_ID` and verifies the returned `ROOT\VIIPERUDE\####` identity. + Rollback omits `DICD_GENERATE_ID`, making `DeviceName` the complete captured + instance ID. It accepts only that namespace or the exact legacy + `ROOT\USB\####` form produced when older builds incorrectly passed the USB + class name, after the existing service/package ownership proof. It then + verifies the restored identity, topology, and signed package hashes rather + than deleting every matching devnode and manufacturing a replacement. - ViGEmBus's root-enumerated bus architecture is used only as the lifecycle reference: the bus owns its exact child identities and separates user-mode submission from PnP mutation. usbip-win2 remains an untouched legacy diff --git a/internal/cmd/native_mutex_windows.go b/internal/cmd/native_mutex_windows.go index a149a916..a11b89ce 100644 --- a/internal/cmd/native_mutex_windows.go +++ b/internal/cmd/native_mutex_windows.go @@ -4,10 +4,178 @@ package cmd import ( "errors" + "fmt" + "runtime" + "strings" + "sync" + "time" + "unsafe" "golang.org/x/sys/windows" ) +const ( + // A private namespace prevents an unprivileged process from pre-creating a + // public Global mutex and making CreateMutex open an attacker-owned object. + // The alias and complete boundary (name plus Administrators SID) identify one + // namespace shared by the package and broker-service transactions. + nativeMutexNamespaceAlias = "VIIPER_NATIVE_INSTALL_NAMESPACE_V1" + nativeMutexBoundaryName = "VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1" + nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" + + nativeMutexNamespaceRaceRetries = 16 +) + +var ( + nativeMutexKernel32 = windows.NewLazySystemDLL("kernel32.dll") + nativeCreateBoundaryDescriptorW = nativeMutexKernel32.NewProc("CreateBoundaryDescriptorW") + nativeAddSIDToBoundaryDescriptor = nativeMutexKernel32.NewProc("AddSIDToBoundaryDescriptor") + nativeDeleteBoundaryDescriptor = nativeMutexKernel32.NewProc("DeleteBoundaryDescriptor") + nativeCreatePrivateNamespaceW = nativeMutexKernel32.NewProc("CreatePrivateNamespaceW") + nativeOpenPrivateNamespaceW = nativeMutexKernel32.NewProc("OpenPrivateNamespaceW") + nativeClosePrivateNamespace = nativeMutexKernel32.NewProc("ClosePrivateNamespace") +) + +type nativeMutexNamespace struct { + boundary windows.Handle + namespace windows.Handle +} + +// close releases the namespace before deleting the boundary descriptor. Every +// caller keeps this scope alive until after its mutex handle is closed, as the +// private-namespace contract requires for new opens and named-object lookup. +func (scope *nativeMutexNamespace) close() { + if scope == nil { + return + } + if scope.namespace != 0 { + nativeClosePrivateNamespace.Call(uintptr(scope.namespace), 0) //nolint:errcheck + scope.namespace = 0 + } + if scope.boundary != 0 { + nativeDeleteBoundaryDescriptor.Call(uintptr(scope.boundary)) //nolint:errcheck + scope.boundary = 0 + } +} + +func nativeMutexCallError(err error) error { + if err == nil || errors.Is(err, windows.ERROR_SUCCESS) { + return windows.ERROR_GEN_FAILURE + } + return err +} + +func createNativeMutexBoundary() (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(nativeMutexBoundaryName) + if err != nil { + return 0, err + } + result, _, callErr := nativeCreateBoundaryDescriptorW.Call( + uintptr(unsafe.Pointer(name)), 0, + ) + runtime.KeepAlive(name) + if result == 0 { + return 0, nativeMutexCallError(callErr) + } + boundary := windows.Handle(result) + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + nativeDeleteBoundaryDescriptor.Call(uintptr(boundary)) //nolint:errcheck + return 0, err + } + result, _, callErr = nativeAddSIDToBoundaryDescriptor.Call( + uintptr(unsafe.Pointer(&boundary)), uintptr(unsafe.Pointer(administrators)), + ) + runtime.KeepAlive(administrators) + if result == 0 { + nativeDeleteBoundaryDescriptor.Call(uintptr(boundary)) //nolint:errcheck + return 0, nativeMutexCallError(callErr) + } + return boundary, nil +} + +// createOrOpenNativeMutexNamespace follows the documented private-namespace +// rendezvous: create with a protected DACL, or open only the namespace with the +// exact alias and Administrators boundary. A creator can disappear between +// ERROR_ALREADY_EXISTS and OpenPrivateNamespace, so retry only that absence +// race; all access and boundary failures remain fail-closed. +func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { + boundary, err := createNativeMutexBoundary() + if err != nil { + return nil, fmt.Errorf("create native install mutex boundary: %w", err) + } + scope := &nativeMutexNamespace{boundary: boundary} + alias, err := windows.UTF16PtrFromString(nativeMutexNamespaceAlias) + if err != nil { + scope.close() + return nil, err + } + descriptor, err := windows.SecurityDescriptorFromString(nativeMutexObjectSDDL) + if err != nil { + scope.close() + return nil, fmt.Errorf("create native install namespace security descriptor: %w", err) + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + + var lastErr error + for attempt := 0; attempt < nativeMutexNamespaceRaceRetries; attempt++ { + result, _, createErr := nativeCreatePrivateNamespaceW.Call( + uintptr(unsafe.Pointer(&attributes)), uintptr(boundary), + uintptr(unsafe.Pointer(alias)), + ) + runtime.KeepAlive(alias) + runtime.KeepAlive(descriptor) + if result != 0 { + scope.namespace = windows.Handle(result) + return scope, nil + } + createErr = nativeMutexCallError(createErr) + if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + scope.close() + return nil, fmt.Errorf("create native install mutex namespace: %w", createErr) + } + + result, _, openErr := nativeOpenPrivateNamespaceW.Call( + uintptr(boundary), uintptr(unsafe.Pointer(alias)), + ) + runtime.KeepAlive(alias) + if result != 0 { + scope.namespace = windows.Handle(result) + return scope, nil + } + openErr = nativeMutexCallError(openErr) + lastErr = openErr + if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) { + scope.close() + return nil, fmt.Errorf("open native install mutex namespace: %w", openErr) + } + runtime.Gosched() + } + scope.close() + return nil, fmt.Errorf("create or open native install mutex namespace after creator race: %w", lastErr) +} + +func nativePrivateMutexName(objectName string) (*uint16, error) { + if objectName == "" || strings.ContainsAny(objectName, `\\/`) { + return nil, fmt.Errorf("invalid native private mutex object name %q", objectName) + } + return windows.UTF16PtrFromString(nativeMutexNamespaceAlias + `\` + objectName) +} + +func nativeMutexSecurityAttributes() (*windows.SecurityAttributes, error) { + descriptor, err := windows.SecurityDescriptorFromString(nativeMutexObjectSDDL) + if err != nil { + return nil, err + } + return &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + }, nil +} + // createNamedNativeMutex normalizes the Win32 CreateMutex contract. A named // mutex that already exists is a successful open: Windows returns its valid // handle and ERROR_ALREADY_EXISTS, and WaitForSingleObject decides ownership. @@ -27,3 +195,108 @@ func createNamedNativeMutex( } return handle, nil } + +func nativeMutexWaitMilliseconds(timeout time.Duration) uint32 { + if timeout <= 0 { + return 0 + } + milliseconds := timeout / time.Millisecond + if milliseconds >= time.Duration(windows.INFINITE) { + return windows.INFINITE - 1 + } + return uint32(milliseconds) +} + +// acquireNativeNamedMutex preserves Win32's thread-affine mutex ownership by +// pinning the goroutine through the returned release closure. Mutex ownership +// and its handle, the namespace, the boundary, and the thread pin are released +// in that order. +func acquireNativeNamedMutex( + objectName string, + timeout time.Duration, + busyMessage string, +) (func(), error) { + name, err := nativePrivateMutexName(objectName) + if err != nil { + return nil, err + } + runtime.LockOSThread() + scope, err := createOrOpenNativeMutexNamespace() + if err != nil { + runtime.UnlockOSThread() + return nil, err + } + attributes, err := nativeMutexSecurityAttributes() + if err != nil { + scope.close() + runtime.UnlockOSThread() + return nil, fmt.Errorf("create native mutex security descriptor: %w", err) + } + handle, err := createNamedNativeMutex(attributes, name) + runtime.KeepAlive(attributes.SecurityDescriptor) + if err != nil { + scope.close() + runtime.UnlockOSThread() + return nil, fmt.Errorf("create protected native mutex: %w", err) + } + status, err := windows.WaitForSingleObject(handle, nativeMutexWaitMilliseconds(timeout)) + if err != nil || (status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED) { + windows.CloseHandle(handle) //nolint:errcheck + scope.close() + runtime.UnlockOSThread() + if err != nil { + return nil, fmt.Errorf("wait for protected native mutex: %w", err) + } + return nil, errors.New(busyMessage) + } + var once sync.Once + return func() { + once.Do(func() { + windows.ReleaseMutex(handle) //nolint:errcheck + windows.CloseHandle(handle) //nolint:errcheck + scope.close() + runtime.UnlockOSThread() + }) + }, nil +} + +// nativeNamedMutexHeldByAnotherOwner opens only the exact object inside the +// Administrators namespace. WAIT_TIMEOUT proves another thread/process owns +// it; an absent, abandoned, or immediately acquirable mutex proves no live +// owner. This is the cross-process nested package-commit probe. +func nativeNamedMutexHeldByAnotherOwner(objectName string) (bool, error) { + name, err := nativePrivateMutexName(objectName) + if err != nil { + return false, err + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + scope, err := createOrOpenNativeMutexNamespace() + if err != nil { + return false, err + } + defer scope.close() + handle, err := windows.OpenMutex( + windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, false, name, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return false, nil + } + return false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return false, err + } + switch status { + case uint32(windows.WAIT_TIMEOUT): + return true, nil + case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): + windows.ReleaseMutex(handle) //nolint:errcheck + return false, nil + default: + return false, fmt.Errorf("unexpected protected native mutex wait status: 0x%08x", status) + } +} diff --git a/internal/cmd/native_mutex_windows_test.go b/internal/cmd/native_mutex_windows_test.go new file mode 100644 index 00000000..284d29b2 --- /dev/null +++ b/internal/cmd/native_mutex_windows_test.go @@ -0,0 +1,143 @@ +//go:build windows + +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +const nativeMutexProbeTestEnvironment = "VIIPER_NATIVE_MUTEX_PROBE_TEST" + +func requireNativeMutexAdministrator(t *testing.T) { + t.Helper() + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatalf("create Administrators SID: %v", err) + } + member, err := windows.GetCurrentProcessToken().IsMember(administrators) + if err != nil { + t.Fatalf("check Administrators token membership: %v", err) + } + if !member { + t.Skip("Administrators-bound private namespace requires an elevated test token") + } +} + +func TestNativeMutexRejectsPublicNamespaceObjectNames(t *testing.T) { + t.Parallel() + for _, name := range []string{"", `Global\VIIPER`, `Local\VIIPER`, `nested/name`} { + if _, err := nativePrivateMutexName(name); err == nil { + t.Errorf("nativePrivateMutexName(%q) accepted a namespace escape", name) + } + } +} + +func TestNativeMutexWaitMillisecondsIsBounded(t *testing.T) { + t.Parallel() + if got := nativeMutexWaitMilliseconds(-time.Second); got != 0 { + t.Fatalf("negative timeout converted to %d, want 0", got) + } + if got := nativeMutexWaitMilliseconds(time.Nanosecond); got != 0 { + t.Fatalf("sub-millisecond timeout converted to %d, want 0", got) + } + if got := nativeMutexWaitMilliseconds(time.Second); got != 1000 { + t.Fatalf("one-second timeout converted to %d, want 1000", got) + } + if got := nativeMutexWaitMilliseconds(time.Duration(windows.INFINITE) * time.Millisecond); got != windows.INFINITE-1 { + t.Fatalf("oversized timeout converted to %d, want %d", got, uint32(windows.INFINITE-1)) + } +} + +func TestNativeMutexNestedPackageProbe(t *testing.T) { + requireNativeMutexAdministrator(t) + name := "VIIPER_NATIVE_PACKAGE_PROBE_TEST_" + filepath.Base(t.TempDir()) + release, err := acquireNamedNativePackageMutex(name, time.Second) + if err != nil { + t.Fatalf("acquire package mutex: %v", err) + } + defer release() + + command := exec.Command(os.Args[0], "-test.run=^TestNativeMutexNestedPackageProbeChild$") + command.Env = append(os.Environ(), nativeMutexProbeTestEnvironment+"="+name) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run nested mutex probe: %v\n%s", err, output) + } +} + +func TestNativeMutexNestedPackageProbeChild(t *testing.T) { + name := os.Getenv(nativeMutexProbeTestEnvironment) + if name == "" { + return + } + held, err := nativePackageMutexHeldByAnotherOwner(name) + if err != nil { + t.Fatalf("probe parent-owned package mutex: %v", err) + } + if !held { + t.Fatal("parent-owned package mutex was not observed inside the exact private namespace") + } +} + +func TestNativeMutexPrivateNamespaceSourceContract(t *testing.T) { + t.Parallel() + _, current, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve native mutex test source") + } + source, err := os.ReadFile(filepath.Join(filepath.Dir(current), "native_mutex_windows.go")) + if err != nil { + t.Fatal(err) + } + text := string(source) + for _, required := range []string{ + `nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)"`, + `windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)`, + `NewProc("CreateBoundaryDescriptorW")`, + `NewProc("AddSIDToBoundaryDescriptor")`, + `NewProc("CreatePrivateNamespaceW")`, + `NewProc("OpenPrivateNamespaceW")`, + `NewProc("ClosePrivateNamespace")`, + `nativeDeleteBoundaryDescriptor.Call`, + `scope.namespace = windows.Handle(result)`, + `createNamedNativeMutex(attributes, name)`, + `runtime.LockOSThread()`, + `windows.WAIT_ABANDONED`, + `windows.OpenMutex(`, + } { + if !strings.Contains(text, required) { + t.Errorf("native mutex namespace lost %q", required) + } + } + for _, forbidden := range []string{`Global\VIIPER.NativePackage`, `Global\VIIPER.NativeBroker`} { + if strings.Contains(text, forbidden) { + t.Errorf("native mutex helper retains squattable public name %q", forbidden) + } + } + if nativePackageMutexName == nativeInstallMutexName { + t.Fatal("package and service transactions unexpectedly share one mutex object") + } + if strings.ContainsAny(nativePackageMutexName+nativeInstallMutexName, `\\/`) { + t.Fatal("native mutex object names escape the private namespace") + } +} + +func TestNativeMutexAbsentProbeIsNotHeld(t *testing.T) { + requireNativeMutexAdministrator(t) + name := "VIIPER_NATIVE_ABSENT_PROBE_TEST_" + filepath.Base(t.TempDir()) + held, err := nativePackageMutexHeldByAnotherOwner(name) + if err != nil { + t.Fatalf("probe absent package mutex: %v", err) + } + if held { + t.Fatal("absent package mutex reported as held") + } +} diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index a089a682..e7404328 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -5,14 +5,19 @@ import ( "errors" "fmt" "log/slog" + "os" "path/filepath" "regexp" + "strconv" "strings" "time" ) var nativePackageHexRevision = regexp.MustCompile(`^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`) var nativePackageSHA256 = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) +var nativePackageInstallProofPattern = regexp.MustCompile( + `(?m)^result=(success|error) operation=install changed=([01]) rebootRequired=([01]) rollback=(not-needed|succeeded|failed) exitCode=([0-9]+)(?: .*)?\r?$`, +) const ( nativePackageTransactionTimeout = 4 * time.Minute @@ -40,13 +45,16 @@ func (e *nativePackageRebootRequiredError) ExitCode() int { // native UDE package. It is hidden because normal users enter through the // signed DS4Windows installer, which embeds the reviewed hashes passed here. type NativePackageInstall struct { - PackageDirectory string `help:"Directory containing the four Microsoft-returned VIIPER UDE files." required:""` + PackageDirectory string `help:"Directory containing the exact Microsoft-returned INF, SYS, and CAT runtime files." required:""` SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` SourceRevision string `help:"Reviewed 40- or 64-character source revision." required:""` DriverHelper string `help:"Path to the packaged ViiperUdeCtl.exe." required:""` ExpectedBrokerSHA256 string `help:"Installer-embedded SHA-256 of this VIIPER executable." required:""` ExpectedHelperSHA256 string `help:"Installer-embedded SHA-256 of ViiperUdeCtl.exe." required:""` ExpectedManifestSHA256 string `help:"Installer-embedded SHA-256 of the reviewed HLK/WHCP manifest." required:""` + ExpectedInfSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.inf." required:""` + ExpectedSysSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.sys." required:""` + ExpectedCatSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.cat." required:""` TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` } @@ -55,17 +63,126 @@ type NativePackageInstall struct { type NativePackageBrokerCommit struct { TokenFile string `help:"Protected package-transaction token path." required:""` ExpectedTokenSHA256 string `help:"SHA-256 of the protected transaction token." required:""` + ExpectedBrokerSHA256 string `help:"Installer-bound SHA-256 of the broker being committed." required:""` TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` TransactionDeadlineUnixMS string `help:"Outer package transaction deadline as Unix milliseconds." required:""` } +type nativePackageBrokerCommitResult struct { + success bool + changed bool + rollback string + exitCode int +} + +type nativePackageInstallProof struct { + success bool + changed bool + rebootRequired bool + rollback string + exitCode int +} + +func parseNativePackageInstallProof(output string, processExitCode int) (nativePackageInstallProof, error) { + matches := nativePackageInstallProofPattern.FindAllStringSubmatch(output, -1) + if len(matches) != 1 { + return nativePackageInstallProof{}, errors.New("driver helper did not emit exactly one structured install outcome") + } + proofExitCode, err := strconv.Atoi(matches[0][5]) + if err != nil { + return nativePackageInstallProof{}, fmt.Errorf("parse driver helper install exit code: %w", err) + } + proof := nativePackageInstallProof{ + success: matches[0][1] == "success", + changed: matches[0][2] == "1", + rebootRequired: matches[0][3] == "1", + rollback: matches[0][4], + exitCode: proofExitCode, + } + if proof.exitCode != processExitCode { + return nativePackageInstallProof{}, fmt.Errorf( + "driver helper install process exit %d disagreed with structured exit %d", + processExitCode, proof.exitCode, + ) + } + switch proof.exitCode { + case 0: + if !proof.success || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid success install outcome") + } + case nativePackageRebootRequiredCode: + if proof.success || !proof.changed || !proof.rebootRequired || proof.rollback != "succeeded" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid reboot-boundary install outcome") + } + case 4: + if proof.success || proof.changed || proof.rebootRequired || proof.rollback != "not-needed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid preflight install outcome") + } + case 1: + settledMutation := proof.changed && proof.rollback == "succeeded" + preMutationFailure := !proof.changed && proof.rollback == "not-needed" + if proof.success || (!settledMutation && !preMutationFailure) { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid failed install outcome") + } + case 3: + if proof.success || !proof.changed || proof.rollback != "failed" { + return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid indeterminate install outcome") + } + default: + return nativePackageInstallProof{}, fmt.Errorf( + "driver helper returned unsupported structured install exit %d", proof.exitCode, + ) + } + return proof, nil +} + +func (r nativePackageBrokerCommitResult) proofLine() string { + status := "error" + if r.success { + status = "success" + } + changed := 0 + if r.changed { + changed = 1 + } + return fmt.Sprintf( + "result=%s operation=native-package-broker-commit changed=%d rollback=%s exitCode=%d\n", + status, changed, r.rollback, r.exitCode, + ) +} + +type nativePackageBrokerCommitError struct { + cause error + exitCode int +} + +func (e *nativePackageBrokerCommitError) Error() string { return e.cause.Error() } +func (e *nativePackageBrokerCommitError) Unwrap() error { return e.cause } +func (e *nativePackageBrokerCommitError) ExitCode() int { return e.exitCode } + +func nativePackageBrokerPreflightFailure(err error) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerCommitResult{rollback: "not-needed", exitCode: 4}, err +} + func (c *NativePackageBrokerCommit) Run(logger *slog.Logger) error { - if !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedTokenSHA256)) { - return errors.New("native package transaction token SHA-256 must contain exactly 64 hexadecimal characters") + var result nativePackageBrokerCommitResult + var err error + if !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedTokenSHA256)) || + !nativePackageSHA256.MatchString(strings.TrimSpace(c.ExpectedBrokerSHA256)) { + result, err = nativePackageBrokerPreflightFailure( + errors.New("native package token and broker SHA-256 values must contain exactly 64 hexadecimal characters"), + ) + } else { + result, err = commitNativePackageBroker(logger, strings.TrimSpace(c.TokenFile), + strings.ToLower(strings.TrimSpace(c.ExpectedTokenSHA256)), + strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), strings.TrimSpace(c.TargetUserSID), + strings.TrimSpace(c.TransactionDeadlineUnixMS)) + } + fmt.Fprint(os.Stdout, result.proofLine()) + if err != nil { + return &nativePackageBrokerCommitError{cause: err, exitCode: result.exitCode} } - return commitNativePackageBroker(logger, strings.TrimSpace(c.TokenFile), - strings.ToLower(strings.TrimSpace(c.ExpectedTokenSHA256)), strings.TrimSpace(c.TargetUserSID), - strings.TrimSpace(c.TransactionDeadlineUnixMS)) + return nil } func (c *NativePackageInstall) Run(logger *slog.Logger) error { @@ -85,6 +202,9 @@ func (c *NativePackageInstall) Run(logger *slog.Logger) error { expectedBrokerSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), expectedHelperSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedHelperSHA256)), expectedManifestSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedManifestSHA256)), + expectedInfSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedInfSHA256)), + expectedSysSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedSysSHA256)), + expectedCatSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCatSHA256)), targetUserSID: strings.TrimSpace(c.TargetUserSID), } if err := request.validate(); err != nil { @@ -104,6 +224,9 @@ type nativePackageRequest struct { expectedBrokerSHA256 string expectedHelperSHA256 string expectedManifestSHA256 string + expectedInfSHA256 string + expectedSysSHA256 string + expectedCatSHA256 string targetUserSID string } @@ -125,8 +248,11 @@ func (r nativePackageRequest) validate() error { } if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || - !nativePackageSHA256.MatchString(r.expectedManifestSHA256) { - return errors.New("native package broker, helper, and manifest SHA-256 values must contain exactly 64 hexadecimal characters") + !nativePackageSHA256.MatchString(r.expectedManifestSHA256) || + !nativePackageSHA256.MatchString(r.expectedInfSHA256) || + !nativePackageSHA256.MatchString(r.expectedSysSHA256) || + !nativePackageSHA256.MatchString(r.expectedCatSHA256) { + return errors.New("native package broker, helper, manifest, INF, SYS, and CAT SHA-256 values must contain exactly 64 hexadecimal characters") } for name, path := range map[string]string{ "broker source": r.brokerSource, "driver package": r.packageDirectory, diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 797d3f44..2e9c2664 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -19,6 +19,10 @@ func TestNativePackageProductionSourceContract(t *testing.T) { filepath.Join(root, "internal", "cmd", "native_package_windows.go")) uninstallWindowsSource := readNativePackageContractFile(t, filepath.Join(root, "internal", "cmd", "native_package_uninstall_windows.go")) + processWaitSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_package_process_windows.go")) + mutexSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_mutex_windows.go")) helperSource := readNativePackageContractFile(t, filepath.Join(root, "native", "udecx", "tools", "ViiperUdeCtl.cpp")) transactionSource := readNativePackageContractFile(t, @@ -29,9 +33,10 @@ func TestNativePackageProductionSourceContract(t *testing.T) { filepath.Join(root, "internal", "cmd", "native_service_install_windows.go")) requiredWindows := []string{ - `runDriverHelper(ctx, "verify", false)`, "expectedManifestSHA256", + "expectedInfSHA256", "expectedSysSHA256", "expectedCatSHA256", "--manifest-sha256", + "--expected-inf-sha256", "--expected-sys-sha256", "--expected-cat-sha256", "nativeBrokerDirectorySDDL", "nativeBrokerExecutableSDDL", "nativePackageServiceWeakExactOwned", @@ -40,15 +45,18 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "slices.Equal(recovery, nativeServiceRecoveryActions)", "service.Delete()", "lockNativeServiceExecutableReadOnly", - `runDriverHelper(ctx, "install", true)`, + `runDriverHelper(ctx)`, + "nestedBrokerCommit", "nestedBrokerHealthy", "nestedMutationStarted", + "nestedRollbackSucceeded", "verifyExactBrokerHealth(ctx)", + "nested native broker service rollback is unsettled", "MOVEFILE_WRITE_THROUGH", "VerifyAuthenticatedHealth", "nativePackageTokenSDDL", "nativePackageMutexHeldByAnotherOwner", - "runtime.LockOSThread()", "lockNativePackageDirectoryChain", "--broker-token-sha256", "nativePackageRebootRequiredError", + "parseNativePackageInstallProof(text, processExitCode)", } for _, fragment := range requiredWindows { if !strings.Contains(windowsSource, fragment) { @@ -72,12 +80,37 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE)", "nativePackageUninstallIsCurrentExecutable(file)", "windows.MOVEFILE_DELAY_UNTIL_REBOOT|windows.MOVEFILE_WRITE_THROUGH", + "renameNativePackageUninstallFileToTombstone(file)", } for _, fragment := range requiredUninstallWindows { if !strings.Contains(uninstallWindowsSource, fragment) { t.Errorf("Windows package uninstall orchestrator lost %q", fragment) } } + for name, source := range map[string]string{ + "package install": windowsSource, + "package uninstall": uninstallWindowsSource, + } { + if strings.Contains(source, "command.Wait()") { + t.Errorf("%s bypasses the retained process-handle join", name) + } + if !strings.Contains(source, "waitNativePackageHelper(command)") { + t.Errorf("%s lost the retained process-handle join", name) + } + } + if strings.Contains(uninstallWindowsSource, + "scheduleNativePackageUninstallFileAtReboot(file.path)") { + t.Error("native uninstall schedules a reusable canonical broker path for reboot deletion") + } + for _, fragment := range []string{ + "process.WithHandle(", "windows.DuplicateHandle(", "windows.SYNCHRONIZE", + "windows.WaitForSingleObject", "windows.INFINITE", + "join.complete(command.Wait())", "nativePackageProcessWaitIndeterminateError", + } { + if !strings.Contains(processWaitSource, fragment) { + t.Errorf("native package helper process join lost %q", fragment) + } + } if strings.Index(uninstallWindowsSource, "acquireNamedNativePackageMutex(nativePackageMutexName") > strings.Index(uninstallWindowsSource, "acquireNativeInstallMutex(budget)") { t.Error("native package uninstall no longer acquires package mutex before service mutex") @@ -85,7 +118,10 @@ func TestNativePackageProductionSourceContract(t *testing.T) { requiredHelper := []string{ "Outcome Verify(", "ValidateCandidateInputs(", "RunBrokerInstall(", "--manifest-sha256", "manifest-installer-hash", "--broker-sha256", + "--expected-inf-sha256", "--expected-sys-sha256", "--expected-cat-sha256", "--broker-token-sha256", "native-package-broker-commit", + "ParseBrokerCommitProof", "driverRollbackAuthorized", "CreatePipe(", + "PROC_THREAD_ATTRIBUTE_HANDLE_LIST", "kMaximumBrokerProofBytes", "RollbackInstall(prior", "broker-reboot-boundary", "--transaction-deadline-unix-ms", "kBrokerRollbackCeilingMs", "CreatePrivateNamespaceW", "WAIT_ABANDONED", "ReleaseMutex", @@ -103,6 +139,22 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("driver helper lost %q", fragment) } } + if strings.Contains(helperSource, "UpdateDriverForPlugAndPlayDevicesW(") { + t.Error("driver helper must bind only an exact selected preinstalled package with DiInstallDevice") + } + if !strings.Contains(helperSource, "InstallPreinstalledDriverOnDevice(") || + !strings.Contains(helperSource, "DiInstallDevice(") { + t.Error("driver helper lost exact preinstalled-driver selection and DiInstallDevice binding") + } + if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { + t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") + } + backupMove := strings.Index(windowsSource, + "moveNativePackageFile(t.destination, backupPath, false)") + backupPublish := strings.Index(windowsSource, "t.backupPath = backupPath") + if backupMove < 0 || backupPublish < 0 || backupPublish < backupMove { + t.Error("native package rollback path is published before the prior broker rename succeeds") + } requiredTransaction := []string{ "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", "prepared = true", "transaction.Prepare(ctx, service)", @@ -128,11 +180,17 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("package uninstall transaction lost %q", fragment) } } + if !strings.Contains(serviceSource, "func acquireNativeInstallMutex(") { + t.Error("native broker service mutex wrapper was removed") + } for _, fragment := range []string{ - "func acquireNativeInstallMutex(", "runtime.LockOSThread()", "runtime.UnlockOSThread()", + "CreateBoundaryDescriptorW", "AddSIDToBoundaryDescriptor", + "CreatePrivateNamespaceW", "OpenPrivateNamespaceW", + "windows.WinBuiltinAdministratorsSid", "nativeMutexObjectSDDL", + "runtime.LockOSThread()", "runtime.UnlockOSThread()", } { - if !strings.Contains(serviceSource, fragment) { - t.Errorf("native broker service mutex lost %q", fragment) + if !strings.Contains(mutexSource, fragment) { + t.Errorf("shared native private mutex namespace lost %q", fragment) } } for name, source := range map[string]string{ diff --git a/internal/cmd/native_package_other.go b/internal/cmd/native_package_other.go index 263bb4e1..05e7d216 100644 --- a/internal/cmd/native_package_other.go +++ b/internal/cmd/native_package_other.go @@ -12,6 +12,10 @@ func installNativePackage(context.Context, *slog.Logger, nativePackageRequest) e return errors.New("native UDE package installation is supported only on Windows") } -func commitNativePackageBroker(*slog.Logger, string, string, string, string) error { - return errors.New("native UDE package installation is supported only on Windows") +func commitNativePackageBroker( + *slog.Logger, string, string, string, string, string, +) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerPreflightFailure( + errors.New("native UDE package installation is supported only on Windows"), + ) } diff --git a/internal/cmd/native_package_process_windows.go b/internal/cmd/native_package_process_windows.go new file mode 100644 index 00000000..08df0b5b --- /dev/null +++ b/internal/cmd/native_package_process_windows.go @@ -0,0 +1,156 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "time" + + "golang.org/x/sys/windows" +) + +const nativePackageProcessJoinRetry = 10 * time.Millisecond + +type nativePackageProcessJoin struct { + handle windows.Handle + wait func(windows.Handle, uint32) (uint32, error) + close func(windows.Handle) error + retry func() +} + +type nativePackageProcessWaitIndeterminateError struct { + cause error +} + +func (e *nativePackageProcessWaitIndeterminateError) Error() string { + return "native package helper process result is indeterminate after independently joining termination: " + + e.cause.Error() +} + +func (e *nativePackageProcessWaitIndeterminateError) Unwrap() error { + return e.cause +} + +// retainNativePackageProcessJoin duplicates Go's exact process handle before +// exec.Cmd.Wait can release it. The duplicate is intentionally wait-only: it +// exists solely to keep the package/service transaction alive until the exact +// mutating child process is signaled. +func retainNativePackageProcessJoin(process *os.Process) (*nativePackageProcessJoin, error) { + if process == nil { + return nil, errors.New("native package helper has no process") + } + var retained windows.Handle + var duplicateErr error + if err := process.WithHandle(func(handle uintptr) { + duplicateErr = windows.DuplicateHandle( + windows.CurrentProcess(), windows.Handle(handle), + windows.CurrentProcess(), &retained, + windows.SYNCHRONIZE, false, 0, + ) + }); err != nil { + if retained != 0 { + windows.CloseHandle(retained) //nolint:errcheck + } + return nil, fmt.Errorf("retain native package helper process handle: %w", err) + } + if duplicateErr != nil { + if retained != 0 { + windows.CloseHandle(retained) //nolint:errcheck + } + return nil, fmt.Errorf("duplicate native package helper process handle: %w", duplicateErr) + } + if retained == 0 { + return nil, errors.New("duplicate native package helper process returned a null handle") + } + return &nativePackageProcessJoin{ + handle: retained, + wait: windows.WaitForSingleObject, + close: windows.CloseHandle, + retry: func() { + time.Sleep(nativePackageProcessJoinRetry) + }, + }, nil +} + +// complete independently joins the retained process object before releasing +// its handle. A non-ExitError from Cmd.Wait cannot establish an exit status, +// so it remains an indeterminate transaction failure even after the child is +// proven terminated. Wait anomalies are retried while the handle and outer +// transaction scope remain held; this function never returns unjoined. +func (j *nativePackageProcessJoin) complete(commandWaitErr error) error { + if j == nil || j.handle == 0 || j.wait == nil || j.close == nil || j.retry == nil { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.New("native package helper process join is unavailable"), + } + } + + var joinAnomaly error + for { + status, err := j.wait(j.handle, windows.INFINITE) + if err == nil && status == windows.WAIT_OBJECT_0 { + break + } + if joinAnomaly == nil { + if err != nil { + joinAnomaly = fmt.Errorf("wait for retained native package helper process: %w", err) + } else { + joinAnomaly = fmt.Errorf( + "wait for retained native package helper process returned 0x%08x", status) + } + } + j.retry() + } + closeErr := j.close(j.handle) + j.handle = 0 + if closeErr != nil { + closeErr = fmt.Errorf("close retained native package helper process handle: %w", closeErr) + } + + var exitError *exec.ExitError + if commandWaitErr != nil && !errors.As(commandWaitErr, &exitError) { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.Join(commandWaitErr, joinAnomaly, closeErr), + } + } + if joinAnomaly != nil || closeErr != nil { + return fmt.Errorf( + "independent native package helper process join failed (command wait: %v): %w", + commandWaitErr, errors.Join(joinAnomaly, closeErr), + ) + } + return commandWaitErr +} + +func waitNativePackageHelper(command *exec.Cmd) error { + return waitNativePackageHelperWith( + command, + retainNativePackageProcessJoin, + func() { time.Sleep(nativePackageProcessJoinRetry) }, + ) +} + +func waitNativePackageHelperWith( + command *exec.Cmd, + retain func(*os.Process) (*nativePackageProcessJoin, error), + retry func(), +) error { + var join *nativePackageProcessJoin + for join == nil { + var err error + join, err = retain(command.Process) + if err == nil { + break + } + // Cmd.Wait has not run, so Go still owns the exact source handle. + // Never unwind a mutating package transaction without an independent + // wait handle; transient resource pressure is retried while every outer + // lock and immutable input handle remains held. + retry() + } + // A recovered pre-Wait duplication retry is not a transaction failure: the + // exact handle was retained before Cmd.Wait and supplies the required join. + return join.complete(command.Wait()) +} diff --git a/internal/cmd/native_package_process_windows_test.go b/internal/cmd/native_package_process_windows_test.go new file mode 100644 index 00000000..fa06269a --- /dev/null +++ b/internal/cmd/native_package_process_windows_test.go @@ -0,0 +1,160 @@ +//go:build windows + +package cmd + +import ( + "errors" + "os" + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +func TestNativePackageProcessJoinPreservesSuccessfulWait(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "0") + if err := command.Start(); err != nil { + t.Fatal(err) + } + join, err := retainNativePackageProcessJoin(command.Process) + if err != nil { + _ = command.Wait() + t.Fatalf("retain process join: %v", err) + } + if err := join.complete(command.Wait()); err != nil { + t.Fatalf("complete successful process join: %v", err) + } +} + +func TestNativePackageProcessJoinPreservesExitError(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "7") + if err := command.Start(); err != nil { + t.Fatal(err) + } + join, err := retainNativePackageProcessJoin(command.Process) + if err != nil { + _ = command.Wait() + t.Fatalf("retain process join: %v", err) + } + err = join.complete(command.Wait()) + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 7 { + t.Fatalf("joined error=%v, want exec.ExitError exit 7", err) + } +} + +func TestNativePackageProcessJoinRecoveredRetainRetryIsNonFatal(t *testing.T) { + command := exec.Command("cmd.exe", "/d", "/c", "exit", "0") + if err := command.Start(); err != nil { + t.Fatal(err) + } + attempts := 0 + err := waitNativePackageHelperWith( + command, + func(process *os.Process) (*nativePackageProcessJoin, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("synthetic DuplicateHandle resource pressure") + } + return retainNativePackageProcessJoin(process) + }, + func() {}, + ) + if err != nil { + t.Fatalf("recovered retain retry overrode successful process result: %v", err) + } + if attempts != 2 { + t.Fatalf("retain attempts=%d, want 2", attempts) + } +} + +func TestNativePackageProcessJoinHoldsScopeAfterAmbiguousCommandWait(t *testing.T) { + entered := make(chan struct{}) + signal := make(chan struct{}) + closed := make(chan struct{}) + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + close(entered) + <-signal + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { + close(closed) + return nil + }, + retry: func() {}, + } + + done := make(chan error, 1) + go func() { + done <- join.complete(errors.New("synthetic Cmd.Wait failure")) + }() + <-entered + select { + case err := <-done: + t.Fatalf("ambiguous wait released transaction scope before process signal: %v", err) + default: + } + close(signal) + err := <-done + var indeterminate *nativePackageProcessWaitIndeterminateError + if !errors.As(err, &indeterminate) { + t.Fatalf("joined ambiguous wait error=%v, want indeterminate failure", err) + } + select { + case <-closed: + default: + t.Fatal("retained process handle was not closed after signal") + } +} + +func TestNativePackageProcessJoinAnomalyDoesNotExposeExitError(t *testing.T) { + commandWaitErr := exec.Command("cmd.exe", "/d", "/c", "exit", "7").Run() + var commandExitError *exec.ExitError + if !errors.As(commandWaitErr, &commandExitError) { + t.Fatalf("test command error=%v, want exec.ExitError", commandWaitErr) + } + waits := 0 + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + waits++ + if waits == 1 { + return windows.WAIT_FAILED, windows.ERROR_INVALID_HANDLE + } + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { return nil }, + retry: func() {}, + } + err := join.complete(commandWaitErr) + var exitError *exec.ExitError + if errors.As(err, &exitError) { + t.Fatalf("join anomaly exposed command ExitError to proof parsing: %v", err) + } +} + +func TestNativePackageProcessJoinRetriesWaitAnomalyUntilSignal(t *testing.T) { + waits := 0 + join := &nativePackageProcessJoin{ + handle: 1, + wait: func(windows.Handle, uint32) (uint32, error) { + waits++ + if waits == 1 { + return windows.WAIT_FAILED, windows.ERROR_INVALID_HANDLE + } + return windows.WAIT_OBJECT_0, nil + }, + close: func(windows.Handle) error { return nil }, + retry: func() {}, + } + err := join.complete(errors.New("synthetic Cmd.Wait failure")) + var indeterminate *nativePackageProcessWaitIndeterminateError + if !errors.As(err, &indeterminate) { + t.Fatalf("joined anomalous wait error=%v, want indeterminate failure", err) + } + if waits != 2 { + t.Fatalf("retained process wait calls=%d, want 2", waits) + } +} diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go index 359abfbe..d66b542b 100644 --- a/internal/cmd/native_package_test.go +++ b/internal/cmd/native_package_test.go @@ -164,14 +164,19 @@ func TestNativePackageTransactionCancellationReconcilesWithBoundedRollback(t *te func TestNativePackageBrokerCommitRejectsUnboundTokenBeforePlatformCall(t *testing.T) { t.Parallel() command := NativePackageBrokerCommit{ - TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, - ExpectedTokenSHA256: "not-a-hash", - TargetUserSID: "S-1-5-21-1-2-3-1001", + TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + ExpectedTokenSHA256: "not-a-hash", + ExpectedBrokerSHA256: strings.Repeat("b", 64), + TargetUserSID: "S-1-5-21-1-2-3-1001", } err := command.Run(nativePackageTestLogger()) if err == nil || !strings.Contains(err.Error(), "64 hexadecimal") { t.Fatalf("error=%v", err) } + var exitCoder interface{ ExitCode() int } + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 4 { + t.Fatalf("preflight error lost exit 4 contract: %v", err) + } } func TestNativePackageBrokerCommitRejectsInvalidDeadlineBeforePlatformCall(t *testing.T) { @@ -179,6 +184,7 @@ func TestNativePackageBrokerCommitRejectsInvalidDeadlineBeforePlatformCall(t *te command := NativePackageBrokerCommit{ TokenFile: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, ExpectedTokenSHA256: strings.Repeat("a", 64), + ExpectedBrokerSHA256: strings.Repeat("b", 64), TargetUserSID: "S-1-5-21-1-2-3-1001", TransactionDeadlineUnixMS: "not-a-deadline", } @@ -188,6 +194,119 @@ func TestNativePackageBrokerCommitRejectsInvalidDeadlineBeforePlatformCall(t *te } } +func TestNativePackageBrokerCommitProofIsCanonical(t *testing.T) { + t.Parallel() + cases := []struct { + name string + result nativePackageBrokerCommitResult + want string + }{ + { + name: "healthy no-op", result: nativePackageBrokerCommitResult{ + success: true, rollback: "not-needed", exitCode: 0, + }, + want: "result=success operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=0\n", + }, + { + name: "healthy repair", result: nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + }, + want: "result=success operation=native-package-broker-commit changed=1 rollback=not-needed exitCode=0\n", + }, + { + name: "preflight", result: nativePackageBrokerCommitResult{ + rollback: "not-needed", exitCode: 4, + }, + want: "result=error operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=4\n", + }, + { + name: "settled rollback", result: nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, + }, + want: "result=error operation=native-package-broker-commit changed=1 rollback=succeeded exitCode=1\n", + }, + { + name: "indeterminate rollback", result: nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + }, + want: "result=error operation=native-package-broker-commit changed=1 rollback=failed exitCode=3\n", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.result.proofLine(); got != test.want { + t.Fatalf("proof=%q want=%q", got, test.want) + } + }) + } +} + +func TestNativePackageInstallProofFailsClosed(t *testing.T) { + t.Parallel() + cases := []struct { + name string + output string + processExit int + wantErr bool + wantSuccess bool + wantReboot bool + }{ + { + name: "healthy no-op", processExit: 0, wantSuccess: true, + output: "result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + }, + { + name: "healthy repair", processExit: 0, wantSuccess: true, + output: "result=success operation=install changed=1 rebootRequired=0 rollback=not-needed exitCode=0\r\n", + }, + { + name: "reboot boundary", processExit: nativePackageRebootRequiredCode, wantReboot: true, + output: `result=error operation=install changed=1 rebootRequired=1 rollback=succeeded exitCode=3010 phase="broker-reboot-boundary" win32Error=3010 message="restart required"` + "\n", + }, + { + name: "settled failure", processExit: 1, + output: "result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1\n", + }, + { + name: "preflight", processExit: 4, + output: "result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4\n", + }, + { + name: "indeterminate", processExit: 3, + output: "result=error operation=install changed=1 rebootRequired=0 rollback=failed exitCode=3\n", + }, + { + name: "missing", processExit: 0, wantErr: true, + output: "not a proof\n", + }, + { + name: "duplicate", processExit: 0, wantErr: true, + output: strings.Repeat("result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", 2), + }, + { + name: "exit mismatch", processExit: 1, wantErr: true, + output: "result=success operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=0\n", + }, + { + name: "unsafe reboot", processExit: nativePackageRebootRequiredCode, wantErr: true, + output: "result=error operation=install changed=1 rebootRequired=1 rollback=failed exitCode=3010\n", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + proof, err := parseNativePackageInstallProof(test.output, test.processExit) + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v proof=%+v", err, test.wantErr, proof) + } + if err == nil && (proof.success != test.wantSuccess || proof.rebootRequired != test.wantReboot) { + t.Fatalf("proof=%+v wantSuccess=%v wantReboot=%v", proof, test.wantSuccess, test.wantReboot) + } + }) + } +} + func TestNativePackageTransactionReportsRollbackAndCloseFailures(t *testing.T) { t.Parallel() fake := &fakeNativePackageTransaction{ @@ -222,6 +341,8 @@ func TestNativePackageRequestFailsClosed(t *testing.T) { driverHelper: `C:\bundle\ViiperUdeCtl.exe`, expectedBrokerSHA256: strings.Repeat("b", 64), expectedHelperSHA256: strings.Repeat("c", 64), targetUserSID: "S-1-5-21-1-2-3-1001", expectedManifestSHA256: strings.Repeat("d", 64), + expectedInfSHA256: strings.Repeat("e", 64), expectedSysSHA256: strings.Repeat("f", 64), + expectedCatSHA256: strings.Repeat("0", 64), } if err := base.validate(); err != nil { t.Fatalf("valid request: %v", err) @@ -230,6 +351,9 @@ func TestNativePackageRequestFailsClosed(t *testing.T) { "relative package": func(r *nativePackageRequest) { r.packageDirectory = `driver` }, "short revision": func(r *nativePackageRequest) { r.sourceRevision = "abc" }, "bad broker hash": func(r *nativePackageRequest) { r.expectedBrokerSHA256 = strings.Repeat("z", 64) }, + "bad INF hash": func(r *nativePackageRequest) { r.expectedInfSHA256 = strings.Repeat("z", 64) }, + "bad SYS hash": func(r *nativePackageRequest) { r.expectedSysSHA256 = strings.Repeat("z", 64) }, + "bad CAT hash": func(r *nativePackageRequest) { r.expectedCatSHA256 = strings.Repeat("z", 64) }, "embedded NUL": func(r *nativePackageRequest) { r.submissionManifest += "\x00evil" }, } for name, mutate := range cases { diff --git a/internal/cmd/native_package_uninstall_windows.go b/internal/cmd/native_package_uninstall_windows.go index 389d4e2e..da639eee 100644 --- a/internal/cmd/native_package_uninstall_windows.go +++ b/internal/cmd/native_package_uninstall_windows.go @@ -5,11 +5,14 @@ package cmd import ( "bytes" "context" + cryptorand "crypto/rand" + "encoding/hex" "errors" "fmt" "log/slog" "os/exec" "path/filepath" + "runtime" "slices" "strconv" "strings" @@ -24,6 +27,8 @@ import ( const nativeFileDispositionInfoClass = 4 +const nativePackageUninstallTombstoneAttempts = 32 + var setNativeFileInformationByHandle = windows.NewLazySystemDLL( "kernel32.dll", ).NewProc("SetFileInformationByHandle") @@ -52,6 +57,13 @@ type windowsNativePackageUninstallFileIdentity struct { fileIndex uint64 } +type nativePackageFileRenameInfo struct { + replaceIfExists uint32 + rootDirectory windows.Handle + fileNameLength uint32 + fileName [1]uint16 +} + type windowsNativePackageUninstallLiveLog struct { path string identity windowsNativePackageUninstallFileIdentity @@ -744,7 +756,7 @@ func (t *windowsNativePackageUninstallTransaction) RemoveDriver( if err := command.Start(); err != nil { return nativePackageRemoveResult{serviceRestoreVerified: true}, err } - waitErr := command.Wait() + waitErr := waitNativePackageHelper(command) exitCode := 0 if waitErr != nil { var exitError *exec.ExitError @@ -810,7 +822,15 @@ func (t *windowsNativePackageUninstallTransaction) Cleanup( if identityErr == nil && isCurrentExecutable && (errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION)) { - if scheduleErr := scheduleNativePackageUninstallFileAtReboot(file.path); scheduleErr == nil { + tombstone, renameErr := renameNativePackageUninstallFileToTombstone(file) + if renameErr != nil { + cleanupErrors = append(cleanupErrors, + fmt.Errorf("rename running exact %s %s to a protected reboot tombstone: %w", + file.kind, file.path, renameErr)) + continue + } + file.path = tombstone + if scheduleErr := scheduleNativePackageUninstallFileAtReboot(tombstone); scheduleErr == nil { cleanupRebootRequired = true if closeErr := windows.CloseHandle(file.handle); closeErr != nil { cleanupErrors = append(cleanupErrors, @@ -913,6 +933,52 @@ func scheduleNativePackageUninstallFileAtReboot(path string) error { ) } +func renameNativePackageUninstallFileToTombstone( + file *windowsNativePackageUninstallFile, +) (string, error) { + if file == nil || file.handle == 0 || file.path == "" { + return "", errors.New("native package uninstall file snapshot is unavailable") + } + parent := filepath.Dir(filepath.Clean(file.path)) + for attempt := 0; attempt < nativePackageUninstallTombstoneAttempts; attempt++ { + var random [16]byte + if _, err := cryptorand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate reboot tombstone identity: %w", err) + } + tombstone := filepath.Join(parent, + ".viiper.uninstall."+hex.EncodeToString(random[:])+".delete") + name, err := windows.UTF16FromString(tombstone) + if err != nil { + return "", err + } + nameBytes := (len(name) - 1) * 2 + var layout nativePackageFileRenameInfo + bufferSize := int(unsafe.Offsetof(layout.fileName)) + nameBytes + buffer := make([]byte, bufferSize) + info := (*nativePackageFileRenameInfo)(unsafe.Pointer(&buffer[0])) + info.fileNameLength = uint32(nameBytes) + copy((*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.fileName[0]))[:nameBytes/2:nameBytes/2], + name[:len(name)-1]) + result, _, callErr := setNativeFileInformationByHandle.Call( + uintptr(file.handle), windows.FileRenameInfo, + uintptr(unsafe.Pointer(&buffer[0])), uintptr(bufferSize), + ) + runtime.KeepAlive(buffer) + if result != 0 { + return tombstone, nil + } + if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) || + errors.Is(callErr, windows.ERROR_FILE_EXISTS) { + continue + } + if callErr == nil || errors.Is(callErr, syscall.Errno(0)) { + callErr = windows.ERROR_GEN_FAILURE + } + return "", callErr + } + return "", errors.New("could not allocate a unique native broker reboot tombstone") +} + func (t *windowsNativePackageUninstallTransaction) RestoreService( ctx context.Context, snapshot nativePackageUninstallServiceSnapshot, diff --git a/internal/cmd/native_package_uninstall_windows_test.go b/internal/cmd/native_package_uninstall_windows_test.go index 877523ca..256b831e 100644 --- a/internal/cmd/native_package_uninstall_windows_test.go +++ b/internal/cmd/native_package_uninstall_windows_test.go @@ -15,7 +15,8 @@ import ( ) func TestNativePackageUninstallSerializesConcurrentPackageTransactions(t *testing.T) { - name := `Local\VIIPER_NATIVE_UNINSTALL_TEST_` + filepath.Base(t.TempDir()) + requireNativeMutexAdministrator(t) + name := `VIIPER_NATIVE_UNINSTALL_TEST_` + filepath.Base(t.TempDir()) releaseFirst, err := acquireNamedNativePackageMutex(name, time.Second) if err != nil { t.Fatalf("acquire first package owner: %v", err) @@ -49,6 +50,47 @@ func TestNativePackageUninstallSerializesConcurrentPackageTransactions(t *testin releaseAfter() } +func TestNativePackageUninstallRenamesRunningImageToUniqueTombstone(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "viiper.exe") + if err := os.WriteFile(path, []byte("exact-native-broker"), 0o600); err != nil { + t.Fatal(err) + } + file, err := lockNativePackageUninstallFile(path, "broker", "", false) + if err != nil { + t.Fatalf("lock test broker: %v", err) + } + defer func() { + if file.handle != 0 { + windows.CloseHandle(file.handle) //nolint:errcheck + } + }() + + tombstone, err := renameNativePackageUninstallFileToTombstone(file) + if err != nil { + t.Fatalf("rename exact broker to reboot tombstone: %v", err) + } + if filepath.Dir(tombstone) != root || + !strings.HasPrefix(filepath.Base(tombstone), ".viiper.uninstall.") || + !strings.HasSuffix(filepath.Base(tombstone), ".delete") { + t.Fatalf("unsafe reboot tombstone path %q", tombstone) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canonical broker path remained after tombstone rename: %v", err) + } + if err := windows.CloseHandle(file.handle); err != nil { + t.Fatalf("close renamed exact broker handle: %v", err) + } + file.handle = 0 + contents, err := os.ReadFile(tombstone) + if err != nil { + t.Fatalf("read renamed tombstone: %v", err) + } + if string(contents) != "exact-native-broker" { + t.Fatalf("renamed tombstone contents=%q", contents) + } +} + func TestNativePackageUninstallDeletesRetainedExactFileHandle(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "owned.log") diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index e2537e29..65ac5b19 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -15,7 +15,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "slices" "strconv" "strings" @@ -27,7 +26,7 @@ import ( "golang.org/x/sys/windows/svc/mgr" ) -const nativePackageMutexName = `Global\VIIPER.NativePackage.Install.v1` +const nativePackageMutexName = "VIIPER.NativePackage.Install.v1" const nativePackageTokenSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" var nativePackageDriverFiles = []string{ @@ -35,13 +34,19 @@ var nativePackageDriverFiles = []string{ } type windowsNativePackageTransaction struct { - logger *slog.Logger - request nativePackageRequest + logger *slog.Logger + request nativePackageRequest + nestedBrokerCommit bool - releaseMutex func() - inputHandles []windows.Handle - sourceHandle windows.Handle - helperHandle windows.Handle + releaseMutex func() + releaseServiceMutex func() + inputHandles []windows.Handle + sourceHandle windows.Handle + helperHandle windows.Handle + nestedBrokerHealthy bool + nestedMutationStarted bool + nestedRollbackSucceeded bool + nestedServiceRollbackSettled bool programFiles string destination string @@ -78,62 +83,102 @@ func installNativePackage( func commitNativePackageBroker( logger *slog.Logger, - tokenPath, expectedTokenSHA256, targetUserSID, deadlineUnixMS string, -) error { + tokenPath, expectedTokenSHA256, expectedBrokerSHA256, targetUserSID, deadlineUnixMS string, +) (nativePackageBrokerCommitResult, error) { + preflightFailure := func(err error) (nativePackageBrokerCommitResult, error) { + return nativePackageBrokerPreflightFailure(err) + } deadlineMilliseconds, err := strconv.ParseInt(deadlineUnixMS, 10, 64) if err != nil || deadlineMilliseconds <= 0 { - return errors.New("native package transaction deadline must be positive Unix milliseconds") + return preflightFailure(errors.New("native package transaction deadline must be positive Unix milliseconds")) } deadline := time.UnixMilli(deadlineMilliseconds) if !deadline.After(time.Now()) || deadline.After(time.Now().Add(nativePackageTransactionTimeout)) { - return errors.New("native package transaction deadline is expired or outside the package budget") + return preflightFailure(errors.New("native package transaction deadline is expired or outside the package budget")) } if !filepath.IsAbs(tokenPath) || strings.IndexByte(tokenPath, 0) >= 0 { - return errors.New("native package transaction token path must be absolute and contain no NUL") + return preflightFailure(errors.New("native package transaction token path must be absolute and contain no NUL")) } if _, err := validateNativeInstallingUserSID(targetUserSID); err != nil { - return fmt.Errorf("validate package transaction target SID: %w", err) + return preflightFailure(fmt.Errorf("validate package transaction target SID: %w", err)) } programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) if err != nil { - return fmt.Errorf("resolve Program Files: %w", err) + return preflightFailure(fmt.Errorf("resolve Program Files: %w", err)) } expectedParent := filepath.Join(filepath.Clean(programFiles), "VIIPER") base := filepath.Base(tokenPath) if !strings.EqualFold(filepath.Dir(filepath.Clean(tokenPath)), expectedParent) || !strings.HasPrefix(strings.ToLower(base), ".viiper.transaction.") || !strings.HasSuffix(strings.ToLower(base), ".token") { - return fmt.Errorf("package transaction token escaped the managed VIIPER directory: %s", tokenPath) + return preflightFailure(fmt.Errorf("package transaction token escaped the managed VIIPER directory: %s", tokenPath)) } handle, err := lockNativePackageInput(tokenPath) if err != nil { - return fmt.Errorf("lock package transaction token: %w", err) + return preflightFailure(fmt.Errorf("lock package transaction token: %w", err)) } defer windows.CloseHandle(handle) //nolint:errcheck if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { - return fmt.Errorf("validate package transaction token ACL: %w", err) + return preflightFailure(fmt.Errorf("validate package transaction token ACL: %w", err)) } hash, err := hashNativePackageHandle(handle) if err != nil { - return fmt.Errorf("hash package transaction token: %w", err) + return preflightFailure(fmt.Errorf("hash package transaction token: %w", err)) } if !strings.EqualFold(hash, expectedTokenSHA256) { - return errors.New("package transaction token SHA-256 does not match the active installer") + return preflightFailure(errors.New("package transaction token SHA-256 does not match the active installer")) + } + if !nativePackageSHA256.MatchString(expectedBrokerSHA256) { + return preflightFailure(errors.New("package transaction broker SHA-256 is malformed")) } held, err := nativePackageMutexHeldByAnotherOwner(nativePackageMutexName) if err != nil { - return fmt.Errorf("verify outer package transaction mutex: %w", err) + return preflightFailure(fmt.Errorf("verify outer package transaction mutex: %w", err)) } if !held { - return errors.New("outer native package transaction mutex is not held") + return preflightFailure(errors.New("outer native package transaction mutex is not held")) } - return installNativeBrokerUntil(logger, targetUserSID, deadline) + executable, err := currentExecutable() + if err != nil { + return preflightFailure(fmt.Errorf("resolve nested broker executable: %w", err)) + } + transaction := &windowsNativePackageTransaction{ + logger: logger, + request: nativePackageRequest{ + brokerSource: executable, expectedBrokerSHA256: expectedBrokerSHA256, + targetUserSID: targetUserSID, + }, + nestedBrokerCommit: true, + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + err = runNativePackageTransaction(ctx, logger, transaction) + if err == nil { + return nativePackageBrokerCommitResult{ + success: true, changed: transaction.nestedMutationStarted, + rollback: "not-needed", exitCode: 0, + }, nil + } + if !transaction.nestedMutationStarted { + return nativePackageBrokerPreflightFailure(err) + } + if transaction.nestedRollbackSucceeded { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, + }, err + } + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + }, err } func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } + if t.nestedBrokerCommit { + return t.preflightNestedBrokerCommit() + } mutexBudget := nativePackageTransactionTimeout if deadline, ok := ctx.Deadline(); ok { mutexBudget = time.Until(deadline) @@ -211,11 +256,18 @@ func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { if matches != 1 { return fmt.Errorf("signed driver package must contain one case-exact regular %s", expected) } - handle, lockErr := lockNativePackageInput(filepath.Join(t.request.packageDirectory, expected)) + expectedHash := map[string]string{ + "ViiperUde.inf": t.request.expectedInfSHA256, + "ViiperUde.sys": t.request.expectedSysSHA256, + "ViiperUde.cat": t.request.expectedCatSHA256, + }[expected] + handle, lockErr := t.lockAndVerifyInput( + filepath.Join(t.request.packageDirectory, expected), expectedHash, false, + ) if lockErr != nil { - return fmt.Errorf("lock signed driver file %s: %w", expected, lockErr) + return fmt.Errorf("verify installer-bound signed driver file %s: %w", expected, lockErr) } - t.inputHandles = append(t.inputHandles, handle) + _ = handle } manifestHandle, err := t.lockAndVerifyInput( t.request.submissionManifest, t.request.expectedManifestSHA256, false, @@ -225,9 +277,6 @@ func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { } _ = manifestHandle - if err := t.runDriverHelper(ctx, "verify", false); err != nil { - return fmt.Errorf("source-bound Microsoft driver verification: %w", err) - } if attributes, attrErr := nativePathAttributes(t.parent); attrErr == nil { if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { @@ -250,9 +299,61 @@ func (t *windowsNativePackageTransaction) Preflight(ctx context.Context) error { return nil } +func (t *windowsNativePackageTransaction) preflightNestedBrokerCommit() error { + t.nestedServiceRollbackSettled = true + if _, err := validateNativeInstallingUserSID(t.request.targetUserSID); err != nil { + return fmt.Errorf("validate nested broker target SID: %w", err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, windows.KF_FLAG_DEFAULT) + if err != nil { + return fmt.Errorf("resolve Program Files known folder: %w", err) + } + t.programFiles = filepath.Clean(programFiles) + t.parent = filepath.Join(t.programFiles, "VIIPER") + t.destination = filepath.Join(t.parent, "viiper.exe") + if _, err := nativeServiceExecutableParent(t.programFiles, t.destination); err != nil { + return err + } + programFilesHandle, err := openNativePathWithoutReparse( + t.programFiles, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return fmt.Errorf("lock Program Files root: %w", err) + } + t.inputHandles = append(t.inputHandles, programFilesHandle) + handles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.brokerSource)) + if err != nil { + return fmt.Errorf("lock nested broker source directory chain: %w", err) + } + t.inputHandles = append(t.inputHandles, handles...) + t.sourceHandle, err = t.lockAndVerifyInput( + t.request.brokerSource, t.request.expectedBrokerSHA256, true, + ) + if err != nil { + return fmt.Errorf("verify installer-bound nested VIIPER broker: %w", err) + } + return nil +} + func (t *windowsNativePackageTransaction) InspectService( ctx context.Context, ) (nativePackageServiceSnapshot, error) { + if !t.nestedBrokerCommit { + t.serviceSnapshot = nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent} + return t.serviceSnapshot, nil + } + budget := nativePackageTransactionTimeout + if deadline, ok := ctx.Deadline(); ok { + budget = time.Until(deadline) + if budget <= 0 { + return nativePackageServiceSnapshot{}, context.DeadlineExceeded + } + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return nativePackageServiceSnapshot{}, fmt.Errorf("lock nested native broker transaction: %w", err) + } + t.releaseServiceMutex = release manager, err := mgr.Connect() if err != nil { return nativePackageServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) @@ -261,7 +362,7 @@ func (t *windowsNativePackageTransaction) InspectService( service, err := t.manager.OpenService(NativeBrokerServiceName) if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { t.serviceSnapshot = nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent} - return t.serviceSnapshot, nil + return t.finalizeServiceInspection(ctx, t.serviceSnapshot) } if err != nil { return nativePackageServiceSnapshot{}, fmt.Errorf("open %s: %w", NativeBrokerServiceName, err) @@ -337,7 +438,76 @@ func (t *windowsNativePackageTransaction) InspectService( disposition: disposition, wasRunning: status.State == svc.Running, } - return t.serviceSnapshot, nil + return t.finalizeServiceInspection(ctx, t.serviceSnapshot) +} + +func (t *windowsNativePackageTransaction) finalizeServiceInspection( + ctx context.Context, + snapshot nativePackageServiceSnapshot, +) (nativePackageServiceSnapshot, error) { + if snapshot.disposition == nativePackageServiceTrusted && snapshot.wasRunning { + healthy, err := t.verifyExactBrokerHealth(ctx) + if err != nil { + if ctx.Err() != nil { + return nativePackageServiceSnapshot{}, ctx.Err() + } + t.logger.Info("Exact native broker requires transactional repair", "reason", err) + } else { + t.nestedBrokerHealthy = healthy + } + } + t.serviceSnapshot = snapshot + return snapshot, nil +} + +func (t *windowsNativePackageTransaction) verifyExactBrokerHealth(ctx context.Context) (bool, error) { + if t.service == nil || !strings.EqualFold(t.priorServiceExecutable, t.destination) { + return false, errors.New("native broker service does not use the canonical package executable") + } + handle, err := lockNativePackageInput(t.priorServiceExecutable) + if err != nil { + return false, fmt.Errorf("lock exact native broker image: %w", err) + } + hash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr != nil { + return false, fmt.Errorf("hash exact native broker image: %w", hashErr) + } + if closeErr != nil { + return false, fmt.Errorf("close exact native broker image: %w", closeErr) + } + if !strings.EqualFold(hash, t.request.expectedBrokerSHA256) { + return false, fmt.Errorf("native broker SHA-256=%s expected=%s", hash, t.request.expectedBrokerSHA256) + } + + credential, err := readNativeCredentialReadOnly(t.request.targetUserSID) + if err != nil { + return false, fmt.Errorf("read protected native broker credential: %w", err) + } + legacy, err := snapshotNativeLegacyStartup(ctx, t.request.targetUserSID) + if err != nil { + return false, fmt.Errorf("inspect legacy native broker ownership: %w", err) + } + if legacy.release != nil { + defer legacy.release() + } + if nativeLegacyStartupOwnsRuntime(legacy) { + return false, errors.New("active legacy VIIPER startup ownership is still registered") + } + + servicePID, err := requireNativeServiceProcess(t.service, 0) + if err != nil { + return false, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err := verifyNativeBrokerOnce(probeCtx, strings.TrimSpace(string(credential))); err != nil { + return false, err + } + if _, err := requireNativeServiceProcess(t.service, servicePID); err != nil { + return false, fmt.Errorf("revalidate exact native broker after authenticated ping: %w", err) + } + return true, nil } func isCanonicalNativePackageService( @@ -362,6 +532,16 @@ func (t *windowsNativePackageTransaction) Prepare( snapshot.wasRunning != t.serviceSnapshot.wasRunning { return errors.New("native service snapshot changed before preparation") } + if !t.nestedBrokerCommit { + return t.preparePackageCoordination() + } + if t.nestedBrokerHealthy { + return nil + } + // From this point onward the nested callback may stop/delete SCM state or + // publish the canonical broker image. Any failure must prove rollback before + // the still-running helper may touch its captured driver snapshot again. + t.nestedMutationStarted = true if t.service != nil && snapshot.disposition == nativePackageServiceWeakExactOwned { if snapshot.wasRunning { if err := stopNativeService(ctx, t.service, waitContext); err != nil { @@ -404,8 +584,29 @@ func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Con if err := ctx.Err(); err != nil { return err } - if err := t.runDriverHelper(ctx, "install", true); err != nil { - return err + if t.nestedBrokerCommit { + if t.releaseServiceMutex == nil { + return errors.New("nested native broker transaction does not hold the service mutex") + } + if !t.nestedBrokerHealthy { + var evidence nativeBrokerInstallEvidence + if err := installNativeBrokerTransactionWithEvidence( + ctx, t.logger, t.destination, + productionNativeInstallDependencies(t.request.targetUserSID), + &evidence, + ); err != nil { + t.nestedServiceRollbackSettled = + !evidence.mutationStarted || evidence.rollbackSucceeded + return fmt.Errorf("repair native broker transaction: %w", err) + } + } + } else { + if t.releaseServiceMutex != nil { + return errors.New("outer native package transaction unexpectedly holds the service mutex") + } + if err := t.runDriverHelper(ctx); err != nil { + return err + } } // A deadline that expires after the synchronous mutating helper starts must // not turn its authenticated success into a contradictory outer rollback. @@ -416,6 +617,15 @@ func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Con } func (t *windowsNativePackageTransaction) VerifyAuthenticatedHealth(ctx context.Context) error { + if t.nestedBrokerCommit && t.nestedBrokerHealthy { + healthy, err := t.verifyExactBrokerHealth(ctx) + if err != nil { + return fmt.Errorf("reverify exact native package no-op: %w", err) + } + if !healthy { + return errors.New("exact native package lost authenticated health before no-op commit") + } + } // ViiperUdeCtl does not return success until the staged broker's native // service transaction has performed authenticated ABI/capability health, // removed legacy ownership, and authenticated a second time. Preserve that @@ -459,7 +669,13 @@ func (t *windowsNativePackageTransaction) Commit(context.Context) error { return nil } -func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) error { +func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultErr error) { + defer func() { + if t.nestedBrokerCommit && t.nestedMutationStarted && resultErr == nil && + t.nestedServiceRollbackSettled { + t.nestedRollbackSucceeded = true + } + }() var rollbackErrors []error if t.destinationRelease != nil { t.destinationRelease() @@ -469,6 +685,16 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) error { rollbackErrors = append(rollbackErrors, fmt.Errorf("remove package transaction token: %w", err)) } + if t.nestedBrokerCommit && t.nestedMutationStarted && + !t.nestedServiceRollbackSettled { + // The inner SCM transaction deliberately leaves an indeterminate service + // stopped. Do not delete/replace the image it may still reference, restore + // a prior image under an indeterminate configuration, or restart it. Keep + // both protected images for explicit external reconciliation. + rollbackErrors = append(rollbackErrors, errors.New( + "nested native broker service rollback is unsettled; retaining staged and prior broker images and leaving the service stopped for external reconciliation")) + return errors.Join(rollbackErrors...) + } restored := true if err := t.restoreBrokerExecutable(); err != nil { restored = false @@ -524,8 +750,13 @@ func (t *windowsNativePackageTransaction) Close() error { for index := len(t.inputHandles) - 1; index >= 0; index-- { windows.CloseHandle(t.inputHandles[index]) //nolint:errcheck } + if t.releaseServiceMutex != nil { + t.releaseServiceMutex() + t.releaseServiceMutex = nil + } if t.releaseMutex != nil { t.releaseMutex() + t.releaseMutex = nil } return nil } @@ -577,29 +808,50 @@ func (t *windowsNativePackageTransaction) lockAndVerifyInput( return handle, nil } -func (t *windowsNativePackageTransaction) runDriverHelper( - ctx context.Context, operation string, broker bool, -) error { +func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) error { + text, err := t.executeDriverHelper(ctx) + processExitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return fmt.Errorf("wait for native driver helper: %w: %s", err, text) + } + processExitCode = exitError.ExitCode() + } + proof, proofErr := parseNativePackageInstallProof(text, processExitCode) + if proofErr != nil { + return fmt.Errorf("validate native driver helper proof: %w: %s", proofErr, text) + } + if proof.exitCode == nativePackageRebootRequiredCode { + return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} + } + if !proof.success { + return fmt.Errorf("native driver helper failed with exit %d: %w: %s", + proof.exitCode, err, text) + } + return nil +} + +func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Context) (string, error) { deadline, ok := ctx.Deadline() if !ok || !deadline.After(time.Now()) { - return context.DeadlineExceeded + return "", context.DeadlineExceeded } arguments := []string{ - operation, filepath.Join(t.request.packageDirectory, "ViiperUde.inf"), + "install", filepath.Join(t.request.packageDirectory, "ViiperUde.inf"), "--manifest", t.request.submissionManifest, "--manifest-sha256", t.request.expectedManifestSHA256, "--source-revision", t.request.sourceRevision, "--validation-mode", "production", + "--expected-inf-sha256", t.request.expectedInfSHA256, + "--expected-sys-sha256", t.request.expectedSysSHA256, + "--expected-cat-sha256", t.request.expectedCatSHA256, "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), - } - if broker { - arguments = append(arguments, - "--broker-executable", t.destination, - "--broker-sha256", t.request.expectedBrokerSHA256, - "--broker-token", t.tokenPath, - "--broker-token-sha256", t.tokenSHA256, - "--target-user-sid", t.request.targetUserSID, - ) + "--broker-executable", t.request.brokerSource, + "--broker-sha256", t.request.expectedBrokerSHA256, + "--broker-token", t.tokenPath, + "--broker-token-sha256", t.tokenSHA256, + "--target-user-sid", t.request.targetUserSID, } // Do not use CommandContext: killing ViiperUdeCtl could interrupt its in-memory // DriverStore rollback or the broker's deferred SCM/credential rollback. @@ -609,40 +861,13 @@ func (t *windowsNativePackageTransaction) runDriverHelper( command.Stdout = &output command.Stderr = &output if err := command.Start(); err != nil { - return err - } - var err error - if operation == "verify" { - // This process is strictly read-only. It is safe to stop if signature or - // manifest inspection wedges; the mutating install path is never killed. - done := make(chan error, 1) - go func() { done <- command.Wait() }() - select { - case err = <-done: - case <-ctx.Done(): - _ = command.Process.Kill() - <-done - return ctx.Err() - } - } else { - // The helper owns the driver snapshot and nested broker rollback. Its - // propagated absolute deadline is cooperative; never terminate it here. - err = command.Wait() + return "", err } + // The helper owns the driver snapshot and nested broker rollback. Its + // propagated absolute deadline is cooperative; never terminate it here. + err := waitNativePackageHelper(command) text := strings.TrimSpace(output.String()) - expected := "result=success operation=" + operation - var exitError *exec.ExitError - if operation == "install" && errors.As(err, &exitError) && - exitError.ExitCode() == nativePackageRebootRequiredCode { - return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} - } - if err != nil || !strings.Contains(text, expected) { - if err == nil { - err = errors.New("driver helper did not emit its structured success proof") - } - return fmt.Errorf("%w: %s", err, text) - } - return nil + return text, err } func reconcileNativePackageServiceRunning(ctx context.Context, service nativeManagedService) error { @@ -723,7 +948,10 @@ func (t *windowsNativePackageTransaction) stageCoordinationToken() error { return nil } -func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { +func (t *windowsNativePackageTransaction) ensureManagedPackageDirectory() error { + if t.parentHandle != 0 { + return nil + } if attributes, err := nativePathAttributes(t.parent); err != nil { if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { @@ -755,10 +983,33 @@ func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { if err := validateNativeSecurityDescriptor(parent, nativeBrokerDirectorySDDL); err != nil { return fmt.Errorf("validate protected VIIPER directory: %w", err) } + return nil +} + +func (t *windowsNativePackageTransaction) preparePackageCoordination() error { + if t.nestedBrokerCommit { + return errors.New("nested broker transaction cannot create the outer coordination token") + } + if err := t.ensureManagedPackageDirectory(); err != nil { + return err + } + if t.tokenPath != "" || t.tokenHandle != 0 { + return errors.New("native package coordination token is already staged") + } if err := t.stageCoordinationToken(); err != nil { return err } + return nil +} +func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { + if !t.nestedBrokerCommit { + return errors.New("broker image staging is owned by the nested service transaction") + } + if err := t.ensureManagedPackageDirectory(); err != nil { + return err + } + var err error if existing, openErr := openNativePathWithoutReparse( t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, ); openErr == nil { @@ -783,13 +1034,17 @@ func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { t.destinationRelease = release return nil } - t.backupPath, err = t.uniqueManagedPath("rollback") + backupPath, err := t.uniqueManagedPath("rollback") if err != nil { return err } - if err := moveNativePackageFile(t.destination, t.backupPath, false); err != nil { + if err := moveNativePackageFile(t.destination, backupPath, false); err != nil { return fmt.Errorf("retain prior broker for rollback: %w", err) } + // Publish rollback ownership only after the atomic rename succeeds. On a + // failed rename the canonical prior image is still in place and may be + // safely revalidated/restarted by Rollback. + t.backupPath = backupPath } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { return fmt.Errorf("inspect existing broker: %w", openErr) @@ -875,42 +1130,9 @@ func (t *windowsNativePackageTransaction) uniqueManagedPath(label string) (strin } func acquireNamedNativePackageMutex(name string, timeout time.Duration) (func(), error) { - // Win32 mutex ownership belongs to an OS thread, not a Go goroutine. Pin - // the caller until the release closure runs or ReleaseMutex can execute on - // a different thread and silently strand/abandon the package lock. - runtime.LockOSThread() - pointer, err := windows.UTF16PtrFromString(name) - if err != nil { - runtime.UnlockOSThread() - return nil, err - } - descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)") - if err != nil { - runtime.UnlockOSThread() - return nil, err - } - attributes := windows.SecurityAttributes{ - Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor, - } - handle, err := createNamedNativeMutex(&attributes, pointer) - if err != nil { - runtime.UnlockOSThread() - return nil, err - } - status, err := windows.WaitForSingleObject(handle, uint32(timeout/time.Millisecond)) - if err != nil || (status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED) { - windows.CloseHandle(handle) //nolint:errcheck - runtime.UnlockOSThread() - if err != nil { - return nil, err - } - return nil, errors.New("another VIIPER native package transaction is still running") - } - return func() { - windows.ReleaseMutex(handle) //nolint:errcheck - windows.CloseHandle(handle) //nolint:errcheck - runtime.UnlockOSThread() - }, nil + return acquireNativeNamedMutex( + name, timeout, "another VIIPER native package transaction is still running", + ) } // nativePackageMutexHeldByAnotherOwner proves that this short-lived broker @@ -919,34 +1141,7 @@ func acquireNamedNativePackageMutex(name string, timeout time.Duration) (func(), // zero-time wait must instead report WAIT_TIMEOUT. If the mutex is absent, // abandoned, or acquirable, no authorized outer transaction exists. func nativePackageMutexHeldByAnotherOwner(name string) (bool, error) { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - pointer, err := windows.UTF16PtrFromString(name) - if err != nil { - return false, err - } - handle, err := windows.OpenMutex(windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, - false, pointer) - if err != nil { - if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { - return false, nil - } - return false, err - } - defer windows.CloseHandle(handle) //nolint:errcheck - status, err := windows.WaitForSingleObject(handle, 0) - if err != nil { - return false, err - } - switch status { - case uint32(windows.WAIT_TIMEOUT): - return true, nil - case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): - windows.ReleaseMutex(handle) //nolint:errcheck - return false, nil - default: - return false, fmt.Errorf("unexpected package mutex wait status: 0x%08x", status) - } + return nativeNamedMutexHeldByAnotherOwner(name) } func lockNativePackageInput(path string) (windows.Handle, error) { diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index 991b45d1..a015019f 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -73,3 +73,37 @@ func TestNativePackageRollbackReconcilesStoppedPriorService(t *testing.T) { t.Fatalf("startCalls=%d state=%d events=%v", service.startCalls, service.status.State, events) } } + +func TestNativePackageRollbackPreservesImagesAndStoppedServiceWhenSCMRollbackIsUnsettled(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + transaction := &windowsNativePackageTransaction{ + nestedBrokerCommit: true, + nestedMutationStarted: true, + nestedServiceRollbackSettled: false, + destinationPublished: true, + backupPath: `C:\Program Files\VIIPER\.prior.rollback.exe`, + stoppedTrustedService: true, + serviceSnapshot: nativePackageServiceSnapshot{wasRunning: true}, + service: service, + } + + err := transaction.Rollback(context.Background()) + if err == nil { + t.Fatal("unsettled nested SCM rollback was reported as restored") + } + if service.startCalls != 0 || len(events) != 0 { + t.Fatalf("indeterminate service was restarted: startCalls=%d events=%v", + service.startCalls, events) + } + if !transaction.destinationPublished { + t.Fatal("staged broker image was removed after unsettled SCM rollback") + } + if transaction.backupPath == "" { + t.Fatal("prior broker backup was consumed after unsettled SCM rollback") + } + if transaction.nestedRollbackSucceeded { + t.Fatal("unsettled SCM rollback was exposed as a safe nested rollback") + } +} diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 6d837c76..5a3f6e22 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -16,7 +16,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "slices" "sort" "strconv" @@ -44,7 +43,7 @@ const ( nativeServiceRecoveryResetSecond = 15 * 60 nativeServiceInstallTimeout = 45 * time.Second nativeServiceStatePoll = 100 * time.Millisecond - nativeInstallMutexName = `Global\VIIPER.NativeBroker.Install.v1` + nativeInstallMutexName = "VIIPER.NativeBroker.Install.v1" nativeBrokerServiceSDDL = "O:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" nativeBrokerDirectorySDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)" nativeBrokerExecutableSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;BU)" @@ -327,6 +326,14 @@ type nativeLegacyState struct { commands []nativeLegacyCommand } +func nativeLegacyStartupOwnsRuntime(state nativeLegacyState) bool { + return state.runValue != nil || + (state.scheduledAction != nil && (state.scheduledActive || state.scheduledEnabled)) || + slices.ContainsFunc(state.commands, func(command nativeLegacyCommand) bool { + return command.running + }) +} + type nativeRunRegistration struct { value string valueType uint32 @@ -546,12 +553,43 @@ func uninstallNativeBrokerTransaction( return nil } +type nativeBrokerInstallEvidence struct { + mutationStarted bool + rollbackSucceeded bool +} + func installNativeBrokerTransaction( ctx context.Context, logger *slog.Logger, executable string, dependencies nativeInstallDependencies, +) error { + return installNativeBrokerTransactionWithEvidence( + ctx, logger, executable, dependencies, nil, + ) +} + +func installNativeBrokerTransactionWithEvidence( + ctx context.Context, + logger *slog.Logger, + executable string, + dependencies nativeInstallDependencies, + evidence *nativeBrokerInstallEvidence, ) (resultErr error) { + rollbackFailed := false + if evidence != nil { + *evidence = nativeBrokerInstallEvidence{} + defer func() { + if resultErr != nil && evidence.mutationStarted { + evidence.rollbackSucceeded = !rollbackFailed + } + }() + } + markMutation := func() { + if evidence != nil { + evidence.mutationStarted = true + } + } if !filepath.IsAbs(executable) { return fmt.Errorf("native broker executable must be an absolute path: %s", executable) } @@ -585,6 +623,7 @@ func installNativeBrokerTransaction( return } if rollbackErr := rollbackCredential(); rollbackErr != nil { + rollbackFailed = true resultErr = errors.Join(resultErr, fmt.Errorf("roll back native broker credential: %w", rollbackErr)) } }() @@ -644,8 +683,12 @@ func installNativeBrokerTransaction( rollbackCtx, manager, service, before, dependencies.wait, rollbackCredential, ) if rollbackErr != nil { + rollbackFailed = true rollbackErrors = append(rollbackErrors, rollbackErr) } + if !safeToRestartLegacy { + rollbackFailed = true + } if !safeToRestartLegacy && credentialProvisioned && !credentialFinalized { // The replacement could still own the key path. Retain the new // credential rather than invalidating a service we failed to stop @@ -656,6 +699,7 @@ func installNativeBrokerTransaction( errors.New("retained native credential because service ownership could not be rolled back safely")) } } else if rollbackErr := rollbackCredential(); rollbackErr != nil { + rollbackFailed = true safeToRestartLegacy = false rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native broker credential before legacy restart: %w", rollbackErr)) @@ -665,12 +709,14 @@ func installNativeBrokerTransaction( // safe for that process to exist again. if registrationsMayHaveChanged && safeToRestartLegacy { if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackFailed = true safeToRestartLegacy = false rollbackErrors = append(rollbackErrors, rollbackErr) } } if legacyStopped && safeToRestartLegacy { if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { + rollbackFailed = true rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior legacy VIIPER process: %w", rollbackErr)) } } @@ -683,11 +729,13 @@ func installNativeBrokerTransaction( // Control(STOP) is itself a mutation. Even if the subsequent wait or // status query fails, rollback must reconcile the snapshotted state. serviceChanged = true + markMutation() if err := stopNativeService(ctx, service, dependencies.wait); err != nil { return fmt.Errorf("stop previous %s service: %w", NativeBrokerServiceName, err) } } legacyStopped = true + markMutation() stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) registrationsMayHaveChanged = legacy.scheduledDisabled if stopLegacyErr != nil { @@ -702,6 +750,7 @@ func installNativeBrokerTransaction( // Existing bytes are retained solely for rollback; they are never trusted as // the new service secret because an unprivileged user may have pre-seeded the // ProgramData path before its ACL was hardened. + markMutation() credential, err = dependencies.provisionCredential() if err != nil { return fmt.Errorf("provision native broker credential: %w", err) @@ -720,6 +769,7 @@ func installNativeBrokerTransaction( // x/sys. Mark the service dirty before the call because a later optional // configuration failure can occur after the base configuration changed. serviceChanged = true + markMutation() if err := service.UpdateConfig(config); err != nil { return fmt.Errorf("update %s service: %w", NativeBrokerServiceName, err) } @@ -732,6 +782,7 @@ func installNativeBrokerTransaction( baseConfig.Description = "" baseConfig.SidType = windows.SERVICE_SID_TYPE_NONE baseConfig.DelayedAutoStart = false + markMutation() service, err = manager.CreateService(NativeBrokerServiceName, executable, baseConfig, arguments...) if err != nil { return fmt.Errorf("create %s service: %w", NativeBrokerServiceName, err) @@ -771,6 +822,7 @@ func installNativeBrokerTransaction( // are removed last so a failed native migration can still restart the exact // legacy command without reconstructing startup ownership. registrationsMayHaveChanged = true + markMutation() if err := dependencies.removeLegacy(ctx, legacy); err != nil { return fmt.Errorf("remove legacy VIIPER startup after native verification: %w", err) } @@ -1222,6 +1274,21 @@ func verifyNativeBroker(ctx context.Context, password string) error { } } +func verifyNativeBrokerOnce(ctx context.Context, password string) error { + if strings.TrimSpace(password) == "" { + return errors.New("native broker credential is empty") + } + client := viiperclient.NewWithConfig(api.DefaultListenAddress, &viiperclient.Config{ + DialTimeout: time.Second, ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, Password: password, + }) + response, err := client.PingCtx(ctx) + if err != nil { + return fmt.Errorf("authenticate exact native broker: %w", err) + } + return validateNativeBrokerPing(response) +} + func validateNativeBrokerPing(response *viipertypes.PingResponse) error { expected, err := udecx.ExpectedBuildIdentity() if err != nil { @@ -1288,45 +1355,10 @@ func waitContext(ctx context.Context, delay time.Duration) error { } func acquireNativeInstallMutex(timeout time.Duration) (func(), error) { - // Win32 mutexes are owned by OS threads. Keep this goroutine pinned through - // its deferred release so the Go scheduler cannot move ReleaseMutex to a - // non-owner thread and leave service installation permanently serialized. - runtime.LockOSThread() - name, err := windows.UTF16PtrFromString(nativeInstallMutexName) - if err != nil { - runtime.UnlockOSThread() - return nil, err - } - descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)") - if err != nil { - runtime.UnlockOSThread() - return nil, fmt.Errorf("create native install mutex security descriptor: %w", err) - } - attributes := windows.SecurityAttributes{ - Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), - SecurityDescriptor: descriptor, - } - handle, err := createNamedNativeMutex(&attributes, name) - if err != nil { - runtime.UnlockOSThread() - return nil, fmt.Errorf("create native install mutex: %w", err) - } - status, err := windows.WaitForSingleObject(handle, uint32(timeout/time.Millisecond)) - if err != nil { - windows.CloseHandle(handle) //nolint:errcheck - runtime.UnlockOSThread() - return nil, fmt.Errorf("wait for native install mutex: %w", err) - } - if status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED { - windows.CloseHandle(handle) //nolint:errcheck - runtime.UnlockOSThread() - return nil, errors.New("another VIIPER native install, update, or uninstall is still running") - } - return func() { - windows.ReleaseMutex(handle) //nolint:errcheck - windows.CloseHandle(handle) //nolint:errcheck - runtime.UnlockOSThread() - }, nil + return acquireNativeNamedMutex( + nativeInstallMutexName, timeout, + "another VIIPER native install, update, or uninstall is still running", + ) } type nativeFileAttributeTagInfo struct { @@ -1972,6 +2004,74 @@ func readNativeCredential(path, userSID string) ([]byte, bool, error) { return contents, true, nil } +func readNativeCredentialReadOnly(userSID string) ([]byte, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return nil, err + } + path, err := nativeServiceKeyFilePath() + if err != nil { + return nil, err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, fmt.Errorf("resolve ProgramData known folder: %w", err) + } + programData = filepath.Clean(programData) + directory := filepath.Join(programData, "VIIPER") + if !strings.EqualFold(filepath.Clean(path), filepath.Join(directory, keyFileName)) { + return nil, fmt.Errorf("native credential escaped the managed ProgramData path: %s", path) + } + rootHandle, err := openNativePathWithoutReparse( + programData, windows.FILE_READ_ATTRIBUTES, true, + ) + if err != nil { + return nil, fmt.Errorf("open ProgramData without reparse traversal: %w", err) + } + defer windows.CloseHandle(rootHandle) //nolint:errcheck + directoryHandle, err := openNativePathWithoutReparse( + directory, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return nil, fmt.Errorf("open protected native credential directory: %w", err) + } + defer windows.CloseHandle(directoryHandle) //nolint:errcheck + if err := validateNativeSecurityDescriptor( + directoryHandle, nativeCredentialDirectorySDDL(userSID), + ); err != nil { + return nil, fmt.Errorf("validate protected native credential directory: %w", err) + } + credentialHandle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + return nil, err + } + if err := requireSingleNativeFileLink(credentialHandle); err != nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, fmt.Errorf("reject hard-linked credential: %w", err) + } + if err := validateNativeSecurityDescriptor( + credentialHandle, nativeCredentialFileSDDL(userSID), + ); err != nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, fmt.Errorf("validate protected native credential: %w", err) + } + file := os.NewFile(uintptr(credentialHandle), path) + if file == nil { + windows.CloseHandle(credentialHandle) //nolint:errcheck + return nil, errors.New("wrap protected native credential handle") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, err + } + if len(contents) == 0 || len(contents) > 64*1024 { + return nil, fmt.Errorf("native credential has invalid length %d", len(contents)) + } + return contents, nil +} + func writeNativeCredentialAtomically(path string, contents []byte, userSID string) error { directory := filepath.Dir(path) temporary, err := os.CreateTemp(directory, ".viiper-key-*.tmp") diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go index 308f5d0a..3726f3b1 100644 --- a/internal/cmd/native_service_install_windows_test.go +++ b/internal/cmd/native_service_install_windows_test.go @@ -370,8 +370,9 @@ func TestNativeInstallRejectsWeakPriorServiceSecurityBeforeMutation(t *testing.T } manager := newFakeNativeSCM(service, &events) dependencies := fakeNativeInstallDependencies(manager, nativeLegacyState{}, &events) - err := installNativeBrokerTransaction(context.Background(), testLogger(), - `C:\Program Files\VIIPER\viiper.exe`, dependencies) + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) if err == nil || !strings.Contains(err.Error(), "untrusted service security descriptor") { t.Fatalf("error=%v", err) } @@ -384,6 +385,9 @@ func TestNativeInstallRejectsWeakPriorServiceSecurityBeforeMutation(t *testing.T if !reflect.DeepEqual(events, []string{"service-open"}) { t.Fatalf("weak prior service caused mutation before rejection: %v", events) } + if evidence.mutationStarted || evidence.rollbackSucceeded { + t.Fatalf("preflight rejection reported mutation evidence: %+v", evidence) + } } func TestNativeTransactionContextBoundsLegacyProviderCalls(t *testing.T) { @@ -692,14 +696,18 @@ func TestNativeInstallRestoresRunningServiceAfterStopWaitFails(t *testing.T) { } } - err := installNativeBrokerTransaction(context.Background(), testLogger(), - `C:\Program Files\VIIPER\viiper.exe`, dependencies) + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) if err == nil { t.Fatal("expected the forward stop failure") } if service.status.State != svc.Running || service.startCalls != 1 { t.Fatalf("prior running state was not reconciled: status=%v starts=%d", service.status.State, service.startCalls) } + if !evidence.mutationStarted || !evidence.rollbackSucceeded { + t.Fatalf("settled rollback evidence=%+v", evidence) + } } func TestNativeRollbackDoesNotStartServiceAfterConfigRestoreFailure(t *testing.T) { @@ -719,8 +727,9 @@ func TestNativeRollbackDoesNotStartServiceAfterConfigRestoreFailure(t *testing.T credentialRolledBack = true return nil } - err := installNativeBrokerTransaction(context.Background(), testLogger(), - `C:\Program Files\VIIPER\viiper.exe`, dependencies) + var evidence nativeBrokerInstallEvidence + err := installNativeBrokerTransactionWithEvidence(context.Background(), testLogger(), + `C:\Program Files\VIIPER\viiper.exe`, dependencies, &evidence) if err == nil { t.Fatal("expected update and rollback failure") } @@ -730,6 +739,9 @@ func TestNativeRollbackDoesNotStartServiceAfterConfigRestoreFailure(t *testing.T if credentialRolledBack { t.Fatal("credential was invalidated while the replacement service configuration remained installed") } + if !evidence.mutationStarted || evidence.rollbackSucceeded { + t.Fatalf("indeterminate rollback evidence=%+v", evidence) + } } func TestNativeInstallRejectsUnrepresentableRecoveryPolicyBeforeMutation(t *testing.T) { diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index c69c717d..04747247 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.5", + ExpectedDriverPackageVersion: "0.1.0.6", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index 97815c4e..3690f2c8 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -48,12 +48,18 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "$nativeIdentityLdflags", "internal/transport/udecx.nativeSourceRevision=", "$ExpectedSourceRevision.ToLowerInvariant()", + "Win32_PnPEntity", + "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", + "$ownedRootDevices[0].PNPDeviceID", "Go reported success without executing required live test", } { if !strings.Contains(contract, required) { t.Fatalf("native release gate omitted %q", required) } } + if strings.Contains(contract, "DeviceID -like 'ROOT\\VIIPER\\UDE*'") { + t.Fatal("native release gate confuses the INF hardware ID with the generated PnP instance ID") + } } func TestNativeMediaProbeRejectsObservableDiscontinuity(t *testing.T) { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 623d6f8f..14f55478 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.5" + DriverPackageVersion = "0.1.0.6" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 96e9f664..edca6a51 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed" + const wantHex = "5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 3536eebe..2f8267e5 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.5 + 0.1.0.6 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index cfe71cb5..77ec3782 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.5" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.6" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index ec980da9..a84296ad 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.5 +DriverVer=08/11/2026,0.1.0.6 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 295e05d8..e1919f57 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -208,8 +208,15 @@ if ($packageHash -ne $installedHash) { throw "The loaded VIIPER UDE service image does not match the verified package. Installed='$installedDriver'." } +$ownedRootDevices = @(Get-CimInstance -ClassName Win32_PnPEntity | Where-Object { + @($_.HardwareID) -contains 'ROOT\VIIPER\UDE' +}) +if ($ownedRootDevices.Count -ne 1) { + throw "Expected exactly one VIIPER UDE hardware-ID owner; found $($ownedRootDevices.Count)." +} +$ownedRootInstance = [string]$ownedRootDevices[0].PNPDeviceID $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { - [string]$_.DeviceID -like 'ROOT\VIIPER\UDE*' + [string]$_.DeviceID -ieq $ownedRootInstance }) if ($devnodes.Count -ne 1) { throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 995bcbdd..a050c664 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -39,6 +39,10 @@ $requiredContracts = [ordered]@{ 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' 'install rollback' = 'RollbackInstall\(' 'broker health transaction' = 'RunBrokerInstall\(' + 'canonical broker proof parser' = 'ParseBrokerCommitProof\(' + 'bounded broker proof channel' = 'kMaximumBrokerProofBytes' + 'explicit inherited broker handles' = 'PROC_THREAD_ATTRIBUTE_HANDLE_LIST' + 'indeterminate broker wait retention' = 'GetExitCodeProcess\(processHandle\.get\(\), &observedExit\)' 'production broker requirement' = 'broker-required' 'staged broker hash binding' = '--broker-sha256' 'protected package token binding' = '--broker-token-sha256' @@ -51,7 +55,32 @@ $requiredContracts = [ordered]@{ 'protected rollback directory' = 'kRollbackDirectorySecurity' 'inherited rollback protection' = 'O:BAD:P\(A;OICI;FA;;;SY\)\(A;OICI;FA;;;BA\)' 'unpredictable rollback directory' = 'CryptGenRandom\(' + 'verified protected rollback ACLs' = 'VerifyProtectedFileSystemSecurity\(' + 'protected exact rollback file copy' = 'CopyProtectedBackupFile\(' + 'exact rollback package tree' = 'ValidateExactPackageDirectory\(destination' + 'durable rollback package payloads' = 'rollback-backup-file-flush' 'immutable rollback package files' = 'LockPackageFiles\(destination, &locks' + 'pre-mutation rollback preservation' = 'ArmPreservation\(' + 'protected recovery record' = 'kRecoveryRecordSecurity' + 'private recovery record staging name' = 'kRecoveryRecordTemporaryName' + 'explicit recovery record flush' = 'FlushFileBuffers\(file\.get\(\)\)' + 'atomic recovery record publish' = + 'MoveFileExW\([\s\S]{0,180}MOVEFILE_WRITE_THROUGH' + 'recovery record read-back verification' = 'recovery-record-verify' + 'prepared write-ahead recovery state' = + '\\"state\\":\\"prepared-remove-transaction\\"' + 'manual-only recovery policy' = '\\"automaticRestore\\":false' + 'recovery signature and hash revalidation' = + '\\"requiredValidation\\":\[\\"inf-signature\\"[\s\S]{0,180}\\"cat-sha256\\"\]' + 'recovery record path emission' = 'recoveryRecordWritten=' + 'retained backup path emission' = 'recoveryBackupRetained=' + 'pre-journal retained backup reporting' = 'recovery-record-not-published' + 'recovery relative path confinement' = 'IsSafeRecoveryRelativePath\(' + 'unique devnode package recovery binding' = '\\"packageIndex\\":' + 'checked rollback backup cleanup' = 'if \(!backupRoot\.Cleanup\(&backups' + 'top-level exception boundary' = 'catch \(\.\.\.\)' + 'exception-safe active recovery path' = 'gActiveRecoveryRecordWritten' + 'exception-safe mutation classification' = 'gTransactionMutationStarted' 'remove deadline parser' = 'ParseRemoveOptions\(' 'remove mutation deadline' = 'remove-deadline-before-device' 'finite remove rollback ceiling' = 'kDriverRollbackCeilingMs' @@ -66,6 +95,22 @@ $requiredContracts = [ordered]@{ 'deadline cancellation' = 'CancelIoEx\(' 'cancelled IO drain ceiling' = 'kCancelledIoDrainMs' 'finite broker rollback ceiling' = 'kBrokerRollbackCeilingMs' + 'nested rollback budget composition' = '3ULL \* 60ULL \* 1000ULL' + 'forward root mutation deadline' = 'transaction-deadline-before-root-registration' + 'forward root property deadline' = 'transaction-deadline-before-root-properties' + 'device binding mutation deadline' = 'transaction-deadline-before-device-binding' + 'driver package mutation deadline' = 'transaction-deadline-before-driver-install' + 'selected driver mutation deadline' = 'transaction-deadline-before-driver-selection' + 'owned generated root namespace' = 'kRootDeviceName\[\] = L"VIIPERUDE"' + 'legacy generated root rollback namespace' = 'kLegacyRootDeviceName\[\] = L"USB"' + 'exact generated root identity validation' = 'IsOwnedGeneratedRootInstanceId\(' + 'forward generated root identity verification' = 'verify-generated-root-instance-id' + 'post-registration cleanup state' = 'registrationSucceeded' + 'captured root namespace validation' = 'device-instance-ownership' + 'actual remove-device mutation deadline' = 'remove-deadline-before-device-mutation' + 'rollback remove-device mutation deadline' = 'rollback-deadline-before-device-removal' + 'rollback root property deadline' = 'rollback-deadline-before-root-properties' + 'rollback root registration deadline' = 'rollback-deadline-before-root-registration' 'exact rollback devnode identity' = 'RegisterRootDeviceExact\(' 'rollback identity verification' = 'rollback-identity-verification' 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' @@ -78,6 +123,43 @@ foreach ($entry in $requiredContracts.GetEnumerator()) { } } +$orderedMutationContracts = [ordered]@{ + 'driver package deadline immediately precedes mutation' = + 'CheckTransactionDeadline\(options,[\s\S]{0,180}transaction-deadline-before-driver-install[\s\S]{0,800}DiInstallDriverW\(' + 'root property deadline immediately precedes mutation' = + 'transaction-deadline-before-root-properties[\s\S]{0,240}mutationStarted[\s\S]{0,180}SetupDiSetDeviceRegistryPropertyW\(' + 'root registration deadline immediately precedes mutation' = + 'transaction-deadline-before-root-registration[\s\S]{0,240}SetupDiCallClassInstaller\(DIF_REGISTERDEVICE' + 'device binding deadline immediately precedes mutation' = + 'transaction-deadline-before-device-binding[\s\S]{0,500}DiInstallDevice\(' + 'selected driver deadline immediately precedes mutation' = + 'transaction-deadline-before-driver-selection[\s\S]{0,300}mutationStarted[\s\S]{0,180}SetupDiSetSelectedDriverW\(' + 'remove deadline immediately precedes device mutation' = + 'CheckTransactionDeadline\(transactionDeadlineUnixMs, deadlinePhase, error\)[\s\S]{0,300}mutationStarted[\s\S]{0,180}DiUninstallDevice\(' + 'first-time root creation uses the owned device name' = + 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}kRootDeviceName[\s\S]{0,120}DICD_GENERATE_ID' + 'registered devnode cleanup state survives post-registration validation' = + 'registeredAndVerified[\s\S]{0,300}createdHere = registrationSucceeded;[\s\S]{0,120}if \(registeredAndVerified\)' + 'recovery journal is published and preservation armed before mutation' = + 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' + 'failed remove rollback preserves published evidence before return' = + 'AttachRecoveryRecord\(&rollbackError\);[\s\S]{0,180}outcome\.rollback = L"failed";[\s\S]{0,300}return outcome;' + 'verified rollback performs checked evidence cleanup' = + 'outcome\.rollback = L"succeeded";[\s\S]{0,300}backupRoot\.Cleanup\(&backups, &cleanupError\)[\s\S]{0,300}return outcome;' + 'committed removal performs checked evidence cleanup before success' = + 'if \(!backupRoot\.Cleanup\(&backups, &cleanupError\)\)[\s\S]{0,240}ExitCode::RollbackFailed;[\s\S]{0,180}return outcome;[\s\S]{0,100}outcome\.success = true;' + 'preservation disarms only after verified evidence absence' = + 'std::filesystem::exists\(path_, presenceError\)[\s\S]{0,260}if \(removalError \|\| presenceError \|\| remains\)[\s\S]{0,900}preserve_ = false;[\s\S]{0,100}ClearActiveRecoveryEvidence\(\);' + 'exception outcome distinguishes preflight from mutation' = + 'const bool changed = gTransactionMutationStarted;[\s\S]{0,180}changed[\s\S]{0,100}ExitCode::RollbackFailed : ExitCode::PreflightRejected;' +} + +foreach ($entry in $orderedMutationContracts.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl violates its $($entry.Key) ordering contract." + } +} + if ($source -match 'SUOI_FORCEDELETE') { throw 'ViiperUdeCtl must never force-delete a published INF.' } @@ -86,6 +168,10 @@ if ($source -match 'TerminateProcess\(') { throw 'ViiperUdeCtl must never hard-terminate the mutating broker transaction.' } +if ($source -match 'std::filesystem::copy_file') { + throw 'Rollback packages must use the protected, write-through, verified exact-file copy path.' +} + foreach ($runtimeExport in @( 'CryptCATAdminAcquireContext2', 'CryptCATAdminCalcHashFromFileHandle2', @@ -108,6 +194,10 @@ if ([regex]::Matches($source, ',\s*DICD_GENERATE_ID\s*,').Count -ne 1) { throw 'Generated root identities are allowed only for first-time forward creation, never rollback.' } +if ($source -match 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}className\.c_str\(\)') { + throw 'Forward root creation must use the VIIPER-owned device-name namespace, not the INF class name.' +} + if ([regex]::Matches($source, '\bRemoveAllExactDevices\(').Count -ne 2) { throw 'All-device removal is allowed only for explicit forward uninstall, never rollback.' } @@ -123,8 +213,8 @@ if ($forceInfUses -ne 1 -or } $forceBindUses = [regex]::Matches($source, '\bINSTALLFLAG_FORCE\b').Count -if ($forceBindUses -ne 2) { - throw "Expected force binding only in controlled downgrade and the shared exact-identity rollback path; found $forceBindUses uses." +if ($forceBindUses -ne 0) { + throw "Selected preinstalled package binding and rollback must not use INSTALLFLAG_FORCE; found $forceBindUses uses." } if (-not [string]::IsNullOrWhiteSpace($BinaryPath)) { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index f335426d..914117a0 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -25,13 +25,16 @@ #include #include #include +#include #include "../include/ViiperUdeProtocol.h" #include #include +#include #include #include +#include #include #include #include @@ -41,11 +44,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -73,6 +78,11 @@ namespace { constexpr wchar_t kHardwareId[] = L"ROOT\\VIIPER\\UDE"; constexpr wchar_t kEnumerator[] = L"ROOT"; +// DICD_GENERATE_ID derives ROOT\\\\#### from this value. Keep +// new devnodes in a VIIPER-owned instance namespace instead of the USB class +// namespace used by older builds. +constexpr wchar_t kRootDeviceName[] = L"VIIPERUDE"; +constexpr wchar_t kLegacyRootDeviceName[] = L"USB"; constexpr wchar_t kServiceName[] = L"ViiperUde"; constexpr wchar_t kProviderName[] = L"VIIPER Project"; constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; @@ -86,16 +96,45 @@ constexpr wchar_t kTransactionObjectSecurity[] = L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"; constexpr size_t kMaximumManifestBytes = 1024U * 1024U; constexpr uint64_t kMaximumTransactionDurationMs = 4ULL * 60ULL * 1000ULL; -constexpr uint64_t kBrokerRollbackCeilingMs = 60ULL * 1000ULL; +// The child can spend 45 seconds in its inner SCM/credential rollback and then +// up to two minutes in the outer protected-image rollback. Keep a bounded +// margin beyond both budgets while retaining the driver mutex until exit. +constexpr uint64_t kBrokerRollbackCeilingMs = 3ULL * 60ULL * 1000ULL; constexpr uint64_t kDriverRollbackCeilingMs = 2ULL * 60ULL * 1000ULL; constexpr DWORD kCancelledIoDrainMs = 5000; +constexpr size_t kMaximumBrokerProofBytes = 64U * 1024U; constexpr wchar_t kRollbackDirectorySecurity[] = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; +constexpr wchar_t kRecoveryRecordSecurity[] = + L"O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)"; +constexpr wchar_t kRecoveryRecordName[] = L"recovery-v1.json"; +constexpr wchar_t kRecoveryRecordTemporaryName[] = L"recovery-v1.json.tmp"; +constexpr size_t kMaximumRecoveryRecordBytes = 256U * 1024U; constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; uint64_t CurrentUnixMilliseconds(); +// A fixed-size, allocation-free copy lets the top-level exception boundary +// report the protected write-ahead record after C++ stack unwinding has closed +// all transaction handles. Only one helper transaction exists per process. +std::array gActiveRecoveryRecord{}; +bool gActiveRecoveryRecordWritten = false; +std::array gActiveBackupRoot{}; +bool gActiveBackupRootRetained = false; +bool gTransactionMutationStarted = false; + +void MarkTransactionMutationStarted() noexcept { + gTransactionMutationStarted = true; +} + +void ClearActiveRecoveryEvidence() noexcept { + gActiveRecoveryRecord.fill(L'\0'); + gActiveRecoveryRecordWritten = false; + gActiveBackupRoot.fill(L'\0'); + gActiveBackupRootRetained = false; +} + constexpr GUID kViiperInterfaceGuid = { 0x32d03f48, 0x725b, 0x4baa, {0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}}; @@ -112,8 +151,20 @@ struct Error { DWORD code = ERROR_SUCCESS; std::wstring phase; std::wstring message; + std::wstring recoveryRecord; + bool recoveryRecordWritten = false; + DWORD recoveryRecordError = ERROR_SUCCESS; + std::wstring recoveryRecordPhase; + std::wstring recoveryRecordMessage; + std::wstring recoveryBackup; + bool recoveryBackupRetained = false; }; +bool CheckTransactionDeadline(uint64_t deadlineUnixMs, const wchar_t* phase, Error* error); +bool IsGeneratedRootInstanceIdForDeviceName( + const std::wstring& instanceId, const wchar_t* deviceName); +bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId); + struct Outcome { bool success = false; bool changed = false; @@ -167,6 +218,24 @@ void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { stream << L" phase=" << std::quoted(outcome.error.phase) << L" win32Error=" << outcome.error.code << L" message=" << std::quoted(outcome.error.message); + if (!outcome.error.recoveryRecord.empty()) { + stream << L" recoveryRecord=" << std::quoted(outcome.error.recoveryRecord) + << L" recoveryRecordWritten=" + << (outcome.error.recoveryRecordWritten ? 1 : 0); + if (!outcome.error.recoveryRecordWritten) { + stream << L" recoveryRecordPhase=" + << std::quoted(outcome.error.recoveryRecordPhase) + << L" recoveryRecordWin32Error=" + << outcome.error.recoveryRecordError + << L" recoveryRecordMessage=" + << std::quoted(outcome.error.recoveryRecordMessage); + } + } + if (!outcome.error.recoveryBackup.empty()) { + stream << L" recoveryBackup=" << std::quoted(outcome.error.recoveryBackup) + << L" recoveryBackupRetained=" + << (outcome.error.recoveryBackupRetained ? 1 : 0); + } } stream << L"\n"; } @@ -412,6 +481,30 @@ bool IsHexRevision(const std::string& value) { }); } +bool CopySha256Argument( + const wchar_t* value, + const wchar_t* name, + std::string* destination, + Error* error) { + const std::wstring wide = value; + destination->clear(); + destination->reserve(wide.size()); + for (const wchar_t character : wide) { + if (character > 0x7f) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" SHA-256 must contain ASCII hexadecimal characters"); + } + destination->push_back(static_cast(character)); + } + if (destination->size() != 64 || + !std::all_of(destination->begin(), destination->end(), + [](unsigned char character) { return std::isxdigit(character) != 0; })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" SHA-256 must contain exactly 64 hexadecimal characters"); + } + return true; +} + struct JsonValue { using Object = std::map; using Array = std::vector; @@ -1043,8 +1136,25 @@ struct PackageInfo { std::wstring publishedName; Version version{}; std::string infSha256; + std::string sysSha256; + std::string catSha256; }; +bool SamePackageBytes(const PackageInfo& left, const PackageInfo& right) { + return left.infSha256 == right.infSha256 && + left.sysSha256 == right.sysSha256 && + left.catSha256 == right.catSha256; +} + +std::string PackageBytesKey(const PackageInfo& package) { + return package.infSha256 + ":" + package.sysSha256 + ":" + package.catSha256; +} + +bool GetDriverStoreInfPath( + const std::filesystem::path& publishedPath, + std::filesystem::path* storePath, + Error* error); + bool InspectInfContract( const std::filesystem::path& infPath, bool* owned, @@ -1257,13 +1367,22 @@ bool VerifyMicrosoftHardwareInfSigner( L"driver catalog signer is not Microsoft Windows Hardware Compatibility Publisher"); } + std::filesystem::path packageInfPath = infPath; + if (_wcsicmp(infPath.filename().c_str(), L"ViiperUde.inf") != 0 && + !GetDriverStoreInfPath(infPath, &packageInfPath, error)) { + return false; + } + std::filesystem::path catalogPath = signer.CatalogFile; + if (catalogPath.is_relative()) { + catalogPath = packageInfPath.parent_path() / catalogPath.filename(); + } + DWORD encoding = 0; HCERTSTORE store = nullptr; HCRYPTMSG message = nullptr; - const std::filesystem::path catalogPath = infPath.parent_path() / kCatalogName; if (!VerifyDriverCatalogMember(catalogPath, infPath, error) || !VerifyDriverCatalogMember(catalogPath, - infPath.parent_path() / kDriverFileName, error)) { + packageInfPath.parent_path() / kDriverFileName, error)) { return false; } if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), @@ -1371,16 +1490,31 @@ bool LoadOwnedPackage( *owned = false; return true; } - if (!VerifyInfSignature(path, nullptr, error)) { + std::filesystem::path catalogPath; + if (!VerifyInfSignature(path, &catalogPath, error)) { + return false; + } + std::filesystem::path packageInfPath = path; + if (_wcsicmp(path.filename().c_str(), L"ViiperUde.inf") != 0 && + !GetDriverStoreInfPath(path, &packageInfPath, error)) { return false; } - std::string hash; - if (!Sha256File(path, &hash, error)) { + if (catalogPath.is_relative()) { + catalogPath = packageInfPath.parent_path() / catalogPath.filename(); + } + std::string infHash; + std::string sysHash; + std::string catHash; + if (!Sha256File(path, &infHash, error) || + !Sha256File(packageInfPath.parent_path() / kDriverFileName, &sysHash, error) || + !Sha256File(catalogPath, &catHash, error)) { return false; } package->infPath = path; package->version = version; - package->infSha256 = std::move(hash); + package->infSha256 = std::move(infHash); + package->sysSha256 = std::move(sysHash); + package->catSha256 = std::move(catHash); *owned = true; return true; } @@ -1522,7 +1656,7 @@ bool FindPublishedCandidate( } size_t matches = 0; for (const PackageInfo& package : packages) { - if (package.version == candidate.version && package.infSha256 == candidate.infSha256) { + if (package.version == candidate.version && SamePackageBytes(package, candidate)) { *published = package; ++matches; } @@ -1709,6 +1843,73 @@ struct Snapshot { std::vector packages; }; +enum class CandidateDisposition { + InstallRequired, + Exact, +}; + +bool ClassifyCandidatePackage( + const PackageInfo& candidate, + const std::vector& installedPackages, + const std::optional& expectedDowngradeFrom, + CandidateDisposition* disposition, + bool* downgrade, + Error* error) { + if (disposition == nullptr || downgrade == nullptr) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"candidate package classification requires output storage"); + } + *disposition = CandidateDisposition::InstallRequired; + *downgrade = false; + + const bool conflictingSameVersion = std::any_of( + installedPackages.begin(), installedPackages.end(), [&](const PackageInfo& package) { + return package.version == candidate.version && + !SamePackageBytes(package, candidate); + }); + if (conflictingSameVersion) { + return SetError(error, L"version-policy", ERROR_REVISION_MISMATCH, + L"same-version INF, SYS, or signing catalog replacement is rejected; increment DriverVer"); + } + + std::optional highest; + for (const PackageInfo& package : installedPackages) { + if (!highest || highest->version < package.version) { + highest = package; + } + } + if (!highest) { + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + return true; + } + + if (candidate.version < highest->version) { + *downgrade = true; + if (!expectedDowngradeFrom || !(*expectedDowngradeFrom == highest->version)) { + return SetError(error, L"version-policy", ERROR_REVISION_MISMATCH, + L"downgrade rejected; pass --allow-controlled-downgrade with the exact installed version " + + VersionToString(highest->version)); + } + return true; + } + if (candidate.version == highest->version) { + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + *disposition = CandidateDisposition::Exact; + return true; + } + if (expectedDowngradeFrom) { + return SetError(error, L"version-policy", ERROR_INVALID_PARAMETER, + L"controlled downgrade guard is valid only for an actual downgrade"); + } + return true; +} + bool CaptureSnapshot(Snapshot* snapshot, Error* error) { snapshot->devices.clear(); if (!EnumerateOwnedPackages(&snapshot->packages, error)) { @@ -1728,6 +1929,10 @@ bool CaptureSnapshot(Snapshot* snapshot, Error* error) { } for (auto& match : matches) { DeviceState& device = match.second; + if (!IsOwnedGeneratedRootInstanceId(device.instanceId)) { + return SetError(error, L"device-instance-ownership", ERROR_INVALID_DATA, + L"ROOT\\VIIPER\\UDE has an instance ID outside the VIIPER or legacy generated root namespace"); + } if (_wcsicmp(device.service.c_str(), kServiceName) != 0 || !IsSafePublishedInfName(device.publishedInf)) { return SetError(error, L"device-ownership", ERROR_NOT_FOUND, @@ -1749,7 +1954,22 @@ bool CaptureSnapshot(Snapshot* snapshot, Error* error) { return true; } -bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired, Error* error) { +bool RemoveDevice( + HDEVINFO set, + SP_DEVINFO_DATA& data, + uint64_t transactionDeadlineUnixMs, + const wchar_t* deadlinePhase, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, deadlinePhase, error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } BOOL reboot = FALSE; if (!DiUninstallDevice(nullptr, set, &data, 0, &reboot)) { return SetLastErrorDetail(error, L"remove-devnode"); @@ -1758,7 +1978,11 @@ bool RemoveDevice(HDEVINFO set, SP_DEVINFO_DATA& data, bool* rebootRequired, Err return true; } -bool RemoveAllExactDevices(bool* rebootRequired, Error* error) { +bool RemoveAllExactDevices( + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { DeviceInfoSet set = OpenRootDevices(); if (!set) { return SetLastErrorDetail(error, L"open-root-devices"); @@ -1773,7 +1997,8 @@ bool RemoveAllExactDevices(bool* rebootRequired, Error* error) { } for (auto& match : matches) { DeviceState& device = match.second; - if (_wcsicmp(device.service.c_str(), kServiceName) != 0 || + if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || !IsSafePublishedInfName(device.publishedInf)) { return SetError(error, L"remove-ownership", ERROR_ACCESS_DENIED, L"refusing to remove an exact hardware ID not owned by the signed VIIPER package"); @@ -1785,7 +2010,9 @@ bool RemoveAllExactDevices(bool* rebootRequired, Error* error) { } } for (auto& match : matches) { - if (!RemoveDevice(set.get(), match.first, rebootRequired, error)) { + if (!RemoveDevice(set.get(), match.first, transactionDeadlineUnixMs, + L"remove-deadline-before-device-mutation", mutationStarted, + rebootRequired, error)) { return false; } } @@ -1794,10 +2021,15 @@ bool RemoveAllExactDevices(bool* rebootRequired, Error* error) { bool RegisterRootDevice( const GUID& classGuid, - const std::wstring& className, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* registrationSucceeded, DeviceInfoSet* set, SP_DEVINFO_DATA* data, Error* error) { + if (registrationSucceeded != nullptr) { + *registrationSucceeded = false; + } *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); if (!*set) { return SetLastErrorDetail(error, L"create-device-info-list"); @@ -1805,36 +2037,192 @@ bool RegisterRootDevice( *data = SP_DEVINFO_DATA{}; data->cbSize = sizeof(*data); if (!SetupDiCreateDeviceInfoW( - set->get(), className.c_str(), &classGuid, nullptr, nullptr, + set->get(), kRootDeviceName, &classGuid, nullptr, nullptr, DICD_GENERATE_ID, data)) { return SetLastErrorDetail(error, L"create-root-devnode"); } const size_t idCharacters = std::size(kHardwareId) + 1; std::vector identifiers(idCharacters, L'\0'); std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-root-properties", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } if (!SetupDiSetDeviceRegistryPropertyW( set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { return SetLastErrorDetail(error, L"set-root-hardware-id"); } + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-root-registration", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { return SetLastErrorDetail(error, L"register-root-devnode"); } + if (registrationSucceeded != nullptr) { + *registrationSucceeded = true; + } + wchar_t instanceId[MAX_DEVICE_ID_LEN]{}; + if (!SetupDiGetDeviceInstanceIdW( + set->get(), data, instanceId, static_cast(std::size(instanceId)), nullptr)) { + return SetLastErrorDetail(error, L"verify-generated-root-instance-id"); + } + if (!IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName)) { + return SetError(error, L"verify-generated-root-instance-id", ERROR_INVALID_DATA, + L"SetupAPI generated a root identity outside the VIIPER-owned namespace"); + } + return true; +} + +bool DriverInfoUsesPublishedPackage( + const std::filesystem::path& driverInfPath, + const std::wstring& expectedPublishedName) { + if (IsSafePublishedInfName(driverInfPath.filename().wstring())) { + return _wcsicmp( + driverInfPath.filename().c_str(), expectedPublishedName.c_str()) == 0; + } + std::filesystem::path publishedPath; + Error ignored; + return GetPublishedInfPath(driverInfPath, &publishedPath, &ignored) && + _wcsicmp(publishedPath.filename().c_str(), expectedPublishedName.c_str()) == 0; +} + +bool InstallPreinstalledDriverOnDevice( + HDEVINFO set, + SP_DEVINFO_DATA* device, + const PackageInfo& publishedPackage, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + if (!SetupDiBuildDriverInfoList(set, device, SPDIT_COMPATDRIVER)) { + return SetLastErrorDetail(error, L"repair-build-compatible-driver-list"); + } + const auto destroyList = [&]() { + return SetupDiDestroyDriverInfoList(set, device, SPDIT_COMPATDRIVER) != FALSE; + }; + + SP_DRVINFO_DATA_W selected{}; + size_t exactMatches = 0; + for (DWORD index = 0;; ++index) { + SP_DRVINFO_DATA_W driver{}; + driver.cbSize = sizeof(driver); + if (!SetupDiEnumDriverInfoW(set, device, SPDIT_COMPATDRIVER, index, &driver)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + const DWORD code = GetLastError(); + destroyList(); + return SetError(error, L"repair-enumerate-compatible-driver", code); + } + break; + } + DWORD required = 0; + SP_DRVINFO_DETAIL_DATA_W probe{}; + probe.cbSize = sizeof(probe); + if (!SetupDiGetDriverInfoDetailW( + set, device, &driver, &probe, sizeof(probe), &required) && + GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + const DWORD code = GetLastError(); + destroyList(); + return SetError(error, L"repair-compatible-driver-detail", code); + } + const DWORD detailBytes = std::max( + required, static_cast(sizeof(SP_DRVINFO_DETAIL_DATA_W))); + std::vector detailBuffer(detailBytes); + auto* detail = reinterpret_cast(detailBuffer.data()); + detail->cbSize = sizeof(SP_DRVINFO_DETAIL_DATA_W); + if (!SetupDiGetDriverInfoDetailW( + set, device, &driver, detail, detailBytes, nullptr)) { + const DWORD code = GetLastError(); + destroyList(); + return SetError(error, L"repair-compatible-driver-detail", code); + } + if (DriverInfoUsesPublishedPackage( + detail->InfFileName, publishedPackage.publishedName)) { + selected = driver; + ++exactMatches; + } + } + if (exactMatches != 1) { + destroyList(); + return SetError(error, L"repair-exact-driver-selection", + exactMatches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"compatible driver list must contain exactly one node for the exact preinstalled package"); + } + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-driver-selection", error)) { + destroyList(); + return false; + } + MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + if (!SetupDiSetSelectedDriverW(set, device, &selected)) { + const DWORD code = GetLastError(); + destroyList(); + return SetError(error, L"repair-select-exact-driver", code); + } + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-device-binding", error)) { + destroyList(); + return false; + } + BOOL reboot = FALSE; + if (!DiInstallDevice(nullptr, set, device, &selected, 0, &reboot)) { + const DWORD code = GetLastError(); + destroyList(); + return SetError(error, L"repair-install-preinstalled-driver", code); + } + if (!destroyList()) { + return SetLastErrorDetail(error, L"repair-destroy-compatible-driver-list"); + } + *rebootRequired = *rebootRequired || reboot != FALSE; + return true; +} + +bool IsGeneratedRootInstanceIdForDeviceName( + const std::wstring& instanceId, + const wchar_t* deviceName) { + const std::wstring prefix = std::wstring(L"ROOT\\") + deviceName + L"\\"; + if (instanceId.size() != prefix.size() + 4 || + _wcsnicmp(instanceId.c_str(), prefix.c_str(), prefix.size()) != 0) { + return false; + } + for (size_t index = prefix.size(); index < instanceId.size(); ++index) { + if (instanceId[index] < L'0' || instanceId[index] > L'9') { + return false; + } + } return true; } +bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId) { + return IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName) || + IsGeneratedRootInstanceIdForDeviceName(instanceId, kLegacyRootDeviceName); +} + bool RegisterRootDeviceExact( const GUID& classGuid, const std::wstring& instanceId, + uint64_t transactionDeadlineUnixMs, DeviceInfoSet* set, SP_DEVINFO_DATA* data, Error* error) { - const std::wstring expectedPrefix = std::wstring(kHardwareId) + L"\\"; - if (instanceId.size() <= expectedPrefix.size() || - _wcsnicmp(instanceId.c_str(), expectedPrefix.c_str(), expectedPrefix.size()) != 0) { + if (!IsOwnedGeneratedRootInstanceId(instanceId)) { return SetError(error, L"rollback-instance-id", ERROR_INVALID_DATA, - L"captured root devnode identity is outside the exact VIIPER hardware namespace"); + L"captured root devnode identity is outside the VIIPER or legacy generated root namespace"); } *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); if (!*set) { @@ -1851,11 +2239,23 @@ bool RegisterRootDeviceExact( const size_t idCharacters = std::size(kHardwareId) + 1; std::vector identifiers(idCharacters, L'\0'); std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"rollback-deadline-before-root-properties", error)) { + return false; + } + MarkTransactionMutationStarted(); if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { return SetLastErrorDetail(error, L"rollback-set-root-hardware-id"); } + if (transactionDeadlineUnixMs != 0 && + !CheckTransactionDeadline(transactionDeadlineUnixMs, + L"rollback-deadline-before-root-registration", error)) { + return false; + } + MarkTransactionMutationStarted(); if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { return SetLastErrorDetail(error, L"rollback-register-exact-root-devnode"); } @@ -2024,12 +2424,10 @@ bool VerifyAbiHealth( return true; } -bool VerifyInstalled( +bool VerifyInstalledBinding( const PackageInfo& candidate, const std::wstring& publishedName, bool allowStopped, - uint64_t healthDeadlineUnixMs, - const std::string* expectedBuildIdentity, Error* error) { Snapshot snapshot; if (!CaptureSnapshot(&snapshot, error)) { @@ -2038,7 +2436,7 @@ bool VerifyInstalled( if (snapshot.devices.size() != 1 || !snapshot.devices[0].present || _wcsicmp(snapshot.devices[0].publishedInf.c_str(), publishedName.c_str()) != 0 || !(snapshot.devices[0].version == candidate.version) || - snapshot.devices[0].package.infSha256 != candidate.infSha256) { + !SamePackageBytes(snapshot.devices[0].package, candidate)) { return SetError(error, L"install-verification", ERROR_REVISION_MISMATCH, L"installed devnode is not bound to the exact candidate package"); } @@ -2046,12 +2444,24 @@ bool VerifyInstalled( return SetError(error, L"install-start", ERROR_DEVICE_NOT_AVAILABLE, L"installed driver did not start; problem=" + std::to_wstring(snapshot.devices[0].problem)); } - return allowStopped || VerifyAbiHealth( - healthDeadlineUnixMs, expectedBuildIdentity, error); + return true; +} + +bool VerifyInstalled( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool allowStopped, + uint64_t healthDeadlineUnixMs, + const std::string* expectedBuildIdentity, + Error* error) { + return VerifyInstalledBinding(candidate, publishedName, allowStopped, error) && + (allowStopped || VerifyAbiHealth( + healthDeadlineUnixMs, expectedBuildIdentity, error)); } bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* error) { BOOL reboot = FALSE; + MarkTransactionMutationStarted(); if (!DiUninstallDriverW(nullptr, package.infPath.c_str(), 0, &reboot)) { return SetLastErrorDetail(error, L"remove-driver-package"); } @@ -2088,7 +2498,11 @@ std::vector NewPackageIndices( return indices; } -bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* error) { +bool RestorePriorBinding( + const Snapshot& prior, + uint64_t transactionDeadlineUnixMs, + bool* rebootRequired, + Error* error) { if (prior.devices.size() > 1) { return SetError(error, L"rollback-topology", ERROR_DUPLICATE_SERVICE_NAME, L"rollback refuses an unsupported multi-devnode native topology"); @@ -2121,7 +2535,9 @@ bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* err return false; } bool removalReboot = false; - if (!RemoveDevice(set.get(), matches[0].first, &removalReboot, error)) { + if (!RemoveDevice(set.get(), matches[0].first, transactionDeadlineUnixMs, + L"rollback-deadline-before-device-removal", nullptr, + &removalReboot, error)) { return false; } *rebootRequired = *rebootRequired || removalReboot; @@ -2140,6 +2556,9 @@ bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* err const DeviceState& expected = prior.devices[0]; const PackageInfo& package = expected.package; + DeviceInfoSet target; + SP_DEVINFO_DATA targetData{}; + targetData.cbSize = sizeof(targetData); if (!keepCurrent) { GUID classGuid{}; wchar_t className[MAX_CLASS_NAME_LEN]{}; @@ -2147,25 +2566,36 @@ bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* err MAX_CLASS_NAME_LEN, nullptr)) { return SetLastErrorDetail(error, L"rollback-inf-class"); } - DeviceInfoSet created; - SP_DEVINFO_DATA createdData{}; - createdData.cbSize = sizeof(createdData); if (!RegisterRootDeviceExact(classGuid, expected.instanceId, - &created, &createdData, error)) { + transactionDeadlineUnixMs, + &target, &targetData, error)) { + return false; + } + } else { + target = OpenRootDevices(); + if (!target) { + return SetLastErrorDetail(error, L"rollback-open-retained-root-device"); + } + std::vector> matches; + if (!FindExactDevices(target.get(), &matches, error) || matches.size() != 1 || + !sameIdentity(matches[0].second.instanceId, expected.instanceId)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-retained-root-identity", ERROR_REVISION_MISMATCH); + } return false; } + targetData = matches[0].first; } - BOOL reboot = FALSE; - if (!UpdateDriverForPlugAndPlayDevicesW( - nullptr, kHardwareId, package.infPath.c_str(), INSTALLFLAG_FORCE, &reboot)) { - return SetLastErrorDetail(error, L"rollback-bind-prior"); + if (!InstallPreinstalledDriverOnDevice( + target.get(), &targetData, package, transactionDeadlineUnixMs, nullptr, + rebootRequired, error)) { + return false; } - *rebootRequired = *rebootRequired || reboot != FALSE; Snapshot restored; if (!CaptureSnapshot(&restored, error) || restored.devices.size() != 1 || !sameIdentity(restored.devices[0].instanceId, expected.instanceId) || - restored.devices[0].package.infSha256 != expected.package.infSha256) { + !SamePackageBytes(restored.devices[0].package, expected.package)) { if (error->code == ERROR_SUCCESS) { SetError(error, L"rollback-identity-verification", ERROR_REVISION_MISMATCH, L"rollback did not restore the exact captured devnode identity and package binding"); @@ -2176,7 +2606,7 @@ bool RestorePriorBinding(const Snapshot& prior, bool* rebootRequired, Error* err } bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) { - if (!RestorePriorBinding(prior, rebootRequired, error)) { + if (!RestorePriorBinding(prior, 0, rebootRequired, error)) { return false; } std::vector current; @@ -2189,9 +2619,10 @@ bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) } } if (!prior.devices.empty() && !*rebootRequired) { - return VerifyInstalled( - prior.devices[0].package, prior.devices[0].publishedInf, false, - CurrentUnixMilliseconds() + 15000, nullptr, error); + // Driver rollback can prove exact instance/package binding, but the + // prior transient started/problem state is not a restorable identity. + return VerifyInstalledBinding( + prior.devices[0].package, prior.devices[0].publishedInf, true, error); } return true; } @@ -2255,6 +2686,9 @@ struct InstallOptions { std::filesystem::path manifestPath; std::string manifestSha256; std::string sourceRevision; + std::string expectedInfSha256; + std::string expectedSysSha256; + std::string expectedCatSha256; bool production = true; std::optional expectedDowngradeFrom; std::filesystem::path brokerExecutable; @@ -2337,6 +2771,12 @@ bool ValidateCandidateInputs( (options.production && !VerifyMicrosoftHardwareInfSigner(lockedInfPath, error))) { return false; } + if (_stricmp(candidate->infSha256.c_str(), options.expectedInfSha256.c_str()) != 0 || + _stricmp(candidate->sysSha256.c_str(), options.expectedSysSha256.c_str()) != 0 || + _stricmp(candidate->catSha256.c_str(), options.expectedCatSha256.c_str()) != 0) { + return SetError(error, L"package-runtime-hash", ERROR_CRC, + L"INF, SYS, or CAT does not match the installer-reviewed runtime package bytes"); + } WinHandle manifest(CreateFileW(options.manifestPath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); if (!manifest) { @@ -2415,8 +2855,131 @@ std::wstring QuoteWindowsArgument(const std::wstring& value) { return quoted; } -bool RunBrokerInstall(const InstallOptions& options, bool* transactionSettled, Error* error) { - *transactionSettled = false; +struct BrokerCommitProof { + bool success = false; + bool changed = false; + std::string rollback; + DWORD exitCode = ERROR_GEN_FAILURE; + bool driverRollbackAuthorized = false; +}; + +bool ParseBrokerCommitProof( + const std::string& output, + DWORD processExitCode, + BrokerCommitProof* proof, + Error* error) { + struct CanonicalProof { + std::string_view record; + bool success; + bool changed; + std::string_view rollback; + DWORD exitCode; + bool driverRollbackAuthorized; + }; + static constexpr std::array canonicalProofs = {{ + {"result=success operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=0", + true, false, "not-needed", ERROR_SUCCESS, false}, + {"result=success operation=native-package-broker-commit changed=1 rollback=not-needed exitCode=0", + true, true, "not-needed", ERROR_SUCCESS, false}, + {"result=error operation=native-package-broker-commit changed=0 rollback=not-needed exitCode=4", + false, false, "not-needed", 4, true}, + {"result=error operation=native-package-broker-commit changed=1 rollback=succeeded exitCode=1", + false, true, "succeeded", 1, true}, + {"result=error operation=native-package-broker-commit changed=1 rollback=failed exitCode=3", + false, true, "failed", 3, false}, + }}; + std::optional parsed; + size_t cursor = 0; + while (cursor < output.size()) { + const size_t newline = output.find('\n', cursor); + const bool terminated = newline != std::string::npos; + std::string line = output.substr( + cursor, terminated ? newline - cursor : output.size() - cursor); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.starts_with("result=")) { + if (!terminated || parsed) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker must emit exactly one newline-terminated canonical outcome"); + } + const auto match = std::find_if( + canonicalProofs.begin(), canonicalProofs.end(), + [&](const CanonicalProof& candidate) { + return candidate.record == line; + }); + if (match == canonicalProofs.end()) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker outcome is not in canonical byte form"); + } + parsed = BrokerCommitProof{ + match->success, + match->changed, + std::string(match->rollback), + match->exitCode, + match->driverRollbackAuthorized, + }; + } + if (!terminated) { + break; + } + cursor = newline + 1; + } + if (!parsed || parsed->exitCode != processExitCode) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker process exit and structured outcome are missing or inconsistent"); + } + *proof = std::move(*parsed); + return true; +} + +bool DrainBrokerProofPipe( + HANDLE pipe, + std::string* output, + bool* overflow, + Error* error) { + for (;;) { + DWORD available = 0; + if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) { + const DWORD code = GetLastError(); + if (code == ERROR_BROKEN_PIPE) { + return true; + } + return SetError(error, L"broker-proof-read", code); + } + if (available == 0) { + return true; + } + std::array buffer{}; + const DWORD requested = std::min( + available, static_cast(buffer.size())); + DWORD read = 0; + if (!ReadFile(pipe, buffer.data(), requested, &read, nullptr)) { + const DWORD code = GetLastError(); + if (code == ERROR_BROKEN_PIPE) { + return true; + } + return SetError(error, L"broker-proof-read", code); + } + const size_t retained = std::min( + read, kMaximumBrokerProofBytes - + std::min(output->size(), kMaximumBrokerProofBytes)); + output->append(buffer.data(), retained); + if (retained != read) { + *overflow = true; + } + } +} + +bool RunBrokerInstall( + const InstallOptions& options, + bool* driverRollbackAuthorized, + bool* brokerChanged, + Error* error) { + // Until CreateProcess succeeds, no nested SCM/image mutation can have + // started, so the caller may safely restore its captured driver snapshot. + *driverRollbackAuthorized = true; + *brokerChanged = false; if (options.brokerExecutable.empty() || !options.brokerExecutable.is_absolute() || options.brokerExecutable.filename().wstring() != L"viiper.exe" || options.brokerToken.empty() || !options.brokerToken.is_absolute() || @@ -2465,55 +3028,173 @@ bool RunBrokerInstall(const InstallOptions& options, bool* transactionSettled, E L" --expected-token-sha256 " + QuoteWindowsArgument(std::wstring( options.brokerTokenSha256.begin(), options.brokerTokenSha256.end())) + + L" --expected-broker-sha256 " + + QuoteWindowsArgument(std::wstring( + options.brokerSha256.begin(), options.brokerSha256.end())) + L" --target-user-sid " + QuoteWindowsArgument(options.targetUserSid) + L" --transaction-deadline-unix-ms " + QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)); std::vector mutableCommand(commandLine.begin(), commandLine.end()); mutableCommand.push_back(L'\0'); - STARTUPINFOW startup{}; - startup.cb = sizeof(startup); + SECURITY_ATTRIBUTES inheritedSecurity{}; + inheritedSecurity.nLength = sizeof(inheritedSecurity); + inheritedSecurity.bInheritHandle = TRUE; + HANDLE rawProofRead = INVALID_HANDLE_VALUE; + HANDLE rawProofWrite = INVALID_HANDLE_VALUE; + if (!CreatePipe( + &rawProofRead, &rawProofWrite, &inheritedSecurity, 0)) { + return SetLastErrorDetail(error, L"broker-proof-pipe"); + } + WinHandle proofRead(rawProofRead); + WinHandle proofWrite(rawProofWrite); + if (!SetHandleInformation( + proofRead.get(), HANDLE_FLAG_INHERIT, 0)) { + return SetLastErrorDetail(error, L"broker-proof-pipe-inheritance"); + } + WinHandle nullInput(CreateFileW( + L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritedSecurity, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!nullInput) { + return SetLastErrorDetail(error, L"broker-null-input"); + } + + SIZE_T attributeBytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeBytes); + if (attributeBytes == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"broker-handle-list-size"); + } + std::vector attributeStorage(attributeBytes); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = nullInput.get(); + startup.StartupInfo.hStdOutput = proofWrite.get(); + startup.StartupInfo.hStdError = proofWrite.get(); + startup.lpAttributeList = reinterpret_cast( + attributeStorage.data()); + if (!InitializeProcThreadAttributeList( + startup.lpAttributeList, 1, 0, &attributeBytes)) { + return SetLastErrorDetail(error, L"broker-handle-list-init"); + } + const auto deleteAttributeList = [&]() { + DeleteProcThreadAttributeList(startup.lpAttributeList); + }; + HANDLE inheritedHandles[] = {nullInput.get(), proofWrite.get()}; + if (!UpdateProcThreadAttribute( + startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inheritedHandles, sizeof(inheritedHandles), nullptr, nullptr)) { + const DWORD code = GetLastError(); + deleteAttributeList(); + return SetError(error, L"broker-handle-list-update", code); + } PROCESS_INFORMATION process{}; if (!CreateProcessW(options.brokerExecutable.c_str(), mutableCommand.data(), nullptr, nullptr, - FALSE, CREATE_NO_WINDOW, nullptr, options.brokerExecutable.parent_path().c_str(), - &startup, &process)) { - return SetLastErrorDetail(error, L"broker-start"); - } + TRUE, CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, nullptr, + options.brokerExecutable.parent_path().c_str(), + &startup.StartupInfo, &process)) { + const DWORD code = GetLastError(); + deleteAttributeList(); + return SetError(error, L"broker-start", code); + } + MarkTransactionMutationStarted(); + deleteAttributeList(); + // From this point forward, only an exact child proof may authorize driver + // rollback. A crash, malformed output, or late/indeterminate exit must not + // compound an unknown SCM transaction with a second SetupAPI mutation. + *driverRollbackAuthorized = false; WinHandle processHandle(process.hProcess); WinHandle threadHandle(process.hThread); + proofWrite.reset(); + nullInput.reset(); // The child shares the exact outer deadline and owns a separately bounded - // rollback. Poll rather than hard-terminate: cancellation is cooperative, - // and the helper retains its driver snapshot until that rollback settles. + // rollback. Poll and drain rather than hard-terminate: cancellation is + // cooperative, and this helper retains the driver mutex until the child + // exits so a foreign helper cannot overlap an indeterminate SCM mutation. const uint64_t brokerCeiling = options.transactionDeadlineUnixMs + kBrokerRollbackCeilingMs; + bool exceededCeiling = false; + bool proofOverflow = false; + bool proofReadFailed = false; + bool waitFailed = false; + Error proofReadError; + Error waitError; + std::string brokerOutput; for (;;) { + if (!proofReadFailed && !DrainBrokerProofPipe( + proofRead.get(), &brokerOutput, &proofOverflow, &proofReadError)) { + proofReadFailed = true; + proofRead.reset(); + } const uint64_t now = CurrentUnixMilliseconds(); - if (now >= brokerCeiling) { - // Never terminate a mutating installer child. Its independently - // bounded rollback owns reconciliation; the parent reports an - // indeterminate transaction and deliberately does not race it by - // attempting driver rollback in parallel. - return SetError(error, L"broker-wait-ceiling", ERROR_TIMEOUT, - L"native broker exceeded its transaction deadline and rollback ceiling; external reconciliation is required"); + if (now >= brokerCeiling && !exceededCeiling) { + exceededCeiling = true; + std::wcerr + << L"native broker exceeded its transaction and rollback deadline; " + L"retaining the driver transaction lock until the child exits\n"; } const DWORD waitSlice = static_cast( - std::min(250, brokerCeiling - now)); + exceededCeiling ? 250 : std::min(250, brokerCeiling - now)); const DWORD wait = WaitForSingleObject(processHandle.get(), waitSlice); if (wait == WAIT_OBJECT_0) { - *transactionSettled = true; break; } if (wait != WAIT_TIMEOUT) { - return SetLastErrorDetail(error, L"broker-wait"); + if (!waitFailed) { + DWORD code = GetLastError(); + if (code == ERROR_SUCCESS) { + code = ERROR_GEN_FAILURE; + } + waitError = Error{code, L"broker-wait", FormatError(code)}; + waitFailed = true; + } + // A failed wait is ambiguous, not permission to release the driver + // transaction mutex while the nested SCM child may still mutate. + // Retain ownership and use the process exit query only as a + // termination observation; the final outcome remains indeterminate. + DWORD observedExit = STILL_ACTIVE; + if (GetExitCodeProcess(processHandle.get(), &observedExit) && + observedExit != STILL_ACTIVE) { + break; + } + Sleep(250); } } + if (!proofReadFailed && !DrainBrokerProofPipe( + proofRead.get(), &brokerOutput, &proofOverflow, &proofReadError)) { + proofReadFailed = true; + } DWORD exitCode = ERROR_GEN_FAILURE; if (!GetExitCodeProcess(processHandle.get(), &exitCode)) { return SetLastErrorDetail(error, L"broker-exit"); } - if (exitCode != ERROR_SUCCESS) { + if (exceededCeiling) { + return SetError(error, L"broker-wait-ceiling", ERROR_TIMEOUT, + L"native broker exited only after its transaction and rollback deadline; external reconciliation is required"); + } + if (waitFailed) { + *error = std::move(waitError); + return false; + } + if (proofReadFailed) { + *error = std::move(proofReadError); + return false; + } + if (proofOverflow) { + return SetError(error, L"broker-proof", ERROR_BUFFER_OVERFLOW, + L"nested broker output exceeded the bounded proof channel"); + } + BrokerCommitProof proof; + if (!ParseBrokerCommitProof(brokerOutput, exitCode, &proof, error)) { + return false; + } + *driverRollbackAuthorized = proof.driverRollbackAuthorized; + *brokerChanged = proof.changed; + if (!proof.success) { return SetError(error, L"broker-health", exitCode, - L"native broker transaction did not reach authenticated healthy state"); + proof.driverRollbackAuthorized + ? L"nested broker transaction failed after proving a settled state" + : L"nested broker transaction failed with indeterminate service state"); } return true; } @@ -2561,45 +3242,14 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - std::optional highest; - for (const PackageInfo& package : prior.packages) { - if (!highest || highest->version < package.version) { - highest = package; - } - } + CandidateDisposition disposition = CandidateDisposition::InstallRequired; bool downgrade = false; - if (highest) { - if (candidate.version < highest->version) { - downgrade = true; - if (!options.expectedDowngradeFrom || - !(*options.expectedDowngradeFrom == highest->version)) { - SetError(&outcome.error, L"version-policy", ERROR_REVISION_MISMATCH, - L"downgrade rejected; pass --allow-controlled-downgrade with the exact installed version " + - VersionToString(highest->version)); - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; - } - } else if (candidate.version == highest->version) { - const bool conflictingSameVersion = std::any_of( - prior.packages.begin(), prior.packages.end(), [&](const PackageInfo& package) { - return package.version == candidate.version && - package.infSha256 != candidate.infSha256; - }); - if (conflictingSameVersion) { - SetError(&outcome.error, L"version-policy", ERROR_REVISION_MISMATCH, - L"same-version package replacement is rejected; increment DriverVer"); - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; - } - } - } - if (options.expectedDowngradeFrom && !downgrade) { - SetError(&outcome.error, L"version-policy", ERROR_INVALID_PARAMETER, - L"controlled downgrade guard is valid only for an actual downgrade"); + if (!ClassifyCandidatePackage( + candidate, prior.packages, options.expectedDowngradeFrom, + &disposition, &downgrade, &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - // Re-enumerate at the last possible point before SetupAPI reopens the // package paths. The four leaf handles already deny write/delete sharing. if (!ValidateExactPackageDirectory(packageDirectory, &outcome.error) || @@ -2607,73 +3257,138 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - outcome.changed = true; - BOOL installReboot = FALSE; - const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; - if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { - const DWORD installCode = GetLastError(); - const Error installError{installCode, L"install-driver-package", FormatError(installCode)}; - Error rollbackError; - bool rollbackReboot = false; - if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { - outcome.rollback = L"succeeded"; + PackageInfo publishedCandidate; + bool exactBindingHealthy = false; + if (disposition == CandidateDisposition::Exact) { + if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error) || + (options.production && !VerifyMicrosoftHardwareInfSigner( + publishedCandidate.infPath, &outcome.error))) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + exactBindingHealthy = prior.devices.size() == 1 && prior.devices[0].present && + prior.devices[0].started && + _wcsicmp(prior.devices[0].publishedInf.c_str(), + publishedCandidate.publishedName.c_str()) == 0 && + prior.devices[0].version == candidate.version && + SamePackageBytes(prior.devices[0].package, candidate); + } + + // Same-version bytes are immutable. An exact package with a missing, + // stopped, or stale binding may repair only the ROOT topology from the + // already-published exact INF. It selects that preinstalled package for the + // specific devnode and calls DiInstallDevice; it never calls + // DiInstallDriverW or UpdateDriverForPlugAndPlayDevicesW and therefore + // cannot replace same-version DriverStore content. + const bool topologyRepair = + disposition == CandidateDisposition::Exact && !exactBindingHealthy; + const bool driverMutation = + disposition == CandidateDisposition::InstallRequired || topologyRepair; + bool driverMutationStarted = false; + if (disposition == CandidateDisposition::InstallRequired) { + if (!CheckTransactionDeadline(options, + L"transaction-deadline-before-driver-install", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + driverMutationStarted = true; + outcome.changed = true; + BOOL installReboot = FALSE; + const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; + MarkTransactionMutationStarted(); + if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { + const DWORD installCode = GetLastError(); + const Error installError{installCode, L"install-driver-package", FormatError(installCode)}; + Error rollbackError; + bool rollbackReboot = false; + if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + outcome.rollback = L"succeeded"; + outcome.rebootRequired = rollbackReboot; + outcome.error = installError; + return outcome; + } + outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; - outcome.error = installError; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; return outcome; } - outcome.rollback = L"failed"; - outcome.rebootRequired = rollbackReboot; - outcome.error = std::move(rollbackError); - outcome.exitCode = ExitCode::RollbackFailed; - return outcome; + outcome.rebootRequired = installReboot != FALSE; + if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { + // Exact Driver Store inventory recorded the failure. + } else if (options.production && !VerifyMicrosoftHardwareInfSigner( + publishedCandidate.infPath, &outcome.error)) { + // The staged package must retain its exact production HLK/WHCP policy. + } } - outcome.rebootRequired = installReboot != FALSE; DeviceInfoSet created; SP_DEVINFO_DATA createdData{}; createdData.cbSize = sizeof(createdData); bool createdHere = false; - if (prior.devices.empty()) { + bool registrationSucceeded = false; + if (outcome.error.code == ERROR_SUCCESS && driverMutation && prior.devices.empty()) { GUID classGuid{}; wchar_t className[MAX_CLASS_NAME_LEN]{}; if (!SetupDiGetINFClassW(candidate.infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { SetLastErrorDetail(&outcome.error, L"candidate-inf-class"); } else { - if (RegisterRootDevice(classGuid, className, &created, &createdData, &outcome.error)) { - createdHere = true; - BOOL bindReboot = FALSE; - const DWORD bindFlags = downgrade ? INSTALLFLAG_FORCE : 0; - if (UpdateDriverForPlugAndPlayDevicesW( - nullptr, kHardwareId, candidate.infPath.c_str(), bindFlags, &bindReboot)) { - outcome.rebootRequired = outcome.rebootRequired || bindReboot != FALSE; - outcome.error = {}; - } else { - SetLastErrorDetail(&outcome.error, L"bind-root-devnode"); - } + const bool registeredAndVerified = RegisterRootDevice( + classGuid, options.transactionDeadlineUnixMs, + &driverMutationStarted, ®istrationSucceeded, + &created, &createdData, &outcome.error); + createdHere = registrationSucceeded; + if (registeredAndVerified) { + InstallPreinstalledDriverOnDevice( + created.get(), &createdData, publishedCandidate, + options.transactionDeadlineUnixMs, &driverMutationStarted, + &outcome.rebootRequired, &outcome.error); } + outcome.changed = outcome.changed || driverMutationStarted; + } + } + + if (outcome.error.code == ERROR_SUCCESS && topologyRepair && !prior.devices.empty()) { + DeviceInfoSet repairSet = OpenRootDevices(); + std::vector> repairDevices; + if (!repairSet) { + SetLastErrorDetail(&outcome.error, L"repair-open-root-devices"); + } else if (!FindExactDevices(repairSet.get(), &repairDevices, &outcome.error)) { + // Exact enumeration recorded the failure. + } else if (repairDevices.size() != 1 || + _wcsicmp(repairDevices[0].second.instanceId.c_str(), + prior.devices[0].instanceId.c_str()) != 0) { + SetError(&outcome.error, L"repair-root-identity", ERROR_REVISION_MISMATCH, + L"root devnode identity changed before exact topology repair"); + } else { + InstallPreinstalledDriverOnDevice( + repairSet.get(), &repairDevices[0].first, publishedCandidate, + options.transactionDeadlineUnixMs, &driverMutationStarted, + &outcome.rebootRequired, &outcome.error); + outcome.changed = outcome.changed || driverMutationStarted; } } - PackageInfo publishedCandidate; - if (outcome.error.code == ERROR_SUCCESS && - !FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { - // Candidate inventory recorded the exact failure. - } if (outcome.error.code == ERROR_SUCCESS) { CheckTransactionDeadline(options, L"transaction-deadline-before-verify", &outcome.error); } if (outcome.error.code == ERROR_SUCCESS && - !VerifyInstalled(candidate, publishedCandidate.publishedName, outcome.rebootRequired, - options.transactionDeadlineUnixMs, &expectedBuildIdentity, &outcome.error)) { + !(options.brokerExecutable.empty() + ? VerifyInstalled(candidate, publishedCandidate.publishedName, + outcome.rebootRequired, options.transactionDeadlineUnixMs, + &expectedBuildIdentity, &outcome.error) + : VerifyInstalledBinding(candidate, publishedCandidate.publishedName, + outcome.rebootRequired, &outcome.error))) { // Verification recorded the exact failure. } - if (outcome.error.code != ERROR_SUCCESS) { + if (outcome.error.code != ERROR_SUCCESS && driverMutationStarted) { const Error installError = outcome.error; Error rollbackError; bool rollbackReboot = outcome.rebootRequired; if (createdHere) { Error cleanupError; - if (!RemoveDevice(created.get(), createdData, &rollbackReboot, &cleanupError)) { + if (!RemoveDevice(created.get(), createdData, 0, nullptr, nullptr, + &rollbackReboot, &cleanupError)) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(cleanupError); @@ -2693,32 +3408,47 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::RollbackFailed; return outcome; } + if (outcome.error.code != ERROR_SUCCESS) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } if (!options.brokerExecutable.empty()) { Error brokerError; - bool brokerTransactionSettled = true; + bool driverRollbackAuthorized = true; + bool brokerChanged = false; if (outcome.rebootRequired) { SetError(&brokerError, L"broker-reboot-boundary", ERROR_SUCCESS_REBOOT_REQUIRED, L"driver activation requires a restart; legacy ownership remains active and broker migration was not attempted"); } else if (!CheckTransactionDeadline( options, L"transaction-deadline-before-broker", &brokerError) || - !RunBrokerInstall(options, &brokerTransactionSettled, &brokerError)) { + !RunBrokerInstall( + options, &driverRollbackAuthorized, &brokerChanged, &brokerError)) { // The broker command includes authenticated health verification and // rolls back its own SCM/credential/legacy transaction. Keep the // driver snapshot alive in this process until that proof succeeds. } + outcome.changed = outcome.changed || brokerChanged; if (brokerError.code != ERROR_SUCCESS) { - if (!brokerTransactionSettled) { + if (!driverRollbackAuthorized) { outcome.rollback = L"failed"; outcome.error = std::move(brokerError); outcome.exitCode = ExitCode::RollbackFailed; return outcome; } + if (!driverMutationStarted) { + outcome.rollback = brokerChanged ? L"succeeded" : L"not-needed"; + outcome.error = std::move(brokerError); + outcome.exitCode = outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; + return outcome; + } Error rollbackError; bool rollbackReboot = outcome.rebootRequired; if (createdHere) { Error cleanupError; - if (!RemoveDevice(created.get(), createdData, &rollbackReboot, &cleanupError)) { + if (!RemoveDevice(created.get(), createdData, 0, nullptr, nullptr, + &rollbackReboot, &cleanupError)) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(cleanupError); @@ -2755,13 +3485,256 @@ struct PackageBackup { std::vector locks; }; +class LocalSecurityDescriptor final { +public: + ~LocalSecurityDescriptor() { + if (value_ != nullptr) { + LocalFree(value_); + } + } + + LocalSecurityDescriptor(const LocalSecurityDescriptor&) = delete; + LocalSecurityDescriptor& operator=(const LocalSecurityDescriptor&) = delete; + + bool Initialize(const wchar_t* sddl, const wchar_t* phase, Error* error) { + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, SDDL_REVISION_1, &value_, nullptr)) { + return SetLastErrorDetail(error, phase); + } + attributes_ = SECURITY_ATTRIBUTES{}; + attributes_.nLength = sizeof(attributes_); + attributes_.lpSecurityDescriptor = value_; + attributes_.bInheritHandle = FALSE; + return true; + } + + SECURITY_ATTRIBUTES* attributes() noexcept { return &attributes_; } + +private: + PSECURITY_DESCRIPTOR value_ = nullptr; + SECURITY_ATTRIBUTES attributes_{}; +}; + +bool VerifyProtectedFileSystemSecurity( + HANDLE handle, + bool directory, + const wchar_t* phase, + Error* error) { + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const DWORD securityError = GetSecurityInfo( + handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + if (securityError != ERROR_SUCCESS) { + return SetError(error, phase, securityError); + } + const auto fail = [&](DWORD code, std::wstring message) { + LocalFree(descriptor); + return SetError(error, phase, code, std::move(message)); + }; + + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + BYTE systemBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD systemSize = sizeof(systemBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize) || + !CreateWellKnownSid(WinLocalSystemSid, nullptr, + systemBuffer, &systemSize)) { + const DWORD code = GetLastError(); + return fail(code, L"could not construct protected backup principals"); + } + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION information{}; + if (owner == nullptr || !EqualSid(owner, administratorsBuffer) || dacl == nullptr || + !GetSecurityDescriptorControl(descriptor, &control, &revision) || + (control & SE_DACL_PROTECTED) == 0 || + !GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) || + information.AceCount != 2) { + return fail(ERROR_INVALID_SECURITY_DESCR, + L"protected backup owner or DACL is not exact"); + } + + const BYTE expectedFlags = directory + ? static_cast(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) : 0; + bool administratorsSeen = false; + bool systemSeen = false; + for (DWORD index = 0; index < information.AceCount; ++index) { + void* rawAce = nullptr; + if (!GetAce(dacl, index, &rawAce) || rawAce == nullptr) { + const DWORD code = GetLastError(); + return fail(code == ERROR_SUCCESS ? ERROR_INVALID_ACL : code, + L"protected backup DACL could not be enumerated"); + } + const auto* ace = static_cast(rawAce); + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + ace->Header.AceFlags != expectedFlags || ace->Mask != FILE_ALL_ACCESS) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL contains an unexpected access rule"); + } + PSID sid = const_cast(&ace->SidStart); + if (EqualSid(sid, administratorsBuffer)) { + if (administratorsSeen) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL duplicates the Administrators rule"); + } + administratorsSeen = true; + } else if (EqualSid(sid, systemBuffer)) { + if (systemSeen) { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL duplicates the LocalSystem rule"); + } + systemSeen = true; + } else { + return fail(ERROR_INVALID_ACL, + L"protected backup DACL grants an unexpected principal"); + } + } + LocalFree(descriptor); + if (!administratorsSeen || !systemSeen) { + return SetError(error, phase, ERROR_INVALID_ACL, + L"protected backup DACL is missing an exact principal"); + } + return true; +} + +bool CreateProtectedBackupDirectory( + const std::filesystem::path& path, + Error* error) { + LocalSecurityDescriptor security; + if (!security.Initialize( + kRollbackDirectorySecurity, L"rollback-backup-directory-security", error)) { + return false; + } + if (!CreateDirectoryW(path.c_str(), security.attributes())) { + return SetLastErrorDetail(error, L"rollback-backup-create"); + } + WinHandle directory(CreateFileW( + path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!directory) { + return SetLastErrorDetail(error, L"rollback-backup-directory-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + directory.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"rollback-backup-directory-open", + ERROR_REPARSE_TAG_MISMATCH); + } + return VerifyProtectedFileSystemSecurity( + directory.get(), true, L"rollback-backup-directory-security", error); +} + +bool CopyProtectedBackupFile( + const std::filesystem::path& sourcePath, + const std::filesystem::path& destinationPath, + Error* error) { + WinHandle source(CreateFileW( + sourcePath.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!source) { + return SetLastErrorDetail(error, L"rollback-backup-source-open"); + } + FILE_ATTRIBUTE_TAG_INFO sourceAttributes{}; + if (!GetFileInformationByHandleEx( + source.get(), FileAttributeTagInfo, &sourceAttributes, + sizeof(sourceAttributes)) || + (sourceAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"rollback-backup-source-open", + ERROR_REPARSE_TAG_MISMATCH, + L"rollback sources must be regular non-reparse files"); + } + + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"rollback-backup-file-security", error)) { + return false; + } + WinHandle destination(CreateFileW( + destinationPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN | FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!destination) { + return SetLastErrorDetail(error, L"rollback-backup-file-create"); + } + FILE_ATTRIBUTE_TAG_INFO destinationAttributes{}; + const BOOL queriedDestination = GetFileInformationByHandleEx( + destination.get(), FileAttributeTagInfo, &destinationAttributes, + sizeof(destinationAttributes)); + const DWORD destinationQueryError = queriedDestination + ? ERROR_SUCCESS : GetLastError(); + if (!queriedDestination || + (destinationAttributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + return SetError(error, L"rollback-backup-file-create", + queriedDestination ? ERROR_REPARSE_TAG_MISMATCH : destinationQueryError); + } + if (!VerifyProtectedFileSystemSecurity( + destination.get(), false, L"rollback-backup-file-security", error)) { + return false; + } + + std::array buffer{}; + for (;;) { + DWORD read = 0; + if (!ReadFile(source.get(), buffer.data(), + static_cast(buffer.size()), &read, nullptr)) { + return SetLastErrorDetail(error, L"rollback-backup-file-read"); + } + if (read == 0) { + break; + } + DWORD offset = 0; + while (offset < read) { + DWORD written = 0; + if (!WriteFile(destination.get(), buffer.data() + offset, + read - offset, &written, nullptr) || written == 0) { + const DWORD writeError = GetLastError(); + const DWORD code = writeError == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : writeError; + return SetError(error, L"rollback-backup-file-write", code); + } + offset += written; + } + } + if (!FlushFileBuffers(destination.get())) { + return SetLastErrorDetail(error, L"rollback-backup-file-flush"); + } + return true; +} + class BackupDirectory final { public: - ~BackupDirectory() { - if (!path_.empty()) { - root_.reset(); - std::error_code ignored; - std::filesystem::remove_all(path_, ignored); + ~BackupDirectory() noexcept { + try { + if (!path_.empty() && !preserve_) { + root_.reset(); + std::error_code removalError; + std::filesystem::remove_all(path_, removalError); + std::error_code presenceError; + const bool remains = std::filesystem::exists(path_, presenceError); + if (!removalError && !presenceError && !remains) { + path_.clear(); + ClearActiveRecoveryEvidence(); + } + } + } catch (...) { + // The top-level boundary must remain able to emit the fixed active + // evidence path after unwinding; a destructor must never terminate it. } } @@ -2838,6 +3811,35 @@ class BackupDirectory final { LocalFree(descriptor); return SetError(error, L"rollback-backup-root", code); } + try { + path_ = candidate; + } catch (...) { + RemoveDirectoryW(candidate.c_str()); + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + throw; + } + const std::wstring& rootValue = path_.native(); + constexpr size_t recordNameLength = std::size(kRecoveryRecordName) - 1; + const size_t recordLength = rootValue.size() + 1 + recordNameLength; + if (rootValue.empty() || rootValue.size() >= gActiveBackupRoot.size() || + recordLength >= gActiveRecoveryRecord.size()) { + if (RemoveDirectoryW(candidate.c_str())) { + path_.clear(); + } + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return SetError(error, L"rollback-backup-root", + ERROR_FILENAME_EXCED_RANGE, + L"protected rollback paths exceed the exception-safe reporting bound"); + } + ClearActiveRecoveryEvidence(); + std::copy(rootValue.begin(), rootValue.end(), gActiveBackupRoot.begin()); + gActiveBackupRootRetained = true; + std::copy(rootValue.begin(), rootValue.end(), gActiveRecoveryRecord.begin()); + gActiveRecoveryRecord[rootValue.size()] = L'\\'; + std::copy_n(kRecoveryRecordName, recordNameLength, + gActiveRecoveryRecord.begin() + rootValue.size() + 1); root_.reset(CreateFileW( candidate.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, @@ -2846,7 +3848,10 @@ class BackupDirectory final { nullptr)); if (!root_) { const DWORD code = GetLastError(); - RemoveDirectoryW(candidate.c_str()); + if (RemoveDirectoryW(candidate.c_str())) { + path_.clear(); + ClearActiveRecoveryEvidence(); + } CryptReleaseContext(provider, 0); LocalFree(descriptor); return SetError(error, L"rollback-backup-root-lock", code); @@ -2858,13 +3863,26 @@ class BackupDirectory final { (rootAttributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || (rootAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { root_.reset(); - RemoveDirectoryW(candidate.c_str()); + if (RemoveDirectoryW(candidate.c_str())) { + path_.clear(); + ClearActiveRecoveryEvidence(); + } CryptReleaseContext(provider, 0); LocalFree(descriptor); return SetError(error, L"rollback-backup-root-lock", ERROR_REPARSE_TAG_MISMATCH); } - path_ = candidate; + if (!VerifyProtectedFileSystemSecurity( + root_.get(), true, L"rollback-backup-root-security", error)) { + root_.reset(); + if (RemoveDirectoryW(candidate.c_str())) { + path_.clear(); + ClearActiveRecoveryEvidence(); + } + CryptReleaseContext(provider, 0); + LocalFree(descriptor); + return false; + } CryptReleaseContext(provider, 0); LocalFree(descriptor); return true; @@ -2877,10 +3895,84 @@ class BackupDirectory final { const std::filesystem::path& path() const noexcept { return path_; } + std::filesystem::path RecoveryRecordPath() const { + return path_ / kRecoveryRecordName; + } + + bool ArmPreservation(const std::filesystem::path& recoveryPath, Error* error) { + const std::wstring& value = recoveryPath.native(); + if (!gActiveBackupRootRetained || gActiveRecoveryRecord[0] == L'\0' || + value != gActiveRecoveryRecord.data()) { + return SetError(error, L"recovery-record-path", ERROR_INVALID_DATA, + L"published recovery record does not match the tracked protected backup root"); + } + gActiveRecoveryRecordWritten = true; + preserve_ = true; + return true; + } + + void AttachRecoveryRecord(Error* error) const { + if (error == nullptr) { + return; + } + if (gActiveBackupRootRetained && gActiveBackupRoot[0] != L'\0') { + error->recoveryBackup = gActiveBackupRoot.data(); + error->recoveryBackupRetained = true; + } else if (!path_.empty()) { + error->recoveryBackup = path_.wstring(); + error->recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + error->recoveryRecord = gActiveRecoveryRecord.data(); + error->recoveryRecordWritten = gActiveRecoveryRecordWritten; + } else if (!path_.empty()) { + error->recoveryRecord = RecoveryRecordPath().wstring(); + error->recoveryRecordWritten = false; + } + if (!error->recoveryRecord.empty() && !error->recoveryRecordWritten) { + error->recoveryRecordError = ERROR_FILE_NOT_FOUND; + error->recoveryRecordPhase = L"recovery-record-not-published"; + error->recoveryRecordMessage = + L"the retained backup predates a verified write-ahead recovery record"; + } + } + + bool Cleanup(std::vector* backups, Error* error) { + if (backups != nullptr) { + backups->clear(); + } + root_.reset(); + if (path_.empty()) { + preserve_ = false; + ClearActiveRecoveryEvidence(); + return true; + } + std::error_code removalError; + std::filesystem::remove_all(path_, removalError); + std::error_code presenceError; + const bool remains = std::filesystem::exists(path_, presenceError); + if (removalError || presenceError || remains) { + preserve_ = true; + const DWORD code = removalError + ? static_cast(removalError.value()) + : presenceError ? static_cast(presenceError.value()) + : ERROR_DIR_NOT_EMPTY; + SetError(error, L"rollback-backup-cleanup", code, + L"protected rollback backup cleanup could not be verified"); + AttachRecoveryRecord(error); + return false; + } + path_.clear(); + preserve_ = false; + ClearActiveRecoveryEvidence(); + return true; + } + private: std::filesystem::path path_; WinHandle parent_; WinHandle root_; + bool preserve_ = false; }; bool BackupPackages( @@ -2906,56 +3998,38 @@ bool BackupPackages( return false; } const std::filesystem::path destination = root->path() / std::to_wstring(index); - std::error_code copyError; - std::filesystem::create_directory(destination, copyError); - if (copyError) { - return SetError(error, L"rollback-backup-create", static_cast(copyError.value())); - } - for (std::filesystem::recursive_directory_iterator iterator(storeInf.parent_path(), copyError), end; - iterator != end && !copyError; iterator.increment(copyError)) { - const std::filesystem::path relative = - std::filesystem::relative(iterator->path(), storeInf.parent_path(), copyError); - if (copyError) break; - const std::filesystem::path target = destination / relative; - if (iterator->is_directory()) { - std::filesystem::create_directories(target, copyError); - } else if (iterator->is_regular_file()) { - std::filesystem::create_directories(target.parent_path(), copyError); - if (!copyError) { - std::filesystem::copy_file(iterator->path(), target, - std::filesystem::copy_options::overwrite_existing, copyError); - } - } - } - if (copyError) { - return SetError(error, L"rollback-backup-copy", static_cast(copyError.value())); - } std::filesystem::path signerCatalog; - if (!VerifyInfSignature(packages[index].infPath, &signerCatalog, error)) { + if (!VerifyInfSignature(storeInf, &signerCatalog, error)) { return false; } - const std::filesystem::path adjacentCatalog = destination / kCatalogName; - const bool hasAdjacentCatalog = std::filesystem::is_regular_file(adjacentCatalog, copyError); - if (copyError) { - return SetError(error, L"rollback-backup-catalog", static_cast(copyError.value())); - } - if (!hasAdjacentCatalog) { - if (signerCatalog.empty() || !std::filesystem::is_regular_file(signerCatalog, copyError)) { - return SetError(error, L"rollback-backup-catalog", ERROR_FILE_NOT_FOUND, - L"cannot construct a self-contained signed rollback package"); - } - std::filesystem::copy_file(signerCatalog, adjacentCatalog, - std::filesystem::copy_options::overwrite_existing, copyError); - if (copyError) { - return SetError(error, L"rollback-backup-catalog", static_cast(copyError.value())); - } + if (signerCatalog.is_relative()) { + signerCatalog = storeInf.parent_path() / signerCatalog.filename(); + } + if (signerCatalog.empty()) { + return SetError(error, L"rollback-backup-catalog", ERROR_FILE_NOT_FOUND, + L"signed rollback package did not resolve a catalog payload"); + } + const std::filesystem::path backupInf = destination / L"ViiperUde.inf"; + if (!CreateProtectedBackupDirectory(destination, error) || + !CopyProtectedBackupFile(storeInf, backupInf, error) || + !CopyProtectedBackupFile( + storeInf.parent_path() / kDriverFileName, + destination / kDriverFileName, error) || + !CopyProtectedBackupFile( + signerCatalog, destination / kCatalogName, error) || + !ValidateExactPackageDirectory(destination, error)) { + return false; } - const std::filesystem::path backupInf = destination / storeInf.filename(); PackageInfo verified; bool owned = false; if (!LoadOwnedPackage(backupInf, true, &verified, &owned, error) || !owned) { return false; } + if (!(verified.version == packages[index].version) || + !SamePackageBytes(verified, packages[index])) { + return SetError(error, L"rollback-backup-identity", ERROR_REVISION_MISMATCH, + L"protected rollback copy does not match the captured signed package"); + } std::vector locks; if (!LockPackageFiles(destination, &locks, error)) { error->phase = L"rollback-backup-lock"; @@ -2967,6 +4041,357 @@ bool BackupPackages( return true; } +bool IsSha256Digest(std::string_view value) { + return value.size() == 64 && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isxdigit(character) != 0; + }); +} + +void AppendJsonString(std::string* output, std::wstring_view value) { + static constexpr char digits[] = "0123456789abcdef"; + output->push_back('"'); + for (wchar_t character : value) { + const uint32_t codePoint = static_cast(character); + if (codePoint == '"' || codePoint == '\\') { + output->push_back('\\'); + output->push_back(static_cast(codePoint)); + } else if (codePoint >= 0x20U && codePoint <= 0x7eU) { + output->push_back(static_cast(codePoint)); + } else if (codePoint <= 0xffffU) { + output->append("\\u"); + output->push_back(digits[(codePoint >> 12U) & 0x0fU]); + output->push_back(digits[(codePoint >> 8U) & 0x0fU]); + output->push_back(digits[(codePoint >> 4U) & 0x0fU]); + output->push_back(digits[codePoint & 0x0fU]); + } else { + const uint32_t supplementary = codePoint - 0x10000U; + const uint32_t high = 0xd800U + (supplementary >> 10U); + const uint32_t low = 0xdc00U + (supplementary & 0x3ffU); + for (uint32_t surrogate : {high, low}) { + output->append("\\u"); + output->push_back(digits[(surrogate >> 12U) & 0x0fU]); + output->push_back(digits[(surrogate >> 8U) & 0x0fU]); + output->push_back(digits[(surrogate >> 4U) & 0x0fU]); + output->push_back(digits[surrogate & 0x0fU]); + } + } + } + output->push_back('"'); +} + +void AppendJsonAsciiString(std::string* output, std::string_view value) { + std::wstring wide; + wide.reserve(value.size()); + for (unsigned char character : value) { + wide.push_back(static_cast(character)); + } + AppendJsonString(output, wide); +} + +bool IsSafeRecoveryRelativePath(const std::filesystem::path& path) { + if (path.empty() || path.is_absolute() || path.has_root_name() || + path.has_root_directory() || path.lexically_normal() != path) { + return false; + } + size_t components = 0; + for (const std::filesystem::path& component : path) { + const std::wstring value = component.wstring(); + if (value.empty() || value == L"." || value == L".." || + value.find(L':') != std::wstring::npos || + std::any_of(value.begin(), value.end(), [](wchar_t character) { + return character < 0x20; + })) { + return false; + } + ++components; + } + return components != 0; +} + +bool RecoveryRelativePath( + const std::filesystem::path& root, + const std::filesystem::path& target, + std::wstring* relative, + Error* error) { + const std::filesystem::path candidate = target.lexically_relative(root); + if (!IsSafeRecoveryRelativePath(candidate) || + (root / candidate).lexically_normal() != target.lexically_normal()) { + return SetError(error, L"recovery-record-path", ERROR_INVALID_NAME, + L"rollback recovery paths must remain relative to the protected backup root"); + } + *relative = candidate.generic_wstring(); + return true; +} + +bool BuildRemoveRecoveryRecord( + const Snapshot& prior, + const std::vector& backups, + const std::filesystem::path& root, + std::string* record, + Error* error) { + if (backups.size() != prior.packages.size()) { + return SetError(error, L"recovery-record-binding", ERROR_INVALID_DATA, + L"rollback backup count does not match the captured package inventory"); + } + record->clear(); + record->append( + "{\"schema\":1,\"kind\":\"VIIPER-UDE-remove-rollback-recovery\"," + "\"state\":\"prepared-remove-transaction\"," + "\"hardwareId\":\"ROOT\\\\VIIPER\\\\UDE\",\"automaticRestore\":false," + "\"requiredValidation\":[\"inf-signature\",\"inf-catalog-membership\"," + "\"sys-catalog-membership\",\"inf-sha256\",\"sys-sha256\",\"cat-sha256\"]," + "\"devices\":["); + for (size_t index = 0; index < prior.devices.size(); ++index) { + const DeviceState& device = prior.devices[index]; + size_t packageIndex = prior.packages.size(); + size_t packageMatches = 0; + for (size_t candidate = 0; candidate < prior.packages.size(); ++candidate) { + const PackageInfo& package = prior.packages[candidate]; + if (_wcsicmp(package.publishedName.c_str(), device.publishedInf.c_str()) == 0 && + package.version == device.version && + SamePackageBytes(package, device.package)) { + packageIndex = candidate; + ++packageMatches; + } + } + if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || + !IsSafePublishedInfName(device.publishedInf) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + !(device.version == device.package.version) || + _wcsicmp(device.package.publishedName.c_str(), device.publishedInf.c_str()) != 0 || + packageMatches != 1 || packageIndex >= backups.size() || + _wcsicmp(backups[packageIndex].original.publishedName.c_str(), + device.publishedInf.c_str()) != 0 || + !(backups[packageIndex].original.version == device.version) || + !SamePackageBytes(backups[packageIndex].original, device.package) || + !IsSha256Digest(device.package.infSha256) || + !IsSha256Digest(device.package.sysSha256) || + !IsSha256Digest(device.package.catSha256)) { + return SetError(error, L"recovery-record-device", ERROR_INVALID_DATA, + L"captured devnode identity is not safe for a recovery record"); + } + if (index != 0) record->push_back(','); + record->append("{\"instanceId\":"); + AppendJsonString(record, device.instanceId); + record->append(",\"present\":"); + record->append(device.present ? "true" : "false"); + record->append(",\"started\":"); + record->append(device.started ? "true" : "false"); + record->append(",\"problem\":"); + record->append(std::to_string(device.problem)); + record->append(",\"service\":"); + AppendJsonString(record, device.service); + record->append(",\"publishedInf\":"); + AppendJsonString(record, device.publishedInf); + record->append(",\"packageIndex\":"); + record->append(std::to_string(packageIndex)); + record->append(",\"version\":"); + AppendJsonString(record, VersionToString(device.version)); + record->append(",\"infSha256\":"); + AppendJsonAsciiString(record, LowerAscii(device.package.infSha256)); + record->append(",\"sysSha256\":"); + AppendJsonAsciiString(record, LowerAscii(device.package.sysSha256)); + record->append(",\"catSha256\":"); + AppendJsonAsciiString(record, LowerAscii(device.package.catSha256)); + record->push_back('}'); + } + record->append("],\"packages\":["); + for (size_t index = 0; index < prior.packages.size(); ++index) { + const PackageInfo& package = prior.packages[index]; + const PackageBackup& backup = backups[index]; + const bool duplicatePublishedName = std::any_of( + prior.packages.begin(), prior.packages.end(), [&](const PackageInfo& candidate) { + return &candidate != &package && + _wcsicmp(candidate.publishedName.c_str(), package.publishedName.c_str()) == 0; + }); + if (!IsSafePublishedInfName(package.publishedName) || + duplicatePublishedName || + !(backup.original.version == package.version) || + _wcsicmp(backup.original.publishedName.c_str(), package.publishedName.c_str()) != 0 || + backup.infPath.parent_path() != backup.directory || + _wcsicmp(backup.infPath.filename().c_str(), L"ViiperUde.inf") != 0 || + !SamePackageBytes(backup.original, package) || + !IsSha256Digest(package.infSha256) || + !IsSha256Digest(package.sysSha256) || + !IsSha256Digest(package.catSha256)) { + return SetError(error, L"recovery-record-package", ERROR_INVALID_DATA, + L"protected rollback package does not match the captured inventory"); + } + std::wstring relativeDirectory; + std::wstring relativeInf; + std::wstring relativeSys; + std::wstring relativeCat; + if (!RecoveryRelativePath(root, backup.directory, &relativeDirectory, error) || + relativeDirectory != std::to_wstring(index) || + !RecoveryRelativePath(root, backup.infPath, &relativeInf, error) || + !RecoveryRelativePath(root, backup.directory / kDriverFileName, &relativeSys, error) || + !RecoveryRelativePath(root, backup.directory / kCatalogName, &relativeCat, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"recovery-record-path", ERROR_INVALID_NAME); + } + return false; + } + if (index != 0) record->push_back(','); + record->append("{\"publishedInf\":"); + AppendJsonString(record, package.publishedName); + record->append(",\"version\":"); + AppendJsonString(record, VersionToString(package.version)); + record->append(",\"infSha256\":"); + AppendJsonAsciiString(record, LowerAscii(package.infSha256)); + record->append(",\"sysSha256\":"); + AppendJsonAsciiString(record, LowerAscii(package.sysSha256)); + record->append(",\"catSha256\":"); + AppendJsonAsciiString(record, LowerAscii(package.catSha256)); + record->append(",\"backupInf\":"); + AppendJsonString(record, relativeInf); + record->append(",\"backupSys\":"); + AppendJsonString(record, relativeSys); + record->append(",\"backupCat\":"); + AppendJsonString(record, relativeCat); + record->push_back('}'); + } + record->append("]}\n"); + if (record->size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"recovery-record-size", ERROR_FILE_TOO_LARGE); + } + return true; +} + +bool WriteProtectedRecoveryRecord( + const std::filesystem::path& path, + std::string_view record, + Error* error) { + if (path.filename() != kRecoveryRecordName || + record.empty() || record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"recovery-record-create", ERROR_INVALID_PARAMETER); + } + const std::filesystem::path temporaryPath = + path.parent_path() / kRecoveryRecordTemporaryName; + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"recovery-record-security", error)) { + return false; + } + WinHandle file(CreateFileW(temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | + FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + const DWORD createError = GetLastError(); + if (!file) { + return SetError(error, L"recovery-record-create", createError); + } + const auto discardTemporary = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + const BOOL queriedAttributes = GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); + const DWORD attributeError = queriedAttributes ? ERROR_SUCCESS : GetLastError(); + if (!queriedAttributes || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + const DWORD code = queriedAttributes + ? ERROR_REPARSE_TAG_MISMATCH : attributeError; + SetError(error, L"recovery-record-create", code, + L"recovery record must be a regular non-reparse file"); + discardTemporary(); + return false; + } + if (!VerifyProtectedFileSystemSecurity( + file.get(), false, L"recovery-record-security", error)) { + discardTemporary(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + DWORD written = 0; + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD writeError = GetLastError(); + const DWORD code = writeError == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : writeError; + SetError(error, L"recovery-record-write", code); + discardTemporary(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"recovery-record-flush"); + discardTemporary(); + return false; + } + file.reset(); + if (!MoveFileExW( + temporaryPath.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"recovery-record-publish", code); + } + + file.reset(CreateFileW(path.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | + FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"recovery-record-reopen"); + } + attributes = {}; + const BOOL queriedPublished = GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); + const DWORD publishedQueryError = queriedPublished + ? ERROR_SUCCESS : GetLastError(); + if (!queriedPublished || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + const DWORD code = queriedPublished + ? ERROR_REPARSE_TAG_MISMATCH : publishedQueryError; + return SetError(error, L"recovery-record-reopen", code, + L"published recovery record must be a regular non-reparse file"); + } + if (!VerifyProtectedFileSystemSecurity( + file.get(), false, L"recovery-record-security", error)) { + return false; + } + offset = 0; + std::array verification{}; + while (offset < record.size()) { + const DWORD requested = static_cast(std::min( + verification.size(), record.size() - offset)); + DWORD read = 0; + if (!ReadFile(file.get(), verification.data(), requested, &read, nullptr)) { + return SetLastErrorDetail(error, L"recovery-record-verify"); + } + if (read != requested || + std::memcmp(verification.data(), record.data() + offset, read) != 0) { + return SetError(error, L"recovery-record-verify", ERROR_CRC, + L"published recovery record bytes do not match the flushed transaction journal"); + } + offset += read; + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr)) { + return SetLastErrorDetail(error, L"recovery-record-verify"); + } + if (trailingRead != 0) { + return SetError(error, L"recovery-record-verify", ERROR_FILE_INVALID, + L"published recovery record contains trailing bytes"); + } + if (!FlushFileBuffers(file.get())) { + return SetLastErrorDetail(error, L"recovery-record-published-flush"); + } + return true; +} + bool RollbackRemove( const Snapshot& prior, const std::vector& backups, @@ -2979,6 +4404,7 @@ bool RollbackRemove( return false; } BOOL reboot = FALSE; + MarkTransactionMutationStarted(); if (!DiInstallDriverW(nullptr, backup.infPath.c_str(), 0, &reboot)) { return SetLastErrorDetail(error, L"remove-rollback-package"); } @@ -2996,7 +4422,7 @@ bool RollbackRemove( for (DeviceState& device : restorablePrior.devices) { const auto package = std::find_if(reinstalledPackages.begin(), reinstalledPackages.end(), [&](const PackageInfo& candidate) { - return candidate.infSha256 == device.package.infSha256 && + return SamePackageBytes(candidate, device.package) && candidate.version == device.package.version; }); if (package == reinstalledPackages.end()) { @@ -3006,7 +4432,8 @@ bool RollbackRemove( device.package = *package; device.publishedInf = package->publishedName; } - if (!RestorePriorBinding(restorablePrior, rebootRequired, error)) { + if (!RestorePriorBinding( + restorablePrior, rollbackDeadlineUnixMs, rebootRequired, error)) { return false; } @@ -3021,10 +4448,10 @@ bool RollbackRemove( std::multiset> expectedPackages; std::multiset> actualPackages; for (const PackageInfo& package : prior.packages) { - expectedPackages.emplace(package.version, package.infSha256); + expectedPackages.emplace(package.version, PackageBytesKey(package)); } for (const PackageInfo& package : restored.packages) { - actualPackages.emplace(package.version, package.infSha256); + actualPackages.emplace(package.version, PackageBytesKey(package)); } if (expectedPackages != actualPackages || restored.devices.size() != prior.devices.size()) { return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, @@ -3032,7 +4459,7 @@ bool RollbackRemove( } if (!prior.devices.empty()) { if (_wcsicmp(restored.devices[0].instanceId.c_str(), prior.devices[0].instanceId.c_str()) != 0 || - restored.devices[0].package.infSha256 != prior.devices[0].package.infSha256) { + !SamePackageBytes(restored.devices[0].package, prior.devices[0].package)) { return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, L"rollback restored a different devnode identity or active package"); } @@ -3095,19 +4522,42 @@ Outcome Remove(const RemoveOptions& options) { } BackupDirectory backupRoot; std::vector backups; - if (!BackupPackages(prior.packages, &backupRoot, &backups, &outcome.error)) { + const auto rejectBeforeMutation = [&](Error failure) { + Error cleanupError; + if (!backupRoot.Cleanup(&backups, &cleanupError)) { + outcome.error = std::move(cleanupError); + } else { + outcome.error = std::move(failure); + } outcome.exitCode = ExitCode::PreflightRejected; + }; + if (!BackupPackages(prior.packages, &backupRoot, &backups, &outcome.error)) { + Error failure = std::move(outcome.error); + rejectBeforeMutation(std::move(failure)); + return outcome; + } + std::string recoveryRecord; + const std::filesystem::path recoveryPath = backupRoot.RecoveryRecordPath(); + Error recoveryError; + if (!BuildRemoveRecoveryRecord( + prior, backups, backupRoot.path(), &recoveryRecord, &recoveryError) || + !WriteProtectedRecoveryRecord(recoveryPath, recoveryRecord, &recoveryError) || + !backupRoot.ArmPreservation(recoveryPath, &recoveryError)) { + rejectBeforeMutation(std::move(recoveryError)); return outcome; } if (!CheckTransactionDeadline( options.transactionDeadlineUnixMs, L"remove-deadline-before-device", &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; + Error failure = std::move(outcome.error); + rejectBeforeMutation(std::move(failure)); return outcome; } - outcome.changed = true; + bool mutationStarted = false; bool reboot = false; Error mutationError; - bool mutationSucceeded = RemoveAllExactDevices(&reboot, &mutationError); + bool mutationSucceeded = RemoveAllExactDevices( + options.transactionDeadlineUnixMs, &mutationStarted, &reboot, &mutationError); + outcome.changed = mutationStarted; if (mutationSucceeded && !CheckTransactionDeadline( options.transactionDeadlineUnixMs, L"remove-deadline-after-device", &mutationError)) { mutationSucceeded = false; @@ -3119,6 +4569,8 @@ Outcome Remove(const RemoveOptions& options) { mutationSucceeded = false; break; } + mutationStarted = true; + outcome.changed = true; if (!UninstallPackage(package, &reboot, &mutationError)) { mutationSucceeded = false; break; @@ -3147,6 +4599,10 @@ Outcome Remove(const RemoveOptions& options) { } } if (!mutationSucceeded) { + if (!mutationStarted) { + rejectBeforeMutation(std::move(mutationError)); + return outcome; + } Error rollbackError; bool rollbackReboot = reboot; // Forward work owns the caller's absolute deadline. Rollback receives @@ -3159,15 +4615,29 @@ Outcome Remove(const RemoveOptions& options) { prior, backups, rollbackDeadline, &rollbackReboot, &rollbackError)) { outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; - outcome.error = mutationError; + Error cleanupError; + if (!backupRoot.Cleanup(&backups, &cleanupError)) { + outcome.error = std::move(cleanupError); + return outcome; + } + outcome.error = std::move(mutationError); return outcome; } + backupRoot.AttachRecoveryRecord(&rollbackError); outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(rollbackError); outcome.exitCode = ExitCode::RollbackFailed; return outcome; } + Error cleanupError; + if (!backupRoot.Cleanup(&backups, &cleanupError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = reboot; + outcome.error = std::move(cleanupError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.success = true; outcome.rebootRequired = reboot; outcome.exitCode = reboot ? ExitCode::RebootRequired : ExitCode::Success; @@ -3215,12 +4685,100 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-version", ERROR_INVALID_DATA); return outcome; } + PackageInfo candidate; + candidate.version = two; + candidate.infSha256 = "candidate-inf"; + candidate.sysSha256 = "candidate-sys"; + candidate.catSha256 = "candidate-cat"; + CandidateDisposition disposition = CandidateDisposition::Exact; + bool downgrade = true; + Error classificationError; + if (!ClassifyCandidatePackage( + candidate, {}, std::nullopt, &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::InstallRequired || downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"an absent candidate was not classified as an install"); + return outcome; + } + PackageInfo exact = candidate; + classificationError = {}; + if (!ClassifyCandidatePackage( + candidate, {exact}, std::nullopt, &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::Exact || downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"an exact same-version candidate was not classified as repair-only"); + return outcome; + } + PackageInfo conflict = candidate; + conflict.infSha256 = "different-inf"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version content replacement was not rejected"); + return outcome; + } + conflict = candidate; + conflict.sysSha256 = "different-sys"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version SYS replacement was not rejected"); + return outcome; + } + conflict = candidate; + conflict.catSha256 = "different-cat"; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {conflict}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"same-version catalog replacement was not rejected"); + return outcome; + } + PackageInfo newer = candidate; + newer.version.parts[3] += 1; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {newer}, std::nullopt, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"implicit downgrade was not rejected"); + return outcome; + } + classificationError = {}; + if (!ClassifyCandidatePackage( + candidate, {newer}, newer.version, + &disposition, &downgrade, &classificationError) || + disposition != CandidateDisposition::InstallRequired || !downgrade) { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"exact controlled-downgrade guard was not honored"); + return outcome; + } + Version wrongDowngradeGuard = newer.version; + ++wrongDowngradeGuard.parts[3]; + classificationError = {}; + if (ClassifyCandidatePackage( + candidate, {newer}, wrongDowngradeGuard, + &disposition, &downgrade, &classificationError) || + classificationError.phase != L"version-policy") { + SetError(&outcome.error, L"self-test-package-classification", ERROR_INVALID_DATA, + L"incorrect controlled-downgrade guard was accepted"); + return outcome; + } std::string buildIdentity; if (!DeriveDriverBuildIdentity( "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "285af3f561a066e0298411cbc7432ae9e804109e8911a18212513cf945f712ed") { + "5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } @@ -3235,6 +4793,15 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-contract", ERROR_INVALID_DATA); return outcome; } + if (!IsOwnedGeneratedRootInstanceId(L"ROOT\\VIIPERUDE\\0000") || + !IsOwnedGeneratedRootInstanceId(L"root\\usb\\0042") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\VIIPER\\UDE\\0000") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\USB\\42") || + IsOwnedGeneratedRootInstanceId(L"ROOT\\USB\\00A0")) { + SetError(&outcome.error, L"self-test-root-instance-id", ERROR_INVALID_DATA, + L"generated root instance namespace validation is not exact"); + return outcome; + } PackageInfo priorPackage; priorPackage.publishedName = L"OEM7.INF"; PackageInfo preservedPackage; @@ -3247,6 +4814,74 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-rollback-cleanup", ERROR_INVALID_DATA); return outcome; } + const std::filesystem::path recoveryRoot = + LR"(C:\Windows\Temp\VIIPER-UDE-rollback-self-test)"; + if (!IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L"ViiperUde.inf") || + IsSafeRecoveryRelativePath(std::filesystem::path(L"..") / L"escape") || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L".." / L"escape") || + IsSafeRecoveryRelativePath(std::filesystem::path(LR"(C:\escape)")) || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"0") / L"ViiperUde.inf:stream")) { + SetError(&outcome.error, L"self-test-recovery-path", ERROR_INVALID_DATA, + L"rollback recovery relative-path validation is not fail-closed"); + return outcome; + } + PackageInfo recoveryPackage; + recoveryPackage.infPath = LR"(C:\Windows\INF\oem7.inf)"; + recoveryPackage.publishedName = L"oem7.inf"; + recoveryPackage.version.parts = {0, 1, 0, 6}; + recoveryPackage.infSha256 = std::string(64, 'A'); + recoveryPackage.sysSha256 = std::string(64, 'B'); + recoveryPackage.catSha256 = std::string(64, 'C'); + DeviceState recoveryDevice; + recoveryDevice.instanceId = LR"(ROOT\VIIPERUDE\0000)"; + recoveryDevice.present = true; + recoveryDevice.started = true; + recoveryDevice.service = kServiceName; + recoveryDevice.publishedInf = recoveryPackage.publishedName; + recoveryDevice.version = recoveryPackage.version; + recoveryDevice.package = recoveryPackage; + Snapshot recoverySnapshot; + recoverySnapshot.devices.push_back(std::move(recoveryDevice)); + recoverySnapshot.packages.push_back(recoveryPackage); + std::vector recoveryBackups; + recoveryBackups.push_back(PackageBackup{ + recoveryPackage, + recoveryRoot / L"0", + recoveryRoot / L"0" / L"ViiperUde.inf", + {}}); + std::string firstRecoveryRecord; + std::string secondRecoveryRecord; + Error recoveryRecordError; + JsonValue recoveryRecordValue; + std::string recoveryRecordParseError; + if (!BuildRemoveRecoveryRecord( + recoverySnapshot, recoveryBackups, recoveryRoot, + &firstRecoveryRecord, &recoveryRecordError) || + !BuildRemoveRecoveryRecord( + recoverySnapshot, recoveryBackups, recoveryRoot, + &secondRecoveryRecord, &recoveryRecordError) || + firstRecoveryRecord != secondRecoveryRecord || + !JsonParser(firstRecoveryRecord).Parse( + &recoveryRecordValue, &recoveryRecordParseError) || + firstRecoveryRecord.find("\"automaticRestore\":false") == std::string::npos || + firstRecoveryRecord.find( + "\"requiredValidation\":[\"inf-signature\"") == std::string::npos || + firstRecoveryRecord.find("\"state\":\"prepared-remove-transaction\"") == + std::string::npos || + firstRecoveryRecord.find("\"packageIndex\":0") == std::string::npos || + firstRecoveryRecord.find("\"backupInf\":\"0/ViiperUde.inf\"") == + std::string::npos || + firstRecoveryRecord.find("C:") != std::string::npos) { + if (recoveryRecordError.code == ERROR_SUCCESS) { + SetError(&recoveryRecordError, L"self-test-recovery-record", ERROR_INVALID_DATA, + L"rollback recovery record is not canonical and relative-path bound"); + } + outcome.error = std::move(recoveryRecordError); + return outcome; + } if (!IsSafeTargetUserSid(L"S-1-5-21-1-2-3-1001") || IsSafeTargetUserSid(L"S-1-5-21-bad") || QuoteWindowsArgument(LR"(C:\Program Files\VIIPER\viiper.exe)") != @@ -3255,6 +4890,88 @@ Outcome SelfTest() { SetError(&outcome.error, L"self-test-broker-command", ERROR_INVALID_DATA); return outcome; } + const std::string brokerSuccess = + "result=success operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=0\n"; + BrokerCommitProof brokerProof; + Error brokerProofError; + if (!ParseBrokerCommitProof( + brokerSuccess, ERROR_SUCCESS, &brokerProof, &brokerProofError) || + !brokerProof.success || brokerProof.changed || + brokerProof.driverRollbackAuthorized || + brokerProof.rollback != "not-needed") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"valid broker success proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + "result=error operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=4\n", + 4, &brokerProof, &brokerProofError) || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"pre-mutation broker failure proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + "result=error operation=native-package-broker-commit changed=1 " + "rollback=succeeded exitCode=1\n", + 1, &brokerProof, &brokerProofError) || + brokerProof.success || !brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"settled broker rollback proof was rejected or misclassified"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + "result=error operation=native-package-broker-commit changed=1 " + "rollback=failed exitCode=3\n", + 3, &brokerProof, &brokerProofError) || + brokerProof.success || !brokerProof.changed || + brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"indeterminate broker rollback proof was not kept fail-closed"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + brokerSuccess + brokerSuccess, ERROR_SUCCESS, + &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"duplicate broker outcomes were not rejected"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + "result=error exitCode=04 rollback=not-needed changed=0 " + "operation=native-package-broker-commit\n", + 4, &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"noncanonical broker outcome was accepted"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (ParseBrokerCommitProof( + "result=error operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=4", + 4, &brokerProof, &brokerProofError) || + brokerProofError.phase != L"broker-proof") { + SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, + L"unterminated broker outcome was accepted"); + return outcome; + } if (!IsProductionHardwareVerificationUsage({kHardwareVerificationOid}) || IsProductionHardwareVerificationUsage( {kHardwareVerificationOid, kAttestationVerificationOid}) || @@ -3277,6 +4994,9 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro bool manifestHashSeen = false; bool revisionSeen = false; bool modeSeen = false; + bool infHashSeen = false; + bool sysHashSeen = false; + bool catHashSeen = false; bool brokerSeen = false; bool brokerHashSeen = false; bool brokerTokenSeen = false; @@ -3336,6 +5056,27 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro L"validation mode must be production or controlled-test"); } modeSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-inf-sha256") == 0 && + index + 1 < argc && !infHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime INF", &options->expectedInfSha256, error)) { + return false; + } + infHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-sys-sha256") == 0 && + index + 1 < argc && !sysHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime SYS", &options->expectedSysSha256, error)) { + return false; + } + sysHashSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--expected-cat-sha256") == 0 && + index + 1 < argc && !catHashSeen) { + if (!CopySha256Argument( + argv[++index], L"runtime CAT", &options->expectedCatSha256, error)) { + return false; + } + catHashSeen = true; } else if (_wcsicmp(argument.c_str(), L"--allow-controlled-downgrade") == 0 && index + 1 < argc && !options->expectedDowngradeFrom) { Version expected{}; @@ -3420,11 +5161,12 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro } } if (!manifestSeen || !manifestHashSeen || !revisionSeen || !modeSeen || + !infHashSeen || !sysHashSeen || !catHashSeen || !transactionDeadlineSeen || brokerSeen != targetUserSeen || brokerSeen != brokerHashSeen || brokerSeen != brokerTokenSeen || brokerSeen != brokerTokenHashSeen) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, - L"manifest, its installer hash, source revision, and validation mode are required; broker executable, hashes, protected token, and target SID must be supplied together"); + L"manifest, its installer hash, source revision, validation mode, and exact INF/SYS/CAT hashes are required; broker executable, hashes, protected token, and target SID must be supplied together"); } return true; } @@ -3465,6 +5207,8 @@ void Usage() { << L"usage:\n" << L" ViiperUdeCtl.exe install --manifest --manifest-sha256 <64 hex> " L"--source-revision <40-or-64 hex> --validation-mode " + L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " + L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms " L"[--allow-controlled-downgrade ] " L"--broker-executable --broker-sha256 <64 hex> " @@ -3472,6 +5216,8 @@ void Usage() { L"--target-user-sid \n" << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " L"--source-revision <40-or-64 hex> --validation-mode " + L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " + L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe status\n" @@ -3480,7 +5226,9 @@ void Usage() { } // namespace -int wmain(int argc, wchar_t** argv) { +int RunViiperUdeCtl(int argc, wchar_t** argv) { + ClearActiveRecoveryEvidence(); + gTransactionMutationStarted = false; if (argc >= 3 && (_wcsicmp(argv[1], L"install") == 0 || _wcsicmp(argv[1], L"verify") == 0)) { InstallOptions options; @@ -3502,7 +5250,8 @@ int wmain(int argc, wchar_t** argv) { EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } - Outcome outcome = _wcsicmp(argv[1], L"verify") == 0 ? Verify(options) : Install(options); + Outcome outcome = + _wcsicmp(argv[1], L"verify") == 0 ? Verify(options) : Install(options); EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } @@ -3538,3 +5287,50 @@ int wmain(int argc, wchar_t** argv) { EmitOutcome(L"unknown", outcome); return static_cast(outcome.exitCode); } + +const wchar_t* ExceptionOperation(int argc, wchar_t** argv) noexcept { + if (argc < 2 || argv == nullptr || argv[1] == nullptr) { + return L"unknown"; + } + for (const wchar_t* operation : + {L"install", L"verify", L"remove", L"status", L"self-test"}) { + if (_wcsicmp(argv[1], operation) == 0) { + return operation; + } + } + return L"unknown"; +} + +int wmain(int argc, wchar_t** argv) { + try { + return RunViiperUdeCtl(argc, argv); + } catch (...) { + const wchar_t* operation = ExceptionOperation(argc, argv); + const bool changed = gTransactionMutationStarted; + const ExitCode exitCode = changed + ? ExitCode::RollbackFailed : ExitCode::PreflightRejected; + std::fwprintf(stderr, + L"result=error operation=%ls changed=%d rebootRequired=0 " + L"rollback=%ls exitCode=%d phase=\"unhandled-cpp-exception\" " + L"win32Error=%lu message=\"%ls\"", + operation, changed ? 1 : 0, changed ? L"failed" : L"not-needed", + static_cast(exitCode), static_cast(ERROR_GEN_FAILURE), + changed + ? L"unhandled C++ exception after transaction mutation; external reconciliation is required" + : L"unhandled C++ exception before transaction mutation"); + if (gActiveRecoveryRecord[0] != L'\0') { + std::fwprintf(stderr, + L" recoveryRecord=\"%ls\" recoveryRecordWritten=%d", + gActiveRecoveryRecord.data(), + gActiveRecoveryRecordWritten ? 1 : 0); + } + if (gActiveBackupRootRetained && gActiveBackupRoot[0] != L'\0') { + std::fwprintf(stderr, + L" recoveryBackup=\"%ls\" recoveryBackupRetained=1", + gActiveBackupRoot.data()); + } + std::fwprintf(stderr, L"\n"); + std::fflush(stderr); + return static_cast(exitCode); + } +} From 44154259ecb6abad27d9a630ee1e6abd2c10a5be Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 15:55:35 -0500 Subject: [PATCH 179/240] Add source-bound native UDE local test route --- .github/scripts/Test-WorkflowSecurity.ps1 | 14 +- .github/workflows/native-ude.yml | 55 ++++- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- docs/architecture/native-udecx-signing.md | 33 ++- internal/cmd/native_package.go | 14 +- internal/cmd/native_package_test.go | 6 +- internal/cmd/native_package_windows.go | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/live_validation_contract_test.go | 6 +- .../udecx/local_test_package_contract_test.go | 116 ++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/README.md | 40 ++++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Enable-ViiperUdeVerifierForNextBoot.ps1 | 9 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 200 ++++++++++++++++++ .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 37 +++- .../Invoke-ViiperUdePerformanceValidation.ps1 | 5 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 182 ++++++++++++++++ .../tools/Test-ViiperUdeCtlTransaction.ps1 | 5 + .../tools/Test-ViiperUdeSignedPackage.ps1 | 119 ++++++++--- native/udecx/tools/ViiperUdeCtl.cpp | 37 +++- 25 files changed, 824 insertions(+), 72 deletions(-) create mode 100644 internal/transport/udecx/local_test_package_contract_test.go create mode 100644 native/udecx/tools/Install-ViiperUdeLocalTest.ps1 create mode 100644 native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 9b100bc6..cfbd2c7f 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,14 +147,24 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe', + 'ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', - 'inputs.upload_release_helper == true')) { + 'inputs.upload_release_helper == true', + 'New-ViiperUdeLocalTestPackage.ps1', + 'ViiperUde-x64-local-test-${{ github.sha }}', + 'native/udecx/x64/Release/ViiperUdeLocalTest/**', + 'retention-days: 7', + 'internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA')) { if (-not $nativeWorkflow.Contains($requiredNativeGate)) { throw "The native build workflow is missing gate '$requiredNativeGate'." } } +if ($nativeWorkflow.Contains('native/udecx/x64/Release/**') -or + $nativeWorkflow.Contains('native/udecx/driver/x64/Release/**') -or + $nativeWorkflow.Contains('native/udecx/package/x64/Release/**')) { + throw 'The local-test artifact must not upload broad compiler output trees.' +} $baseBuildWorkflow = Get-Content -LiteralPath (Join-Path $workflowDirectory 'build_base.yml') -Raw if (-not $baseBuildWorkflow.Contains('VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}')) { diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 720d3500..9c7a3c77 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -62,6 +62,17 @@ on: type: boolean default: false workflow_dispatch: + inputs: + upload_artifacts: + description: Upload the compact source-bound local test package. + required: false + type: boolean + default: true + upload_release_helper: + description: Upload source-bound helpers and live probes separately. + required: false + type: boolean + default: false permissions: actions: read @@ -97,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.6 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 - if ($identity -cne '5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe') { + -DriverPackageVersion 0.1.0.7 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + if ($identity -cne 'ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] @@ -153,6 +164,10 @@ jobs: runs-on: windows-2025-vs2026 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: c-cpp @@ -254,6 +269,18 @@ jobs: $probeManifestPath = Join-Path $outputDir 'ViiperUdeLiveProbes.manifest.json' $probeManifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $probeManifestPath -Encoding utf8NoBOM if (-not (Test-Path -LiteralPath $probeManifestPath -PathType Leaf)) { throw "Live-probe manifest was not created" } + - name: Build source-bound native broker + shell: pwsh + run: | + $output = 'native/udecx/x64/Release/viiper.exe' + $env:CGO_ENABLED = '0' + $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + go build -tags release -trimpath ` + -ldflags "-s -w -X main.Version=0.1.0-local-test -X main.Commit=$env:GITHUB_SHA -X main.Date=$buildDate -X github.com/Alia5/VIIPER/internal/codegen/common.Version=0.1.0-local-test -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA" ` + -o $output ./cmd/viiper + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw 'Source-bound native broker build failed.' + } - name: Upload source-bound native live probes if: ${{ inputs.upload_release_helper == true }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -284,6 +311,22 @@ jobs: -OutputPath native/udecx/x64/Release/ViiperUdeAttestationStructure.cab ` -SourceRevision $env:GITHUB_SHA ` -AcknowledgeTestingOnly + - name: Compose compact source-bound local test package + if: ${{ inputs.upload_artifacts == true }} + shell: pwsh + run: | + ./native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 ` + -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` + -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -CatalogPath native/udecx/x64/Release/ViiperUde/ViiperUde.cat ` + -BrokerPath native/udecx/x64/Release/viiper.exe ` + -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe ` + -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe ` + -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe ` + -ProbeManifestPath native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json ` + -OutputDirectory native/udecx/x64/Release/ViiperUdeLocalTest ` + -SourceRevision $env:GITHUB_SHA - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: @@ -291,9 +334,7 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ inputs.upload_artifacts == true }} with: - name: ViiperUde-x64-test-signed - path: | - native/udecx/x64/Release/** - native/udecx/driver/x64/Release/** - native/udecx/package/x64/Release/** + name: ViiperUde-x64-local-test-${{ github.sha }} + path: native/udecx/x64/Release/ViiperUdeLocalTest/** if-no-files-found: error + retention-days: 7 diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 60293fd0..1d1a223e 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.6", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.7", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 4650f33e..8cb19328 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 9, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.6", + "expectedDriverPackageVersion": "0.1.0.7", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index ad767b5a..0f9555e8 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -6,6 +6,27 @@ package must be signed by Microsoft through Hardware Dev Center. ## Supported release paths +### Local development: WDK test signing + +The manually dispatched native workflow can publish one compact, +seven-day-retention `LocalTest` artifact for the exact source SHA. It contains +the WDK test-signed INF/SYS/PDB/CAT evidence, an exact three-file runtime driver +directory, the source-bound broker/helper and live probes, the exported test +certificate, and a closed SHA-256 lock. Installation requires explicit +disposable-machine acknowledgement, elevation, the exact source revision and +interactive-user SID, and a current boot entry reporting `TESTSIGNING Yes`. +It imports only the artifact-bound certificate and then executes the normal +package-to-service transaction through `viiper.exe native-package-install`; +the helper is never invoked as a standalone mutation. Authenticated ABI 1.9, +capability, package-version, and loaded-kernel identity health must succeed +before the transaction commits. + +This route is deliberately non-release (`releaseEligible=false`, +`signingRoute=LocalTest`). It cannot satisfy controlled-attestation or +production validation, is never consumed by release composition, and does not +change the requirement that even test-mode 64-bit drivers carry a valid test +signature. + ### Controlled testing: attestation signing Microsoft now documents attestation signing as **testing-only**. An @@ -69,12 +90,13 @@ mode. That mode rejects the attestation EKU and requires a release-eligible archive contains exactly the release `viiper.exe` broker, `ViiperUdeCtl.exe`, INF, SYS, CAT, and the validated submission manifest. Release composition rejects every missing or additional file. -- Controlled-test and production signatures are separate validation modes; - an attestation EKU can never satisfy the production release gate. +- Local-test, controlled-test, and production signatures are separate + validation modes; neither a local certificate nor an attestation EKU can + satisfy the production release gate. - Test certificates, test-signing state, or disabled Secure Boot are never a release prerequisite. -- The installer refuses an unsigned, test-signed, mismatched, downgraded, or - non-Microsoft driver package before any driver-store mutation. +- The production installer refuses an unsigned, test-signed, mismatched, + downgraded, or non-Microsoft driver package before any driver-store mutation. - Updating a live kernel package remains a reboot-safe transaction; it is not overwritten in place. @@ -157,6 +179,9 @@ the HLK/DevFund matrix. ## Primary Microsoft references - [Driver code-signing requirements](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-reqs) +- [TESTSIGNING boot configuration](https://learn.microsoft.com/windows-hardware/drivers/install/the-testsigning-boot-configuration-option) +- [Install a test-signed driver package](https://learn.microsoft.com/windows-hardware/drivers/install/how-to-install-test-signed-driver-for-setup-and-boot) +- [Verify a test-signed catalog](https://learn.microsoft.com/windows-hardware/drivers/install/verifying-the-signature-of-a-test-signed-catalog-file) - [Attestation-sign Windows drivers](https://learn.microsoft.com/windows-hardware/drivers/dashboard/code-signing-attestation) - [Driver-signing options and best practices](https://learn.microsoft.com/windows-hardware/drivers/dashboard/driver-signing-offerings) - [Components of a driver package](https://learn.microsoft.com/windows-hardware/drivers/install/components-of-a-driver-package) diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index e7404328..cb9acc29 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -41,9 +41,11 @@ func (e *nativePackageRebootRequiredError) ExitCode() int { return nativePackageRebootRequiredCode } -// NativePackageInstall is the narrow bootstrapper boundary for the production -// native UDE package. It is hidden because normal users enter through the -// signed DS4Windows installer, which embeds the reviewed hashes passed here. +// NativePackageInstall is the narrow bootstrapper boundary for the native UDE +// package. Production is the default and normal users enter through the signed +// DS4Windows installer. The explicit local-test route retains the same hashes, +// rollback, service, and authenticated health transaction for disposable +// TESTSIGNING machines without relaxing the production route. type NativePackageInstall struct { PackageDirectory string `help:"Directory containing the exact Microsoft-returned INF, SYS, and CAT runtime files." required:""` SubmissionManifest string `help:"Source-bound HLK/WHCP submission manifest." required:""` @@ -56,6 +58,7 @@ type NativePackageInstall struct { ExpectedSysSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.sys." required:""` ExpectedCatSHA256 string `help:"Installer-embedded SHA-256 of the Microsoft-returned ViiperUde.cat." required:""` TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` + DriverValidationMode string `help:"Driver signature route: production or local-test." default:"production" enum:"production,local-test" hidden:""` } // NativePackageBrokerCommit is invoked only by ViiperUdeCtl while the signed @@ -206,6 +209,7 @@ func (c *NativePackageInstall) Run(logger *slog.Logger) error { expectedSysSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedSysSHA256)), expectedCatSHA256: strings.ToLower(strings.TrimSpace(c.ExpectedCatSHA256)), targetUserSID: strings.TrimSpace(c.TargetUserSID), + driverValidationMode: strings.ToLower(strings.TrimSpace(c.DriverValidationMode)), } if err := request.validate(); err != nil { return err @@ -228,6 +232,7 @@ type nativePackageRequest struct { expectedSysSHA256 string expectedCatSHA256 string targetUserSID string + driverValidationMode string } func (r nativePackageRequest) validate() error { @@ -246,6 +251,9 @@ func (r nativePackageRequest) validate() error { if !nativePackageHexRevision.MatchString(r.sourceRevision) { return errors.New("native package source revision must contain exactly 40 or 64 hexadecimal characters") } + if r.driverValidationMode != "production" && r.driverValidationMode != "local-test" { + return errors.New("native package driver validation mode must be production or local-test") + } if !nativePackageSHA256.MatchString(r.expectedBrokerSHA256) || !nativePackageSHA256.MatchString(r.expectedHelperSHA256) || !nativePackageSHA256.MatchString(r.expectedManifestSHA256) || diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go index d66b542b..cfa4f60e 100644 --- a/internal/cmd/native_package_test.go +++ b/internal/cmd/native_package_test.go @@ -342,7 +342,8 @@ func TestNativePackageRequestFailsClosed(t *testing.T) { expectedHelperSHA256: strings.Repeat("c", 64), targetUserSID: "S-1-5-21-1-2-3-1001", expectedManifestSHA256: strings.Repeat("d", 64), expectedInfSHA256: strings.Repeat("e", 64), expectedSysSHA256: strings.Repeat("f", 64), - expectedCatSHA256: strings.Repeat("0", 64), + expectedCatSHA256: strings.Repeat("0", 64), + driverValidationMode: "production", } if err := base.validate(); err != nil { t.Fatalf("valid request: %v", err) @@ -355,6 +356,9 @@ func TestNativePackageRequestFailsClosed(t *testing.T) { "bad SYS hash": func(r *nativePackageRequest) { r.expectedSysSHA256 = strings.Repeat("z", 64) }, "bad CAT hash": func(r *nativePackageRequest) { r.expectedCatSHA256 = strings.Repeat("z", 64) }, "embedded NUL": func(r *nativePackageRequest) { r.submissionManifest += "\x00evil" }, + "bad validation mode": func(r *nativePackageRequest) { + r.driverValidationMode = "controlled-test" + }, } for name, mutate := range cases { name, mutate := name, mutate diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index 65ac5b19..39296e69 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -842,7 +842,7 @@ func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Contex "--manifest", t.request.submissionManifest, "--manifest-sha256", t.request.expectedManifestSHA256, "--source-revision", t.request.sourceRevision, - "--validation-mode", "production", + "--validation-mode", t.request.driverValidationMode, "--expected-inf-sha256", t.request.expectedInfSHA256, "--expected-sys-sha256", t.request.expectedSysSHA256, "--expected-cat-sha256", t.request.expectedCatSHA256, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 04747247..ccd3ba48 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.6", + ExpectedDriverPackageVersion: "0.1.0.7", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index 3690f2c8..44bd82c4 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -33,7 +33,11 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "Test-LiveProbeManifest", "sourceRevision", "Get-FileHash -LiteralPath $path -Algorithm SHA256", - "-ProbeManifestPath is required whenever a production live probe is used", + "-ProbeManifestPath is required whenever a source-bound live probe is used", + "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", + "$SignatureValidationMode -eq 'LocalTest'", + "testsigning Yes", + "-LocalTestCertificatePath $LocalTestCertificatePath", "rev-parse --verify HEAD", "status --porcelain=v1 --untracked-files=all", "submodule status --recursive", diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go new file mode 100644 index 00000000..d815cb59 --- /dev/null +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -0,0 +1,116 @@ +package udecx + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { + root := filepath.Join("..", "..", "..") + read := func(path ...string) string { + t.Helper() + contents, err := os.ReadFile(filepath.Join(append([]string{root}, path...)...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(path...), err) + } + return strings.ReplaceAll(string(contents), "\r\n", "\n") + } + + workflow := read(".github", "workflows", "native-ude.yml") + for _, required := range []string{ + "workflow_dispatch:", + "New-ViiperUdeLocalTestPackage.ps1", + "-BrokerPath native/udecx/x64/Release/viiper.exe", + "ViiperUde-x64-local-test-${{ github.sha }}", + "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", + "retention-days: 7", + } { + if !strings.Contains(workflow, required) { + t.Fatalf("local-test workflow omitted %q", required) + } + } + for _, forbidden := range []string{ + "native/udecx/x64/Release/**", + "native/udecx/driver/x64/Release/**", + "native/udecx/package/x64/Release/**", + } { + if strings.Contains(workflow, forbidden) { + t.Fatalf("local-test workflow uploads broad build tree %q", forbidden) + } + } + + composer := read("native", "udecx", "tools", "New-ViiperUdeLocalTestPackage.ps1") + for _, required := range []string{ + "[string]$BrokerPath", + "Resolve-ExactInput $BrokerPath 'viiper.exe'", + "signingRoute = 'LocalTest'", + "releaseEligible = $false", + "testSignerCertificateSha256", + "-ValidationMode LocalTest", + "-RequireLocalTestToolchainValidation", + "local-test-package.lock.json", + } { + if !strings.Contains(composer, required) { + t.Fatalf("local-test composer omitted %q", required) + } + } + + installer := read("native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1") + for _, required := range []string{ + "[string]$TargetUserSID", + "& $brokerPath native-package-install", + "--expected-broker-sha256 $brokerHash", + "--expected-helper-sha256 $helperHash", + "--target-user-sid $TargetUserSID", + "--driver-validation-mode local-test", + "-AcknowledgeDisposableTestMachine", + "testsigning\\s+Yes", + } { + if !strings.Contains(installer, required) { + t.Fatalf("local-test installer omitted %q", required) + } + } + if strings.Contains(installer, "& $helperPath install") { + t.Fatal("local-test installation bypasses the full broker/package transaction") + } + + packageCommand := read("internal", "cmd", "native_package.go") + packageWindows := read("internal", "cmd", "native_package_windows.go") + for _, required := range []string{ + `default:"production" enum:"production,local-test"`, + `r.driverValidationMode != "production" && r.driverValidationMode != "local-test"`, + } { + if !strings.Contains(packageCommand, required) { + t.Fatalf("native package command omitted %q", required) + } + } + if !strings.Contains(packageWindows, + `"--validation-mode", t.request.driverValidationMode`) { + t.Fatal("native package transaction does not pass the validated signature route to its retained helper") + } +} + +func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { + root := filepath.Join("..", "..", "..", "native", "udecx", "tools") + contents, err := os.ReadFile(filepath.Join(root, "Test-ViiperUdeSignedPackage.ps1")) + if err != nil { + t.Fatalf("read signed-package validator: %v", err) + } + contract := strings.ReplaceAll(string(contents), "\r\n", "\n") + for _, required := range []string{ + "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", + "$ValidationMode -eq 'LocalTest'", + "testSignerCertificateSha256", + "Production validation requires a release-eligible HLK/WHCP", + "HLK/WHCP", + "Assert-DriverSignature", + "Microsoft Corporation", + "$requireExternalTools = $ValidationMode -ne 'LocalTest' -or $RequireLocalTestToolchainValidation", + } { + if !strings.Contains(contract, required) { + t.Fatalf("signature route separation omitted %q", required) + } + } +} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 14f55478..b54b6990 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.6" + DriverPackageVersion = "0.1.0.7" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index edca6a51..190f6c41 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe" + const wantHex = "ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/README.md b/native/udecx/README.md index 2efcf497..4c6ab814 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -37,6 +37,13 @@ never accepted by a Release recipe or production workflow. source revision, DriverVer, ABI, exact capability mask, and loaded-image build identity. Microsoft currently restricts attestation to testing scenarios; production release requires HLK/WHCP. +- `tools/New-ViiperUdeLocalTestPackage.ps1` composes the exact WDK test-signed + driver, source-bound broker/helper, and live probes into a compact + short-retention artifact. `tools/Install-ViiperUdeLocalTest.ps1` accepts it + only on an elevated disposable machine whose current boot entry has + `TESTSIGNING` enabled, imports its exact hash-bound test certificate, and + runs the same driver-plus-broker transaction and authenticated health proof + used by production. This route is never release-eligible. - `tools/Test-ViiperUdeSignedPackage.ps1` validates the Microsoft-returned driver and catalog against kernel signing policy, proves that INF and SYS are members of that exact catalog, distinguishes testing-only attestation from @@ -133,6 +140,39 @@ means verified installation/removal requires a restart, `4` is a preflight rejection, and `3` means rollback itself failed. Every command emits one final key/value result line including `rebootRequired` and rollback status. +For an exact branch build on a disposable local-test machine, download the +`ViiperUde-x64-local-test-` artifact from a manually dispatched +native workflow, then run from the matching clean checkout: + +```powershell +.\native\udecx\tools\Install-ViiperUdeLocalTest.ps1 ` + -PackageRoot C:\ViiperUdeLocalTest ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -TargetUserSID S-1-5-21-111111111-222222222-333333333-1001 ` + -AcknowledgeDisposableTestMachine +``` + +The local route does not bypass driver signing: the SYS and catalog must carry +the exact WDK test signature sealed into the artifact lock, and Windows must +trust that certificate while `TESTSIGNING` is active. It does not change the +production Microsoft HLK/WHCP gate. + +After installation (and any requested restart), the same artifact supplies the +source-bound evidence and probes for the real UdeCx test: + +```powershell +.\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` + -SignedPackageDirectory C:\ViiperUdeLocalTest\signed-package ` + -SubmissionManifestPath C:\ViiperUdeLocalTest\submission-manifest.json ` + -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -SignatureValidationMode LocalTest ` + -LocalTestCertificatePath C:\ViiperUdeLocalTest\ViiperUdeTest.cer ` + -MediaProbePath C:\ViiperUdeLocalTest\ViiperUdeMediaProbe.exe ` + -InputProbePath C:\ViiperUdeLocalTest\ViiperUdeInputProbe.exe ` + -ProbeManifestPath C:\ViiperUdeLocalTest\ViiperUdeLiveProbes.manifest.json ` + -Iterations 10 -MediaDurationSeconds 30 -DisposableTestMachine +``` + Production uninstall is similarly owned by the signed installer. It calls `viiper uninstall` with the packaged `ViiperUdeCtl.exe`, the installer-bound helper SHA-256, and the target-user SID. The broker is only stopped while the diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 2f8267e5..9b63a6a2 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.6 + 0.1.0.7 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 77ec3782..e0fbc447 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.6" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.7" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index a84296ad..6d3d2f98 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.6 +DriverVer=08/11/2026,0.1.0.7 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 index 9c012c2d..ebec69af 100644 --- a/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 +++ b/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1 @@ -10,9 +10,11 @@ param( [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, - [ValidateSet('ControlledTest', 'Production')] + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] [string]$SignatureValidationMode = 'Production', + [string]$LocalTestCertificatePath, + [switch]$DisposableTestMachine ) @@ -53,7 +55,8 @@ $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' -PackageDirectory $SignedPackageDirectory ` -SubmissionManifestPath $SubmissionManifestPath ` -ExpectedSourceRevision $ExpectedSourceRevision ` - -ValidationMode $SignatureValidationMode + -ValidationMode $SignatureValidationMode ` + -LocalTestCertificatePath $LocalTestCertificatePath $packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path $packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') @@ -69,7 +72,7 @@ $installedDriver = Resolve-DriverImagePath -ImagePath ([string]$service.ImagePat $packageHash = (Get-FileHash -LiteralPath $packageDrivers[0].FullName -Algorithm SHA256).Hash $installedHash = (Get-FileHash -LiteralPath $installedDriver -Algorithm SHA256).Hash if ($packageHash -ne $installedHash) { - throw "The installed VIIPER UDE driver does not match the verified Microsoft-signed package. Installed='$installedDriver'." + throw "The installed VIIPER UDE driver does not match the verified source-bound package. Installed='$installedDriver'." } $existingOutput = (& verifier.exe /querysettings 2>&1 | Out-String) diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 new file mode 100644 index 00000000..1bc5d074 --- /dev/null +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -0,0 +1,200 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$PackageRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + [Parameter(Mandatory = $true)] + [ValidatePattern('^S-1-5-21-(?:[0-9]+-){3}[0-9]+$')] + [string]$TargetUserSID, + [switch]$AcknowledgeDisposableTestMachine +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $AcknowledgeDisposableTestMachine) { + throw 'Local test driver installation is for a disposable test machine only. Pass -AcknowledgeDisposableTestMachine.' +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Local VIIPER driver installation requires an elevated PowerShell session.' +} +$bcdOutput = (& bcdedit.exe /enum '{current}' 2>&1 | Out-String) +if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { + throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before installation.`n$bcdOutput" +} + +$root = (Resolve-Path -LiteralPath $PackageRoot -ErrorAction Stop).Path +$lockPath = Join-Path $root 'local-test-package.lock.json' +$manifestPath = Join-Path $root 'submission-manifest.json' +$certificatePath = Join-Path $root 'ViiperUdeTest.cer' +$helperPath = Join-Path $root 'ViiperUdeCtl.exe' +$brokerPath = Join-Path $root 'viiper.exe' +$signedPackage = Join-Path $root 'signed-package' +$driverDirectory = Join-Path $root 'driver' + +function Assert-ExactDirectoryEntries { + param( + [Parameter(Mandatory = $true)][string]$Directory, + [Parameter(Mandatory = $true)][string[]]$Expected + ) + + $directoryItem = Get-Item -LiteralPath $Directory -Force -ErrorAction Stop + if (-not $directoryItem.PSIsContainer -or + ($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local test package directory is missing or unsafe: '$Directory'." + } + $actual = @(Get-ChildItem -LiteralPath $Directory -Force | + ForEach-Object Name | Sort-Object -CaseSensitive) + $wanted = @($Expected | Sort-Object -CaseSensitive) + if ($actual.Count -ne $wanted.Count -or + (Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count -ne 0) { + throw "Local test package directory has missing, extra, or case-mismatched entries: '$Directory'." + } +} + +Assert-ExactDirectoryEntries $root @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', 'local-test-package.lock.json', + 'driver', 'signed-package' +) +Assert-ExactDirectoryEntries $driverDirectory @( + 'ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat' +) +Assert-ExactDirectoryEntries $signedPackage @( + 'ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat' +) + +$lock = Get-Content -LiteralPath $lockPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop +$source = $ExpectedSourceRevision.ToLowerInvariant() +if ([int]$lock.schema -ne 1 -or [string]$lock.sourceRevision -cne $source -or + [string]$lock.driverBuildIdentity -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.testSignerCertificateSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'The local test package lock does not match the requested source or schema.' +} + +$expectedPaths = @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', + 'driver/ViiperUde.inf', 'driver/ViiperUde.sys', 'driver/ViiperUde.cat', + 'signed-package/ViiperUde.inf', 'signed-package/ViiperUde.sys', + 'signed-package/ViiperUde.pdb', 'signed-package/ViiperUde.cat' +) +$entries = @($lock.files) +if ($entries.Count -ne $expectedPaths.Count) { + throw 'The local test package lock has an incomplete or extra file list.' +} +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($entry in $entries) { + $relative = [string]$entry.path + if ($expectedPaths -cnotcontains $relative -or -not $seen.Add($relative) -or + [long]$entry.length -le 0 -or [string]$entry.sha256 -notmatch '^[0-9a-f]{64}$') { + throw "The local test package lock contains an invalid entry '$relative'." + } + $path = Join-Path $root $relative.Replace('/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -ne [long]$entry.length -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne + [string]$entry.sha256) { + throw "Local test package file validation failed for '$relative'." + } +} + +$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($certificatePath) +$algorithm = [Security.Cryptography.SHA256]::Create() +try { + $certificateSha256 = ([BitConverter]::ToString( + $algorithm.ComputeHash($certificate.RawData))).Replace('-', '').ToLowerInvariant() +} +finally { + $algorithm.Dispose() +} +if ($certificateSha256 -cne [string]$lock.testSignerCertificateSha256) { + throw 'The local test certificate does not match the source-bound package lock.' +} + +$expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) +$addedStores = [Collections.Generic.List[string]]::new() +try { + foreach ($storeName in @('Root', 'TrustedPublisher')) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + try { + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $present = @($store.Certificates | Where-Object { + [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes + }).Count -ne 0 + if (-not $present) { + $store.Add($certificate) + $addedStores.Add($storeName) + } + } + finally { + $store.Close() + } + } + + & (Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1') ` + -PackageDirectory $signedPackage ` + -SubmissionManifestPath $manifestPath ` + -ExpectedSourceRevision $source ` + -ValidationMode LocalTest ` + -LocalTestCertificatePath $certificatePath +} +catch { + foreach ($storeName in $addedStores) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + try { + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + @($store.Certificates | Where-Object { + [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes + }) | ForEach-Object { $store.Remove($_) } + } + finally { + $store.Close() + } + } + throw +} + +foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { + $runtime = Join-Path $driverDirectory $name + $evidence = Join-Path $signedPackage $name + if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { + throw "Runtime driver file '$name' differs from its validated evidence copy." + } +} + +$manifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() +$infHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.inf') -Algorithm SHA256).Hash.ToLowerInvariant() +$sysHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.sys') -Algorithm SHA256).Hash.ToLowerInvariant() +$catHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.cat') -Algorithm SHA256).Hash.ToLowerInvariant() +$brokerHash = (Get-FileHash -LiteralPath $brokerPath -Algorithm SHA256).Hash.ToLowerInvariant() +$helperHash = (Get-FileHash -LiteralPath $helperPath -Algorithm SHA256).Hash.ToLowerInvariant() +$output = @(& $brokerPath native-package-install ` + --package-directory $driverDirectory --submission-manifest $manifestPath ` + --source-revision $source --driver-helper $helperPath ` + --expected-broker-sha256 $brokerHash --expected-helper-sha256 $helperHash ` + --expected-manifest-sha256 $manifestHash --expected-inf-sha256 $infHash ` + --expected-sys-sha256 $sysHash --expected-cat-sha256 $catHash ` + --target-user-sid $TargetUserSID --driver-validation-mode local-test 2>&1) +$exitCode = $LASTEXITCODE +$output | ForEach-Object { Write-Host $_ } +if ($exitCode -notin @(0, 3010)) { + throw "Local VIIPER driver transaction failed with exit code $exitCode." +} +if ($exitCode -eq 3010) { + Write-Warning 'The verified driver transaction requires a reboot. Restart before running live validation.' + exit 3010 +} + +Write-Host 'The exact local test-signed VIIPER UdeCx driver and native broker are installed, authenticated, and ready.' +Write-Host 'Next: enable Driver Verifier for ViiperUde.sys, reboot, then run Invoke-ViiperUdeLiveValidation.ps1 in LocalTest mode.' diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index e1919f57..21c4fd4d 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -10,9 +10,11 @@ param( [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, - [ValidateSet('ControlledTest', 'Production')] + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] [string]$SignatureValidationMode = 'Production', + [string]$LocalTestCertificatePath, + [ValidateRange(1, 100)] [int]$Iterations = 1, @@ -154,34 +156,35 @@ if ($ReleaseGate) { $hasMediaProbe = -not [string]::IsNullOrWhiteSpace($MediaProbePath) $hasInputProbe = -not [string]::IsNullOrWhiteSpace($InputProbePath) -if ($SignatureValidationMode -eq 'Production' -and ($hasMediaProbe -or $hasInputProbe) -and +if ($SignatureValidationMode -in @('Production', 'LocalTest') -and + ($hasMediaProbe -or $hasInputProbe) -and [string]::IsNullOrWhiteSpace($ProbeManifestPath)) { - throw '-ProbeManifestPath is required whenever a production live probe is used.' + throw '-ProbeManifestPath is required whenever a source-bound live probe is used.' } $repository = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path -if ($SignatureValidationMode -eq 'Production') { +if ($SignatureValidationMode -in @('Production', 'LocalTest')) { $git = Get-Command git.exe -ErrorAction Stop $headOutput = & $git.Source -C $repository rev-parse --verify HEAD 2>&1 if ($LASTEXITCODE -ne 0) { - throw "The production live-test harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" + throw "The source-bound live-test harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" } $headRevision = ($headOutput | Select-Object -First 1).Trim() if (-not [string]::Equals($headRevision, $ExpectedSourceRevision, [StringComparison]::OrdinalIgnoreCase)) { - throw "The production live-test harness is source '$headRevision', not '$ExpectedSourceRevision'." + throw "The source-bound live-test harness is source '$headRevision', not '$ExpectedSourceRevision'." } $treeStatus = @(& $git.Source -C $repository status --porcelain=v1 --untracked-files=all 2>&1) if ($LASTEXITCODE -ne 0) { - throw "Could not verify the production live-test source tree.`n$($treeStatus -join [Environment]::NewLine)" + throw "Could not verify the source-bound live-test source tree.`n$($treeStatus -join [Environment]::NewLine)" } if ($treeStatus.Count -ne 0) { - throw ("The production live-test source tree is not clean; refusing unreviewed test code or data:`n" + + throw ("The source-bound live-test source tree is not clean; refusing unreviewed test code or data:`n" + ($treeStatus -join [Environment]::NewLine)) } $submoduleStatus = @(& $git.Source -C $repository submodule status --recursive 2>&1) if ($LASTEXITCODE -ne 0 -or @($submoduleStatus | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) { - throw "The production live-test source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" + throw "The source-bound live-test source tree has an unbound submodule state.`n$($submoduleStatus -join [Environment]::NewLine)" } } $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' @@ -189,7 +192,8 @@ $signatureGate = Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1' -PackageDirectory $SignedPackageDirectory ` -SubmissionManifestPath $SubmissionManifestPath ` -ExpectedSourceRevision $ExpectedSourceRevision ` - -ValidationMode $SignatureValidationMode + -ValidationMode $SignatureValidationMode ` + -LocalTestCertificatePath $LocalTestCertificatePath $packageRoot = (Resolve-Path -LiteralPath $SignedPackageDirectory -ErrorAction Stop).Path $packageDrivers = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File -Filter 'ViiperUde.sys') @@ -221,7 +225,11 @@ $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { if ($devnodes.Count -ne 1) { throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." } -if (-not [bool]$devnodes[0].IsSigned -or [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { +if (-not [bool]$devnodes[0].IsSigned -or [string]::IsNullOrWhiteSpace([string]$devnodes[0].Signer)) { + throw "The installed VIIPER UDE devnode is not backed by a signed driver (Signer='$($devnodes[0].Signer)')." +} +if ($SignatureValidationMode -ne 'LocalTest' -and + [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." } @@ -231,6 +239,13 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra throw 'Live VIIPER UDE validation must run from an elevated PowerShell session.' } +if ($SignatureValidationMode -eq 'LocalTest') { + $bcdOutput = (& bcdedit.exe /enum '{current}' 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { + throw "LocalTest requires the current boot entry to report 'testsigning Yes'. Enable TESTSIGNING and reboot before retrying.`n$bcdOutput" + } +} + if ($ReleaseGate) { $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop $build = 0 diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 index 8ba3d9b5..f772a96b 100644 --- a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -10,9 +10,11 @@ param( [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, - [ValidateSet('ControlledTest', 'Production')] + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] [string]$SignatureValidationMode = 'Production', + [string]$LocalTestCertificatePath, + [Parameter(Mandatory = $true)] [string]$OutputPath, @@ -111,6 +113,7 @@ $validationArguments = @{ SubmissionManifestPath = $SubmissionManifestPath ExpectedSourceRevision = $ExpectedSourceRevision SignatureValidationMode = $SignatureValidationMode + LocalTestCertificatePath = $LocalTestCertificatePath Iterations = $Iterations MediaProbePath = $MediaProbePath InputProbePath = $InputProbePath diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 new file mode 100644 index 00000000..ad6e2f91 --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InfPath, + [Parameter(Mandatory = $true)][string]$SysPath, + [Parameter(Mandatory = $true)][string]$PdbPath, + [Parameter(Mandatory = $true)][string]$CatalogPath, + [Parameter(Mandatory = $true)][string]$BrokerPath, + [Parameter(Mandatory = $true)][string]$HelperPath, + [Parameter(Mandatory = $true)][string]$MediaProbePath, + [Parameter(Mandatory = $true)][string]$InputProbePath, + [Parameter(Mandatory = $true)][string]$ProbeManifestPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CertificateSha256 { + param([Parameter(Mandatory = $true)]$Certificate) + + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString( + $algorithm.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } +} + +function Resolve-ExactInput { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + $item = Get-Item -LiteralPath $resolved -Force + if (-not $item.PSIsContainer -and $item.Length -gt 0 -and + $item.Name -ceq $ExpectedName -and + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + return $resolved + } + throw "Local test input must be a nonempty, case-exact, non-reparse '$ExpectedName': '$Path'." +} + +$inputs = [ordered]@{ + 'ViiperUde.inf' = Resolve-ExactInput $InfPath 'ViiperUde.inf' + 'ViiperUde.sys' = Resolve-ExactInput $SysPath 'ViiperUde.sys' + 'ViiperUde.pdb' = Resolve-ExactInput $PdbPath 'ViiperUde.pdb' + 'ViiperUde.cat' = Resolve-ExactInput $CatalogPath 'ViiperUde.cat' +} +$helper = Resolve-ExactInput $HelperPath 'ViiperUdeCtl.exe' +$broker = Resolve-ExactInput $BrokerPath 'viiper.exe' +$mediaProbe = Resolve-ExactInput $MediaProbePath 'ViiperUdeMediaProbe.exe' +$inputProbe = Resolve-ExactInput $InputProbePath 'ViiperUdeInputProbe.exe' +$probeManifest = Resolve-ExactInput $ProbeManifestPath 'ViiperUdeLiveProbes.manifest.json' + +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { + throw "Refusing to overwrite local test package '$output'." +} + +$catalogSignature = Get-AuthenticodeSignature -LiteralPath $inputs['ViiperUde.cat'] +$driverSignature = Get-AuthenticodeSignature -LiteralPath $inputs['ViiperUde.sys'] +if ($catalogSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or + $driverSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or + $null -eq $catalogSignature.SignerCertificate -or + $null -eq $driverSignature.SignerCertificate) { + throw 'The local package composer requires valid WDK test signatures on the catalog and driver.' +} +$certificateSha256 = Get-CertificateSha256 $catalogSignature.SignerCertificate +if ((Get-CertificateSha256 $driverSignature.SignerCertificate) -cne $certificateSha256) { + throw 'The local catalog and driver were signed by different test certificates.' +} + +[void][IO.Directory]::CreateDirectory($output) +$signedDirectory = Join-Path $output 'signed-package' +$driverDirectory = Join-Path $output 'driver' +[void][IO.Directory]::CreateDirectory($signedDirectory) +[void][IO.Directory]::CreateDirectory($driverDirectory) + +foreach ($entry in $inputs.GetEnumerator()) { + [IO.File]::Copy($entry.Value, (Join-Path $signedDirectory $entry.Key), $false) + if ($entry.Key -cne 'ViiperUde.pdb') { + [IO.File]::Copy($entry.Value, (Join-Path $driverDirectory $entry.Key), $false) + } +} +[IO.File]::Copy($helper, (Join-Path $output 'ViiperUdeCtl.exe'), $false) +[IO.File]::Copy($broker, (Join-Path $output 'viiper.exe'), $false) +[IO.File]::Copy($mediaProbe, (Join-Path $output 'ViiperUdeMediaProbe.exe'), $false) +[IO.File]::Copy($inputProbe, (Join-Path $output 'ViiperUdeInputProbe.exe'), $false) +[IO.File]::Copy($probeManifest, (Join-Path $output 'ViiperUdeLiveProbes.manifest.json'), $false) +$certificatePath = Join-Path $output 'ViiperUdeTest.cer' +[IO.File]::WriteAllBytes($certificatePath, $catalogSignature.SignerCertificate.Export( + [Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + +[xml]$project = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj') -Raw +$namespace = [Xml.XmlNamespaceManager]::new($project.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') +$versionNodes = @($project.SelectNodes('//msb:ViiperUdeDriverVersion', $namespace)) +if ($versionNodes.Count -ne 1) { + throw 'The native driver project must declare one package version.' +} +$driverVersion = $versionNodes[0].InnerText.Trim() +$source = $SourceRevision.ToLowerInvariant() +$buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` + -SourceRevision $source -DriverPackageVersion $driverVersion ` + -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + +$manifest = [ordered]@{ + schema = 2 + purpose = 'Local test-signed VIIPER UdeCx package; disposable test machines only' + releaseEligible = $false + signingRoute = 'LocalTest' + requiredProductionRoute = 'HLK/WHCP dashboard signing' + sourceRevision = $source + driverPackageVersion = $driverVersion + driverABIMajor = 1 + driverABIMinor = 9 + driverCapabilities = '0x0000000d' + driverBuildIdentity = $buildIdentity + testSignerCertificateSha256 = $certificateSha256 + files = @( + foreach ($entry in $inputs.GetEnumerator()) { + $path = Join-Path $signedDirectory $entry.Key + [ordered]@{ + name = $entry.Key + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + ) +} +$manifestPath = Join-Path $output 'submission-manifest.json' +[IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + +$payloadNames = @( + 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', + 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', + 'submission-manifest.json', + 'driver/ViiperUde.inf', 'driver/ViiperUde.sys', 'driver/ViiperUde.cat', + 'signed-package/ViiperUde.inf', 'signed-package/ViiperUde.sys', + 'signed-package/ViiperUde.pdb', 'signed-package/ViiperUde.cat' +) +$lockFiles = @( + foreach ($relative in $payloadNames) { + $path = Join-Path $output $relative.Replace('/', [IO.Path]::DirectorySeparatorChar) + [ordered]@{ + path = $relative + length = (Get-Item -LiteralPath $path).Length + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } +) +$lock = [ordered]@{ + schema = 1 + sourceRevision = $source + driverPackageVersion = $driverVersion + driverBuildIdentity = $buildIdentity + testSignerCertificateSha256 = $certificateSha256 + files = $lockFiles +} +[IO.File]::WriteAllText((Join-Path $output 'local-test-package.lock.json'), + ($lock | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) + +& (Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1') ` + -PackageDirectory $signedDirectory ` + -SubmissionManifestPath $manifestPath ` + -ExpectedSourceRevision $source ` + -ValidationMode LocalTest ` + -LocalTestCertificatePath $certificatePath ` + -RequireLocalTestToolchainValidation + +Write-Host "Created compact source-bound local test package at '$output'." +Write-Host "Source: $source" +Write-Host "Driver: $driverVersion / ABI 1.9 / $buildIdentity" +Write-Host "Test signer certificate SHA-256: $certificateSha256" diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index a050c664..c12daf79 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -44,6 +44,11 @@ $requiredContracts = [ordered]@{ 'explicit inherited broker handles' = 'PROC_THREAD_ATTRIBUTE_HANDLE_LIST' 'indeterminate broker wait retention' = 'GetExitCodeProcess\(processHandle\.get\(\), &observedExit\)' 'production broker requirement' = 'broker-required' + 'local test broker transaction requirement' = 'options\.production \|\| options\.localTest' + 'explicit local test route' = 'validation mode must be production, controlled-test, or local-test' + 'local test manifest separation' = '"signingRoute"[\s\S]{0,6000}"LocalTest"' + 'non-release local test enforcement' = 'else if \(localTest\)[\s\S]{0,900}\*releaseValue' + 'local test signer digest shape' = 'testSignerCertificateSha256Value->size\(\) == 64' 'staged broker hash binding' = '--broker-sha256' 'protected package token binding' = '--broker-token-sha256' 'nested package broker commit' = 'native-package-broker-commit' diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index adc5dfdc..41e25fab 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -10,8 +10,12 @@ param( [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, - [ValidateSet('ControlledTest', 'Production')] - [string]$ValidationMode = 'Production' + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$ValidationMode = 'Production', + + [string]$LocalTestCertificatePath, + + [switch]$RequireLocalTestToolchainValidation ) Set-StrictMode -Version Latest @@ -41,21 +45,50 @@ function Get-CertificateEkuOids { return ,$oids } -function Assert-MicrosoftHardwareSignature { +function Get-CertificateSha256 { + param( + [Parameter(Mandatory = $true)] + [Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString( + $algorithm.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } +} + +function Assert-DriverSignature { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] - [ValidateSet('ControlledTest', 'Production')] - [string]$Mode + [ValidateSet('LocalTest', 'ControlledTest', 'Production')] + [string]$Mode, + + [string]$ExpectedLocalTestCertificateSha256 ) $signature = Get-AuthenticodeSignature -LiteralPath $Path if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." } - if ($null -eq $signature.SignerCertificate -or + if ($null -eq $signature.SignerCertificate) { + throw "'$Path' did not expose its signing certificate." + } + if ($Mode -eq 'LocalTest') { + $actual = Get-CertificateSha256 -Certificate $signature.SignerCertificate + if ($ExpectedLocalTestCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or + $actual -cne $ExpectedLocalTestCertificateSha256) { + throw "'$Path' is not signed by the exact source-bound local test certificate." + } + return + } + if ( $signature.SignerCertificate.Subject -notmatch '(?i)(^|,\s*)O=Microsoft Corporation(,|$)') { throw "'$Path' is not signed by Microsoft Corporation." } @@ -81,6 +114,29 @@ if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { throw 'The signed package path must be a directory.' } +$localTestCertificate = $null +$localTestCertificateSha256 = $null +if ($ValidationMode -eq 'LocalTest') { + if ([string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is required for LocalTest validation.' + } + $resolvedCertificate = (Resolve-Path -LiteralPath $LocalTestCertificatePath -ErrorAction Stop).Path + $certificateItem = Get-Item -LiteralPath $resolvedCertificate -Force + if ($certificateItem.PSIsContainer -or $certificateItem.Length -le 0 -or + $certificateItem.Name -cne 'ViiperUdeTest.cer' -or + ($certificateItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'The local test certificate must be a nonempty, case-exact, non-reparse ViiperUdeTest.cer file.' + } + $localTestCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($resolvedCertificate) + $localTestCertificateSha256 = Get-CertificateSha256 -Certificate $localTestCertificate +} +elseif (-not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { + throw '-LocalTestCertificatePath is valid only with -ValidationMode LocalTest.' +} +if ($RequireLocalTestToolchainValidation -and $ValidationMode -ne 'LocalTest') { + throw '-RequireLocalTestToolchainValidation is valid only with -ValidationMode LocalTest.' +} + $expectedNames = @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat') $allEntries = @(Get-ChildItem -LiteralPath $root.Path -Force) if (@($allEntries | Where-Object PSIsContainer).Count -ne 0) { @@ -128,7 +184,13 @@ if ($manifest.schema -ne 2 -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' } -if ($ValidationMode -eq 'ControlledTest') { +if ($ValidationMode -eq 'LocalTest') { + if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'LocalTest' -or + [string]$manifest.testSignerCertificateSha256 -cne $localTestCertificateSha256) { + throw 'LocalTest validation requires a non-release manifest bound to the exact local test certificate.' + } +} +elseif ($ValidationMode -eq 'ControlledTest') { if ([bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'ControlledTestAttestation') { throw 'Controlled-test validation requires a testing-only attestation submission manifest.' } @@ -164,27 +226,36 @@ foreach ($name in @('ViiperUde.inf', 'ViiperUde.pdb')) { } } -$signTool = Get-Command signtool.exe -ErrorAction Stop foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { - & $signTool.Source verify /kp /v $files[$name] - if ($LASTEXITCODE -ne 0) { - throw "Kernel-policy signature validation failed for '$name' with exit code $LASTEXITCODE." - } - Assert-MicrosoftHardwareSignature -Path $files[$name] -Mode $ValidationMode + Assert-DriverSignature -Path $files[$name] -Mode $ValidationMode ` + -ExpectedLocalTestCertificateSha256 $localTestCertificateSha256 } -foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { - & $signTool.Source verify /kp /v /c $files['ViiperUde.cat'] $files[$name] - if ($LASTEXITCODE -ne 0) { - throw "'$name' is not a verified member of the Microsoft-signed catalog (exit code $LASTEXITCODE)." +$requireExternalTools = $ValidationMode -ne 'LocalTest' -or $RequireLocalTestToolchainValidation +if ($requireExternalTools) { + $signTool = Get-Command signtool.exe -ErrorAction Stop + foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { + $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } + & $signTool.Source verify $policy /v $files[$name] + if ($LASTEXITCODE -ne 0) { + throw "Signature policy validation failed for '$name' with exit code $LASTEXITCODE." + } + } + foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { + $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } + & $signTool.Source verify $policy /v /c $files['ViiperUde.cat'] $files[$name] + if ($LASTEXITCODE -ne 0) { + throw "'$name' is not a verified member of the exact catalog (exit code $LASTEXITCODE)." + } } -} -$infVerif = Get-Command infverif.exe -ErrorAction Stop -foreach ($mode in @('/h', '/u')) { - & $infVerif.Source $mode $files['ViiperUde.inf'] - if ($LASTEXITCODE -ne 0) { - throw "InfVerif $mode rejected the Microsoft-signed package with exit code $LASTEXITCODE." + $infVerif = Get-Command infverif.exe -ErrorAction Stop + foreach ($mode in @('/h', '/u')) { + & $infVerif.Source $mode $files['ViiperUde.inf'] + if ($LASTEXITCODE -ne 0) { + throw "InfVerif $mode rejected the signed package with exit code $LASTEXITCODE." + } } } -Write-Host "Validated source-bound Microsoft-signed VIIPER native UDE package in $ValidationMode mode at '$($root.Path)'." +$signatureKind = if ($ValidationMode -eq 'LocalTest') { 'local test-signed' } else { 'Microsoft-signed' } +Write-Host "Validated source-bound $signatureKind VIIPER native UDE package in $ValidationMode mode at '$($root.Path)'." diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 914117a0..0ac42a23 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -962,6 +962,7 @@ bool ValidateManifest( const std::string& rawManifest, const std::string& expectedRevision, bool production, + bool localTest, const std::filesystem::path& packageDirectory, Error* error) { std::string raw = rawManifest; @@ -984,6 +985,8 @@ bool ValidateManifest( const JsonValue* revision = ObjectField(*object, "sourceRevision"); const JsonValue* releaseEligible = ObjectField(*object, "releaseEligible"); const JsonValue* signingRoute = ObjectField(*object, "signingRoute"); + const JsonValue* testSignerCertificateSha256 = + ObjectField(*object, "testSignerCertificateSha256"); const JsonValue* driverVersion = ObjectField(*object, "driverPackageVersion"); const JsonValue* driverMajor = ObjectField(*object, "driverABIMajor"); const JsonValue* driverMinor = ObjectField(*object, "driverABIMinor"); @@ -994,6 +997,8 @@ bool ValidateManifest( const auto* revisionValue = revision == nullptr ? nullptr : std::get_if(&revision->value); const auto* releaseValue = releaseEligible == nullptr ? nullptr : std::get_if(&releaseEligible->value); const auto* routeValue = signingRoute == nullptr ? nullptr : std::get_if(&signingRoute->value); + const auto* testSignerCertificateSha256Value = testSignerCertificateSha256 == nullptr ? + nullptr : std::get_if(&testSignerCertificateSha256->value); const auto* driverVersionValue = driverVersion == nullptr ? nullptr : std::get_if(&driverVersion->value); const auto* driverMajorValue = driverMajor == nullptr ? nullptr : std::get_if(&driverMajor->value); const auto* driverMinorValue = driverMinor == nullptr ? nullptr : std::get_if(&driverMinor->value); @@ -1023,6 +1028,18 @@ bool ValidateManifest( return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, L"production installation requires a release-eligible HLK/WHCP manifest"); } + } else if (localTest) { + const bool signerDigestValid = testSignerCertificateSha256Value != nullptr && + testSignerCertificateSha256Value->size() == 64 && + std::all_of(testSignerCertificateSha256Value->begin(), + testSignerCertificateSha256Value->end(), [](char value) { + return (value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f'); + }); + if (*releaseValue || *routeValue != "LocalTest" || !signerDigestValid) { + return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, + L"local test installation requires its explicit non-release LocalTest manifest and signer digest"); + } } else if (*releaseValue || *routeValue != "ControlledTestAttestation") { return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, L"controlled-test installation requires its testing-only attestation manifest"); @@ -2690,6 +2707,7 @@ struct InstallOptions { std::string expectedSysSha256; std::string expectedCatSha256; bool production = true; + bool localTest = false; std::optional expectedDowngradeFrom; std::filesystem::path brokerExecutable; std::string brokerSha256; @@ -2805,6 +2823,7 @@ bool ValidateCandidateInputs( return false; } return ValidateManifest(manifestContents, options.sourceRevision, options.production, + options.localTest, *packageDirectory, error) && CheckTransactionDeadline(options, L"transaction-deadline-preflight", error); } @@ -4778,7 +4797,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "5a303ea9407bac958ab81eef7023cd108adbed1a478b88a863ea440cd097f1fe") { + "ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } @@ -5049,11 +5068,16 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro const std::wstring mode = argv[++index]; if (_wcsicmp(mode.c_str(), L"production") == 0) { options->production = true; + options->localTest = false; } else if (_wcsicmp(mode.c_str(), L"controlled-test") == 0) { options->production = false; + options->localTest = false; + } else if (_wcsicmp(mode.c_str(), L"local-test") == 0) { + options->production = false; + options->localTest = true; } else { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, - L"validation mode must be production or controlled-test"); + L"validation mode must be production, controlled-test, or local-test"); } modeSeen = true; } else if (_wcsicmp(argument.c_str(), L"--expected-inf-sha256") == 0 && @@ -5206,7 +5230,7 @@ void Usage() { std::wcerr << L"usage:\n" << L" ViiperUdeCtl.exe install --manifest --manifest-sha256 <64 hex> " - L"--source-revision <40-or-64 hex> --validation-mode " + L"--source-revision <40-or-64 hex> --validation-mode " L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms " @@ -5215,7 +5239,7 @@ void Usage() { L"--broker-token --broker-token-sha256 <64 hex> " L"--target-user-sid \n" << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " - L"--source-revision <40-or-64 hex> --validation-mode " + L"--source-revision <40-or-64 hex> --validation-mode " L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms \n" @@ -5241,11 +5265,12 @@ int RunViiperUdeCtl(int argc, wchar_t** argv) { EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); } - if (_wcsicmp(argv[1], L"install") == 0 && options.production && + if (_wcsicmp(argv[1], L"install") == 0 && + (options.production || options.localTest) && options.brokerExecutable.empty()) { Outcome outcome; SetError(&outcome.error, L"broker-required", ERROR_INVALID_PARAMETER, - L"production driver installation requires the authenticated broker transaction"); + L"production and local-test driver installation require the authenticated broker transaction"); outcome.exitCode = ExitCode::PreflightRejected; EmitOutcome(argv[1], outcome); return static_cast(outcome.exitCode); From 3e89c0bfb225d59bf84199126e23eab61bc37e89 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 16:31:09 -0500 Subject: [PATCH 180/240] Harden native UDE local test installation --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 40 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/README.md | 14 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 478 ++++++++++++++++-- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 10 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 9 +- native/udecx/tools/ViiperUdeCtl.cpp | 101 +++- 16 files changed, 596 insertions(+), 78 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index cfbd2c7f..4f0d89bb 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - 'ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68', + '335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 9c7a3c77..3653c280 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.7 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 - if ($identity -cne 'ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68') { + -DriverPackageVersion 0.1.0.8 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + if ($identity -cne '335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 1d1a223e..85c9b177 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.7", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.8", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 8cb19328..3c150dc4 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 9, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.7", + "expectedDriverPackageVersion": "0.1.0.8", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index ccd3ba48..5aedf4d1 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.7", + ExpectedDriverPackageVersion: "0.1.0.8", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index d815cb59..a2a79bfc 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -48,9 +48,11 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "signingRoute = 'LocalTest'", "releaseEligible = $false", "testSignerCertificateSha256", + "installerScriptSha256", "-ValidationMode LocalTest", "-RequireLocalTestToolchainValidation", "local-test-package.lock.json", + "Local test package lock SHA-256: $lockSha256", } { if !strings.Contains(composer, required) { t.Fatalf("local-test composer omitted %q", required) @@ -60,20 +62,44 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { installer := read("native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1") for _, required := range []string{ "[string]$TargetUserSID", - "& $brokerPath native-package-install", - "--expected-broker-sha256 $brokerHash", - "--expected-helper-sha256 $helperHash", - "--target-user-sid $TargetUserSID", - "--driver-validation-mode local-test", + "[string]$ExpectedPackageLockSHA256", + "$installerScriptStream", + "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", + "$lockAlgorithm.ComputeHash($lockBytes)", + "out-of-band workflow digest", + "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", + "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", + "Copy-ExactBrokerToProtectedStage", + "[IO.FileShare]::Read", + "[IO.FileOptions]::WriteThrough", + "$lockByPath['viiper.exe']", + "Remove-ProtectedStagingDirectory", + "Invoke-JoinedNativeProcess", + "if (-not $process.Start())", + "$Started.Value = $true", + "$process.WaitForExit()", + "$retainTrustOnFailure = $processStarted", + "'--expected-broker-sha256', $brokerHash", + "'--expected-helper-sha256', $helperHash", + "'--target-user-sid', $TargetUserSID", + "'--driver-validation-mode', 'local-test'", "-AcknowledgeDisposableTestMachine", "testsigning\\s+Yes", + "Restart, rerun this identical install command", } { if !strings.Contains(installer, required) { t.Fatalf("local-test installer omitted %q", required) } } - if strings.Contains(installer, "& $helperPath install") { - t.Fatal("local-test installation bypasses the full broker/package transaction") + for _, forbidden := range []string{ + "& $helperPath install", + "Test-ViiperUdeSignedPackage.ps1", + "git.exe", + "status --porcelain", + } { + if strings.Contains(installer, forbidden) { + t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) + } } packageCommand := read("internal", "cmd", "native_package.go") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index b54b6990..2054cc18 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.7" + DriverPackageVersion = "0.1.0.8" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 190f6c41..f5205971 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68" + const wantHex = "335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/README.md b/native/udecx/README.md index 4c6ab814..03e2a29b 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -142,12 +142,18 @@ key/value result line including `rebootRequired` and rollback status. For an exact branch build on a disposable local-test machine, download the `ViiperUde-x64-local-test-` artifact from a manually dispatched -native workflow, then run from the matching clean checkout: +native workflow. Copy the `Local test package lock SHA-256` value from that +exact workflow log as the out-of-band artifact binding, then run from the +matching source checkout. The installer holds its own script file deny-write +and deny-delete and requires its SHA-256 to match that authenticated lock; it +does not execute Git hooks or another repository PowerShell script while +elevated: ```powershell .\native\udecx\tools\Install-ViiperUdeLocalTest.ps1 ` -PackageRoot C:\ViiperUdeLocalTest ` -ExpectedSourceRevision 0123456789abcdef0123456789abcdef01234567 ` + -ExpectedPackageLockSHA256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ` -TargetUserSID S-1-5-21-111111111-222222222-333333333-1001 ` -AcknowledgeDisposableTestMachine ``` @@ -157,8 +163,10 @@ the exact WDK test signature sealed into the artifact lock, and Windows must trust that certificate while `TESTSIGNING` is active. It does not change the production Microsoft HLK/WHCP gate. -After installation (and any requested restart), the same artifact supplies the -source-bound evidence and probes for the real UdeCx test: +Exit `3010` means the attempted native transaction was safely rolled back: +reboot, rerun the identical installer command, and do not start live validation +until that retry returns exit `0`. After verified installation, the same +artifact supplies the source-bound evidence and probes for the real UdeCx test: ```powershell .\native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1 ` diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 9b63a6a2..ad9ac037 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.7 + 0.1.0.8 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index e0fbc447..bb5d83f2 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.7" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.8" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 6d3d2f98..23b441a3 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.7 +DriverVer=08/11/2026,0.1.0.8 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 1bc5d074..65e22a5f 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -5,6 +5,9 @@ param( [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] [string]$ExpectedSourceRevision, [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$ExpectedPackageLockSHA256, + [Parameter(Mandatory = $true)] [ValidatePattern('^S-1-5-21-(?:[0-9]+-){3}[0-9]+$')] [string]$TargetUserSID, [switch]$AcknowledgeDisposableTestMachine @@ -13,15 +16,37 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$installerScriptPath = (Resolve-Path -LiteralPath $PSCommandPath -ErrorAction Stop).Path +$installerScriptItem = Get-Item -LiteralPath $installerScriptPath -Force -ErrorAction Stop +if ($installerScriptItem.PSIsContainer -or + ($installerScriptItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'The local-test installer must be a regular non-reparse file.' +} +$installerScriptStream = [IO.FileStream]::new( + $installerScriptPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read) +try { +$installerScriptAlgorithm = [Security.Cryptography.SHA256]::Create() +try { + $actualInstallerScriptSha256 = ([BitConverter]::ToString( + $installerScriptAlgorithm.ComputeHash($installerScriptStream))).Replace('-', '').ToLowerInvariant() +} +finally { + $installerScriptAlgorithm.Dispose() +} + if (-not $AcknowledgeDisposableTestMachine) { throw 'Local test driver installation is for a disposable test machine only. Pass -AcknowledgeDisposableTestMachine.' } +$source = $ExpectedSourceRevision.ToLowerInvariant() +$expectedPackageLockSha256 = $ExpectedPackageLockSHA256.ToLowerInvariant() $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Local VIIPER driver installation requires an elevated PowerShell session.' } -$bcdOutput = (& bcdedit.exe /enum '{current}' 2>&1 | Out-String) +$bcdeditPath = Join-Path ([Environment]::SystemDirectory) 'bcdedit.exe' +$bcdOutput = (& $bcdeditPath /enum '{current}' 2>&1 | Out-String) if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before installation.`n$bcdOutput" } @@ -31,7 +56,7 @@ $lockPath = Join-Path $root 'local-test-package.lock.json' $manifestPath = Join-Path $root 'submission-manifest.json' $certificatePath = Join-Path $root 'ViiperUdeTest.cer' $helperPath = Join-Path $root 'ViiperUdeCtl.exe' -$brokerPath = Join-Path $root 'viiper.exe' +$packageBrokerPath = Join-Path $root 'viiper.exe' $signedPackage = Join-Path $root 'signed-package' $driverDirectory = Join-Path $root 'driver' @@ -55,6 +80,233 @@ function Assert-ExactDirectoryEntries { } } +function Initialize-ProtectedStagingDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + if (Test-Path -LiteralPath $Path) { + throw "Refusing to reuse local-test staging directory '$Path'." + } + $expectedSecurity = [Security.AccessControl.DirectorySecurity]::new() + $expectedSecurity.SetSecurityDescriptorSddlForm( + 'O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)', + [Security.AccessControl.AccessControlSections]::All) + $directory = [IO.Directory]::CreateDirectory($Path, $expectedSecurity) + if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local-test staging directory is a reparse point: '$Path'." + } + $actualSecurity = $directory.GetAccessControl( + [Security.AccessControl.AccessControlSections]::All) + $expectedBinary = [byte[]]::new($expectedSecurity.BinaryLength) + $actualBinary = [byte[]]::new($actualSecurity.BinaryLength) + $expectedSecurity.GetSecurityDescriptorBinaryForm($expectedBinary, 0) + $actualSecurity.GetSecurityDescriptorBinaryForm($actualBinary, 0) + if ([Convert]::ToBase64String($actualBinary) -cne + [Convert]::ToBase64String($expectedBinary)) { + throw "Local-test staging directory ACL verification failed for '$Path'." + } +} + +function Copy-ExactBrokerToProtectedStage { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$DestinationDirectory, + [Parameter(Mandatory = $true)][long]$ExpectedLength, + [Parameter(Mandatory = $true)][string]$ExpectedSHA256 + ) + + $destinationPath = Join-Path $DestinationDirectory 'viiper.exe' + $sourceStream = [IO.FileStream]::new( + $SourcePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read) + try { + if ($sourceStream.Length -ne $ExpectedLength) { + throw 'The broker changed before protected staging.' + } + $sourceAlgorithm = [Security.Cryptography.SHA256]::Create() + try { + $sourceDigest = ([BitConverter]::ToString( + $sourceAlgorithm.ComputeHash($sourceStream))).Replace('-', '').ToLowerInvariant() + } + finally { + $sourceAlgorithm.Dispose() + } + if ($sourceDigest -cne $ExpectedSHA256) { + throw 'The broker changed before protected staging.' + } + $sourceStream.Position = 0 + $destinationStream = [IO.FileStream]::new( + $destinationPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 1MB, [IO.FileOptions]::WriteThrough) + try { + $sourceStream.CopyTo($destinationStream) + $destinationStream.Flush($true) + } + finally { + $destinationStream.Dispose() + } + } + finally { + $sourceStream.Dispose() + } + $staged = Get-Item -LiteralPath $destinationPath -Force -ErrorAction Stop + if ($staged.PSIsContainer -or + ($staged.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $staged.Length -ne $ExpectedLength -or + (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne + $ExpectedSHA256) { + throw 'The protected staged broker failed exact verification.' + } + return $destinationPath +} + +function Remove-ProtectedStagingDirectory { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProgramDataRoot + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + $fullPath = [IO.Path]::GetFullPath($Path) + $expectedParent = [IO.Path]::GetFullPath($ProgramDataRoot).TrimEnd( + [IO.Path]::DirectorySeparatorChar) + if ([IO.Path]::GetDirectoryName($fullPath) -cne $expectedParent -or + [IO.Path]::GetFileName($fullPath) -notmatch '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$') { + throw "Refusing unsafe local-test staging cleanup '$Path'." + } + $directory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing unsafe local-test staging cleanup '$Path'." + } + $children = @(Get-ChildItem -LiteralPath $fullPath -Force) + if ($children.Count -gt 1 -or + ($children.Count -eq 1 -and + ($children[0].Name -cne 'viiper.exe' -or $children[0].PSIsContainer -or + ($children[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))) { + throw "Refusing local-test staging cleanup with unexpected entries in '$Path'." + } + if ($children.Count -eq 1) { + [IO.File]::Delete($children[0].FullName) + } + [IO.Directory]::Delete($fullPath, $false) +} + +function ConvertTo-WindowsProcessArgument { + param([AllowEmptyString()][Parameter(Mandatory = $true)][string]$Value) + + if ($Value.IndexOf([char]0) -ge 0) { + throw 'Native process argument contains NUL.' + } + if ($Value.Length -ne 0 -and $Value -notmatch '[\s"]') { + return $Value + } + $builder = [Text.StringBuilder]::new() + [void]$builder.Append([char]34) + $slashes = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq [char]92) { + ++$slashes + continue + } + if ($character -eq [char]34) { + [void]$builder.Append([char]92, (2 * $slashes) + 1) + [void]$builder.Append([char]34) + $slashes = 0 + continue + } + if ($slashes -ne 0) { + [void]$builder.Append([char]92, $slashes) + $slashes = 0 + } + [void]$builder.Append($character) + } + if ($slashes -ne 0) { + [void]$builder.Append([char]92, 2 * $slashes) + } + [void]$builder.Append([char]34) + return $builder.ToString() +} + +function Set-ExactProcessArguments { + param( + [Parameter(Mandatory = $true)][Diagnostics.ProcessStartInfo]$StartInfo, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + if ($null -ne $StartInfo.PSObject.Properties['ArgumentList']) { + foreach ($argument in $Arguments) { + $StartInfo.ArgumentList.Add($argument) + } + return + } + $StartInfo.Arguments = (($Arguments | ForEach-Object { + ConvertTo-WindowsProcessArgument -Value $_ + }) -join ' ') +} + +function Invoke-JoinedNativeProcess { + param( + [Parameter(Mandatory = $true)][string]$FileName, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$WorkingDirectory, + [Parameter(Mandatory = $true)][ref]$Started + ) + + $Started.Value = $false + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FileName + $startInfo.WorkingDirectory = $WorkingDirectory + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + Set-ExactProcessArguments -StartInfo $startInfo -Arguments $Arguments + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $joined = $false + try { + if (-not $process.Start()) { + throw 'The protected native broker process was not created.' + } + $Started.Value = $true + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + while (-not $joined) { + try { + $process.WaitForExit() + $joined = $true + } + catch { + # Never unwind while the exact mutating child may remain alive. + Start-Sleep -Milliseconds 250 + } + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $combined = @($stdout, $stderr) -join [Environment]::NewLine + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = @($combined -split '\r?\n' | Where-Object { $_.Length -ne 0 }) + } + } + finally { + if ($Started.Value -and -not $joined) { + while (-not $joined) { + try { + $process.WaitForExit() + $joined = $true + } + catch { + Start-Sleep -Milliseconds 250 + } + } + } + $process.Dispose() + } +} + Assert-ExactDirectoryEntries $root @( 'viiper.exe', 'ViiperUdeCtl.exe', 'ViiperUdeMediaProbe.exe', 'ViiperUdeInputProbe.exe', 'ViiperUdeLiveProbes.manifest.json', 'ViiperUdeTest.cer', @@ -68,11 +320,25 @@ Assert-ExactDirectoryEntries $signedPackage @( 'ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.pdb', 'ViiperUde.cat' ) -$lock = Get-Content -LiteralPath $lockPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop -$source = $ExpectedSourceRevision.ToLowerInvariant() +$lockBytes = [IO.File]::ReadAllBytes($lockPath) +$lockAlgorithm = [Security.Cryptography.SHA256]::Create() +try { + $actualPackageLockSha256 = ([BitConverter]::ToString( + $lockAlgorithm.ComputeHash($lockBytes))).Replace('-', '').ToLowerInvariant() +} +finally { + $lockAlgorithm.Dispose() +} +if ($actualPackageLockSha256 -cne $expectedPackageLockSha256) { + throw 'The local test package lock does not match the out-of-band workflow digest.' +} +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$lock = $strictUtf8.GetString($lockBytes) | ConvertFrom-Json -ErrorAction Stop if ([int]$lock.schema -ne 1 -or [string]$lock.sourceRevision -cne $source -or [string]$lock.driverBuildIdentity -notmatch '^[0-9a-f]{64}$' -or - [string]$lock.testSignerCertificateSha256 -notmatch '^[0-9a-f]{64}$') { + [string]$lock.testSignerCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.installerScriptSha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$lock.installerScriptSha256 -cne $actualInstallerScriptSha256) { throw 'The local test package lock does not match the requested source or schema.' } @@ -89,12 +355,14 @@ if ($entries.Count -ne $expectedPaths.Count) { throw 'The local test package lock has an incomplete or extra file list.' } $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$lockByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) foreach ($entry in $entries) { $relative = [string]$entry.path if ($expectedPaths -cnotcontains $relative -or -not $seen.Add($relative) -or [long]$entry.length -le 0 -or [string]$entry.sha256 -notmatch '^[0-9a-f]{64}$') { throw "The local test package lock contains an invalid entry '$relative'." } + $lockByPath.Add($relative, $entry) $path = Join-Path $root $relative.Replace('/', [IO.Path]::DirectorySeparatorChar) $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop if ($item.PSIsContainer -or @@ -121,80 +389,186 @@ if ($certificateSha256 -cne [string]$lock.testSignerCertificateSha256) { $expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) $addedStores = [Collections.Generic.List[string]]::new() -try { - foreach ($storeName in @('Root', 'TrustedPublisher')) { +function Remove-NewLocalTestTrust { + $removalErrors = [Collections.Generic.List[Exception]]::new() + foreach ($storeName in $addedStores) { $store = [Security.Cryptography.X509Certificates.X509Store]::new( $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) try { $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - $present = @($store.Certificates | Where-Object { + @($store.Certificates | Where-Object { [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes - }).Count -ne 0 - if (-not $present) { - $store.Add($certificate) - $addedStores.Add($storeName) - } + }) | ForEach-Object { $store.Remove($_) } + } + catch { + $removalErrors.Add($_.Exception) } finally { $store.Close() } } + if ($removalErrors.Count -ne 0) { + throw [AggregateException]::new( + 'Failed to remove one or more local-test trust anchors after a settled failure.', + [Exception[]]$removalErrors.ToArray()) + } +} + +function Test-SettledLocalTestFailure { + param([Parameter(Mandatory = $true)][object[]]$Lines) - & (Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1') ` - -PackageDirectory $signedPackage ` - -SubmissionManifestPath $manifestPath ` - -ExpectedSourceRevision $source ` - -ValidationMode LocalTest ` - -LocalTestCertificatePath $certificatePath + $pattern = '(?m)^result=error operation=install changed=(?[01]) ' + + 'rebootRequired=(?[01]) rollback=(?not-needed|succeeded|failed) ' + + 'exitCode=(?[0-9]+)(?: .*)?\r?$' + $matches = [regex]::Matches(($Lines | Out-String), $pattern) + if ($matches.Count -ne 1) { + return $false + } + $match = $matches[0] + return ($match.Groups['changed'].Value -ceq '0' -and + $match.Groups['reboot'].Value -ceq '0' -and + $match.Groups['rollback'].Value -ceq 'not-needed' -and + $match.Groups['exit'].Value -in @('1', '4')) -or + ($match.Groups['changed'].Value -ceq '1' -and + $match.Groups['reboot'].Value -ceq '0' -and + $match.Groups['rollback'].Value -ceq 'succeeded' -and + $match.Groups['exit'].Value -ceq '1') } -catch { - foreach ($storeName in $addedStores) { + +$trustCommitted = $false +$retainTrustOnFailure = $false +$stageDirectory = $null +$programDataRoot = $null +try { + foreach ($storeName in @('Root', 'TrustedPublisher')) { $store = [Security.Cryptography.X509Certificates.X509Store]::new( $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) try { $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - @($store.Certificates | Where-Object { + $present = @($store.Certificates | Where-Object { [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes - }) | ForEach-Object { $store.Remove($_) } + }).Count -ne 0 + if (-not $present) { + $store.Add($certificate) + $addedStores.Add($storeName) + } } finally { $store.Close() } } - throw -} -foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { - $runtime = Join-Path $driverDirectory $name - $evidence = Join-Path $signedPackage $name - if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne - (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { - throw "Runtime driver file '$name' differs from its validated evidence copy." + foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys', 'ViiperUde.cat')) { + $runtime = Join-Path $driverDirectory $name + $evidence = Join-Path $signedPackage $name + if ((Get-FileHash -LiteralPath $runtime -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $evidence -Algorithm SHA256).Hash) { + throw "Runtime driver file '$name' differs from its validated evidence copy." + } } -} -$manifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() -$infHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.inf') -Algorithm SHA256).Hash.ToLowerInvariant() -$sysHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.sys') -Algorithm SHA256).Hash.ToLowerInvariant() -$catHash = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.cat') -Algorithm SHA256).Hash.ToLowerInvariant() -$brokerHash = (Get-FileHash -LiteralPath $brokerPath -Algorithm SHA256).Hash.ToLowerInvariant() -$helperHash = (Get-FileHash -LiteralPath $helperPath -Algorithm SHA256).Hash.ToLowerInvariant() -$output = @(& $brokerPath native-package-install ` - --package-directory $driverDirectory --submission-manifest $manifestPath ` - --source-revision $source --driver-helper $helperPath ` - --expected-broker-sha256 $brokerHash --expected-helper-sha256 $helperHash ` - --expected-manifest-sha256 $manifestHash --expected-inf-sha256 $infHash ` - --expected-sys-sha256 $sysHash --expected-cat-sha256 $catHash ` - --target-user-sid $TargetUserSID --driver-validation-mode local-test 2>&1) -$exitCode = $LASTEXITCODE -$output | ForEach-Object { Write-Host $_ } -if ($exitCode -notin @(0, 3010)) { - throw "Local VIIPER driver transaction failed with exit code $exitCode." + $manifestHash = [string]($lockByPath['submission-manifest.json'].sha256) + $infHash = [string]($lockByPath['driver/ViiperUde.inf'].sha256) + $sysHash = [string]($lockByPath['driver/ViiperUde.sys'].sha256) + $catHash = [string]($lockByPath['driver/ViiperUde.cat'].sha256) + $brokerEntry = $lockByPath['viiper.exe'] + $brokerHash = [string]$brokerEntry.sha256 + $helperHash = [string]($lockByPath['ViiperUdeCtl.exe'].sha256) + + $programDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path + $programDataItem = Get-Item -LiteralPath $programDataRoot -Force -ErrorAction Stop + if (-not $programDataItem.PSIsContainer -or + ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "ProgramData is not a safe staging parent: '$programDataRoot'." + } + $stageDirectory = Join-Path $programDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + Initialize-ProtectedStagingDirectory -Path $stageDirectory + $brokerPath = Copy-ExactBrokerToProtectedStage ` + -SourcePath $packageBrokerPath -DestinationDirectory $stageDirectory ` + -ExpectedLength ([long]$brokerEntry.length) -ExpectedSHA256 $brokerHash + + $output = @() + $exitCode = $null + $launchError = $null + $processStarted = $false + $brokerArguments = @( + 'native-package-install', + '--package-directory', $driverDirectory, + '--submission-manifest', $manifestPath, + '--source-revision', $source, + '--driver-helper', $helperPath, + '--expected-broker-sha256', $brokerHash, + '--expected-helper-sha256', $helperHash, + '--expected-manifest-sha256', $manifestHash, + '--expected-inf-sha256', $infHash, + '--expected-sys-sha256', $sysHash, + '--expected-cat-sha256', $catHash, + '--target-user-sid', $TargetUserSID, + '--driver-validation-mode', 'local-test' + ) + try { + $processResult = Invoke-JoinedNativeProcess ` + -FileName $brokerPath -Arguments $brokerArguments ` + -WorkingDirectory $stageDirectory -Started ([ref]$processStarted) + $retainTrustOnFailure = $processStarted + $exitCode = [int]$processResult.ExitCode + $output = @($processResult.Output) + } + catch { + $retainTrustOnFailure = $processStarted + $launchError = $_ + } + $output | ForEach-Object { Write-Host $_ } + if ($null -ne $exitCode) { + if ($exitCode -in @(0, 3010)) { + $trustCommitted = $true + } + elseif (Test-SettledLocalTestFailure -Lines $output) { + $retainTrustOnFailure = $false + } + } + Remove-ProtectedStagingDirectory ` + -Path $stageDirectory -ProgramDataRoot $programDataRoot + $stageDirectory = $null + if ($null -ne $launchError) { + throw $launchError + } + if ($exitCode -notin @(0, 3010)) { + throw "Local VIIPER driver transaction failed with exit code $exitCode." + } + if ($exitCode -eq 3010) { + Write-Warning 'The attempted native transaction was safely rolled back and requires a reboot. Restart, rerun this identical install command, and proceed to live validation only after it returns exit 0.' + exit 3010 + } } -if ($exitCode -eq 3010) { - Write-Warning 'The verified driver transaction requires a reboot. Restart before running live validation.' - exit 3010 +catch { + $failure = $_ + $cleanupFailure = $null + if ($null -ne $stageDirectory -and $null -ne $programDataRoot) { + try { + Remove-ProtectedStagingDirectory ` + -Path $stageDirectory -ProgramDataRoot $programDataRoot + $stageDirectory = $null + } + catch { + $cleanupFailure = $_ + } + } + if (-not $trustCommitted -and -not $retainTrustOnFailure) { + Remove-NewLocalTestTrust + } + if ($null -ne $cleanupFailure) { + throw [AggregateException]::new( + 'Local VIIPER installation failed and protected staging cleanup also failed.', + [Exception[]]@($failure.Exception, $cleanupFailure.Exception)) + } + throw $failure } Write-Host 'The exact local test-signed VIIPER UdeCx driver and native broker are installed, authenticated, and ready.' Write-Host 'Next: enable Driver Verifier for ViiperUde.sys, reboot, then run Invoke-ViiperUdeLiveValidation.ps1 in LocalTest mode.' +} +finally { + $installerScriptStream.Dispose() +} diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index ad6e2f91..75e23e70 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -157,16 +157,23 @@ $lockFiles = @( } } ) +$installerScriptPath = (Resolve-Path -LiteralPath ( + Join-Path $PSScriptRoot 'Install-ViiperUdeLocalTest.ps1') -ErrorAction Stop).Path +$installerScriptSha256 = (Get-FileHash -LiteralPath $installerScriptPath ` + -Algorithm SHA256).Hash.ToLowerInvariant() $lock = [ordered]@{ schema = 1 sourceRevision = $source driverPackageVersion = $driverVersion driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 + installerScriptSha256 = $installerScriptSha256 files = $lockFiles } -[IO.File]::WriteAllText((Join-Path $output 'local-test-package.lock.json'), +$lockPath = Join-Path $output 'local-test-package.lock.json' +[IO.File]::WriteAllText($lockPath, ($lock | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) +$lockSha256 = (Get-FileHash -LiteralPath $lockPath -Algorithm SHA256).Hash.ToLowerInvariant() & (Join-Path $PSScriptRoot 'Test-ViiperUdeSignedPackage.ps1') ` -PackageDirectory $signedDirectory ` @@ -180,3 +187,4 @@ Write-Host "Created compact source-bound local test package at '$output'." Write-Host "Source: $source" Write-Host "Driver: $driverVersion / ABI 1.9 / $buildIdentity" Write-Host "Test signer certificate SHA-256: $certificateSha256" +Write-Host "Local test package lock SHA-256: $lockSha256" diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index c12daf79..1d109604 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -49,6 +49,11 @@ $requiredContracts = [ordered]@{ 'local test manifest separation' = '"signingRoute"[\s\S]{0,6000}"LocalTest"' 'non-release local test enforcement' = 'else if \(localTest\)[\s\S]{0,900}\*releaseValue' 'local test signer digest shape' = 'testSignerCertificateSha256Value->size\(\) == 64' + 'local test native signer verification' = 'VerifyLocalTestPackageSigner\(' + 'local test exact signer certificate digest' = + 'actualCertificateSha256 != expectedCertificateSha256' + 'local test INF and SYS catalog membership' = + 'bool VerifyLocalTestPackageSigner\([\s\S]{0,220}Error\* error\) \{[\s\S]{0,1800}VerifyDriverCatalogMember\(catalogPath, infPath[\s\S]{0,180}infPath\.parent_path\(\) / kDriverFileName' 'staged broker hash binding' = '--broker-sha256' 'protected package token binding' = '--broker-token-sha256' 'nested package broker commit' = 'native-package-broker-commit' @@ -207,8 +212,8 @@ if ([regex]::Matches($source, '\bRemoveAllExactDevices\(').Count -ne 2) { throw 'All-device removal is allowed only for explicit forward uninstall, never rollback.' } -if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -ne 2) { - throw 'Production validation must bind both the exact INF and SYS to the exact adjacent catalog.' +if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -ne 4) { + throw 'Production and LocalTest validation must each bind the exact INF and SYS to the exact adjacent catalog.' } $forceInfUses = [regex]::Matches($source, '\bDIIRFLAG_FORCE_INF\b').Count diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 0ac42a23..36922de8 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -958,6 +958,11 @@ bool FileLength(const std::filesystem::path& path, uint64_t* length, Error* erro return true; } +bool VerifyLocalTestPackageSigner( + const std::filesystem::path& infPath, + std::string_view expectedCertificateSha256, + Error* error); + bool ValidateManifest( const std::string& rawManifest, const std::string& expectedRevision, @@ -1040,6 +1045,12 @@ bool ValidateManifest( return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, L"local test installation requires its explicit non-release LocalTest manifest and signer digest"); } + if (!VerifyLocalTestPackageSigner( + packageDirectory / L"ViiperUde.inf", + *testSignerCertificateSha256Value, + error)) { + return false; + } } else if (*releaseValue || *routeValue != "ControlledTestAttestation") { return SetError(error, L"manifest-release-route", ERROR_INVALID_DATA, L"controlled-test installation requires its testing-only attestation manifest"); @@ -1365,7 +1376,93 @@ bool VerifyDriverCatalogMember( releasePolicy(); if (status != ERROR_SUCCESS) { return SetError(error, L"catalog-member-policy", static_cast(status), - L"package file is not a valid member of the exact Microsoft driver catalog"); + L"package file is not a valid member of the exact trusted driver catalog"); + } + return true; +} + +bool VerifyLocalTestPackageSigner( + const std::filesystem::path& infPath, + std::string_view expectedCertificateSha256, + Error* error) { + SP_INF_SIGNER_INFO_W signer{}; + signer.cbSize = sizeof(signer); + if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { + return SetLastErrorDetail(error, L"inf-local-test-signature"); + } + if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { + return SetError(error, L"inf-local-test-signature", ERROR_INVALID_DATA, + L"local test INF did not report a catalog and signer"); + } + const std::filesystem::path reportedCatalogPath = signer.CatalogFile; + if (_wcsicmp(reportedCatalogPath.filename().c_str(), kCatalogName) != 0) { + return SetError(error, L"inf-local-test-signature", ERROR_INVALID_DATA, + L"local test INF did not report the exact VIIPER catalog"); + } + const std::filesystem::path catalogPath = infPath.parent_path() / kCatalogName; + if (!VerifyDriverCatalogMember(catalogPath, infPath, error) || + !VerifyDriverCatalogMember(catalogPath, + infPath.parent_path() / kDriverFileName, error)) { + return false; + } + + DWORD encoding = 0; + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED | CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, nullptr, nullptr, + &store, &message, nullptr)) { + return SetLastErrorDetail(error, L"local-test-catalog-signature-open"); + } + const auto closeCatalog = [&]() { + if (message != nullptr) { + CryptMsgClose(message); + } + if (store != nullptr) { + CertCloseStore(store, 0); + } + }; + DWORD signerSize = 0; + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &signerSize) || + signerSize < sizeof(CMSG_SIGNER_INFO)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-info", code); + } + std::vector signerBytes(signerSize); + if (!CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, + signerBytes.data(), &signerSize)) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-info", code); + } + const auto* signerInfo = reinterpret_cast(signerBytes.data()); + CERT_INFO certificateIdentity{}; + certificateIdentity.Issuer = signerInfo->Issuer; + certificateIdentity.SerialNumber = signerInfo->SerialNumber; + PCCERT_CONTEXT certificate = CertFindCertificateInStore(store, encoding, 0, + CERT_FIND_SUBJECT_CERT, &certificateIdentity, nullptr); + if (certificate == nullptr) { + const DWORD code = GetLastError(); + closeCatalog(); + return SetError(error, L"local-test-catalog-signer-certificate", code); + } + std::string actualCertificateSha256; + const std::string_view encodedCertificate( + reinterpret_cast(certificate->pbCertEncoded), + certificate->cbCertEncoded); + const bool hashed = Sha256Data( + encodedCertificate, &actualCertificateSha256, error); + CertFreeCertificateContext(certificate); + closeCatalog(); + if (!hashed) { + return false; + } + if (actualCertificateSha256 != expectedCertificateSha256) { + return SetError(error, L"local-test-catalog-signer-certificate", + ERROR_CRC, + L"local test catalog signer does not match the source-bound manifest digest"); } return true; } @@ -4797,7 +4894,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "ef471e2e53b7c110cbadd3c15d17b10d26ce4cefe2bef7a11e72c2aca657cc68") { + "335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 3e2be1bb888d409a2d23c63757fcae035406078a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 17:31:11 -0500 Subject: [PATCH 181/240] Preserve native controller transitions at host cadence --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- device/dualsense/device.go | 120 +++++----- device/dualsense/ds_handler.go | 5 +- device/dualsense/scheduled_input_test.go | 39 ++++ device/dualshock4/device.go | 114 ++++----- device/dualshock4/handler.go | 9 +- device/dualshock4/scheduled_input_test.go | 39 ++++ device/internal/inputstatequeue/queue.go | 221 ++++++++++++++++++ device/internal/inputstatequeue/queue_test.go | 125 ++++++++++ docs/api/overview.md | 4 +- .../native-udecx-package-install.md | 2 +- docs/architecture/native-udecx-signing.md | 4 +- docs/architecture/native-udecx.md | 56 +++-- internal/cmd/native_transport_test.go | 6 +- internal/server/api/handler/ping_test.go | 4 +- internal/server/api/router.go | 14 ++ internal/server/api/server.go | 43 +++- internal/server/usb/native.go | 12 + internal/server/usb/native_test.go | 58 +++++ internal/transport/udecx/client_windows.go | 3 + .../udecx/driver_dispatch_contract_test.go | 43 +++- internal/transport/udecx/host.go | 38 ++- internal/transport/udecx/host_test.go | 83 +++++++ internal/transport/udecx/protocol.go | 23 +- .../transport/udecx/protocol_contract_test.go | 4 +- internal/transport/udecx/protocol_test.go | 24 +- native/udecx/driver/Device.c | 216 +++++++++++++---- native/udecx/driver/ViiperUde.h | 10 + native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 11 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 2 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 6 +- .../tools/Test-ViiperUdeReleaseBundle.ps1 | 4 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 4 +- usb/device.go | 20 ++ 38 files changed, 1137 insertions(+), 243 deletions(-) create mode 100644 device/internal/inputstatequeue/queue.go create mode 100644 device/internal/inputstatequeue/queue_test.go diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 4f0d89bb..c2e67859 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2', + '0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 3653c280..f8182f58 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.8 -ABIMajor 1 -ABIMinor 9 -Capabilities 13 - if ($identity -cne '335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2') { + -DriverPackageVersion 0.1.0.9 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 85c9b177..6d9eb8de 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.8", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.9", LoadedDriverBuildIdentity: expected, }, } diff --git a/device/dualsense/device.go b/device/dualsense/device.go index ca3ce069..55024da9 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -4,7 +4,6 @@ import ( "context" "encoding/binary" "encoding/json" - "errors" "fmt" "io" "log/slog" @@ -14,6 +13,7 @@ import ( "time" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/inputstatequeue" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" ) @@ -51,19 +51,19 @@ const ( outputFlag2LightbarBrightness = 0x01 outputFlag2LightbarSetup = 0x02 - outputRightTriggerOffset = 11 - outputLeftTriggerOffset = 22 - outputTriggerLength = 11 - outputPlayerLedsOffset = 44 - outputLightbarOffset = 45 + outputRightTriggerOffset = 11 + outputLeftTriggerOffset = 22 + outputTriggerLength = 11 + outputPlayerLedsOffset = 44 + outputLightbarOffset = 45 + inputTransitionQueueCapacity = 256 ) type DualSense struct { - deviceType string - inputCh chan InputState - inputState InputState - inputPublishMu sync.Mutex - metaState *MetaState + deviceType string + inputQueue *inputstatequeue.Queue[InputState] + inputState InputState + metaState *MetaState // Output and media publication use independent gates. HID state remains // valid across an audio-pipe reset, while speaker/haptics data does not. @@ -198,8 +198,9 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = *NewInputState() - d.inputCh = make(chan InputState, 1) - d.inputCh <- d.inputState + d.inputQueue = inputstatequeue.New( + d.inputState, dualSenseInputEdgeSignature(d.inputState), + inputTransitionQueueCapacity) d.timestampBase = time.Now() return d, nil @@ -314,26 +315,35 @@ func (d *DualSense) beginSpeakerStream() *dualSenseSpeakerStreamTelemetry { return telemetry } -func (d *DualSense) UpdateInputState(state *InputState) { - d.inputPublishMu.Lock() - defer d.inputPublishMu.Unlock() +func (d *DualSense) UpdateInputState(state *InputState) error { + return d.UpdateInputStateUntil(nil, state) +} +func (d *DualSense) UpdateInputStateUntil(done <-chan struct{}, state *InputState) error { next := *NewInputState() if state != nil { next = *state } - + if err := d.inputQueue.PublishUntil( + done, next, dualSenseInputEdgeSignature(next)); err != nil { + return err + } d.mtx.Lock() d.inputState = next d.mtx.Unlock() + return nil +} - select { - case <-d.inputCh: - default: - } - select { - case d.inputCh <- next: - default: +func dualSenseInputEdgeSignature(state InputState) uint64 { + return uint64(state.Buttons) | + uint64(state.DPad)<<32 | + uint64(encodeTouchStatus(state.Touch1Active, state.Touch1Tracking))<<40 | + uint64(encodeTouchStatus(state.Touch2Active, state.Touch2Tracking))<<48 +} + +func (d *DualSense) InvalidateInterruptInput(endpoint uint8) { + if endpoint == 0 || endpoint&0x0f == EndpointIn&0x0f { + d.inputQueue.Invalidate() } } @@ -476,22 +486,14 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o if dir == usb.DirectionIn { switch epNumber { case EndpointIn & 0x0F: - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - d.mtx.Lock() - is := d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) - } + is, _, err := d.inputQueue.Wait(ctx, nil) + if err != nil { return nil - case is := <-d.inputCh: - d.mtx.Lock() - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) case EndpointMicrophoneIn & 0x0F: return d.handleMicrophoneIn(ctx) default: @@ -517,7 +519,8 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o // completed, so encoding here removes the per-sample report allocation without // changing USB/IP behavior. func (d *DualSense) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { - return d.readInterruptInput(ctx, nil, ep, dst) + written, _, err := d.readInterruptInput(ctx, nil, ep, dst) + return written, err } // ReadScheduledInterruptInput preserves the DualSense report encoder and its @@ -526,43 +529,34 @@ func (d *DualSense) ReadInterruptInput(ctx context.Context, ep uint32, dst []byt func (d *DualSense) ReadScheduledInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, ) (int, error) { + written, _, err := d.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (d *DualSense) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { return d.readInterruptInput(ctx, deadline, ep, dst) } func (d *DualSense) readInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, -) (int, error) { +) (int, bool, error) { if ep&0x0f != EndpointIn&0x0f { - return 0, fmt.Errorf("DualSense interrupt-IN endpoint %d is unsupported", ep) + return 0, false, fmt.Errorf("DualSense interrupt-IN endpoint %d is unsupported", ep) } if deadline != nil && ctx.Err() != nil { - return 0, ctx.Err() + return 0, false, ctx.Err() } - var is InputState - select { - case <-ctx.Done(): - if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { - return 0, ctx.Err() - } - d.mtx.Lock() - is = d.inputState - d.mtx.Unlock() - case <-deadline: - if ctx.Err() != nil { - return 0, ctx.Err() - } - d.mtx.Lock() - is = d.inputState - d.mtx.Unlock() - case is = <-d.inputCh: - if deadline != nil && ctx.Err() != nil { - return 0, ctx.Err() - } + is, transition, err := d.inputQueue.Wait(ctx, deadline) + if err != nil { + return 0, false, err } d.mtx.Lock() ms := *d.metaState d.mtx.Unlock() - return d.buildUSBInputReportInto(&is, &ms, dst) + written, err := d.buildUSBInputReportInto(&is, &ms, dst) + return written, transition, err } func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 94869358..799cae05 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -193,6 +193,7 @@ func releaseDualSenseIdentity(devPtr *usb.Device, deviceName string) { } func readDualSenseV5InputStream(conn net.Conn, dse *DualSense, logger *slog.Logger) error { + streamDone := api.StreamDone(conn) header := make([]byte, StreamFrameHeaderSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) @@ -265,7 +266,9 @@ func readDualSenseV5InputStream(conn net.Conn, dse *DualSense, logger *slog.Logg if err := state.UnmarshalBinary(input); err != nil { return fmt.Errorf("unmarshal framed input state: %w", err) } - dse.UpdateInputState(&state) + if err := dse.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue framed DualSense input state: %w", err) + } case StreamFrameMicrophonePCM: dse.QueueMicrophonePCMFrame(microphonePCM) } diff --git a/device/dualsense/scheduled_input_test.go b/device/dualsense/scheduled_input_test.go index 8e009d34..e7367cb6 100644 --- a/device/dualsense/scheduled_input_test.go +++ b/device/dualsense/scheduled_input_test.go @@ -60,3 +60,42 @@ func TestScheduledInterruptInputPreservesDualSenseStateAndCadence(t *testing.T) t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) } } + +func TestClassifiedNativeInputPreservesQueuedDualSenseTransitions(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + press := NewInputState() + press.LX, press.Buttons = -43, ButtonCross + release := NewInputState() + release.LX = 59 + dev.UpdateInputState(press) + dev.UpdateInputState(release) + + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(press.LX)+128) { + t.Fatalf("press read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(release.LX)+128) { + t.Fatalf("release read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } + analog := *release + analog.LX = 21 + if err = dev.UpdateInputState(&analog); err != nil { + t.Fatal(err) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || transition || + buffer[1] != uint8(int16(analog.LX)+128) { + t.Fatalf("analog read=(%d, %t, %v) state=%x", written, transition, err, buffer[:11]) + } +} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 1a3d7c5c..9e5ddd34 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -4,7 +4,6 @@ import ( "context" "encoding/binary" "encoding/json" - "errors" "fmt" "io" "log/slog" @@ -13,6 +12,7 @@ import ( "time" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/inputstatequeue" "github.com/Alia5/VIIPER/device/internal/microphonebuffer" "github.com/Alia5/VIIPER/usb" ) @@ -20,13 +20,13 @@ import ( const ( microphoneTargetClientFrames = 6 // 60 ms absorbs the DS4's 8/8/8/16 ms framing and host scheduling jitter. microphoneMaximumClientFrames = 20 // 200 ms emergency ceiling for full-duplex BT bursts; steady state remains about 55 ms. + inputTransitionQueueCapacity = 256 ) type DualShock4 struct { - inputCh chan *InputState - inputState *InputState - inputPublishMu sync.Mutex - metaState *MetaState + inputQueue *inputstatequeue.Queue[InputState] + inputState *InputState + metaState *MetaState outputPublishMu sync.RWMutex speakerPublishMu sync.RWMutex @@ -121,8 +121,9 @@ func New(o *device.CreateOptions) (*DualShock4, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = NewInputState() - d.inputCh = make(chan *InputState, 1) - d.inputCh <- d.inputState + d.inputQueue = inputstatequeue.New( + *d.inputState, dualShock4InputEdgeSignature(*d.inputState), + inputTransitionQueueCapacity) d.timestampBase = time.Now() return d, nil @@ -187,24 +188,40 @@ func (d *DualShock4) replaceSpeakerCallbacks(update func()) { } } -func (d *DualShock4) UpdateInputState(state *InputState) { - d.inputPublishMu.Lock() - defer d.inputPublishMu.Unlock() +func (d *DualShock4) UpdateInputState(state *InputState) error { + return d.UpdateInputStateUntil(nil, state) +} +func (d *DualShock4) UpdateInputStateUntil(done <-chan struct{}, state *InputState) error { next := *NewInputState() if state != nil { next = *state } - nextPtr := &next - + if err := d.inputQueue.PublishUntil( + done, next, dualShock4InputEdgeSignature(next)); err != nil { + return err + } d.mtx.Lock() - d.inputState = nextPtr + d.inputState = &next d.mtx.Unlock() - select { - case <-d.inputCh: - default: + return nil +} + +func dualShock4InputEdgeSignature(state InputState) uint64 { + signature := uint64(state.Buttons) | uint64(state.DPad)<<16 + if state.Touch1Active { + signature |= 1 << 24 + } + if state.Touch2Active { + signature |= 1 << 32 + } + return signature +} + +func (d *DualShock4) InvalidateInterruptInput(endpoint uint8) { + if endpoint == 0 || endpoint&0x0f == EndpointIn&0x0f { + d.inputQueue.Invalidate() } - d.inputCh <- nextPtr } func (d *DualShock4) GetDescriptor() *usb.Descriptor { @@ -320,22 +337,14 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if dir == usb.DirectionIn { switch epNumber { case 4: - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - d.mtx.Lock() - is := d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(is, &ms) - } + is, _, err := d.inputQueue.Wait(ctx, nil) + if err != nil { return nil - case is := <-d.inputCh: - d.mtx.Lock() - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(is, &ms) } + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) case EndpointMicrophoneIn & 0x0F: return d.handleMicrophoneIn(ctx) default: @@ -401,7 +410,8 @@ func (d *DualShock4) publishSpeakerPCM(revision uint64, pcm []byte) bool { // writes the controller's next HID sample into caller-owned storage; USB/IP // continues to use HandleTransfer and its independently owned report slice. func (d *DualShock4) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { - return d.readInterruptInput(ctx, nil, ep, dst) + written, _, err := d.readInterruptInput(ctx, nil, ep, dst) + return written, err } // ReadScheduledInterruptInput keeps the exact DualShock 4 packet counter and @@ -410,44 +420,34 @@ func (d *DualShock4) ReadInterruptInput(ctx context.Context, ep uint32, dst []by func (d *DualShock4) ReadScheduledInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, ) (int, error) { + written, _, err := d.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (d *DualShock4) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { return d.readInterruptInput(ctx, deadline, ep, dst) } func (d *DualShock4) readInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, -) (int, error) { +) (int, bool, error) { if ep&0x0f != EndpointIn&0x0f { - return 0, fmt.Errorf("DualShock 4 interrupt-IN endpoint %d is unsupported", ep) + return 0, false, fmt.Errorf("DualShock 4 interrupt-IN endpoint %d is unsupported", ep) } if deadline != nil && ctx.Err() != nil { - return 0, ctx.Err() + return 0, false, ctx.Err() } - var is InputState - select { - case <-ctx.Done(): - if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { - return 0, ctx.Err() - } - d.mtx.Lock() - is = *d.inputState - d.mtx.Unlock() - case <-deadline: - if ctx.Err() != nil { - return 0, ctx.Err() - } - d.mtx.Lock() - is = *d.inputState - d.mtx.Unlock() - case next := <-d.inputCh: - if deadline != nil && ctx.Err() != nil { - return 0, ctx.Err() - } - is = *next + is, transition, err := d.inputQueue.Wait(ctx, deadline) + if err != nil { + return 0, false, err } d.mtx.Lock() ms := *d.metaState d.mtx.Unlock() - return d.buildUSBInputReportInto(&is, &ms, dst) + written, err := d.buildUSBInputReportInto(&is, &ms, dst) + return written, transition, err } func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index c6bac016..ba30720f 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -486,6 +486,7 @@ func (w *dualShock4OutputWriter) requestStop() { func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, logger *slog.Logger, microphoneInput bool, frameVersion byte) error { + streamDone := api.StreamDone(conn) if !microphoneInput { buf := make([]byte, InputStateSize) for { @@ -501,7 +502,9 @@ func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, if err := state.UnmarshalBinary(buf); err != nil { return fmt.Errorf("unmarshal input state: %w", err) } - ds4.UpdateInputState(&state) + if err := ds4.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue input state: %w", err) + } } } @@ -581,7 +584,9 @@ func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, if err := state.UnmarshalBinary(input); err != nil { return fmt.Errorf("unmarshal framed DualShock 4 input state: %w", err) } - ds4.UpdateInputState(&state) + if err := ds4.UpdateInputStateUntil(streamDone, &state); err != nil { + return fmt.Errorf("queue framed DualShock 4 input state: %w", err) + } case StreamFrameMicrophonePCM: ds4.QueueMicrophonePCMFrame(microphonePCM) } diff --git a/device/dualshock4/scheduled_input_test.go b/device/dualshock4/scheduled_input_test.go index 96747dba..b1bfaa8b 100644 --- a/device/dualshock4/scheduled_input_test.go +++ b/device/dualshock4/scheduled_input_test.go @@ -60,3 +60,42 @@ func TestScheduledInterruptInputPreservesDualShock4StateAndCadence(t *testing.T) t.Fatalf("post-cancel timestamp=%d before previous=%d", thirdTimestamp, secondTimestamp) } } + +func TestClassifiedNativeInputPreservesQueuedDualShock4Transitions(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + press := NewInputState() + press.LX, press.Buttons = -51, ButtonCross + release := NewInputState() + release.LX = 67 + dev.UpdateInputState(press) + dev.UpdateInputState(release) + + buffer := make([]byte, InputReportSize) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(press.LX)+128) { + t.Fatalf("press read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || !transition || + buffer[1] != uint8(int16(release.LX)+128) { + t.Fatalf("release read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } + analog := *release + analog.LX = 19 + if err = dev.UpdateInputState(&analog); err != nil { + t.Fatal(err) + } + written, transition, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, EndpointIn&0x0f, buffer) + if err != nil || written != InputReportSize || transition || + buffer[1] != uint8(int16(analog.LX)+128) { + t.Fatalf("analog read=(%d, %t, %v) state=%x", written, transition, err, buffer[:12]) + } +} diff --git a/device/internal/inputstatequeue/queue.go b/device/internal/inputstatequeue/queue.go new file mode 100644 index 00000000..1bdbf4ee --- /dev/null +++ b/device/internal/inputstatequeue/queue.go @@ -0,0 +1,221 @@ +package inputstatequeue + +import ( + "context" + "errors" + "math" + "sync" + "time" +) + +var ( + ErrRevisionExhausted = errors.New("input state revision is exhausted") + ErrGenerationChanged = errors.New("input transition generation changed") + ErrBackpressureTimeout = errors.New("input transition backpressure timed out") +) + +const defaultBackpressureTimeout = 5 * time.Second + +type entry[T any] struct { + state T + revision uint64 +} + +// Queue retains every discrete controller transition in a fixed ring while +// coalescing analog/motion-only updates into one latest-state snapshot. Signal +// is edge-triggered; revision bookkeeping re-arms it until all retained work +// has been observed. +type Queue[T any] struct { + mu sync.Mutex + + transitions []entry[T] + signal chan struct{} + space chan struct{} + head int + count int + + latest T + latestRevision uint64 + deliveredRevision uint64 + edgeSignature uint64 + generation uint64 + backpressureTimeout time.Duration +} + +func New[T any](initial T, edgeSignature uint64, capacity int) *Queue[T] { + if capacity <= 0 { + panic("input transition queue capacity must be positive") + } + return &Queue[T]{ + transitions: make([]entry[T], capacity), + signal: make(chan struct{}, 1), + space: make(chan struct{}, 1), + latest: initial, + latestRevision: 1, + deliveredRevision: 1, + edgeSignature: edgeSignature, + backpressureTimeout: defaultBackpressureTimeout, + } +} + +// Publish accepts one source-ordered state. A changed edge signature is +// retained exactly; an unchanged signature updates only the latest snapshot. +// Capacity pressure is propagated to the producer before any state is +// accepted, so no half-committed controller state can be published. +func (q *Queue[T]) Publish(state T, edgeSignature uint64) error { + return q.PublishUntil(nil, state, edgeSignature) +} + +// PublishUntil applies bounded backpressure for a discrete transition. Closing +// done cancels a publication which has not yet been accepted; nil waits until +// lifecycle invalidation or the consumer frees a slot. +func (q *Queue[T]) PublishUntil( + done <-chan struct{}, state T, edgeSignature uint64, +) error { + var generation uint64 + generationKnown := false + var backpressureTimer *time.Timer + defer func() { + if backpressureTimer != nil { + backpressureTimer.Stop() + } + }() + for { + if done != nil { + select { + case <-done: + return context.Canceled + default: + } + } + q.mu.Lock() + if !generationKnown { + generation = q.generation + generationKnown = true + } else if generation != q.generation { + q.mu.Unlock() + return ErrGenerationChanged + } + transition := edgeSignature != q.edgeSignature + if !transition || q.count < len(q.transitions) { + if q.latestRevision == math.MaxUint64 { + q.mu.Unlock() + return ErrRevisionExhausted + } + q.latestRevision++ + q.latest = state + q.edgeSignature = edgeSignature + if transition { + index := (q.head + q.count) % len(q.transitions) + q.transitions[index] = entry[T]{state: state, revision: q.latestRevision} + q.count++ + } + q.notify() + q.mu.Unlock() + return nil + } + q.mu.Unlock() + + if backpressureTimer == nil { + backpressureTimer = time.NewTimer(q.backpressureTimeout) + } + select { + case <-done: + return context.Canceled + case <-q.space: + case <-backpressureTimer.C: + return ErrBackpressureTimeout + } + } +} + +// Wait returns the oldest retained transition, otherwise the latest snapshot. +// A nil deadline preserves the legacy context-deadline behavior used by the +// USB/IP poller; native callers pass a reusable endpoint timer channel. +func (q *Queue[T]) Wait( + ctx context.Context, deadline <-chan time.Time, +) (state T, transition bool, err error) { + select { + case <-ctx.Done(): + if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return state, false, ctx.Err() + } + return q.take(true) + case <-deadline: + if err := ctx.Err(); err != nil { + return state, false, err + } + return q.take(true) + case <-q.signal: + if err := ctx.Err(); err != nil { + if deadline == nil && errors.Is(err, context.DeadlineExceeded) { + return q.take(false) + } + return state, false, err + } + return q.take(false) + } +} + +// Invalidate establishes a lifecycle generation boundary. Retained pre-reset +// transitions are discarded, while the current snapshot remains available to +// the first post-boundary host poll. +func (q *Queue[T]) Invalidate() { + q.mu.Lock() + q.head = 0 + q.count = 0 + q.generation++ + q.deliveredRevision = q.latestRevision + select { + case <-q.signal: + default: + } + q.notifySpace() + q.mu.Unlock() +} + +func (q *Queue[T]) take(drainSignal bool) (state T, transition bool, err error) { + q.mu.Lock() + if drainSignal { + select { + case <-q.signal: + default: + } + } + if q.count > 0 { + item := q.transitions[q.head] + var zero entry[T] + q.transitions[q.head] = zero + q.head = (q.head + 1) % len(q.transitions) + q.count-- + q.deliveredRevision = item.revision + state = item.state + transition = true + } else { + state = q.latest + q.deliveredRevision = q.latestRevision + } + pending := q.count > 0 || q.latestRevision > q.deliveredRevision + if transition { + q.notifySpace() + } + if pending { + q.notify() + } + q.mu.Unlock() + return state, transition, nil +} + +func (q *Queue[T]) notify() { + select { + case q.signal <- struct{}{}: + default: + } +} + +func (q *Queue[T]) notifySpace() { + select { + case q.space <- struct{}{}: + default: + } +} diff --git a/device/internal/inputstatequeue/queue_test.go b/device/internal/inputstatequeue/queue_test.go new file mode 100644 index 00000000..153d0f95 --- /dev/null +++ b/device/internal/inputstatequeue/queue_test.go @@ -0,0 +1,125 @@ +package inputstatequeue + +import ( + "context" + "errors" + "testing" + "time" +) + +type testState struct { + edge uint64 + analog int +} + +func TestQueuePreservesEdgesAndCoalescesLatestSnapshot(t *testing.T) { + q := New(testState{}, 0, 4) + for _, state := range []testState{ + {edge: 1, analog: 10}, + {edge: 1, analog: 20}, + {edge: 0, analog: 30}, + } { + if err := q.Publish(state, state.edge); err != nil { + t.Fatal(err) + } + } + + state, transition, err := q.take(false) + if err != nil || !transition || state.edge != 1 || state.analog != 10 { + t.Fatalf("press=(%+v,%t,%v)", state, transition, err) + } + state, transition, err = q.take(false) + if err != nil || !transition || state.edge != 0 || state.analog != 30 { + t.Fatalf("release=(%+v,%t,%v)", state, transition, err) + } + state, transition, err = q.take(false) + if err != nil || transition || state.edge != 0 || state.analog != 30 { + t.Fatalf("latest=(%+v,%t,%v)", state, transition, err) + } +} + +func TestQueueDeadlineConsumptionDrainsStaleWakeToken(t *testing.T) { + q := New(testState{}, 0, 2) + if err := q.Publish(testState{analog: 7}, 0); err != nil { + t.Fatal(err) + } + state, transition, err := q.take(true) + if err != nil || transition || state.analog != 7 { + t.Fatalf("deadline take=(%+v,%t,%v)", state, transition, err) + } + select { + case <-q.signal: + t.Fatal("deadline consumption left a stale immediate wake token") + default: + } +} + +func TestQueueInvalidationCancelsBlockedGeneration(t *testing.T) { + q := New(testState{}, 0, 1) + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + result <- q.PublishUntil(nil, testState{edge: 2}, 2) + }() + + select { + case err := <-result: + t.Fatalf("blocked publication returned early: %v", err) + case <-time.After(10 * time.Millisecond): + } + q.Invalidate() + select { + case err := <-result: + if !errors.Is(err, ErrGenerationChanged) { + t.Fatalf("generation result=%v", err) + } + case <-time.After(time.Second): + t.Fatal("generation invalidation did not release producer") + } + + deadline := make(chan time.Time, 1) + deadline <- time.Now() + state, transition, err := q.Wait(context.Background(), deadline) + if err != nil || transition || state.edge != 1 { + t.Fatalf("post-boundary latest=(%+v,%t,%v)", state, transition, err) + } +} + +func TestQueueCloseCancelsBlockedProducer(t *testing.T) { + q := New(testState{}, 0, 1) + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + result := make(chan error, 1) + go func() { + result <- q.PublishUntil(done, testState{edge: 2}, 2) + }() + close(done) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("close result=%v", err) + } + case <-time.After(time.Second): + t.Fatal("stream close did not release producer") + } +} + +func TestQueueBoundsBackpressureWhenPeerCloseIsUnobservable(t *testing.T) { + q := New(testState{}, 0, 1) + q.backpressureTimeout = 10 * time.Millisecond + if err := q.Publish(testState{edge: 1}, 1); err != nil { + t.Fatal(err) + } + started := time.Now() + err := q.PublishUntil(make(chan struct{}), testState{edge: 2}, 2) + if !errors.Is(err, ErrBackpressureTimeout) { + t.Fatalf("backpressure result=%v", err) + } + if time.Since(started) > time.Second { + t.Fatal("bounded backpressure did not release the stream handler") + } +} diff --git a/docs/api/overview.md b/docs/api/overview.md index 3c150dc4..00b85921 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -191,9 +191,9 @@ kept matched. "ready": true, "nativeUde": { "abiMajor": 1, - "abiMinor": 9, + "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.8", + "expectedDriverPackageVersion": "0.1.0.9", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index 0e376972..cf4b815d 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -70,7 +70,7 @@ of the source-provenance evidence without becoming a user-machine dependency. owns the package mutex, then acquires the broker-service mutex. 5. The nested command first checks for a true no-op: canonical protected service/image/credential state, no live legacy owner, stable service PID, and - authenticated `ping` with `Ready=true`, ABI 1.9, the exact capability mask, + authenticated `ping` with `Ready=true`, ABI 1.10, the exact capability mask, package version, and loaded-kernel build identity. If any part is unhealthy, it transactionally publishes the exact broker through a flushed protected sibling, creates or repairs the LocalSystem service, rotates its credential, diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 0f9555e8..b34d6f1b 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -17,7 +17,7 @@ disposable-machine acknowledgement, elevation, the exact source revision and interactive-user SID, and a current boot entry reporting `TESTSIGNING Yes`. It imports only the artifact-bound certificate and then executes the normal package-to-service transaction through `viiper.exe native-package-install`; -the helper is never invoked as a standalone mutation. Authenticated ABI 1.9, +the helper is never invoked as a standalone mutation. Authenticated ABI 1.10, capability, package-version, and loaded-kernel identity health must succeed before the transaction commits. @@ -81,7 +81,7 @@ mode. That mode rejects the attestation EKU and requires a release-eligible names only `ViiperUde.cat`. - The schema-2 submission manifest identifies the exact reviewed bits and the SHA-256 build identity derived from source revision, four-part DriverVer, - ABI 1.9, and the exact capability mask. That same identity is compiled into + ABI 1.10, and the exact capability mask. That same identity is compiled into the SYS that the signed catalog seals and is returned by the loaded kernel. - Returned packages contain only the canonical INF, SYS, PDB, and CAT in one directory. The unchanged INF/PDB must match the submission manifest, and diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index bd5f51fc..f0a110ac 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -76,7 +76,7 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. installer to the exact matching native-driver package. The service also recognizes the parameter/length errors returned by native previews from before that distinct status existed, so an upgrade cannot strand ABI 1.7. - ABI 1.9 additionally returns the 32-byte identity compiled into the loaded + ABI 1.9+ additionally returns the 32-byte identity compiled into the loaded kernel image: SHA-256 over the canonical source revision, driver-package version, ABI, and exact-capability tuple. The broker binary and schema-2 accepted-package manifest derive the same value from their protected build @@ -92,16 +92,16 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. The transport is intentionally split by USB semantics: -- interrupt-IN input reports use the ViGEm-style manual-queue fast path. The - Windows poll stays parked in the endpoint queue; one versioned - `SUBMIT_INPUT_REPORT` call atomically replaces the endpoint's preallocated - latest-state cache and completes a waiting URB without an allocation or - broker round trip. If the state arrives first, KMDF's manual-queue ready - notification schedules one preallocated passive work item, which completes - one later poll from that cache. The ready callback itself never completes an - URB because KMDF can invoke it synchronously on UdeCx's submitter thread. - The Go publisher allocates one buffer from the endpoint's descriptor at - publisher startup and supported controller engines encode directly into it. +- interrupt-IN input reports use a manual-queue fast path. The Windows poll + stays parked in the endpoint queue; one versioned `SUBMIT_INPUT_REPORT` call + completes a waiting URB without an allocation or broker round trip. ABI 1.10 + classifies newly queued controller states separately from deadline-generated + cadence snapshots. Each endpoint holds a bounded preallocated transition + FIFO and one latest-state snapshot: Windows consumes every accepted edge in + order, while idle 1 ms DS4/DualSense reports update only the snapshot and + cannot crowd edges out. The passive ready callback copies one report directly + and transfers terminal ownership to the required completion DPC. The Go + publisher allocates one descriptor-sized buffer at endpoint start. Each active publisher also owns one reusable service-deadline timer. The controller receives that timer's channel separately from the lifecycle context, so an idle deadline still replays cached state while purge, reset, @@ -125,14 +125,10 @@ The transport is intentionally split by USB semantics: instead of being consumed and silently truncated. The USB/IP microphone path retains its existing allocation and timeout ownership contract. -The input counters intentionally measure opposite sides of that cache: -`InputReportsSubmitted` counts accepted latest-state publications, while -`InputReportsCompleted` counts Windows interrupt-IN polls completed from the -cache. Several publications can coalesce before a Windows poll, so completion -can trail submission. A one-shot cached-delivery token ensures a publication -cannot replay itself into multiple successor polls. Live validation requires -both forward publication and a completed Windows poll without inventing a -strict one-to-one relationship. +The input counters intentionally measure opposite sides of the fast path: +`InputReportsSubmitted` counts accepted publications, while +`InputReportsCompleted` counts Windows interrupt-IN polls. Idle publications +may coalesce into the latest snapshot, but transition publications do not. Microsoft's UDE programming guide and host-controller I/O guide require URB completion at `DISPATCH_LEVEL` for USB-client compatibility. They additionally @@ -176,7 +172,7 @@ and the removed generation remains closed. Dynamic endpoint cleanup leaves an address-scoped retirement tombstone for the current device generation. This lets the kernel acknowledge and discard -the single latest-state input report that can cross asynchronous cleanup before +the final admitted input report that can cross asynchronous cleanup before user mode consumes the ordered purge event, without accepting reports for an endpoint that was never configured. @@ -436,7 +432,7 @@ a wedged provider cannot retain the installer mutex indefinitely. restart or disappear across a live or pre-callback-delivery request, and the client never mutates UdeCx-owned queue state. - Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx - management requests, not notifications. ABI 1.9 preserves the + management requests, not notifications. ABI 1.10 preserves the generation-bound management tokens introduced in ABI 1.8 and adds the source-bound loaded-kernel identity to negotiation. Windows receives the request completion only after the Go controller engine has applied the reset @@ -481,8 +477,9 @@ a wedged provider cannot retain the installer mutex indefinitely. - Each fast interrupt-IN endpoint has its own passive lock. Different controllers publish concurrently, while accidental concurrent submissions for one endpoint cannot reorder reports or replay a coalesced sequence. -- If a fresh report arrives before Windows posts its next HID poll, the passive - manual-queue ready callback copies that cached state immediately and hands +- If reports arrive before Windows posts its next HID poll, the passive + manual-queue ready callback copies the oldest transition (or latest idle + snapshot when no transition is pending) and hands terminal ownership to the shared completion DPC. The required asynchronous DISPATCH_LEVEL completion remains intact without an intervening system-worker scheduling hop on first poll, resume, or idle recovery. @@ -511,12 +508,13 @@ a wedged provider cannot retain the installer mutex indefinitely. span. Chained or short mappings fall through to a bounded MDL-chain walk; the driver never treats the URB length as permission to overrun one mapping. - Interrupt-IN queues are manual and completed from a generation-owned, - sequence-checked latest-state cache. The queue-ready callback only enqueues a - preallocated work item; that separate execution boundary consumes one cached - delivery token and one poll. A synchronously replenished Windows poll is left - parked for the next producer instead of becoming a kernel replay loop. - Endpoint purge/reset and device reset/D0 exit invalidate the cache and token - after closing admission, so no held button can cross a lifecycle boundary. + sequence-checked transition FIFO plus latest-state snapshot. The passive + queue-ready callback copies directly, then the shared DPC performs the only + terminal UdeCx completion. A synchronously replenished Windows poll drains at + most one queued transition and is otherwise left parked for the next + producer. Endpoint purge/reset and device reset/D0 exit invalidate the FIFO, + snapshot, and delivery token after closing admission, so no held button can + cross a lifecycle boundary. Output and media endpoints retain independent ordered queues. - A direct input report that was already submitted when D0 exit, device reset, unplug, or endpoint purge begins is acknowledged and discarded at that exact diff --git a/internal/cmd/native_transport_test.go b/internal/cmd/native_transport_test.go index 3caeb77b..5f5b5b97 100644 --- a/internal/cmd/native_transport_test.go +++ b/internal/cmd/native_transport_test.go @@ -54,16 +54,16 @@ func TestNativeUDETransportCloseWaitsForHostBeforeClosingClient(t *testing.T) { func TestNativeUDETransportStatusIsSnapshot(t *testing.T) { session := &nativeUDETransportSession{} session.info.ABIMajor = 1 - session.info.ABIMinor = 9 + session.info.ABIMinor = 10 session.ready.Store(true) ready, first := session.Status() - if !ready || first.ABIMajor != 1 || first.ABIMinor != 9 { + if !ready || first.ABIMajor != 1 || first.ABIMinor != 10 { t.Fatalf("unexpected native status: ready=%v info=%+v", ready, first) } first.ABIMinor = 99 _, second := session.Status() - if second.ABIMinor != 9 { + if second.ABIMinor != 10 { t.Fatal("Status exposed mutable session state") } } diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 5aedf4d1..5052105e 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -37,8 +37,8 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ABIMinor: 9, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.8", + ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, + ExpectedDriverPackageVersion: "0.1.0.9", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/server/api/router.go b/internal/server/api/router.go index 5e37e89e..e64834c0 100644 --- a/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -32,6 +32,20 @@ type HandlerFunc func(req *Request, res *Response, logger *slog.Logger) error // the handler encountered a terminal failure; the dispatcher/server will log it. type StreamHandlerFunc func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error +type streamLifetime interface { + StreamDone() <-chan struct{} +} + +// StreamDone is closed when the server, peer, or a replacement stream closes +// conn. Device handlers use it to cancel bounded backpressure without leaving +// displaced stream cleanup blocked behind an input queue. +func StreamDone(conn net.Conn) <-chan struct{} { + if lifetime, ok := conn.(streamLifetime); ok { + return lifetime.StreamDone() + } + return nil +} + // Router implements simple path pattern matching with placeholders in {name}. type Router struct { routes []routeEntry diff --git a/internal/server/api/server.go b/internal/server/api/server.go index d78fe91a..4f24c52b 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -12,6 +12,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/Alia5/VIIPER/internal/server/api/auth" @@ -313,7 +314,8 @@ func (s *Server) handleConn(conn net.Conn) { // path. Keep that reader in front of the connection for the device // handler; otherwise the first input/microphone frame of a reconnect can // disappear in the handshake reader and stall framing indefinitely. - streamConn := &bufferedReadConn{Conn: conn, reader: r} + streamConn := newStreamLifetimeConn( + &bufferedReadConn{Conn: conn, reader: r}) busIDStr, ok := params["busId"] if !ok { s.writeError(w, apierror.ErrBadRequest("missing busId parameter")) @@ -401,6 +403,45 @@ type bufferedReadConn struct { reader *bufio.Reader } +type streamLifetimeConn struct { + net.Conn + done chan struct{} + once sync.Once +} + +func newStreamLifetimeConn(conn net.Conn) *streamLifetimeConn { + return &streamLifetimeConn{Conn: conn, done: make(chan struct{})} +} + +func (c *streamLifetimeConn) StreamDone() <-chan struct{} { + return c.done +} + +func (c *streamLifetimeConn) Read(buffer []byte) (int, error) { + n, err := c.Conn.Read(buffer) + if err != nil { + c.signalClosed() + } + return n, err +} + +func (c *streamLifetimeConn) Write(buffer []byte) (int, error) { + n, err := c.Conn.Write(buffer) + if err != nil { + c.signalClosed() + } + return n, err +} + +func (c *streamLifetimeConn) Close() error { + c.signalClosed() + return c.Conn.Close() +} + +func (c *streamLifetimeConn) signalClosed() { + c.once.Do(func() { close(c.done) }) +} + func (c *bufferedReadConn) Read(buffer []byte) (int, error) { return c.reader.Read(buffer) } diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index c2bcc02b..314ff321 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -136,10 +136,17 @@ func (p *NativeProcessor) lockSession(key nativeSessionKey) *nativeSessionState func (p *NativeProcessor) resetDeviceLocked(dev usbdevice.Device, identity udecx.DeviceIdentity, session *nativeSessionState) { + p.invalidateInterruptInput(dev, 0) p.server.resetInterfaceAlts(dev) p.clearDeviceTransportLocked(identity, session) } +func (p *NativeProcessor) invalidateInterruptInput(dev usbdevice.Device, endpoint uint8) { + if input, ok := dev.(usbdevice.InterruptInputLifecycleDevice); ok { + input.InvalidateInterruptInput(endpoint) + } +} + func (p *NativeProcessor) clearDeviceTransportLocked(identity udecx.DeviceIdentity, session *nativeSessionState) { p.mu.Lock() @@ -181,9 +188,11 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o switch op.Kind { case udecx.OperationEndpointStart: p.clearEndpointLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) p.activateEndpointLocked(dev, op, session) case udecx.OperationEndpointPurge: p.clearEndpointLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) // Closing the last endpoint of an alternate setting already establishes // the controller's media-generation boundary through // SetInterfaceAltSetting(0). Reset the individual pipe only when the @@ -196,6 +205,7 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o } case udecx.OperationEndpointReset: p.clearEndpointLanes(key) + p.invalidateInterruptInput(dev, op.EndpointAddress) if resetter, ok := dev.(usbdevice.EndpointResetDevice); ok { resetter.ResetEndpoint(op.EndpointAddress) } @@ -205,6 +215,7 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o // A link-power transition is not a USB reset. Preserve the selected // audio interfaces and controller state, but discard stale service-clock // anchors so the first resumed transfer starts from the current time. + p.invalidateInterruptInput(dev, 0) p.clearDeviceLanes(identity) case udecx.OperationSetInterface: // UdeCx is documented by usbip-win2 0.9.7.8 to return incorrect @@ -536,6 +547,7 @@ func (p *NativeProcessor) processControl(ctx context.Context, dev usbdevice.Devi // publishes that notification exactly once. Retire the native endpoint // activity and media-clock state here after the host's device barrier has // joined every pre-configuration callback. + p.invalidateInterruptInput(dev, 0) p.clearDeviceTransportLocked(identity, session) session.mu.Unlock() } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 1cf2ab60..68774cd0 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -23,6 +23,64 @@ func nativeProcessorForTest(t *testing.T) *NativeProcessor { return processor } +type inputLifecycleTestDevice struct { + *altSettingTestDevice + invalidated []uint8 +} + +func (d *inputLifecycleTestDevice) InvalidateInterruptInput(endpoint uint8) { + d.invalidated = append(d.invalidated, endpoint) +} + +func TestNativeProcessorInvalidatesRetainedInputAtEveryGenerationBoundary(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{{ + Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 0, BAlternateSetting: 0, BNumEndpoints: 1, + }, + Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x84, BMAttributes: 0x03, + WMaxPacketSize: 64, BInterval: 4, + }}, + }}, + } + dev := &inputLifecycleTestDevice{ + altSettingTestDevice: &altSettingTestDevice{desc: desc}, + } + processor := nativeProcessorForTest(t) + endpoint := udecx.Operation{ + DeviceID: 1, Generation: 1, EndpointAddress: 0x84, + EndpointAttributes: 0x03, EndpointInterval: 4, + EndpointMaxPacketSize: 64, + } + for _, kind := range []udecx.OperationKind{ + udecx.OperationEndpointStart, + udecx.OperationEndpointPurge, + udecx.OperationEndpointReset, + } { + endpoint.Kind = kind + if err := processor.Lifecycle(context.Background(), dev, endpoint); err != nil { + t.Fatal(err) + } + } + for _, kind := range []udecx.OperationKind{ + udecx.OperationDeviceD0Exit, + udecx.OperationDeviceD0Entry, + udecx.OperationDeviceReset, + } { + if err := processor.Lifecycle(context.Background(), dev, udecx.Operation{ + DeviceID: 1, Generation: 1, Kind: kind, + }); err != nil { + t.Fatal(err) + } + } + want := []uint8{0x84, 0x84, 0x84, 0, 0, 0} + if !bytes.Equal(dev.invalidated, want) { + t.Fatalf("input invalidations=%x want %x", dev.invalidated, want) + } +} + func TestNativeProcessorServesControlDescriptor(t *testing.T) { dev := newNativeTransportTestDevice() op := udecx.Operation{ diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 1af8b2c8..ef5ede5c 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -686,6 +686,9 @@ func (c *Client) SubmitInputReport(ctx context.Context, report InputReport) erro return err } _, err := c.ioctl(ctx, ioctlSubmitInputReport, metadata[:], report.Payload) + if errors.Is(err, windows.ERROR_BUSY) { + return ErrInputQueueFull + } return err } diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index a955dfe0..16ce42bd 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -237,10 +237,15 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { requireContractOrder(t, ready, "ViiperEndpointOperationStarted(endpoint);", "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", + "for (;;)", "WdfIoQueueRetrieveNextRequest(Queue, &request)", "ViiperPrepareCachedInputUrb(endpoint, request);", + "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);", "WdfWaitLockRelease(endpointContext->InputLock);", - "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);") + "ViiperEndpointOperationCompleted(endpoint);") + if strings.Count(ready, "ViiperEndpointOperationStarted(endpoint);") != 2 { + t.Fatal("ReadyNotify must hold one callback rundown reference and one per queued DPC") + } if strings.Contains(ready, "WdfWorkItemEnqueue") || strings.Contains(ready, "UdecxUrbComplete(") { t.Fatal("ReadyNotify either retains a worker hop or completes a UDE URB synchronously") @@ -252,6 +257,38 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { } } +func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") + if !strings.Contains(header, "#define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01") { + t.Fatal("native ABI does not classify discrete input transitions") + } + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + submit := normalizedContract(nativeCFunction(t, device, "ViiperSubmitInputReport")) + requireContractOrder(t, submit, + "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 &&", + "return STATUS_DEVICE_BUSY;", + "InterlockedExchange64(&endpointContext->LastInputSequence", + "RtlCopyMemory(endpointContext->InputReport", + "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) {", + "InterlockedIncrement(&endpointContext->InputTransitionCount);", + "WdfIoQueueRetrieveNextRequest(endpointContext->Queue") + ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) + requireContractOrder(t, ready, + "ViiperPrepareCachedInputUrb(endpoint, request);", + "&endpointContext->CachedDeliveryPending", + "&endpointContext->InputTransitionCount", + "&endpointContext->InputSnapshotPending", + "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);") + prepare := normalizedContract(nativeCFunction(t, device, "ViiperPrepareCachedInputUrb")) + requireContractOrder(t, prepare, + "if (InterlockedCompareExchange(&endpointContext->InputTransitionCount", + "report = endpointContext->InputTransitionReports", + "ViiperCopyTransferBuffer(Request, urb, report, reportLength, TRUE)", + "if (!NT_SUCCESS(status))", + "UdecxUrbSetBytesCompleted(Request, reportLength);", + "InterlockedDecrement(&endpointContext->InputTransitionCount)") +} + func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") @@ -559,8 +596,8 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. t.Fatalf("ActiveOperations has %d decrement sites, want one BrokerLock-owned transition", got) } startedCalls := regexp.MustCompile(`ViiperEndpointOperationStarted\s*\([^)]*\)\s*;`) - if got := len(startedCalls.FindAllString(broker+device, -1)); got != 4 { - t.Fatalf("endpoint rundown has %d admission call sites, want the four audited BrokerLock callers", got) + if got := len(startedCalls.FindAllString(broker+device, -1)); got != 5 { + t.Fatalf("endpoint rundown has %d admission call sites, want four callback admissions plus the ReadyNotify DPC handoff", got) } for _, name := range []string{ diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 861a254d..411185a0 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -527,6 +527,7 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p defer close(publisher.done) reader, direct := entry.device.(usb.InterruptInputDevice) scheduledReader, scheduled := entry.device.(usb.ScheduledInterruptInputDevice) + classifiedReader, classified := entry.device.(usb.ClassifiedScheduledInterruptInputDevice) var reportBuffer []byte var deadlineTimer *time.Timer if direct { @@ -543,10 +544,16 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p } for { var payload []byte + transition := false if direct { var written int var err error - if deadlineTimer != nil { + if deadlineTimer != nil && classified { + deadlineTimer.Reset(publisher.interval) + written, transition, err = classifiedReader.ReadClassifiedScheduledInterruptInput( + ctx, deadlineTimer.C, uint32(publisher.endpoint&0x0f), reportBuffer) + stopInputDeadlineTimer(deadlineTimer) + } else if deadlineTimer != nil { deadlineTimer.Reset(publisher.interval) written, err = scheduledReader.ReadScheduledInterruptInput( ctx, deadlineTimer.C, uint32(publisher.endpoint&0x0f), reportBuffer) @@ -619,10 +626,33 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p entry.identity.DeviceID, publisher.endpoint, err)) return } - if err := h.input.SubmitInputReport(publisher.submitCtx, InputReport{ + report := InputReport{ DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, - EndpointAddress: publisher.endpoint, Sequence: sequence, Payload: payload, - }); err != nil { + EndpointAddress: publisher.endpoint, Transition: transition, + Sequence: sequence, Payload: payload, + } + for { + err = h.input.SubmitInputReport(publisher.submitCtx, report) + if !errors.Is(err, ErrInputQueueFull) { + break + } + // The kernel retained every earlier transition and rejected this one + // before accepting its sequence. Wait one endpoint interval, then retry + // this exact report. This propagates bounded backpressure without + // dropping an edge or faulting the owner session. + retryInterval := publisher.interval + if retryInterval <= 0 { + retryInterval = time.Millisecond + } + retryTimer := time.NewTimer(retryInterval) + select { + case <-publisher.submitCtx.Done(): + stopInputDeadlineTimer(retryTimer) + return + case <-retryTimer.C: + } + } + if err != nil { if publisher.submitCtx.Err() != nil { return } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 4e437eb5..052bdc19 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -41,6 +41,28 @@ type gatedFastInputDriver struct { gates chan *inputSubmitGate } +type backpressureFastInputDriver struct { + *fastInputDriver + busyRemaining atomic.Int32 + attempts chan InputReport +} + +func (d *backpressureFastInputDriver) SubmitInputReport( + ctx context.Context, report InputReport, +) error { + copyReport := report + copyReport.Payload = append([]byte(nil), report.Payload...) + select { + case d.attempts <- copyReport: + case <-ctx.Done(): + return ctx.Err() + } + if d.busyRemaining.Add(-1) >= 0 { + return ErrInputQueueFull + } + return d.fastInputDriver.SubmitInputReport(ctx, report) +} + type independentlyBlockingCreateDriver struct { *fakeHostDriver blockedDevice uint64 @@ -994,6 +1016,67 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { } } +func TestHostRetriesExactInputTransitionAfterKernelBackpressure(t *testing.T) { + base := &fastInputDriver{ + fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 2), + } + driver := &backpressureFastInputDriver{ + fastInputDriver: base, attempts: make(chan InputReport, 2), + } + driver.busyRemaining.Store(1) + processor := &recordingProcessor{ + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 2) + if err != nil { + t.Fatal(err) + } + device := newInputPublisherTestDevice() + identity, err := host.Register(context.Background(), 441, device) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("endpoint start was not processed") + } + device.reports <- []byte{0x11, 0x22, 0x33} + first := <-driver.attempts + second := <-driver.attempts + if first.Sequence != second.Sequence || first.DeviceID != second.DeviceID || + first.Generation != second.Generation || first.EndpointAddress != second.EndpointAddress || + string(first.Payload) != string(second.Payload) { + t.Fatalf("retry changed accepted report: first=%+v second=%+v", first, second) + } + select { + case accepted := <-driver.reports: + if accepted.Sequence != first.Sequence || string(accepted.Payload) != string(first.Payload) { + t.Fatalf("accepted report=%+v first=%+v", accepted, first) + } + case <-time.After(time.Second): + t.Fatal("kernel backpressure was not retried") + } + cancel() + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("host did not stop") + } +} + func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 2054cc18..41cef57d 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -16,12 +16,12 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 9 + ABIMinor uint16 = 10 // DriverPackageVersion is the native driver package version built and // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.8" + DriverPackageVersion = "0.1.0.9" BuildIdentitySize = sha256.Size HeaderSize = 16 @@ -36,12 +36,13 @@ const ( InputReportSize = 48 StatsSize = 144 - MaxDevices = 32 - MaxDescriptorBytes = 256 * 1024 - MaxTransferBytes = 1024 * 1024 - MaxIsoPackets = 1024 - MaxInputReportBytes = 4096 - MaxPendingOperations = 4096 + MaxDevices = 32 + MaxDescriptorBytes = 256 * 1024 + MaxTransferBytes = 1024 * 1024 + MaxIsoPackets = 1024 + MaxInputReportBytes = 4096 + MaxPendingOperations = 4096 + InputReportTransition uint8 = 0x01 // TransferFlagDirectionIn is the wire value of // USBD_TRANSFER_DIRECTION_IN from usb.h. TransferFlagDirectionIn uint32 = 0x00000001 @@ -67,6 +68,7 @@ var ( ErrInvalidRange = errors.New("native UDE message contains an invalid range") ErrLimitExceeded = errors.New("native UDE message exceeds a negotiated limit") ErrBuildIdentity = errors.New("native UDE build identity is unavailable or invalid") + ErrInputQueueFull = errors.New("native UDE input transition queue is full") ) type Capabilities uint32 @@ -495,6 +497,7 @@ type InputReport struct { DeviceID uint64 Generation uint32 EndpointAddress uint8 + Transition bool Sequence uint64 Payload []byte } @@ -519,6 +522,10 @@ func (m InputReport) marshalMetadata(dst []byte) error { binary.LittleEndian.PutUint64(dst[16:24], m.DeviceID) binary.LittleEndian.PutUint32(dst[24:28], m.Generation) dst[28] = m.EndpointAddress + dst[29], dst[30], dst[31] = 0, 0, 0 + if m.Transition { + dst[29] = InputReportTransition + } binary.LittleEndian.PutUint32(dst[32:36], InputReportSize) binary.LittleEndian.PutUint32(dst[36:40], uint32(len(m.Payload))) binary.LittleEndian.PutUint64(dst[40:48], m.Sequence) diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index 00e9e769..b0626ea8 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -129,7 +129,8 @@ type contractInputReport struct { DeviceId uint64 Generation uint32 EndpointAddress uint8 - Reserved1 [3]uint8 + Flags uint8 + Reserved1 [2]uint8 PayloadOffset uint32 PayloadLength uint32 Sequence uint64 @@ -210,6 +211,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, + "VIIPER_UDE_INPUT_REPORT_TRANSITION": uint64(InputReportTransition), "VIIPER_UDE_MS_OS_10_STRING_INDEX": uint64(MicrosoftOS10StringIndex), "VIIPER_UDE_MS_OS_10_STRING_LENGTH": MicrosoftOS10StringLength, "VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET": MicrosoftOS10VendorCodeOffset, diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index f5205971..b23aa3d8 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2" + const wantHex = "0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -414,12 +414,13 @@ func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { func TestInputReportMarshalling(t *testing.T) { raw, err := (InputReport{ DeviceID: 5, Generation: 7, EndpointAddress: 0x81, - Sequence: 11, Payload: []byte{1, 2, 3}, + Transition: true, Sequence: 11, Payload: []byte{1, 2, 3}, }).MarshalBinary() if err != nil { t.Fatal(err) } if len(raw) != InputReportSize+3 || + raw[29] != InputReportTransition || raw[30] != 0 || raw[31] != 0 || binary.LittleEndian.Uint32(raw[32:36]) != InputReportSize || binary.LittleEndian.Uint32(raw[36:40]) != 3 || binary.LittleEndian.Uint64(raw[40:48]) != 11 || @@ -444,6 +445,25 @@ func TestInputReportMetadataEncodingDoesNotAllocate(t *testing.T) { } } +func TestInputReportMetadataClearsReusedTransitionFlag(t *testing.T) { + report := InputReport{ + DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + Transition: true, Sequence: 11, Payload: []byte{1}, + } + var metadata [InputReportSize]byte + if err := report.marshalMetadata(metadata[:]); err != nil { + t.Fatal(err) + } + report.Transition = false + report.Sequence++ + if err := report.marshalMetadata(metadata[:]); err != nil { + t.Fatal(err) + } + if metadata[29] != 0 || metadata[30] != 0 || metadata[31] != 0 { + t.Fatalf("reused input metadata retained flags/reserved bytes: %x", metadata[29:32]) + } +} + func TestIdentityAndStatsLayout(t *testing.T) { identity, err := (DeviceIdentity{DeviceID: 0x1122334455667788, Generation: 7}).MarshalBinary() if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 4e5f232f..dd5d0285 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -947,6 +947,19 @@ ViiperEvtVirtualDeviceCleanup( } } +static +VOID +ViiperClearEndpointInputReportLocked( + _In_ VIIPER_UDE_ENDPOINT_CONTEXT *EndpointContext + ) +{ + InterlockedExchange(&EndpointContext->InputReportValid, FALSE); + InterlockedExchange(&EndpointContext->CachedDeliveryPending, FALSE); + InterlockedExchange(&EndpointContext->InputSnapshotPending, FALSE); + InterlockedExchange(&EndpointContext->InputTransitionHead, 0); + InterlockedExchange(&EndpointContext->InputTransitionCount, 0); +} + static VOID ViiperInvalidateEndpointInputReport( @@ -955,8 +968,13 @@ ViiperInvalidateEndpointInputReport( { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - InterlockedExchange(&endpointContext->InputReportValid, FALSE); - InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); + if (endpointContext->InputLock != WDF_NO_HANDLE) { + WdfWaitLockAcquire(endpointContext->InputLock, NULL); + } + ViiperClearEndpointInputReportLocked(endpointContext); + if (endpointContext->InputLock != WDF_NO_HANDLE) { + WdfWaitLockRelease(endpointContext->InputLock); + } } static @@ -976,7 +994,9 @@ ViiperInvalidateInputIfLifecycleClosed( InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { - ViiperInvalidateEndpointInputReport(Endpoint); + // Both callers already own InputLock. Clearing under that same lock + // makes lifecycle invalidation atomic with FIFO append/dequeue. + ViiperClearEndpointInputReportLocked(endpointContext); } WdfSpinLockRelease(controllerContext->BrokerLock); } @@ -1328,6 +1348,10 @@ ViiperEvtEndpointAdd( dispatchType = WdfIoQueueDispatchSequential; } else if ((descriptor.bEndpointAddress & USB_ENDPOINT_DIRECTION_MASK) != 0 && (descriptor.bmAttributes & USB_ENDPOINT_TYPE_MASK) == USB_ENDPOINT_TYPE_INTERRUPT) { + ULONG packetBytes = descriptor.wMaxPacketSize & 0x07ff; + ULONG transactions = 1 + ((descriptor.wMaxPacketSize >> 11) & 0x03); + SIZE_T transitionBytes; + endpointContext->FastInput = TRUE; dispatchType = WdfIoQueueDispatchManual; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -1336,6 +1360,34 @@ ViiperEvtEndpointAdd( if (!NT_SUCCESS(status)) { return status; } + endpointContext->InputTransitionStride = packetBytes * transactions; + if (endpointContext->InputTransitionStride == 0 || + endpointContext->InputTransitionStride > VIIPER_UDE_MAX_INPUT_REPORT_BYTES) { + return STATUS_INVALID_PARAMETER; + } + endpointContext->InputTransitionCapacity = min( + VIIPER_UDE_MAX_INPUT_TRANSITIONS, + VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES / endpointContext->InputTransitionStride); + if (endpointContext->InputTransitionCapacity == 0) { + return STATUS_INVALID_PARAMETER; + } + transitionBytes = (SIZE_T)endpointContext->InputTransitionStride * + endpointContext->InputTransitionCapacity; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495549, + transitionBytes, + &endpointContext->InputTransitionMemory, + (PVOID *)&endpointContext->InputTransitionReports); + if (!NT_SUCCESS(status)) { + endpointContext->InputTransitionMemory = WDF_NO_HANDLE; + endpointContext->InputTransitionReports = NULL; + return status; + } + RtlZeroMemory(endpointContext->InputTransitionReports, transitionBytes); } else { dispatchType = WdfIoQueueDispatchParallel; } @@ -1433,9 +1485,22 @@ ViiperPrepareCachedInputUrb( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); PURB urb = ViiperGetUrb(Request); + PUCHAR report = endpointContext->InputReport; + ULONG reportLength = endpointContext->InputReportLength; + ULONG transitionHead = 0; + BOOLEAN transition = FALSE; ULONG transferLength; NTSTATUS status; + if (InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0) { + transitionHead = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionHead, 0, 0); + report = endpointContext->InputTransitionReports + + ((SIZE_T)transitionHead * endpointContext->InputTransitionStride); + reportLength = endpointContext->InputTransitionLengths[transitionHead]; + transition = TRUE; + } + if (urb == NULL || (urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER && urb->UrbHeader.Function != URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL) || @@ -1443,22 +1508,26 @@ ViiperPrepareCachedInputUrb( return STATUS_INVALID_DEVICE_REQUEST; } transferLength = urb->UrbBulkOrInterruptTransfer.TransferBufferLength; - if (endpointContext->InputReportLength > transferLength) { - return STATUS_BUFFER_TOO_SMALL; + if (reportLength > transferLength) { + status = STATUS_BUFFER_TOO_SMALL; + } else { + status = ViiperCopyTransferBuffer(Request, urb, report, reportLength, TRUE); } - status = ViiperCopyTransferBuffer( - Request, - urb, - endpointContext->InputReport, - endpointContext->InputReportLength, - TRUE); if (!NT_SUCCESS(status)) { return status; } - urb->UrbBulkOrInterruptTransfer.TransferBufferLength = endpointContext->InputReportLength; - UdecxUrbSetBytesCompleted(Request, endpointContext->InputReportLength); - InterlockedAdd64(&controllerContext->BytesFromDevice, endpointContext->InputReportLength); + urb->UrbBulkOrInterruptTransfer.TransferBufferLength = reportLength; + UdecxUrbSetBytesCompleted(Request, reportLength); + if (transition) { + InterlockedExchange( + &endpointContext->InputTransitionHead, + (LONG)((transitionHead + 1) % endpointContext->InputTransitionCapacity)); + InterlockedDecrement(&endpointContext->InputTransitionCount); + } else { + InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); + } + InterlockedAdd64(&controllerContext->BytesFromDevice, reportLength); InterlockedIncrement64(&controllerContext->InputReportsCompleted); return STATUS_SUCCESS; } @@ -1478,7 +1547,7 @@ ViiperEvtFastInputQueueReady( NTSTATUS completionStatus = STATUS_SUCCESS; BOOLEAN admitted = FALSE; BOOLEAN deliveryReady = FALSE; - BOOLEAN completionQueued = FALSE; + BOOLEAN completionAdmitted = FALSE; PAGED_CODE(); // KMDF explicitly permits a passive ReadyNotify callback to retrieve the @@ -1527,30 +1596,49 @@ ViiperEvtFastInputQueueReady( return; } - // One cached-delivery token represents one accepted publication which - // arrived before its Windows poll. Consume exactly one parked request. - // Completing it can cause HIDClass to post a successor; leaving that poll - // parked prevents a cache replay loop and lets the next producer update - // complete it on the allocation-free direct path. - if (NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { - InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); + // ReadyNotify is edge-triggered on empty -> non-empty. Microsoft requires + // manual-queue callbacks to retrieve in a loop, otherwise multiple host + // polls which arrived together can remain stranded while retained input + // also remains pending. The initial operation is a callback-lifetime hold; + // each retrieved URB receives its own rundown count transferred to the DPC. + for (;;) { + completionAdmitted = FALSE; + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) == 0 && + InterlockedCompareExchange(&endpointContext->InputReportValid, 0, 0) != 0 && + (InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0)) { + ViiperEndpointOperationStarted(endpoint); + completionAdmitted = TRUE; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (!completionAdmitted) { + break; + } + + request = WDF_NO_HANDLE; + if (!NT_SUCCESS(WdfIoQueueRetrieveNextRequest(Queue, &request))) { + ViiperEndpointOperationCompleted(endpoint); + break; + } ViiperInvalidateInputIfLifecycleClosed(endpoint); completionStatus = ViiperPrepareCachedInputUrb(endpoint, request); - completionQueued = TRUE; - } else { - ViiperInvalidateInputIfLifecycleClosed(endpoint); - } - WdfWaitLockRelease(endpointContext->InputLock); - if (completionQueued) { - // Queue only after the last endpoint-local lock access. The DPC may run - // immediately and performs the final rundown release after UDE's - // mandatory DISPATCH_LEVEL terminal completion. + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0); ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus); - } else { - // This is the final endpoint access: the locked decrement may let the - // purge worker complete and permit UdeCx cleanup immediately after it. - ViiperEndpointOperationCompleted(endpoint); } + WdfWaitLockRelease(endpointContext->InputLock); + // Release the callback-lifetime hold only after the final endpoint access. + // Every queued completion owns a separate count until its DPC completes. + ViiperEndpointOperationCompleted(endpoint); } NTSTATUS @@ -1612,7 +1700,8 @@ ViiperSubmitInputReport( input->PayloadOffset != sizeof(*input) || input->PayloadLength == 0 || input->PayloadLength > VIIPER_UDE_MAX_INPUT_REPORT_BYTES || payloadLength != input->PayloadLength || - input->Reserved1[0] != 0 || input->Reserved1[1] != 0 || input->Reserved1[2] != 0) { + (input->Flags & ~VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 || + input->Reserved1[0] != 0 || input->Reserved1[1] != 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } @@ -1708,26 +1797,60 @@ ViiperSubmitInputReport( ViiperEndpointOperationCompleted(endpoint); return STATUS_INVALID_DEVICE_STATE; } - // Claim and cache every accepted sequence, including when no Windows poll - // is parked. The queue-ready callback will satisfy the next poll from this - // exact latest state instead of waiting for or fabricating another feeder - // update. + if (input->PayloadLength > endpointContext->InputTransitionStride) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_BUFFER_SIZE; + } + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 && + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) >= + (LONG)endpointContext->InputTransitionCapacity) { + WdfWaitLockRelease(endpointContext->InputLock); + ViiperEndpointOperationCompleted(endpoint); + InterlockedIncrement64(&controllerContext->QueueExhaustions); + return STATUS_DEVICE_BUSY; + } + // Every accepted sample refreshes the cadence snapshot. Only a newly + // queued controller state is also appended to the bounded transition FIFO; + // deadline-generated idle samples therefore cannot crowd out press/release + // edges while Windows has no interrupt poll parked. InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength); endpointContext->InputReportLength = input->PayloadLength; InterlockedExchange(&endpointContext->InputReportValid, TRUE); + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) { + ULONG count = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionCount, 0, 0); + ULONG head = (ULONG)InterlockedCompareExchange( + &endpointContext->InputTransitionHead, 0, 0); + ULONG tail = (head + count) % endpointContext->InputTransitionCapacity; + RtlCopyMemory( + endpointContext->InputTransitionReports + + ((SIZE_T)tail * endpointContext->InputTransitionStride), + payload, + input->PayloadLength); + endpointContext->InputTransitionLengths[tail] = (USHORT)input->PayloadLength; + InterlockedIncrement(&endpointContext->InputTransitionCount); + InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); + } else { + InterlockedExchange(&endpointContext->InputSnapshotPending, TRUE); + } InterlockedIncrement64(&controllerContext->InputReportsSubmitted); status = WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest); if (!NT_SUCCESS(status)) { InterlockedExchange( &endpointContext->CachedDeliveryPending, - status == STATUS_NO_MORE_ENTRIES ? TRUE : FALSE); + InterlockedCompareExchange( + &endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange( + &endpointContext->InputSnapshotPending, 0, 0) != 0); ViiperInvalidateInputIfLifecycleClosed(endpoint); WdfWaitLockRelease(endpointContext->InputLock); ViiperEndpointOperationCompleted(endpoint); // The cached report now owns this state. Queue-ready delivery services // the next Windows poll even if the physical feeder becomes idle. - return status == STATUS_NO_MORE_ENTRIES ? STATUS_SUCCESS : status; + return STATUS_SUCCESS; } InterlockedExchange(&endpointContext->CachedDeliveryPending, FALSE); // Lifecycle admission can close after this operation was admitted. The @@ -1736,12 +1859,19 @@ ViiperSubmitInputReport( // either this path or the lifecycle callback performs the final clear. ViiperInvalidateInputIfLifecycleClosed(endpoint); status = ViiperPrepareCachedInputUrb(endpoint, urbRequest); + InterlockedExchange( + &endpointContext->CachedDeliveryPending, + InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || + InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0); WdfWaitLockRelease(endpointContext->InputLock); // This call is the active-operation handoff. It performs every remaining // endpoint lookup before enqueuing the DPC; the caller performs no endpoint // access after a concurrently running DPC can release rundown. ViiperCompleteRetrievedInputUrb(endpoint, urbRequest, status); - return status; + // The producer publication was accepted before servicing this host poll. + // A malformed/short URB fails through its own DPC but does not make user + // mode retry an already-accepted sequence or discard the retained edge. + return STATUS_SUCCESS; } _IRQL_requires_(PASSIVE_LEVEL) diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 6103655f..ea857102 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -17,6 +17,8 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; // host-controller interface must retain UdeCx's canonical unqualified path. #define VIIPER_UDE_BROKER_REFERENCE_STRING L"broker" #define VIIPER_UDE_MAX_PENDING_MANAGEMENT 256 +#define VIIPER_UDE_MAX_INPUT_TRANSITIONS 256 +#define VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES 65536 #define VIIPER_UDE_MANAGEMENT_SLOT_FLAG 0x80000000UL typedef enum VIIPER_UDE_PENDING_STATE { @@ -281,8 +283,16 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { volatile LONG64 NextIsoStartFrame; volatile LONG InputReportValid; volatile LONG CachedDeliveryPending; + volatile LONG InputSnapshotPending; ULONG InputReportLength; UCHAR InputReport[VIIPER_UDE_MAX_INPUT_REPORT_BYTES]; + WDFMEMORY InputTransitionMemory; + PUCHAR InputTransitionReports; + ULONG InputTransitionStride; + ULONG InputTransitionCapacity; + volatile LONG InputTransitionHead; + volatile LONG InputTransitionCount; + USHORT InputTransitionLengths[VIIPER_UDE_MAX_INPUT_TRANSITIONS]; // BrokerLock protects this FIFO and every slot AdmissionEntry. It keeps // same-endpoint publication ordered without scanning the controller-wide // 4096-slot table on every USB transfer. diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index ad9ac037..77385f7a 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.8 + 0.1.0.9 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -104,6 +104,6 @@ - + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index bb5d83f2..58bef84e 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,8 +35,8 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(9) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.8" +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.9" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ @@ -58,6 +58,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAX_ISO_PACKETS VIIPER_UDE_UINT32_C(1024) #define VIIPER_UDE_MAX_INPUT_REPORT_BYTES VIIPER_UDE_UINT32_C(4096) #define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) +#define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01 /* Microsoft OS 1.0 defines this reserved string outside normal LANGID rules. */ #define VIIPER_UDE_MS_OS_10_STRING_INDEX VIIPER_UDE_UINT16_C(0x00ee) @@ -221,7 +222,8 @@ typedef struct VIIPER_UDE_INPUT_REPORT { VIIPER_UDE_UINT64 DeviceId; VIIPER_UDE_UINT32 Generation; VIIPER_UDE_UINT8 EndpointAddress; - VIIPER_UDE_UINT8 Reserved1[3]; + VIIPER_UDE_UINT8 Flags; + VIIPER_UDE_UINT8 Reserved1[2]; VIIPER_UDE_UINT32 PayloadOffset; VIIPER_UDE_UINT32 PayloadLength; VIIPER_UDE_UINT64 Sequence; @@ -389,7 +391,8 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Reserved, 64); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, DeviceId, 16); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Generation, 24); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, EndpointAddress, 28); -VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Reserved1, 29); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Flags, 29); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Reserved1, 30); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadOffset, 32); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadLength, 36); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Sequence, 40); diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 23b441a3..edd3730f 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.8 +DriverVer=08/11/2026,0.1.0.9 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index a243efe8..fe28a6ed 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -81,7 +81,7 @@ if ($versionNodes.Count -ne 1) { } $driverPackageVersion = $versionNodes[0].InnerText.Trim() $driverABIMajor = 1 -$driverABIMinor = 9 +$driverABIMinor = 10 $driverCapabilities = [uint32]13 $driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $SourceRevision ` diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 75e23e70..c06d9696 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -109,7 +109,7 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $source = $SourceRevision.ToLowerInvariant() $buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $source -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + -ABIMajor 1 -ABIMinor 10 -Capabilities 13 $manifest = [ordered]@{ schema = 2 @@ -120,7 +120,7 @@ $manifest = [ordered]@{ sourceRevision = $source driverPackageVersion = $driverVersion driverABIMajor = 1 - driverABIMinor = 9 + driverABIMinor = 10 driverCapabilities = '0x0000000d' driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 @@ -185,6 +185,6 @@ $lockSha256 = (Get-FileHash -LiteralPath $lockPath -Algorithm SHA256).Hash.ToLow Write-Host "Created compact source-bound local test package at '$output'." Write-Host "Source: $source" -Write-Host "Driver: $driverVersion / ABI 1.9 / $buildIdentity" +Write-Host "Driver: $driverVersion / ABI 1.10 / $buildIdentity" Write-Host "Test signer certificate SHA-256: $certificateSha256" Write-Host "Local test package lock SHA-256: $lockSha256" diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index 9ef0f81d..02d8010c 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -156,11 +156,11 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + -ABIMajor 1 -ABIMinor 10 -Capabilities 13 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or [string]$manifest.driverPackageVersion -cne $driverVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 9 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 10 -or [string]$manifest.driverCapabilities -cne '0x0000000d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or -not [bool]$manifest.releaseEligible -or diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 41e25fab..e17158b9 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -175,11 +175,11 @@ $driverPackageVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverPackageVersion ` - -ABIMajor 1 -ABIMinor 9 -Capabilities 13 + -ABIMajor 1 -ABIMinor 10 -Capabilities 13 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 9 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 10 -or [string]$manifest.driverCapabilities -cne '0x0000000d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' diff --git a/usb/device.go b/usb/device.go index 0142edef..a77ea7c2 100644 --- a/usb/device.go +++ b/usb/device.go @@ -61,6 +61,18 @@ type ScheduledInterruptInputDevice interface { ) (int, error) } +// ClassifiedScheduledInterruptInputDevice identifies whether a scheduled +// report came from a newly queued controller state or from the deadline replay +// of the current state. Native transports use that distinction to preserve +// discrete edges while coalescing only idle cadence snapshots when Windows has +// not yet posted its next interrupt poll. +type ClassifiedScheduledInterruptInputDevice interface { + ScheduledInterruptInputDevice + ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, + ) (written int, transition bool, err error) +} + // IsochronousInputDevice is the corresponding optional caller-buffer contract // for isochronous IN packets. The transport supplies exactly the packet region // owned by the current URB. The native scheduler invokes this at the packet's @@ -101,3 +113,11 @@ type InterfaceAltSettingDevice interface { type EndpointResetDevice interface { ResetEndpoint(endpointAddress uint8) } + +// InterruptInputLifecycleDevice discards retained pre-boundary controller +// transitions without changing the device's current state. Native transport +// reset, purge, configuration, and power boundaries invoke it after joining +// the old publisher so stale edges cannot replay into the next generation. +type InterruptInputLifecycleDevice interface { + InvalidateInterruptInput(endpointAddress uint8) +} From 96612feff8b5e80f79d2c0c94aabaab31861a116 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 17:35:23 -0500 Subject: [PATCH 182/240] Fix native package CI contracts --- internal/cmd/native_mutex_windows.go | 19 +++++++++++++++---- internal/cmd/native_mutex_windows_test.go | 3 +++ native/udecx/tools/ViiperUdeCtl.cpp | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/cmd/native_mutex_windows.go b/internal/cmd/native_mutex_windows.go index a11b89ce..40a03b22 100644 --- a/internal/cmd/native_mutex_windows.go +++ b/internal/cmd/native_mutex_windows.go @@ -23,7 +23,7 @@ const ( nativeMutexBoundaryName = "VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1" nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" - nativeMutexNamespaceRaceRetries = 16 + nativeMutexNamespaceRaceRetries = 100 ) var ( @@ -34,6 +34,7 @@ var ( nativeCreatePrivateNamespaceW = nativeMutexKernel32.NewProc("CreatePrivateNamespaceW") nativeOpenPrivateNamespaceW = nativeMutexKernel32.NewProc("OpenPrivateNamespaceW") nativeClosePrivateNamespace = nativeMutexKernel32.NewProc("ClosePrivateNamespace") + nativeMutexNamespaceOpenMu sync.Mutex ) type nativeMutexNamespace struct { @@ -100,6 +101,14 @@ func createNativeMutexBoundary() (windows.Handle, error) { // ERROR_ALREADY_EXISTS and OpenPrivateNamespace, so retry only that absence // race; all access and boundary failures remain fail-closed. func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { + // CreatePrivateNamespace and OpenPrivateNamespace are not an atomic + // create-or-open operation. Serialize callers in this process so a second + // installer goroutine cannot observe the first creator's half-published + // alias. The bounded retry below still closes the same race with another + // elevated process. + nativeMutexNamespaceOpenMu.Lock() + defer nativeMutexNamespaceOpenMu.Unlock() + boundary, err := createNativeMutexBoundary() if err != nil { return nil, fmt.Errorf("create native install mutex boundary: %w", err) @@ -133,7 +142,8 @@ func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { return scope, nil } createErr = nativeMutexCallError(createErr) - if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) && + !errors.Is(createErr, windows.ERROR_DUP_NAME) { scope.close() return nil, fmt.Errorf("create native install mutex namespace: %w", createErr) } @@ -148,11 +158,12 @@ func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { } openErr = nativeMutexCallError(openErr) lastErr = openErr - if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) { + if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_DUP_NAME) { scope.close() return nil, fmt.Errorf("open native install mutex namespace: %w", openErr) } - runtime.Gosched() + time.Sleep(time.Millisecond) } scope.close() return nil, fmt.Errorf("create or open native install mutex namespace after creator race: %w", lastErr) diff --git a/internal/cmd/native_mutex_windows_test.go b/internal/cmd/native_mutex_windows_test.go index 284d29b2..ae93bf12 100644 --- a/internal/cmd/native_mutex_windows_test.go +++ b/internal/cmd/native_mutex_windows_test.go @@ -107,6 +107,9 @@ func TestNativeMutexPrivateNamespaceSourceContract(t *testing.T) { `NewProc("OpenPrivateNamespaceW")`, `NewProc("ClosePrivateNamespace")`, `nativeDeleteBoundaryDescriptor.Call`, + `nativeMutexNamespaceOpenMu.Lock()`, + `errors.Is(createErr, windows.ERROR_DUP_NAME)`, + `errors.Is(openErr, windows.ERROR_DUP_NAME)`, `scope.namespace = windows.Handle(result)`, `createNamedNativeMutex(attributes, name)`, `runtime.LockOSThread()`, diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 36922de8..b35dbacb 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -3603,6 +3603,8 @@ struct PackageBackup { class LocalSecurityDescriptor final { public: + LocalSecurityDescriptor() = default; + ~LocalSecurityDescriptor() { if (value_ != nullptr) { LocalFree(value_); From 184aa66b3dc017576a99dfd1f369161706d0c4f8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 17:38:11 -0500 Subject: [PATCH 183/240] Retain native mutex namespace for process lifetime --- internal/cmd/native_mutex_windows.go | 40 +++++++++++------------ internal/cmd/native_mutex_windows_test.go | 3 +- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/cmd/native_mutex_windows.go b/internal/cmd/native_mutex_windows.go index 40a03b22..52101c2a 100644 --- a/internal/cmd/native_mutex_windows.go +++ b/internal/cmd/native_mutex_windows.go @@ -23,7 +23,7 @@ const ( nativeMutexBoundaryName = "VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1" nativeMutexObjectSDDL = "O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" - nativeMutexNamespaceRaceRetries = 100 + nativeMutexNamespaceRaceRetries = 5000 ) var ( @@ -34,7 +34,9 @@ var ( nativeCreatePrivateNamespaceW = nativeMutexKernel32.NewProc("CreatePrivateNamespaceW") nativeOpenPrivateNamespaceW = nativeMutexKernel32.NewProc("OpenPrivateNamespaceW") nativeClosePrivateNamespace = nativeMutexKernel32.NewProc("ClosePrivateNamespace") - nativeMutexNamespaceOpenMu sync.Mutex + nativeMutexNamespaceOnce sync.Once + nativeMutexNamespaceProcessScope *nativeMutexNamespace + nativeMutexNamespaceProcessErr error ) type nativeMutexNamespace struct { @@ -42,9 +44,10 @@ type nativeMutexNamespace struct { namespace windows.Handle } -// close releases the namespace before deleting the boundary descriptor. Every -// caller keeps this scope alive until after its mutex handle is closed, as the -// private-namespace contract requires for new opens and named-object lookup. +// close is used only while initialization is incomplete. A successfully +// created/opened namespace is retained for the process lifetime: Microsoft +// documents that after the creator closes its namespace handle, existing +// objects continue to work but subsequent OpenPrivateNamespace calls fail. func (scope *nativeMutexNamespace) close() { if scope == nil { return @@ -101,14 +104,6 @@ func createNativeMutexBoundary() (windows.Handle, error) { // ERROR_ALREADY_EXISTS and OpenPrivateNamespace, so retry only that absence // race; all access and boundary failures remain fail-closed. func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { - // CreatePrivateNamespace and OpenPrivateNamespace are not an atomic - // create-or-open operation. Serialize callers in this process so a second - // installer goroutine cannot observe the first creator's half-published - // alias. The bounded retry below still closes the same race with another - // elevated process. - nativeMutexNamespaceOpenMu.Lock() - defer nativeMutexNamespaceOpenMu.Unlock() - boundary, err := createNativeMutexBoundary() if err != nil { return nil, fmt.Errorf("create native install mutex boundary: %w", err) @@ -169,6 +164,14 @@ func createOrOpenNativeMutexNamespace() (*nativeMutexNamespace, error) { return nil, fmt.Errorf("create or open native install mutex namespace after creator race: %w", lastErr) } +func nativeMutexProcessNamespace() (*nativeMutexNamespace, error) { + nativeMutexNamespaceOnce.Do(func() { + nativeMutexNamespaceProcessScope, nativeMutexNamespaceProcessErr = + createOrOpenNativeMutexNamespace() + }) + return nativeMutexNamespaceProcessScope, nativeMutexNamespaceProcessErr +} + func nativePrivateMutexName(objectName string) (*uint16, error) { if objectName == "" || strings.ContainsAny(objectName, `\\/`) { return nil, fmt.Errorf("invalid native private mutex object name %q", objectName) @@ -232,28 +235,25 @@ func acquireNativeNamedMutex( return nil, err } runtime.LockOSThread() - scope, err := createOrOpenNativeMutexNamespace() + scope, err := nativeMutexProcessNamespace() if err != nil { runtime.UnlockOSThread() return nil, err } attributes, err := nativeMutexSecurityAttributes() if err != nil { - scope.close() runtime.UnlockOSThread() return nil, fmt.Errorf("create native mutex security descriptor: %w", err) } handle, err := createNamedNativeMutex(attributes, name) runtime.KeepAlive(attributes.SecurityDescriptor) if err != nil { - scope.close() runtime.UnlockOSThread() return nil, fmt.Errorf("create protected native mutex: %w", err) } status, err := windows.WaitForSingleObject(handle, nativeMutexWaitMilliseconds(timeout)) if err != nil || (status != windows.WAIT_OBJECT_0 && status != windows.WAIT_ABANDONED) { windows.CloseHandle(handle) //nolint:errcheck - scope.close() runtime.UnlockOSThread() if err != nil { return nil, fmt.Errorf("wait for protected native mutex: %w", err) @@ -265,7 +265,7 @@ func acquireNativeNamedMutex( once.Do(func() { windows.ReleaseMutex(handle) //nolint:errcheck windows.CloseHandle(handle) //nolint:errcheck - scope.close() + runtime.KeepAlive(scope) runtime.UnlockOSThread() }) }, nil @@ -282,11 +282,11 @@ func nativeNamedMutexHeldByAnotherOwner(objectName string) (bool, error) { } runtime.LockOSThread() defer runtime.UnlockOSThread() - scope, err := createOrOpenNativeMutexNamespace() + scope, err := nativeMutexProcessNamespace() if err != nil { return false, err } - defer scope.close() + defer runtime.KeepAlive(scope) handle, err := windows.OpenMutex( windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, false, name, ) diff --git a/internal/cmd/native_mutex_windows_test.go b/internal/cmd/native_mutex_windows_test.go index ae93bf12..7723f476 100644 --- a/internal/cmd/native_mutex_windows_test.go +++ b/internal/cmd/native_mutex_windows_test.go @@ -107,7 +107,8 @@ func TestNativeMutexPrivateNamespaceSourceContract(t *testing.T) { `NewProc("OpenPrivateNamespaceW")`, `NewProc("ClosePrivateNamespace")`, `nativeDeleteBoundaryDescriptor.Call`, - `nativeMutexNamespaceOpenMu.Lock()`, + `nativeMutexNamespaceOnce.Do(func()`, + `nativeMutexProcessNamespace()`, `errors.Is(createErr, windows.ERROR_DUP_NAME)`, `errors.Is(openErr, windows.ERROR_DUP_NAME)`, `scope.namespace = windows.Handle(result)`, From 54949b2cddcfc4e7fed46eecb6ef84b69816ac79 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 17:42:07 -0500 Subject: [PATCH 184/240] Fix native helper identity self-test vector --- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b35dbacb..98f4002d 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4896,7 +4896,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "335a8840a585df17ecc7bafa05fed2dc1b43c376a7b39245b6ee3185e50219d2") { + "0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 6974e001e62540d4a438aa41fbba2125e59601f2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 17:45:11 -0500 Subject: [PATCH 185/240] Fix recovery journal self-test fixture --- native/udecx/tools/ViiperUdeCtl.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 98f4002d..1d88b16e 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4947,8 +4947,8 @@ Outcome SelfTest() { return outcome; } PackageInfo recoveryPackage; - recoveryPackage.infPath = LR"(C:\Windows\INF\oem7.inf)"; - recoveryPackage.publishedName = L"oem7.inf"; + recoveryPackage.infPath = LR"(C:\Windows\INF\oem42.inf)"; + recoveryPackage.publishedName = L"oem42.inf"; recoveryPackage.version.parts = {0, 1, 0, 6}; recoveryPackage.infSha256 = std::string(64, 'A'); recoveryPackage.sysSha256 = std::string(64, 'B'); From 1b45d7745a80b446a1315ec27a195c037eaed74d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 18:00:03 -0500 Subject: [PATCH 186/240] Bind local test package to WDK signer --- .github/workflows/native-ude.yml | 45 ++++++++++++++----- .../udecx/local_test_package_contract_test.go | 4 ++ .../tools/New-ViiperUdeLocalTestPackage.ps1 | 15 ++++++- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index f8182f58..f4da521a 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -315,18 +315,39 @@ jobs: if: ${{ inputs.upload_artifacts == true }} shell: pwsh run: | - ./native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 ` - -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` - -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` - -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` - -CatalogPath native/udecx/x64/Release/ViiperUde/ViiperUde.cat ` - -BrokerPath native/udecx/x64/Release/viiper.exe ` - -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe ` - -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe ` - -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe ` - -ProbeManifestPath native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json ` - -OutputDirectory native/udecx/x64/Release/ViiperUdeLocalTest ` - -SourceRevision $env:GITHUB_SHA + $certificatePath = (Resolve-Path 'native/udecx/x64/Release/ViiperUde.cer').Path + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificatePath) + $addedTrust = [Collections.Generic.List[string]]::new() + try { + foreach ($storeName in @('Root', 'TrustedPublisher')) { + $storePath = "Cert:\CurrentUser\$storeName\$($certificate.Thumbprint)" + if (-not (Test-Path -LiteralPath $storePath)) { + Import-Certificate -FilePath $certificatePath ` + -CertStoreLocation "Cert:\CurrentUser\$storeName" | Out-Null + $addedTrust.Add($storePath) + } + } + ./native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 ` + -InfPath native/udecx/x64/Release/ViiperUde/ViiperUde.inf ` + -SysPath native/udecx/x64/Release/ViiperUde/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -CatalogPath native/udecx/x64/Release/ViiperUde/ViiperUde.cat ` + -TestCertificatePath $certificatePath ` + -BrokerPath native/udecx/x64/Release/viiper.exe ` + -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe ` + -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe ` + -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe ` + -ProbeManifestPath native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json ` + -OutputDirectory native/udecx/x64/Release/ViiperUdeLocalTest ` + -SourceRevision $env:GITHUB_SHA + } + finally { + foreach ($storePath in $addedTrust) { + Remove-Item -LiteralPath $storePath -Force + } + $certificate.Dispose() + } - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index a2a79bfc..723be8b4 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -22,6 +22,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { for _, required := range []string{ "workflow_dispatch:", "New-ViiperUdeLocalTestPackage.ps1", + "Import-Certificate -FilePath $certificatePath", + "Remove-Item -LiteralPath $storePath -Force", "-BrokerPath native/udecx/x64/Release/viiper.exe", "ViiperUde-x64-local-test-${{ github.sha }}", "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", @@ -44,6 +46,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { composer := read("native", "udecx", "tools", "New-ViiperUdeLocalTestPackage.ps1") for _, required := range []string{ "[string]$BrokerPath", + "[string]$TestCertificatePath", + "The local catalog and driver do not match the exact WDK-exported test certificate.", "Resolve-ExactInput $BrokerPath 'viiper.exe'", "signingRoute = 'LocalTest'", "releaseEligible = $false", diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index c06d9696..d2c7a5f0 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -4,6 +4,7 @@ param( [Parameter(Mandatory = $true)][string]$SysPath, [Parameter(Mandatory = $true)][string]$PdbPath, [Parameter(Mandatory = $true)][string]$CatalogPath, + [Parameter(Mandatory = $true)][string]$TestCertificatePath, [Parameter(Mandatory = $true)][string]$BrokerPath, [Parameter(Mandatory = $true)][string]$HelperPath, [Parameter(Mandatory = $true)][string]$MediaProbePath, @@ -58,6 +59,7 @@ $broker = Resolve-ExactInput $BrokerPath 'viiper.exe' $mediaProbe = Resolve-ExactInput $MediaProbePath 'ViiperUdeMediaProbe.exe' $inputProbe = Resolve-ExactInput $InputProbePath 'ViiperUdeInputProbe.exe' $probeManifest = Resolve-ExactInput $ProbeManifestPath 'ViiperUdeLiveProbes.manifest.json' +$testCertificate = Resolve-ExactInput $TestCertificatePath 'ViiperUde.cer' $output = [IO.Path]::GetFullPath($OutputDirectory) if (Test-Path -LiteralPath $output) { @@ -76,6 +78,16 @@ $certificateSha256 = Get-CertificateSha256 $catalogSignature.SignerCertificate if ((Get-CertificateSha256 $driverSignature.SignerCertificate) -cne $certificateSha256) { throw 'The local catalog and driver were signed by different test certificates.' } +$expectedCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $testCertificate) +try { + if ((Get-CertificateSha256 $expectedCertificate) -cne $certificateSha256) { + throw 'The local catalog and driver do not match the exact WDK-exported test certificate.' + } +} +finally { + $expectedCertificate.Dispose() +} [void][IO.Directory]::CreateDirectory($output) $signedDirectory = Join-Path $output 'signed-package' @@ -95,8 +107,7 @@ foreach ($entry in $inputs.GetEnumerator()) { [IO.File]::Copy($inputProbe, (Join-Path $output 'ViiperUdeInputProbe.exe'), $false) [IO.File]::Copy($probeManifest, (Join-Path $output 'ViiperUdeLiveProbes.manifest.json'), $false) $certificatePath = Join-Path $output 'ViiperUdeTest.cer' -[IO.File]::WriteAllBytes($certificatePath, $catalogSignature.SignerCertificate.Export( - [Security.Cryptography.X509Certificates.X509ContentType]::Cert)) +[IO.File]::Copy($testCertificate, $certificatePath, $false) [xml]$project = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\driver\ViiperUde.vcxproj') -Raw $namespace = [Xml.XmlNamespaceManager]::new($project.NameTable) From a987d553805378b2a03849201097641b43846158 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 18:13:47 -0500 Subject: [PATCH 187/240] Make WDK test trust import noninteractive --- .github/workflows/native-ude.yml | 60 ++++++++++++++++--- .../udecx/local_test_package_contract_test.go | 5 +- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index f4da521a..768a85ad 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -318,14 +318,36 @@ jobs: $certificatePath = (Resolve-Path 'native/udecx/x64/Release/ViiperUde.cer').Path $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( $certificatePath) - $addedTrust = [Collections.Generic.List[string]]::new() + $certificateSha256 = $certificate.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) + $addedTrust = @() try { - foreach ($storeName in @('Root', 'TrustedPublisher')) { - $storePath = "Cert:\CurrentUser\$storeName\$($certificate.Thumbprint)" - if (-not (Test-Path -LiteralPath $storePath)) { - Import-Certificate -FilePath $certificatePath ` - -CertStoreLocation "Cert:\CurrentUser\$storeName" | Out-Null - $addedTrust.Add($storePath) + foreach ($storeName in @( + [Security.Cryptography.X509Certificates.StoreName]::Root, + [Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher)) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, + [Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser) + try { + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $exactMatch = @($matches | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($matches.Count -ne $exactMatch.Count) { + throw "Certificate thumbprint collision in CurrentUser\\$storeName." + } + if ($exactMatch.Count -eq 0) { + $store.Add($certificate) + $addedTrust += $storeName + } + } + finally { + $store.Close() } } ./native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 ` @@ -343,8 +365,28 @@ jobs: -SourceRevision $env:GITHUB_SHA } finally { - foreach ($storePath in $addedTrust) { - Remove-Item -LiteralPath $storePath -Force + foreach ($storeName in $addedTrust) { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, + [Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser) + try { + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $exactMatch = @($matches | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($exactMatch.Count -ne 1) { + throw "Exact temporary certificate missing from CurrentUser\\$storeName." + } + $store.Remove($exactMatch[0]) + } + finally { + $store.Close() + } } $certificate.Dispose() } diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 723be8b4..513ea391 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -22,8 +22,9 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { for _, required := range []string{ "workflow_dispatch:", "New-ViiperUdeLocalTestPackage.ps1", - "Import-Certificate -FilePath $certificatePath", - "Remove-Item -LiteralPath $storePath -Force", + "[Security.Cryptography.X509Certificates.X509Store]::new(", + "$store.Add($certificate)", + "$store.Remove($exactMatch[0])", "-BrokerPath native/udecx/x64/Release/viiper.exe", "ViiperUde-x64-local-test-${{ github.sha }}", "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", From 6edb7b5c23fa71670409f82b6fe776ebf14f8f5f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 18:30:23 -0500 Subject: [PATCH 188/240] Bound native signature validation tools --- .../udecx/local_test_package_contract_test.go | 13 +++ .../tools/Test-ViiperUdeSignedPackage.ps1 | 100 ++++++++++++++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 513ea391..1b5d0202 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -132,6 +132,11 @@ func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { contract := strings.ReplaceAll(string(contents), "\r\n", "\n") for _, required := range []string{ "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", + "Invoke-BoundedValidationTool", + "$process.WaitForExit($TimeoutMilliseconds)", + "$process.StandardOutput.ReadToEndAsync()", + "$process.StandardError.ReadToEndAsync()", + "$process.Kill()", "$ValidationMode -eq 'LocalTest'", "testSignerCertificateSha256", "Production validation requires a release-eligible HLK/WHCP", @@ -144,4 +149,12 @@ func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { t.Fatalf("signature route separation omitted %q", required) } } + for _, forbidden := range []string{ + "& $signTool.Source verify", + "& $infVerif.Source", + } { + if strings.Contains(contract, forbidden) { + t.Fatalf("signature validation retained unbounded child execution %q", forbidden) + } + } } diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index e17158b9..9a376e8c 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -21,6 +21,82 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +function ConvertTo-WindowsCommandLineArgument { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { + return $Value + } + $builder = [Text.StringBuilder]::new() + [void]$builder.Append('"') + $backslashes = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq '\') { + ++$backslashes + continue + } + if ($character -eq '"') { + [void]$builder.Append(('\' * ($backslashes * 2 + 1))) + [void]$builder.Append('"') + $backslashes = 0 + continue + } + [void]$builder.Append(('\' * $backslashes)) + $backslashes = 0 + [void]$builder.Append($character) + } + [void]$builder.Append(('\' * ($backslashes * 2))) + [void]$builder.Append('"') + return $builder.ToString() +} + +function Invoke-BoundedValidationTool { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Operation, + [int]$TimeoutMilliseconds = 120000 + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.Arguments = (($Arguments | ForEach-Object { + ConvertTo-WindowsCommandLineArgument -Value $_ + }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "$Operation did not start." + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + try { + $process.Kill() + $process.WaitForExit() + } + catch { + throw "$Operation exceeded $TimeoutMilliseconds ms and could not be joined: $($_.Exception.Message)" + } + throw "$Operation exceeded $TimeoutMilliseconds ms and was terminated before package mutation." + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + if ($stdout) { Write-Host $stdout.TrimEnd() } + if ($stderr) { Write-Host $stderr.TrimEnd() } + return $process.ExitCode + } + finally { + $process.Dispose() + } +} + function Get-CertificateEkuOids { param( [Parameter(Mandatory = $true)] @@ -235,24 +311,30 @@ if ($requireExternalTools) { $signTool = Get-Command signtool.exe -ErrorAction Stop foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } - & $signTool.Source verify $policy /v $files[$name] - if ($LASTEXITCODE -ne 0) { - throw "Signature policy validation failed for '$name' with exit code $LASTEXITCODE." + $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` + -Arguments @('verify', $policy, '/v', $files[$name]) ` + -Operation "SignTool signature validation for '$name'" + if ($exitCode -ne 0) { + throw "Signature policy validation failed for '$name' with exit code $exitCode." } } foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { $policy = if ($ValidationMode -eq 'LocalTest') { '/pa' } else { '/kp' } - & $signTool.Source verify $policy /v /c $files['ViiperUde.cat'] $files[$name] - if ($LASTEXITCODE -ne 0) { - throw "'$name' is not a verified member of the exact catalog (exit code $LASTEXITCODE)." + $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` + -Arguments @('verify', $policy, '/v', '/c', $files['ViiperUde.cat'], $files[$name]) ` + -Operation "SignTool catalog membership validation for '$name'" + if ($exitCode -ne 0) { + throw "'$name' is not a verified member of the exact catalog (exit code $exitCode)." } } $infVerif = Get-Command infverif.exe -ErrorAction Stop foreach ($mode in @('/h', '/u')) { - & $infVerif.Source $mode $files['ViiperUde.inf'] - if ($LASTEXITCODE -ne 0) { - throw "InfVerif $mode rejected the signed package with exit code $LASTEXITCODE." + $exitCode = Invoke-BoundedValidationTool -FilePath $infVerif.Source ` + -Arguments @($mode, $files['ViiperUde.inf']) ` + -Operation "InfVerif $mode validation" + if ($exitCode -ne 0) { + throw "InfVerif $mode rejected the signed package with exit code $exitCode." } } } From 7c02eb28bd0d40ba0d79605fb8c7a11636049c9f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 18:47:16 -0500 Subject: [PATCH 189/240] Bound native Authenticode validation --- .github/workflows/native-ude.yml | 2 +- .../udecx/local_test_package_contract_test.go | 4 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 16 +-- .../tools/Test-ViiperUdeSignedPackage.ps1 | 130 ++++++++++++------ 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 768a85ad..56d5f2f2 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -138,7 +138,7 @@ jobs: CGO_ENABLED: "0" run: go test -run=^$ ./_testing/e2e - name: Fuzz native protocol decoders - run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=20s ./internal/transport/udecx + run: go test -run=^$ -fuzz=FuzzProtocolDecoders -fuzztime=1000000x ./internal/transport/udecx race: runs-on: ubuntu-24.04 diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 1b5d0202..7f8793dd 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -48,7 +48,7 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { for _, required := range []string{ "[string]$BrokerPath", "[string]$TestCertificatePath", - "The local catalog and driver do not match the exact WDK-exported test certificate.", + "$certificateSha256 = Get-CertificateSha256 $expectedCertificate", "Resolve-ExactInput $BrokerPath 'viiper.exe'", "signingRoute = 'LocalTest'", "releaseEligible = $false", @@ -133,6 +133,8 @@ func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { for _, required := range []string{ "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", "Invoke-BoundedValidationTool", + "Get-BoundedAuthenticodeSignature", + "'-NoProfile', '-NonInteractive', '-EncodedCommand'", "$process.WaitForExit($TimeoutMilliseconds)", "$process.StandardOutput.ReadToEndAsync()", "$process.StandardError.ReadToEndAsync()", diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index d2c7a5f0..7da5e5e6 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -66,24 +66,10 @@ if (Test-Path -LiteralPath $output) { throw "Refusing to overwrite local test package '$output'." } -$catalogSignature = Get-AuthenticodeSignature -LiteralPath $inputs['ViiperUde.cat'] -$driverSignature = Get-AuthenticodeSignature -LiteralPath $inputs['ViiperUde.sys'] -if ($catalogSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or - $driverSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or - $null -eq $catalogSignature.SignerCertificate -or - $null -eq $driverSignature.SignerCertificate) { - throw 'The local package composer requires valid WDK test signatures on the catalog and driver.' -} -$certificateSha256 = Get-CertificateSha256 $catalogSignature.SignerCertificate -if ((Get-CertificateSha256 $driverSignature.SignerCertificate) -cne $certificateSha256) { - throw 'The local catalog and driver were signed by different test certificates.' -} $expectedCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( $testCertificate) try { - if ((Get-CertificateSha256 $expectedCertificate) -cne $certificateSha256) { - throw 'The local catalog and driver do not match the exact WDK-exported test certificate.' - } + $certificateSha256 = Get-CertificateSha256 $expectedCertificate } finally { $expectedCertificate.Dispose() diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 9a376e8c..3b28c047 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -55,7 +55,8 @@ function Invoke-BoundedValidationTool { [Parameter(Mandatory = $true)][string]$FilePath, [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$Operation, - [int]$TimeoutMilliseconds = 120000 + [int]$TimeoutMilliseconds = 120000, + [switch]$SuppressOutput ) $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -88,9 +89,13 @@ function Invoke-BoundedValidationTool { } $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() - if ($stdout) { Write-Host $stdout.TrimEnd() } - if ($stderr) { Write-Host $stderr.TrimEnd() } - return $process.ExitCode + if (-not $SuppressOutput -and $stdout) { Write-Host $stdout.TrimEnd() } + if (-not $SuppressOutput -and $stderr) { Write-Host $stderr.TrimEnd() } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StandardOutput = $stdout + StandardError = $stderr + } } finally { $process.Dispose() @@ -137,6 +142,48 @@ function Get-CertificateSha256 { } } +function Get-BoundedAuthenticodeSignature { + param([Parameter(Mandatory = $true)][string]$Path) + + $encodedPath = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Path)) + $command = @" +`$ErrorActionPreference = 'Stop' +`$ProgressPreference = 'SilentlyContinue' +`$path = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$encodedPath')) +`$signature = Get-AuthenticodeSignature -LiteralPath `$path +`$certificate = if (`$null -eq `$signature.SignerCertificate) { '' } else { + [Convert]::ToBase64String(`$signature.SignerCertificate.RawData) +} +[ordered]@{ status = `$signature.Status.ToString(); certificate = `$certificate } | + ConvertTo-Json -Compress +"@ + $encodedCommand = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($command)) + $hostPath = (Get-Process -Id $PID).Path + $result = Invoke-BoundedValidationTool -FilePath $hostPath ` + -Arguments @('-NoProfile', '-NonInteractive', '-EncodedCommand', $encodedCommand) ` + -Operation "Authenticode validation for '$Path'" -SuppressOutput + if ($result.ExitCode -ne 0) { + throw "Authenticode validation failed for '$Path' with exit code $($result.ExitCode)." + } + try { + $value = $result.StandardOutput.Trim() | ConvertFrom-Json -ErrorAction Stop + if ([string]$value.status -notmatch '^[A-Za-z]+$' -or + [string]::IsNullOrEmpty([string]$value.certificate)) { + throw 'missing status or signer certificate' + } + $certificateBytes = [Convert]::FromBase64String([string]$value.certificate) + return [pscustomobject]@{ + Status = [string]$value.status + SignerCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificateBytes) + } + } + catch { + throw "Authenticode validation returned malformed evidence for '$Path': $($_.Exception.Message)" + } +} + function Assert-DriverSignature { param( [Parameter(Mandatory = $true)] @@ -149,39 +196,44 @@ function Assert-DriverSignature { [string]$ExpectedLocalTestCertificateSha256 ) - $signature = Get-AuthenticodeSignature -LiteralPath $Path - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { - throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." - } - if ($null -eq $signature.SignerCertificate) { - throw "'$Path' did not expose its signing certificate." - } - if ($Mode -eq 'LocalTest') { - $actual = Get-CertificateSha256 -Certificate $signature.SignerCertificate - if ($ExpectedLocalTestCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or - $actual -cne $ExpectedLocalTestCertificateSha256) { - throw "'$Path' is not signed by the exact source-bound local test certificate." + $signature = Get-BoundedAuthenticodeSignature -Path $Path + try { + if ($signature.Status -cne 'Valid') { + throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." + } + if ($null -eq $signature.SignerCertificate) { + throw "'$Path' did not expose its signing certificate." + } + if ($Mode -eq 'LocalTest') { + $actual = Get-CertificateSha256 -Certificate $signature.SignerCertificate + if ($ExpectedLocalTestCertificateSha256 -notmatch '^[0-9a-f]{64}$' -or + $actual -cne $ExpectedLocalTestCertificateSha256) { + throw "'$Path' is not signed by the exact source-bound local test certificate." + } + return + } + if ( + $signature.SignerCertificate.Subject -notmatch '(?i)(^|,\s*)O=Microsoft Corporation(,|$)') { + throw "'$Path' is not signed by Microsoft Corporation." } - return - } - if ( - $signature.SignerCertificate.Subject -notmatch '(?i)(^|,\s*)O=Microsoft Corporation(,|$)') { - throw "'$Path' is not signed by Microsoft Corporation." - } - $ekuOids = Get-CertificateEkuOids -Certificate $signature.SignerCertificate - $hardwareVerificationOid = '1.3.6.1.4.1.311.10.3.5' - $attestedVerificationOid = '1.3.6.1.4.1.311.10.3.5.1' - if (-not $ekuOids.Contains($hardwareVerificationOid)) { - throw "'$Path' lacks the Windows Hardware Driver Verification EKU." - } - if ($Mode -eq 'ControlledTest') { - if (-not $ekuOids.Contains($attestedVerificationOid)) { - throw "'$Path' is not a Microsoft attestation-signed controlled-test artifact." + $ekuOids = Get-CertificateEkuOids -Certificate $signature.SignerCertificate + $hardwareVerificationOid = '1.3.6.1.4.1.311.10.3.5' + $attestedVerificationOid = '1.3.6.1.4.1.311.10.3.5.1' + if (-not $ekuOids.Contains($hardwareVerificationOid)) { + throw "'$Path' lacks the Windows Hardware Driver Verification EKU." + } + if ($Mode -eq 'ControlledTest') { + if (-not $ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is not a Microsoft attestation-signed controlled-test artifact." + } + } + elseif ($ekuOids.Contains($attestedVerificationOid)) { + throw "'$Path' is attestation signed and cannot pass the production HLK/WHCP release gate." } } - elseif ($ekuOids.Contains($attestedVerificationOid)) { - throw "'$Path' is attestation signed and cannot pass the production HLK/WHCP release gate." + finally { + $signature.SignerCertificate.Dispose() } } @@ -314,8 +366,8 @@ if ($requireExternalTools) { $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` -Arguments @('verify', $policy, '/v', $files[$name]) ` -Operation "SignTool signature validation for '$name'" - if ($exitCode -ne 0) { - throw "Signature policy validation failed for '$name' with exit code $exitCode." + if ($exitCode.ExitCode -ne 0) { + throw "Signature policy validation failed for '$name' with exit code $($exitCode.ExitCode)." } } foreach ($name in @('ViiperUde.inf', 'ViiperUde.sys')) { @@ -323,8 +375,8 @@ if ($requireExternalTools) { $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` -Arguments @('verify', $policy, '/v', '/c', $files['ViiperUde.cat'], $files[$name]) ` -Operation "SignTool catalog membership validation for '$name'" - if ($exitCode -ne 0) { - throw "'$name' is not a verified member of the exact catalog (exit code $exitCode)." + if ($exitCode.ExitCode -ne 0) { + throw "'$name' is not a verified member of the exact catalog (exit code $($exitCode.ExitCode))." } } @@ -333,8 +385,8 @@ if ($requireExternalTools) { $exitCode = Invoke-BoundedValidationTool -FilePath $infVerif.Source ` -Arguments @($mode, $files['ViiperUde.inf']) ` -Operation "InfVerif $mode validation" - if ($exitCode -ne 0) { - throw "InfVerif $mode rejected the signed package with exit code $exitCode." + if ($exitCode.ExitCode -ne 0) { + throw "InfVerif $mode rejected the signed package with exit code $($exitCode.ExitCode)." } } } From 6203a6d8f0b5a026f9ae31302520360182277a6a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 11 Aug 2026 19:35:10 -0500 Subject: [PATCH 190/240] Bound native validation process trees --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 204 ++++++-- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 39 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 440 ++++++++++++++++-- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 13 files changed, 627 insertions(+), 76 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index c2e67859..2a0cb646 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90', + '498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 56d5f2f2..f04cc7d4 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.9 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90') { + -DriverPackageVersion 0.1.0.10 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] @@ -321,13 +321,118 @@ jobs: $certificateSha256 = $certificate.GetCertHashString( [Security.Cryptography.HashAlgorithmName]::SHA256) $addedTrust = @() + if (-not ('ViiperNativeCertificateStore' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' + using System; + using System.ComponentModel; + using System.Runtime.InteropServices; + + public static class ViiperNativeCertificateStore + { + private const int CERT_STORE_PROV_SYSTEM_W = 10; + private const uint CERT_SYSTEM_STORE_LOCAL_MACHINE = 0x00020000; + private const uint CERT_STORE_OPEN_EXISTING_FLAG = 0x00004000; + private const uint CERT_STORE_MAXIMUM_ALLOWED_FLAG = 0x00001000; + private const uint CERT_ENCODING = 0x00010001; + private const uint CERT_STORE_ADD_NEW = 1; + private const uint CERT_FIND_EXISTING = 0x000d0000; + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CertOpenStore( + IntPtr provider, uint encoding, IntPtr cryptProvider, + uint flags, string storeName); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertAddEncodedCertificateToStore( + IntPtr store, uint encoding, byte[] certificate, uint length, + uint disposition, out IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertCreateCertificateContext( + uint encoding, byte[] certificate, uint length); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertFindCertificateInStore( + IntPtr store, uint encoding, uint findFlags, uint findType, + IntPtr findParameter, IntPtr previousContext); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertDeleteCertificateFromStore(IntPtr context); + + [DllImport("crypt32.dll")] + private static extern bool CertFreeCertificateContext(IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertCloseStore(IntPtr store, uint flags); + + private static IntPtr Open(string storeName) + { + IntPtr store = CertOpenStore( + new IntPtr(CERT_STORE_PROV_SYSTEM_W), 0, IntPtr.Zero, + CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG | + CERT_STORE_MAXIMUM_ALLOWED_FLAG, + storeName); + if (store == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CertOpenStore"); + return store; + } + + public static void Add(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr context = IntPtr.Zero; + try + { + if (!CertAddEncodedCertificateToStore( + store, CERT_ENCODING, certificate, (uint)certificate.Length, + CERT_STORE_ADD_NEW, out context)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertAddEncodedCertificateToStore"); + } + finally + { + if (context != IntPtr.Zero) CertFreeCertificateContext(context); + CertCloseStore(store, 0); + } + } + + public static bool Remove(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr search = IntPtr.Zero; + try + { + search = CertCreateCertificateContext( + CERT_ENCODING, certificate, (uint)certificate.Length); + if (search == IntPtr.Zero) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertCreateCertificateContext"); + IntPtr found = CertFindCertificateInStore( + store, CERT_ENCODING, 0, CERT_FIND_EXISTING, search, IntPtr.Zero); + if (found == IntPtr.Zero) return false; + if (!CertDeleteCertificateFromStore(found)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertDeleteCertificateFromStore"); + return true; + } + finally + { + if (search != IntPtr.Zero) CertFreeCertificateContext(search); + CertCloseStore(store, 0); + } + } + } + '@ + } + $operationError = $null + $cleanupErrors = [Collections.Generic.List[string]]::new() try { foreach ($storeName in @( [Security.Cryptography.X509Certificates.StoreName]::Root, [Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher)) { $store = [Security.Cryptography.X509Certificates.X509Store]::new( $storeName, - [Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser) + [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) try { $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) $matches = $store.Certificates.Find( @@ -339,11 +444,25 @@ jobs: $certificateSha256 }) if ($matches.Count -ne $exactMatch.Count) { - throw "Certificate thumbprint collision in CurrentUser\\$storeName." + throw "Certificate thumbprint collision in LocalMachine\\$storeName." } if ($exactMatch.Count -eq 0) { - $store.Add($certificate) + [ViiperNativeCertificateStore]::Add( + $storeName.ToString(), $certificate.RawData) $addedTrust += $storeName + $store.Close() + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $installed = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $installedExact = @($installed | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($installed.Count -ne 1 -or $installedExact.Count -ne 1) { + throw "Exact temporary certificate was not installed in LocalMachine\\$storeName." + } } } finally { @@ -364,31 +483,64 @@ jobs: -OutputDirectory native/udecx/x64/Release/ViiperUdeLocalTest ` -SourceRevision $env:GITHUB_SHA } + catch { + $operationError = $_ + } finally { - foreach ($storeName in $addedTrust) { - $store = [Security.Cryptography.X509Certificates.X509Store]::new( - $storeName, - [Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser) - try { - $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - $matches = $store.Certificates.Find( - [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, - $certificate.Thumbprint, $false) - $exactMatch = @($matches | Where-Object { - $_.GetCertHashString( - [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq - $certificateSha256 - }) - if ($exactMatch.Count -ne 1) { - throw "Exact temporary certificate missing from CurrentUser\\$storeName." + try { + foreach ($storeName in $addedTrust) { + $store = $null + try { + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $storeName, + [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + $exactMatch = @($matches | Where-Object { + $_.GetCertHashString( + [Security.Cryptography.HashAlgorithmName]::SHA256) -ceq + $certificateSha256 + }) + if ($exactMatch.Count -ne 1) { + if ($exactMatch.Count -eq 0) { continue } + throw "Temporary certificate collision in LocalMachine\\$storeName." + } + $store.Close() + if (-not [ViiperNativeCertificateStore]::Remove( + $storeName.ToString(), $certificate.RawData)) { + throw "Temporary certificate disappeared from LocalMachine\\$storeName." + } + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $remaining = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificate.Thumbprint, $false) + if ($remaining.Count -ne 0) { + throw "Temporary certificate remained in LocalMachine\\$storeName after cleanup." + } + } + catch { + [void]$cleanupErrors.Add("LocalMachine\\$storeName cleanup failed: $($_.Exception.Message)") + } + finally { + if ($null -ne $store) { $store.Close() } } - $store.Remove($exactMatch[0]) - } - finally { - $store.Close() } } - $certificate.Dispose() + finally { + $certificate.Dispose() + } + } + if ($cleanupErrors.Count -ne 0) { + $message = $cleanupErrors -join '; ' + if ($null -ne $operationError) { + $message = "$($operationError.Exception.Message); $message" + } + throw $message + } + if ($null -ne $operationError) { + throw $operationError } - name: Analyze native driver and setup helper uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 6d9eb8de..37000da9 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.9", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.10", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 00b85921..ad8fea27 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.9", + "expectedDriverPackageVersion": "0.1.0.10", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 5052105e..fb967e54 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.9", + ExpectedDriverPackageVersion: "0.1.0.10", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 7f8793dd..d71639b0 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -23,8 +23,19 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "workflow_dispatch:", "New-ViiperUdeLocalTestPackage.ps1", "[Security.Cryptography.X509Certificates.X509Store]::new(", - "$store.Add($certificate)", - "$store.Remove($exactMatch[0])", + "ViiperNativeCertificateStore", + "CertAddEncodedCertificateToStore(", + "CertFindCertificateInStore(", + "CertDeleteCertificateFromStore(found)", + "CERT_STORE_ADD_NEW", + "$addedTrust += $storeName", + "CERT_SYSTEM_STORE_LOCAL_MACHINE", + "[Security.Cryptography.X509Certificates.StoreName]::Root", + "[Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher", + "[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine", + "foreach ($storeName in $addedTrust)", + "$cleanupErrors.Add(", + "$certificate.Dispose()", "-BrokerPath native/udecx/x64/Release/viiper.exe", "ViiperUde-x64-local-test-${{ github.sha }}", "path: native/udecx/x64/Release/ViiperUdeLocalTest/**", @@ -38,6 +49,12 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "native/udecx/x64/Release/**", "native/udecx/driver/x64/Release/**", "native/udecx/package/x64/Release/**", + "$store.Add($certificate)", + "$store.Remove($exactMatch[0])", + "certutil.exe", + "Invoke-BoundedCertUtil", + "CERT_SYSTEM_STORE_CURRENT_USER", + "[Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser", } { if strings.Contains(workflow, forbidden) { t.Fatalf("local-test workflow uploads broad build tree %q", forbidden) @@ -135,10 +152,15 @@ func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { "Invoke-BoundedValidationTool", "Get-BoundedAuthenticodeSignature", "'-NoProfile', '-NonInteractive', '-EncodedCommand'", - "$process.WaitForExit($TimeoutMilliseconds)", - "$process.StandardOutput.ReadToEndAsync()", - "$process.StandardError.ReadToEndAsync()", - "$process.Kill()", + "CREATE_SUSPENDED | CREATE_NO_WINDOW", + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", + "new AnonymousPipeServerStream(", + "QueryInformationJobObject(", + "AssignProcessToJobObject(job, process.hProcess)", + "ResumeThread(process.hThread)", + "TerminateJobObject(job, 1)", + "WaitForJobEmpty(job, remaining)", + "Task.WaitAll(outputTasks, 10000)", "$ValidationMode -eq 'LocalTest'", "testSignerCertificateSha256", "Production validation requires a release-eligible HLK/WHCP", @@ -151,6 +173,11 @@ func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { t.Fatalf("signature route separation omitted %q", required) } } + assign := strings.Index(contract, "AssignProcessToJobObject(job, process.hProcess)") + resume := strings.Index(contract, "ResumeThread(process.hThread)") + if assign < 0 || resume < 0 || assign > resume { + t.Fatal("validation child is not assigned to its private job while still suspended") + } for _, forbidden := range []string{ "& $signTool.Source verify", "& $infVerif.Source", diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 41cef57d..c13c371b 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.9" + DriverPackageVersion = "0.1.0.10" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index b23aa3d8..0a6547ab 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90" + const wantHex = "498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 77385f7a..624646ca 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/11/2026 - 0.1.0.9 + 0.1.0.10 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 58bef84e..7984ef92 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.9" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.10" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index edd3730f..0dff2500 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.9 +DriverVer=08/11/2026,0.1.0.10 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 3b28c047..5bf2825e 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -21,6 +21,393 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +Write-Host 'Initializing native bounded validation runner.' +if (-not ('ViiperUdeBoundedProcessRunner' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public sealed class ViiperUdeBoundedProcessResult +{ + public int ExitCode; + public string StandardOutput; + public string StandardError; +} + +public static class ViiperUdeBoundedProcessRunner +{ + private const uint CREATE_SUSPENDED = 0x00000004; + private const uint CREATE_NO_WINDOW = 0x08000000; + private const uint STARTF_USESTDHANDLES = 0x00000100; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int JobObjectBasicAccountingInformation = 1; + private const int JobObjectExtendedLimitInformation = 9; + private const uint WAIT_OBJECT_0 = 0; + private const uint WAIT_TIMEOUT = 258; + private const uint INFINITE = 0xffffffff; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + IntPtr job, int informationClass, out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(IntPtr job, uint exitCode); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, StringBuilder commandLine, IntPtr processAttributes, + IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, + string currentDirectory, ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + private static void ThrowLastError(string operation) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + private static void ConfigureJob(IntPtr job) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = + new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject( + job, JobObjectExtendedLimitInformation, buffer, (uint)size)) + { + ThrowLastError("SetInformationJobObject"); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static uint RemainingMilliseconds(Stopwatch clock, int timeoutMilliseconds) + { + long remaining = timeoutMilliseconds - clock.ElapsedMilliseconds; + if (remaining <= 0) + { + return 0; + } + return remaining > int.MaxValue ? (uint)int.MaxValue : (uint)remaining; + } + + private static bool WaitForJobEmpty(IntPtr job, uint timeoutMilliseconds) + { + Stopwatch clock = Stopwatch.StartNew(); + int size = Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + for (;;) + { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting; + if (!QueryInformationJobObject( + job, JobObjectBasicAccountingInformation, out accounting, + (uint)size, IntPtr.Zero)) + { + ThrowLastError("QueryInformationJobObject"); + } + if (accounting.ActiveProcesses == 0) + { + return true; + } + if (clock.ElapsedMilliseconds >= timeoutMilliseconds) + { + return false; + } + Thread.Sleep(10); + } + } + + public static ViiperUdeBoundedProcessResult Run( + string applicationName, string commandLine, int timeoutMilliseconds) + { + if (timeoutMilliseconds <= 0) + { + throw new ArgumentOutOfRangeException("timeoutMilliseconds"); + } + + IntPtr job = IntPtr.Zero; + PROCESS_INFORMATION process = new PROCESS_INFORMATION(); + bool processCreated = false; + bool processAssigned = false; + bool jobDrained = false; + AnonymousPipeServerStream stdoutPipe = null; + AnonymousPipeServerStream stderrPipe = null; + StreamReader stdoutReader = null; + StreamReader stderrReader = null; + Task stdoutTask = null; + Task stderrTask = null; + Stopwatch clock = Stopwatch.StartNew(); + try + { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + { + ThrowLastError("CreateJobObject"); + } + ConfigureJob(job); + + stdoutPipe = new AnonymousPipeServerStream( + PipeDirection.In, HandleInheritability.Inheritable); + stderrPipe = new AnonymousPipeServerStream( + PipeDirection.In, HandleInheritability.Inheritable); + STARTUPINFO startup = new STARTUPINFO(); + startup.cb = Marshal.SizeOf(typeof(STARTUPINFO)); + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = GetStdHandle(-10); + startup.hStdOutput = stdoutPipe.ClientSafePipeHandle.DangerousGetHandle(); + startup.hStdError = stderrPipe.ClientSafePipeHandle.DangerousGetHandle(); + + if (!CreateProcess( + applicationName, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, + true, CREATE_SUSPENDED | CREATE_NO_WINDOW, IntPtr.Zero, null, + ref startup, out process)) + { + ThrowLastError("CreateProcess"); + } + processCreated = true; + stdoutPipe.DisposeLocalCopyOfClientHandle(); + stderrPipe.DisposeLocalCopyOfClientHandle(); + stdoutReader = new StreamReader(stdoutPipe, Encoding.UTF8, true, 4096, true); + stderrReader = new StreamReader(stderrPipe, Encoding.UTF8, true, 4096, true); + stdoutTask = stdoutReader.ReadToEndAsync(); + stderrTask = stderrReader.ReadToEndAsync(); + + if (!AssignProcessToJobObject(job, process.hProcess)) + { + ThrowLastError("AssignProcessToJobObject"); + } + processAssigned = true; + if (ResumeThread(process.hThread) == UInt32.MaxValue) + { + ThrowLastError("ResumeThread"); + } + + uint remaining = RemainingMilliseconds(clock, timeoutMilliseconds); + uint wait = WaitForSingleObject(process.hProcess, remaining); + if (wait == WAIT_TIMEOUT) + { + throw new TimeoutException("validation process exceeded its deadline"); + } + if (wait != WAIT_OBJECT_0) + { + ThrowLastError("WaitForSingleObject(process)"); + } + + remaining = RemainingMilliseconds(clock, timeoutMilliseconds); + if (!WaitForJobEmpty(job, remaining)) + { + throw new TimeoutException("validation process tree exceeded its deadline"); + } + jobDrained = true; + + Task[] outputTasks = new Task[] { stdoutTask, stderrTask }; + if (!Task.WaitAll(outputTasks, 10000)) + { + throw new TimeoutException("validation output did not drain within 10000 ms"); + } + uint exitCode; + if (!GetExitCodeProcess(process.hProcess, out exitCode)) + { + ThrowLastError("GetExitCodeProcess"); + } + return new ViiperUdeBoundedProcessResult + { + ExitCode = unchecked((int)exitCode), + StandardOutput = stdoutTask.GetAwaiter().GetResult(), + StandardError = stderrTask.GetAwaiter().GetResult() + }; + } + catch (Exception failure) + { + string cleanupFailure = null; + try + { + if (processAssigned && !jobDrained) + { + if (!TerminateJobObject(job, 1)) + { + ThrowLastError("TerminateJobObject"); + } + if (!WaitForJobEmpty(job, 10000)) + { + throw new TimeoutException( + "terminated validation job did not drain within 10000 ms"); + } + jobDrained = true; + } + else if (processCreated && !processAssigned) + { + if (!TerminateProcess(process.hProcess, 1)) + { + ThrowLastError("TerminateProcess"); + } + if (WaitForSingleObject(process.hProcess, 10000) != WAIT_OBJECT_0) + { + throw new TimeoutException( + "unassigned suspended validation process did not terminate within 10000 ms"); + } + } + } + catch (Exception cleanup) + { + cleanupFailure = cleanup.Message; + } + if (cleanupFailure != null) + { + throw new InvalidOperationException( + failure.Message + "; validation process cleanup failed: " + cleanupFailure, + failure); + } + throw; + } + finally + { + if (process.hThread != IntPtr.Zero && process.hThread != InvalidHandleValue) + { + CloseHandle(process.hThread); + } + if (process.hProcess != IntPtr.Zero && process.hProcess != InvalidHandleValue) + { + CloseHandle(process.hProcess); + } + if (job != IntPtr.Zero && job != InvalidHandleValue) + { + CloseHandle(job); + } + if (stdoutReader != null) stdoutReader.Dispose(); + if (stderrReader != null) stderrReader.Dispose(); + if (stdoutPipe != null) stdoutPipe.Dispose(); + if (stderrPipe != null) stderrPipe.Dispose(); + } + } +} +'@ +} +Write-Host 'Initialized native bounded validation runner.' + function ConvertTo-WindowsCommandLineArgument { param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) @@ -59,46 +446,31 @@ function Invoke-BoundedValidationTool { [switch]$SuppressOutput ) - $startInfo = [Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = $FilePath - $startInfo.Arguments = (($Arguments | ForEach-Object { - ConvertTo-WindowsCommandLineArgument -Value $_ - }) -join ' ') - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo + $commandLine = ConvertTo-WindowsCommandLineArgument -Value $FilePath + if ($Arguments.Count -gt 0) { + $commandLine += ' ' + (($Arguments | ForEach-Object { + ConvertTo-WindowsCommandLineArgument -Value $_ + }) -join ' ') + } try { - if (-not $process.Start()) { - throw "$Operation did not start." + Write-Host "Starting bounded validation: $Operation" + $result = [ViiperUdeBoundedProcessRunner]::Run( + $FilePath, $commandLine, $TimeoutMilliseconds) + Write-Host "Completed bounded validation: $Operation" + if (-not $SuppressOutput -and $result.StandardOutput) { + Write-Host $result.StandardOutput.TrimEnd() } - $stdoutTask = $process.StandardOutput.ReadToEndAsync() - $stderrTask = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - try { - $process.Kill() - $process.WaitForExit() - } - catch { - throw "$Operation exceeded $TimeoutMilliseconds ms and could not be joined: $($_.Exception.Message)" - } - throw "$Operation exceeded $TimeoutMilliseconds ms and was terminated before package mutation." + if (-not $SuppressOutput -and $result.StandardError) { + Write-Host $result.StandardError.TrimEnd() } - $stdout = $stdoutTask.GetAwaiter().GetResult() - $stderr = $stderrTask.GetAwaiter().GetResult() - if (-not $SuppressOutput -and $stdout) { Write-Host $stdout.TrimEnd() } - if (-not $SuppressOutput -and $stderr) { Write-Host $stderr.TrimEnd() } return [pscustomobject]@{ - ExitCode = $process.ExitCode - StandardOutput = $stdout - StandardError = $stderr + ExitCode = $result.ExitCode + StandardOutput = $result.StandardOutput + StandardError = $result.StandardError } } - finally { - $process.Dispose() + catch { + throw "$Operation failed closed: $($_.Exception.Message)" } } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 1d88b16e..3aa9b960 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4896,7 +4896,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "0a82bea09a529c6bf632234ceda2bcaa536713a71008a4fc7f262cd602850a90") { + "498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From aedb155ba43a11255f75a86b944daed9dd003829 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 20:52:26 -0500 Subject: [PATCH 191/240] Fix native local test deployment preflight --- .../udecx/local_test_package_contract_test.go | 27 ++++++++++- .../tools/Install-ViiperUdeLocalTest.ps1 | 12 ++--- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 45 +++++++++++++++++++ native/udecx/tools/ViiperUdeCtl.cpp | 4 +- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index d71639b0..dd208540 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -75,6 +75,10 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "-RequireLocalTestToolchainValidation", "local-test-package.lock.json", "Local test package lock SHA-256: $lockSha256", + "$broker native-package-install --help", + "$expectedBrokerFlags", + "$helper verify (Join-Path $driverDirectory 'ViiperUde.inf')", + "result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0", } { if !strings.Contains(composer, required) { t.Fatalf("local-test composer omitted %q", required) @@ -101,8 +105,12 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "$Started.Value = $true", "$process.WaitForExit()", "$retainTrustOnFailure = $processStarted", - "'--expected-broker-sha256', $brokerHash", - "'--expected-helper-sha256', $helperHash", + "'--expected-broker-sha-256', $brokerHash", + "'--expected-helper-sha-256', $helperHash", + "'--expected-manifest-sha-256', $manifestHash", + "'--expected-inf-sha-256', $infHash", + "'--expected-sys-sha-256', $sysHash", + "'--expected-cat-sha-256', $catHash", "'--target-user-sid', $TargetUserSID", "'--driver-validation-mode', 'local-test'", "-AcknowledgeDisposableTestMachine", @@ -118,6 +126,12 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "Test-ViiperUdeSignedPackage.ps1", "git.exe", "status --porcelain", + "'--expected-broker-sha256'", + "'--expected-helper-sha256'", + "'--expected-manifest-sha256'", + "'--expected-inf-sha256'", + "'--expected-sys-sha256'", + "'--expected-cat-sha256'", } { if strings.Contains(installer, forbidden) { t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) @@ -126,6 +140,7 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { packageCommand := read("internal", "cmd", "native_package.go") packageWindows := read("internal", "cmd", "native_package_windows.go") + helperSource := read("native", "udecx", "tools", "ViiperUdeCtl.cpp") for _, required := range []string{ `default:"production" enum:"production,local-test"`, `r.driverValidationMode != "production" && r.driverValidationMode != "local-test"`, @@ -138,6 +153,14 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { `"--validation-mode", t.request.driverValidationMode`) { t.Fatal("native package transaction does not pass the validated signature route to its retained helper") } + if !strings.Contains(helperSource, + `if (!SetupGetStringFieldW(&context, field, nullptr, 0, &required) ||`) { + t.Fatal("native helper does not honor SetupGetStringFieldW's successful size-query contract") + } + if strings.Contains(helperSource, + `GetLastError() != ERROR_INSUFFICIENT_BUFFER`) { + t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") + } } func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 65e22a5f..212235bf 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -498,12 +498,12 @@ try { '--submission-manifest', $manifestPath, '--source-revision', $source, '--driver-helper', $helperPath, - '--expected-broker-sha256', $brokerHash, - '--expected-helper-sha256', $helperHash, - '--expected-manifest-sha256', $manifestHash, - '--expected-inf-sha256', $infHash, - '--expected-sys-sha256', $sysHash, - '--expected-cat-sha256', $catHash, + '--expected-broker-sha-256', $brokerHash, + '--expected-helper-sha-256', $helperHash, + '--expected-manifest-sha-256', $manifestHash, + '--expected-inf-sha-256', $infHash, + '--expected-sys-sha-256', $sysHash, + '--expected-cat-sha-256', $catHash, '--target-user-sid', $TargetUserSID, '--driver-validation-mode', 'local-test' ) diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 7da5e5e6..df0c65f7 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -180,6 +180,51 @@ $lockSha256 = (Get-FileHash -LiteralPath $lockPath -Algorithm SHA256).Hash.ToLow -LocalTestCertificatePath $certificatePath ` -RequireLocalTestToolchainValidation +# Bind the locked installer arguments to the compiled Kong command surface. +$brokerHelpOutput = @(& $broker native-package-install --help 2>&1) +$brokerHelpExitCode = $LASTEXITCODE +$brokerHelpText = $brokerHelpOutput -join [Environment]::NewLine +$expectedBrokerFlags = @( + '--expected-broker-sha-256', '--expected-helper-sha-256', + '--expected-manifest-sha-256', '--expected-inf-sha-256', + '--expected-sys-sha-256', '--expected-cat-sha-256' +) +if ($brokerHelpExitCode -ne 0 -or + @($expectedBrokerFlags | Where-Object { + $brokerHelpText -notmatch [regex]::Escape($_) + }).Count -ne 0) { + throw "Compiled local-test broker command contract is incompatible with the locked installer.`n$brokerHelpText" +} + +# Exercise the compiled helper's exact read-only SetupAPI/INF contract before +# publishing an installer artifact. Static source checks cannot prove the +# Windows API's two-call buffer-sizing behavior. +$manifestSha256 = (Get-FileHash -LiteralPath $manifestPath ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$infSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.inf') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$sysSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.sys') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$catSha256 = (Get-FileHash -LiteralPath (Join-Path $driverDirectory 'ViiperUde.cat') ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$deadline = [DateTimeOffset]::UtcNow.AddMinutes(4).ToUnixTimeMilliseconds().ToString() +$helperVerifyOutput = @(& $helper verify (Join-Path $driverDirectory 'ViiperUde.inf') ` + --manifest $manifestPath ` + --manifest-sha256 $manifestSha256 ` + --source-revision $source ` + --validation-mode local-test ` + --expected-inf-sha256 $infSha256 ` + --expected-sys-sha256 $sysSha256 ` + --expected-cat-sha256 $catSha256 ` + --transaction-deadline-unix-ms $deadline 2>&1) +$helperVerifyExitCode = $LASTEXITCODE +$helperVerifyText = $helperVerifyOutput -join [Environment]::NewLine +if ($helperVerifyExitCode -ne 0 -or + @([regex]::Matches($helperVerifyText, + '(?m)^result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0\r?$')).Count -ne 1) { + throw "Compiled local-test helper verification failed (exit $helperVerifyExitCode).`n$helperVerifyText" +} + Write-Host "Created compact source-bound local test package at '$output'." Write-Host "Source: $source" Write-Host "Driver: $driverVersion / ABI 1.10 / $buildIdentity" diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 3aa9b960..6efb7188 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -1112,8 +1112,8 @@ bool GetInfField( return SetLastErrorDetail(error, L"inf-contract-line"); } DWORD required = 0; - SetupGetStringFieldW(&context, field, nullptr, 0, &required); - if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + if (!SetupGetStringFieldW(&context, field, nullptr, 0, &required) || + required == 0) { return SetLastErrorDetail(error, L"inf-contract-field"); } std::vector buffer(required); From 75ffda48e285c32050e622415fd0ea771b3eaf56 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 20:56:13 -0500 Subject: [PATCH 192/240] Scope native INF contract assertion --- internal/transport/udecx/local_test_package_contract_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index dd208540..28c0860f 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -158,7 +158,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { t.Fatal("native helper does not honor SetupGetStringFieldW's successful size-query contract") } if strings.Contains(helperSource, - `GetLastError() != ERROR_INSUFFICIENT_BUFFER`) { + "SetupGetStringFieldW(&context, field, nullptr, 0, &required);\n"+ + " if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER)") { t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") } } From d85a58a97b4ae67dd7b023f93cfbc27e6559aa7a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 21:18:33 -0500 Subject: [PATCH 193/240] Fix trusted local driver signature verification --- .../udecx/local_test_package_contract_test.go | 8 ++++++++ native/udecx/tools/ViiperUdeCtl.cpp | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 28c0860f..824b2559 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -162,6 +162,14 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { " if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER)") { t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") } + if strings.Count(helperSource, + "code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER") != 2 { + t.Fatal("native helper does not recognize SetupAPI's exact trusted-Authenticode success classification") + } + if strings.Contains(helperSource, + "ERROR_AUTHENTICODE_TRUST_NOT_ESTABLISHED") { + t.Fatal("native helper accepts an Authenticode publisher that is not in TrustedPublisher") + } } func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 6efb7188..58a93249 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -1243,7 +1243,16 @@ bool VerifyInfSignature( SP_INF_SIGNER_INFO_W signer{}; signer.cbSize = sizeof(signer); if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { - return SetLastErrorDetail(error, L"inf-signature"); + const DWORD code = GetLastError(); + // SetupAPI reports a valid non-WHQL Authenticode package by returning + // FALSE with this classification. The local-test route has already + // installed the exact source-bound signer into TrustedPublisher; its + // manifest validation below still proves that exact certificate and + // the INF/SYS membership in the exact catalog. Production separately + // requires the Microsoft hardware publisher and never relies on this. + if (code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER) { + return SetError(error, L"inf-signature", code); + } } if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { return SetError(error, L"inf-signature", ERROR_INVALID_DATA, @@ -1388,7 +1397,10 @@ bool VerifyLocalTestPackageSigner( SP_INF_SIGNER_INFO_W signer{}; signer.cbSize = sizeof(signer); if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { - return SetLastErrorDetail(error, L"inf-local-test-signature"); + const DWORD code = GetLastError(); + if (code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER) { + return SetError(error, L"inf-local-test-signature", code); + } } if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { return SetError(error, L"inf-local-test-signature", ERROR_INVALID_DATA, From 9402ad2de6f2127dbe277fad88a07f0007a427ee Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 21:33:32 -0500 Subject: [PATCH 194/240] Use Authenticode policy for catalog membership --- .../transport/udecx/local_test_package_contract_test.go | 8 ++++++++ native/udecx/tools/ViiperUdeCtl.cpp | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 824b2559..1bfd156a 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -170,6 +170,14 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "ERROR_AUTHENTICODE_TRUST_NOT_ESTABLISHED") { t.Fatal("native helper accepts an Authenticode publisher that is not in TrustedPublisher") } + if !strings.Contains(helperSource, + "GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2;") { + t.Fatal("native helper does not use Authenticode policy for exact catalog-member verification") + } + if strings.Contains(helperSource, + "GUID action = DRIVER_ACTION_VERIFY;") { + t.Fatal("native helper incorrectly uses the WHQL-only policy for test catalog membership") + } } func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 58a93249..b8f03ece 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -1378,7 +1378,13 @@ bool VerifyDriverCatalogMember( trust.pCatalog = &catalog; trust.dwStateAction = WTD_STATEACTION_VERIFY; trust.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL; - GUID action = DRIVER_ACTION_VERIFY; + // This operation proves that the exact member hash is present in the + // supplied, Authenticode-trusted catalog. Microsoft documents + // DRIVER_ACTION_VERIFY as the WHQL-specific add-on policy; using it here + // incorrectly rejects a valid test-signed package before deployment. + // Production hardware-publisher policy is enforced separately by + // VerifyMicrosoftHardwareInfSigner. + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; const LONG status = WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); trust.dwStateAction = WTD_STATEACTION_CLOSE; WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); From f1fa0370580826f62ce24ac5cca43d5706295a02 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 21:46:41 -0500 Subject: [PATCH 195/240] Fix singleton local package validation --- internal/transport/udecx/local_test_package_contract_test.go | 1 + native/udecx/tools/Install-ViiperUdeLocalTest.ps1 | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 1bfd156a..fc2219da 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -92,6 +92,7 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "$installerScriptStream", "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", "$lockAlgorithm.ComputeHash($lockBytes)", + "@(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count", "out-of-band workflow digest", "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 212235bf..a4cf8594 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -75,7 +75,7 @@ function Assert-ExactDirectoryEntries { ForEach-Object Name | Sort-Object -CaseSensitive) $wanted = @($Expected | Sort-Object -CaseSensitive) if ($actual.Count -ne $wanted.Count -or - (Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count -ne 0) { + @(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count -ne 0) { throw "Local test package directory has missing, extra, or case-mismatched entries: '$Directory'." } } From 987b7c9a75bea933682bb3771820b0dac53f6f8c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 22:03:45 -0500 Subject: [PATCH 196/240] Gate local deployment with PowerShell preflight --- .../udecx/local_test_package_contract_test.go | 13 ++++++ .../tools/Install-ViiperUdeLocalTest.ps1 | 46 +++++++++++++++---- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 21 +++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index fc2219da..f304cace 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -93,6 +93,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", "$lockAlgorithm.ComputeHash($lockBytes)", "@(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count", + "$expectedSecurity.GetSecurityDescriptorBinaryForm()", + "$actualSecurity.GetSecurityDescriptorBinaryForm()", "out-of-band workflow digest", "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", @@ -117,11 +119,22 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "-AcknowledgeDisposableTestMachine", "testsigning\\s+Yes", "Restart, rerun this identical install command", + "[switch]$PreflightOnly", + "operation=local-test-preflight", } { if !strings.Contains(installer, required) { t.Fatalf("local-test installer omitted %q", required) } } + for _, required := range []string{ + "System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-PreflightOnly", + "Windows PowerShell 5.1 local-test installer preflight failed", + } { + if !strings.Contains(composer, required) { + t.Fatalf("local-test composer omitted Windows PowerShell preflight contract %q", required) + } + } for _, forbidden := range []string{ "& $helperPath install", "Test-ViiperUdeSignedPackage.ps1", diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index a4cf8594..3852b9a5 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -10,7 +10,8 @@ param( [Parameter(Mandatory = $true)] [ValidatePattern('^S-1-5-21-(?:[0-9]+-){3}[0-9]+$')] [string]$TargetUserSID, - [switch]$AcknowledgeDisposableTestMachine + [switch]$AcknowledgeDisposableTestMachine, + [switch]$PreflightOnly ) Set-StrictMode -Version Latest @@ -45,10 +46,12 @@ $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Local VIIPER driver installation requires an elevated PowerShell session.' } -$bcdeditPath = Join-Path ([Environment]::SystemDirectory) 'bcdedit.exe' -$bcdOutput = (& $bcdeditPath /enum '{current}' 2>&1 | Out-String) -if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { - throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before installation.`n$bcdOutput" +if (-not $PreflightOnly) { + $bcdeditPath = Join-Path ([Environment]::SystemDirectory) 'bcdedit.exe' + $bcdOutput = (& $bcdeditPath /enum '{current}' 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') { + throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before installation.`n$bcdOutput" + } } $root = (Resolve-Path -LiteralPath $PackageRoot -ErrorAction Stop).Path @@ -96,10 +99,8 @@ function Initialize-ProtectedStagingDirectory { } $actualSecurity = $directory.GetAccessControl( [Security.AccessControl.AccessControlSections]::All) - $expectedBinary = [byte[]]::new($expectedSecurity.BinaryLength) - $actualBinary = [byte[]]::new($actualSecurity.BinaryLength) - $expectedSecurity.GetSecurityDescriptorBinaryForm($expectedBinary, 0) - $actualSecurity.GetSecurityDescriptorBinaryForm($actualBinary, 0) + $expectedBinary = $expectedSecurity.GetSecurityDescriptorBinaryForm() + $actualBinary = $actualSecurity.GetSecurityDescriptorBinaryForm() if ([Convert]::ToBase64String($actualBinary) -cne [Convert]::ToBase64String($expectedBinary)) { throw "Local-test staging directory ACL verification failed for '$Path'." @@ -387,6 +388,33 @@ if ($certificateSha256 -cne [string]$lock.testSignerCertificateSha256) { throw 'The local test certificate does not match the source-bound package lock.' } +if ($PreflightOnly) { + $brokerEntry = $lockByPath['viiper.exe'] + $brokerHash = [string]$brokerEntry.sha256 + $preflightProgramDataRoot = (Resolve-Path -LiteralPath $env:ProgramData -ErrorAction Stop).Path + $programDataItem = Get-Item -LiteralPath $preflightProgramDataRoot -Force -ErrorAction Stop + if (-not $programDataItem.PSIsContainer -or + ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "ProgramData is not a safe staging parent: '$preflightProgramDataRoot'." + } + $preflightStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + try { + Initialize-ProtectedStagingDirectory -Path $preflightStage + [void](Copy-ExactBrokerToProtectedStage ` + -SourcePath $packageBrokerPath -DestinationDirectory $preflightStage ` + -ExpectedLength ([long]$brokerEntry.length) -ExpectedSHA256 $brokerHash) + } + finally { + if (Test-Path -LiteralPath $preflightStage) { + Remove-ProtectedStagingDirectory ` + -Path $preflightStage -ProgramDataRoot $preflightProgramDataRoot + } + } + Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0' + return +} + $expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) $addedStores = [Collections.Generic.List[string]]::new() function Remove-NewLocalTestTrust { diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index df0c65f7..2ec046e3 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -225,6 +225,27 @@ if ($helperVerifyExitCode -ne 0 -or throw "Compiled local-test helper verification failed (exit $helperVerifyExitCode).`n$helperVerifyText" } +# Run the exact elevated installer validation and protected-staging path under +# inbox Windows PowerShell 5.1 before publishing it. This route never imports +# trust, launches the broker, or changes driver/device/service state. +$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$preflightSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$preflightOutput = @(& $windowsPowerShell -NoProfile -ExecutionPolicy Bypass ` + -File $installerScriptPath ` + -PackageRoot $output ` + -ExpectedSourceRevision $source ` + -ExpectedPackageLockSHA256 $lockSha256 ` + -TargetUserSID $preflightSid ` + -AcknowledgeDisposableTestMachine ` + -PreflightOnly 2>&1) +$preflightExitCode = $LASTEXITCODE +$preflightText = $preflightOutput -join [Environment]::NewLine +if ($preflightExitCode -ne 0 -or + @([regex]::Matches($preflightText, + '(?m)^result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0\r?$')).Count -ne 1) { + throw "Windows PowerShell 5.1 local-test installer preflight failed (exit $preflightExitCode).`n$preflightText" +} + Write-Host "Created compact source-bound local test package at '$output'." Write-Host "Source: $source" Write-Host "Driver: $driverVersion / ABI 1.10 / $buildIdentity" From 8684d8e4006d9073f787fcdb0341fb9e10fc60be Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 22:13:26 -0500 Subject: [PATCH 197/240] Harden local test staging ACL verification --- .../udecx/local_test_package_contract_test.go | 12 +++++- .../tools/Install-ViiperUdeLocalTest.ps1 | 39 ++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index f304cace..51b96385 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -93,11 +93,17 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "$lock.installerScriptSha256 -cne $actualInstallerScriptSha256", "$lockAlgorithm.ComputeHash($lockBytes)", "@(Compare-Object -ReferenceObject $wanted -DifferenceObject $actual -CaseSensitive).Count", - "$expectedSecurity.GetSecurityDescriptorBinaryForm()", - "$actualSecurity.GetSecurityDescriptorBinaryForm()", "out-of-band workflow digest", "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", + "$directory.SetAccessControl($expectedSecurity)", + "$actualSecurity.AreAccessRulesProtected", + "$actualSecurity.GetOwner([Security.Principal.SecurityIdentifier])", + "$actualSecurity.GetAccessRules(", + "@('S-1-5-18', 'S-1-5-32-544')", + "[Security.AccessControl.FileSystemRights]::FullControl", + "[Security.AccessControl.InheritanceFlags]::ContainerInherit", + "[Security.AccessControl.InheritanceFlags]::ObjectInherit", "Copy-ExactBrokerToProtectedStage", "[IO.FileShare]::Read", "[IO.FileOptions]::WriteThrough", @@ -146,6 +152,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "'--expected-inf-sha256'", "'--expected-sys-sha256'", "'--expected-cat-sha256'", + "GetSecurityDescriptorBinaryForm", + "BinaryLength", } { if strings.Contains(installer, forbidden) { t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 3852b9a5..caefa637 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -97,13 +97,40 @@ function Initialize-ProtectedStagingDirectory { if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Local-test staging directory is a reparse point: '$Path'." } + $directory.SetAccessControl($expectedSecurity) $actualSecurity = $directory.GetAccessControl( - [Security.AccessControl.AccessControlSections]::All) - $expectedBinary = $expectedSecurity.GetSecurityDescriptorBinaryForm() - $actualBinary = $actualSecurity.GetSecurityDescriptorBinaryForm() - if ([Convert]::ToBase64String($actualBinary) -cne - [Convert]::ToBase64String($expectedBinary)) { - throw "Local-test staging directory ACL verification failed for '$Path'." + [Security.AccessControl.AccessControlSections]::Owner -bor + [Security.AccessControl.AccessControlSections]::Access) + if (-not $actualSecurity.AreAccessRulesProtected) { + throw "Local-test staging directory inherited an unsafe DACL for '$Path'." + } + $owner = $actualSecurity.GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -cne 'S-1-5-32-544') { + throw "Local-test staging directory has an unexpected owner for '$Path'." + } + $rules = @($actualSecurity.GetAccessRules( + $true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -ne 2) { + throw "Local-test staging directory has an unexpected access-rule count for '$Path'." + } + $expectedInheritance = + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + foreach ($expectedSID in @('S-1-5-18', 'S-1-5-32-544')) { + $matches = @($rules | Where-Object { + $_.IdentityReference.Value -ceq $expectedSID + }) + if ($matches.Count -ne 1) { + throw "Local-test staging directory is missing an exact protected principal for '$Path'." + } + $rule = $matches[0] + if ($rule.IsInherited -or + $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or + $rule.InheritanceFlags -ne $expectedInheritance -or + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) { + throw "Local-test staging directory has an unexpected access rule for '$Path'." + } } } From 8cc615217ecdd70c602f51b077b797d54f3dc405 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 22:47:21 -0500 Subject: [PATCH 198/240] Fix native ACL object mapping validation --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- .../cmd/native_service_install_windows.go | 130 ++++++++++++-- .../native_service_install_windows_test.go | 164 +++++++++++++++++- internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 13 files changed, 293 insertions(+), 27 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 2a0cb646..2785fc18 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c', + '73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index f04cc7d4..22837cab 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.10 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c') { + -DriverPackageVersion 0.1.0.11 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 37000da9..6f60c04d 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.10", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.11", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index ad8fea27..f0796d42 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.10", + "expectedDriverPackageVersion": "0.1.0.11", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 5a3f6e22..e165db03 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -47,6 +47,36 @@ const ( nativeBrokerServiceSDDL = "O:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)" nativeBrokerDirectorySDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)" nativeBrokerExecutableSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;BU)" + nativeFileAllAccess = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff + nativeServiceGenericRead = windows.STANDARD_RIGHTS_READ | windows.SERVICE_QUERY_CONFIG | + windows.SERVICE_QUERY_STATUS | windows.SERVICE_INTERROGATE | windows.SERVICE_ENUMERATE_DEPENDENTS + nativeServiceGenericWrite = windows.STANDARD_RIGHTS_WRITE | windows.SERVICE_CHANGE_CONFIG + nativeServiceGenericExecute = windows.STANDARD_RIGHTS_EXECUTE | windows.SERVICE_START | + windows.SERVICE_STOP | windows.SERVICE_PAUSE_CONTINUE | windows.SERVICE_USER_DEFINED_CONTROL +) + +type nativeGenericAccessMapping struct { + read windows.ACCESS_MASK + write windows.ACCESS_MASK + execute windows.ACCESS_MASK + all windows.ACCESS_MASK +} + +type nativeAccessAllowedACE struct { + flags uint8 + mask windows.ACCESS_MASK + sid string +} + +var ( + nativeFileAccessMapping = nativeGenericAccessMapping{ + read: windows.FILE_GENERIC_READ, write: windows.FILE_GENERIC_WRITE, + execute: windows.FILE_GENERIC_EXECUTE, all: nativeFileAllAccess, + } + nativeServiceAccessMapping = nativeGenericAccessMapping{ + read: nativeServiceGenericRead, write: nativeServiceGenericWrite, + execute: nativeServiceGenericExecute, all: windows.SERVICE_ALL_ACCESS, + } ) var nativeServiceRecoveryActions = []mgr.RecoveryAction{ @@ -1610,7 +1640,9 @@ func compareNativeSecurityDescriptorStrings(actual, expected string) error { if err != nil { return fmt.Errorf("parse expected security descriptor: %w", err) } - return nativeSecurityDescriptorsEqual(actualDescriptor, expectedDescriptor) + return nativeSecurityDescriptorsEqual( + actualDescriptor, expectedDescriptor, nativeServiceAccessMapping, + ) } func requireSingleNativeFileLink(handle windows.Handle) error { @@ -1920,19 +1952,87 @@ func validateNativeSecurityDescriptor(handle windows.Handle, expectedSDDL string if err != nil { return err } - return nativeSecurityDescriptorsEqual(actual, expected) + return nativeSecurityDescriptorsEqual(actual, expected, nativeFileAccessMapping) +} + +func normalizeNativeAccessMask( + mask windows.ACCESS_MASK, + mapping nativeGenericAccessMapping, +) windows.ACCESS_MASK { + generic := mask & (windows.GENERIC_READ | windows.GENERIC_WRITE | + windows.GENERIC_EXECUTE | windows.GENERIC_ALL) + mask &^= windows.GENERIC_READ | windows.GENERIC_WRITE | + windows.GENERIC_EXECUTE | windows.GENERIC_ALL + if generic&windows.GENERIC_READ != 0 { + mask |= mapping.read + } + if generic&windows.GENERIC_WRITE != 0 { + mask |= mapping.write + } + if generic&windows.GENERIC_EXECUTE != 0 { + mask |= mapping.execute + } + if generic&windows.GENERIC_ALL != 0 { + mask |= mapping.all + } + return mask } -func nativeSecurityDescriptorsEqual(actual, expected *windows.SECURITY_DESCRIPTOR) error { - actualOwner, _, err := actual.Owner() +func nativeAccessAllowedACEs( + dacl *windows.ACL, + mapping nativeGenericAccessMapping, +) ([]nativeAccessAllowedACE, error) { + if dacl == nil { + return nil, errors.New("security descriptor has no DACL") + } + entries := make([]nativeAccessAllowedACE, 0, dacl.AceCount) + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + return nil, fmt.Errorf("read DACL ACE %d: %w", index, err) + } + if ace == nil || ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return nil, fmt.Errorf("DACL ACE %d is not an explicit access-allowed ACE", index) + } + sidOffset := unsafe.Offsetof(ace.SidStart) + aceSize := uintptr(ace.Header.AceSize) + if aceSize < sidOffset+8 { + return nil, fmt.Errorf("DACL ACE %d is truncated", index) + } + remaining := aceSize - sidOffset + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + subAuthorityCount := *(*uint8)(unsafe.Add(unsafe.Pointer(sid), 1)) + sidLength := uintptr(8 + 4*uint32(subAuthorityCount)) + if sidLength > remaining || !sid.IsValid() || uintptr(sid.Len()) != sidLength { + return nil, fmt.Errorf("DACL ACE %d contains an invalid SID", index) + } + sidString := sid.String() + if sidString == "" { + return nil, fmt.Errorf("DACL ACE %d SID could not be serialized", index) + } + entries = append(entries, nativeAccessAllowedACE{ + flags: ace.Header.AceFlags, + mask: normalizeNativeAccessMask(ace.Mask, mapping), + sid: sidString, + }) + } + return entries, nil +} + +func nativeSecurityDescriptorsEqual( + actual, expected *windows.SECURITY_DESCRIPTOR, + mapping nativeGenericAccessMapping, +) error { + actualOwner, actualOwnerDefaulted, err := actual.Owner() if err != nil { return err } - expectedOwner, _, err := expected.Owner() + expectedOwner, expectedOwnerDefaulted, err := expected.Owner() if err != nil { return err } - if actualOwner == nil || expectedOwner == nil || !actualOwner.Equals(expectedOwner) { + if actualOwner == nil || expectedOwner == nil || + actualOwnerDefaulted != expectedOwnerDefaulted || !actualOwner.Equals(expectedOwner) { return errors.New("security descriptor owner is not the trusted installer owner") } actualDACL, actualDefaulted, err := actual.DACL() @@ -1943,11 +2043,19 @@ func nativeSecurityDescriptorsEqual(actual, expected *windows.SECURITY_DESCRIPTO if err != nil { return err } - actualSDDL := actual.String() - expectedCanonicalSDDL := expected.String() - if actualDACL == nil || expectedDACL == nil || actualDefaulted != expectedDefaulted || - actualSDDL == "" || expectedCanonicalSDDL == "" || actualSDDL != expectedCanonicalSDDL { - return errors.New("security descriptor DACL is not the canonical protected DACL") + if actualDACL == nil || expectedDACL == nil || actualDefaulted != expectedDefaulted { + return errors.New("security descriptor DACL metadata does not match") + } + actualEntries, err := nativeAccessAllowedACEs(actualDACL, mapping) + if err != nil { + return fmt.Errorf("inspect actual security descriptor DACL: %w", err) + } + expectedEntries, err := nativeAccessAllowedACEs(expectedDACL, mapping) + if err != nil { + return fmt.Errorf("inspect expected security descriptor DACL: %w", err) + } + if !slices.Equal(actualEntries, expectedEntries) { + return errors.New("security descriptor DACL access rules do not match") } actualControl, _, err := actual.Control() if err != nil { diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go index 3726f3b1..1edb9e61 100644 --- a/internal/cmd/native_service_install_windows_test.go +++ b/internal/cmd/native_service_install_windows_test.go @@ -1253,23 +1253,181 @@ func TestCredentialDirectorySecurityRejectsPrecreatedOwnerOrDACL(t *testing.T) { t.Fatal(err) } identical, _ := windows.SecurityDescriptorFromString(nativeCredentialDirectorySDDL(userSID)) - if err := nativeSecurityDescriptorsEqual(identical, expected); err != nil { + if err := nativeSecurityDescriptorsEqual(identical, expected, nativeFileAccessMapping); err != nil { t.Fatalf("exact protected descriptor rejected: %v", err) } wrongOwner, _ := windows.SecurityDescriptorFromString( "O:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", ) - if err := nativeSecurityDescriptorsEqual(wrongOwner, expected); err == nil { + if err := nativeSecurityDescriptorsEqual(wrongOwner, expected, nativeFileAccessMapping); err == nil { t.Fatal("accepted user-precreated credential directory with wrong owner") } unprotected, _ := windows.SecurityDescriptorFromString( "O:BAD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;" + userSID + ")", ) - if err := nativeSecurityDescriptorsEqual(unprotected, expected); err == nil { + if err := nativeSecurityDescriptorsEqual(unprotected, expected, nativeFileAccessMapping); err == nil { t.Fatal("accepted credential directory without protected canonical DACL") } } +func TestNativeFileSecurityComparisonAcceptsWindowsMaterializedGenericRights(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + actual, err := windows.SecurityDescriptorFromString( + "O:BAG:S-1-5-21-1-2-3-1001D:P" + + "(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)", + ) + if err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual(actual, expected, nativeFileAccessMapping); err != nil { + t.Fatalf("rejected exact Windows-materialized file DACL: %v", err) + } +} + +func TestNativeFileSecurityComparisonRoundTripsThroughObjectManager(t *testing.T) { + path := filepath.Join(t.TempDir(), "protected") + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + ownerSID := user.User.Sid.String() + if ownerSID == "" { + t.Fatal("current process token returned an empty owner SID") + } + roundTripSDDL := strings.Replace(nativeBrokerDirectorySDDL, "O:BA", "O:"+ownerSID, 1) + security, err := nativeSecurityAttributes(roundTripSDDL) + if err != nil { + t.Fatal(err) + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + if err := windows.CreateDirectory(pointer, security); err != nil { + t.Fatal(err) + } + handle, err := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(handle, roundTripSDDL); err != nil { + actual, queryErr := windows.GetSecurityInfo( + handle, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if queryErr != nil { + t.Fatalf("round-trip rejected (%v), then query failed: %v", err, queryErr) + } + t.Fatalf("round-trip rejected: %v (actual=%s)", err, actual.String()) + } +} + +func TestNativeSecurityComparisonRejectsWidenedOrNonAllowDACLs(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + for name, sddl := range map[string]string{ + "widened": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;BU)", + "narrowed": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GR;;;BU)", + "wrong_sid": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICI;GRGX;;;WD)", + "wrong_flags": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OI;GRGX;;;BU)", + "inherited": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICIID;GRGX;;;BU)", + "reordered": "O:BAD:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)" + + "(A;OICI;GRGX;;;BU)", + "extra": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + "(A;OICI;GRGX;;;BU)(A;OICI;GR;;;WD)", + "deny": "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(D;OICI;GW;;;BU)" + + "(A;OICI;GRGX;;;BU)", + } { + t.Run(name, func(t *testing.T) { + actual, parseErr := windows.SecurityDescriptorFromString(sddl) + if parseErr != nil { + t.Fatal(parseErr) + } + if err := nativeSecurityDescriptorsEqual( + actual, expected, nativeFileAccessMapping, + ); err == nil { + t.Fatal("accepted a non-exact protected DACL") + } + }) + } +} + +func TestNativeSecurityComparisonRejectsMissingNullOrDefaultedDACL(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerDirectorySDDL) + if err != nil { + t.Fatal(err) + } + owner, _, err := expected.Owner() + if err != nil { + t.Fatal(err) + } + dacl, _, err := expected.DACL() + if err != nil { + t.Fatal(err) + } + for name, build := range map[string]func(*windows.SECURITY_DESCRIPTOR) error{ + "missing": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(nil, false, false) + }, + "null": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(nil, true, false) + }, + "defaulted": func(descriptor *windows.SECURITY_DESCRIPTOR) error { + return descriptor.SetDACL(dacl, true, true) + }, + } { + t.Run(name, func(t *testing.T) { + actual, newErr := windows.NewSecurityDescriptor() + if newErr != nil { + t.Fatal(newErr) + } + if err := actual.SetOwner(owner, false); err != nil { + t.Fatal(err) + } + if err := build(actual); err != nil { + t.Fatal(err) + } + if err := actual.SetControl( + windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED, + ); err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual( + actual, expected, nativeFileAccessMapping, + ); err == nil { + t.Fatal("accepted missing, NULL, or defaulted DACL") + } + }) + } +} + +func TestNativeServiceSecurityComparisonMapsGenericAll(t *testing.T) { + expected, err := windows.SecurityDescriptorFromString(nativeBrokerServiceSDDL) + if err != nil { + t.Fatal(err) + } + actual, err := windows.SecurityDescriptorFromString( + "O:BAG:S-1-5-21-1-2-3-1001D:P(A;;0xf01ff;;;SY)(A;;0xf01ff;;;BA)", + ) + if err != nil { + t.Fatal(err) + } + if err := nativeSecurityDescriptorsEqual(actual, expected, nativeServiceAccessMapping); err != nil { + t.Fatalf("rejected exact Windows-materialized service DACL: %v", err) + } +} + func TestParseWindowsCommandRejectsNonViiperAndPreservesArguments(t *testing.T) { identity := func(value string) (string, error) { return value, nil } command, err := parseWindowsCommand( diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index fb967e54..0efd1cd7 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.10", + ExpectedDriverPackageVersion: "0.1.0.11", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index c13c371b..0e4cce18 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.10" + DriverPackageVersion = "0.1.0.11" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 0a6547ab..84bcb9e7 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c" + const wantHex = "73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 624646ca..27ebf889 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,8 +13,8 @@ ViiperUde 17.0 x64 - 08/11/2026 - 0.1.0.10 + 08/12/2026 + 0.1.0.11 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 7984ef92..4092de7f 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.10" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.11" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 0dff2500..cf8c99ad 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/11/2026,0.1.0.10 +DriverVer=08/12/2026,0.1.0.11 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b8f03ece..a392ffde 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4914,7 +4914,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "498cbb221644d53102f63bd20c13ef24cd46a3b940f8ec677a5fc9b856099c2c") { + "73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 0bc65bcebdc4556ad04b08fe7c441734502b8376 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 12 Aug 2026 23:12:15 -0500 Subject: [PATCH 199/240] Fix native controller device security initialization --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 ++-- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/driver_dispatch_contract_test.go | 8 ++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Controller.c | 7 ++++++- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 | 7 +++++++ native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 14 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 2785fc18..24518530 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9', + '6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 22837cab..d29e71fc 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.11 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9') { + -DriverPackageVersion 0.1.0.12 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 6f60c04d..e451d447 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.11", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.12", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index f0796d42..ddf7eb92 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.11", + "expectedDriverPackageVersion": "0.1.0.12", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 0efd1cd7..88ff1f03 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.11", + ExpectedDriverPackageVersion: "0.1.0.12", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 16ce42bd..4eda19f0 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -6,6 +6,14 @@ import ( "testing" ) +func TestNativeControllerNamesDeviceBeforeAssigningSecurity(t *testing.T) { + controller := normalizedContract(nativeContractSource(t, + "native", "udecx", "driver", "Controller.c")) + requireContractOrder(t, controller, + "WdfDeviceInitSetCharacteristics( DeviceInit, FILE_DEVICE_SECURE_OPEN | FILE_AUTOGENERATED_DEVICE_NAME, FALSE);", + "status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl);") +} + func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 0e4cce18..899dca7a 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.11" + DriverPackageVersion = "0.1.0.12" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 84bcb9e7..61dcf108 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9" + const wantHex = "6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 2c873ce9..de4c715b 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -129,7 +129,12 @@ ViiperEvtDeviceAdd( PAGED_CODE(); UNREFERENCED_PARAMETER(Driver); - WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE); + // WdfDeviceInitAssignSDDLString requires a named device object. Let KMDF + // generate the private NT name; user mode opens only the device interface. + WdfDeviceInitSetCharacteristics( + DeviceInit, + FILE_DEVICE_SECURE_OPEN | FILE_AUTOGENERATED_DEVICE_NAME, + FALSE); status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl); if (!NT_SUCCESS(status)) { return status; diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 27ebf889..76266493 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/12/2026 - 0.1.0.11 + 0.1.0.12 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 4092de7f..5b479a2f 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.11" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.12" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index cf8c99ad..04c83f74 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/12/2026,0.1.0.11 +DriverVer=08/12/2026,0.1.0.12 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 8fbec08c..d53b5e48 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -109,6 +109,13 @@ $allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter ' Sort-Object -Property FullName | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" +if ($controllerSource -notmatch + 'WdfDeviceInitSetCharacteristics\s*\(\s*DeviceInit\s*,\s*FILE_DEVICE_SECURE_OPEN\s*\|\s*FILE_AUTOGENERATED_DEVICE_NAME\s*,\s*FALSE\s*\)\s*;' -or + $controllerSource -notmatch + 'FILE_AUTOGENERATED_DEVICE_NAME[\s\S]{0,300}?WdfDeviceInitAssignSDDLString\s*\(\s*DeviceInit') { + throw 'The controller must name its device before assigning the broker-only SDDL.' +} + foreach ($requiredHeaderContract in @( 'EX_PUSH_LOCK DeviceLock;', 'ULONG InputDeviceCount;', diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index a392ffde..01d95446 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -4914,7 +4914,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "73d488839228708c594f1582a343ec07660d2d2fc4d009cb4db2d99cb9e554c9") { + "6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From e144525ec95459468f96c6e41abdcc2a0266c9f7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 05:23:20 -0500 Subject: [PATCH 200/240] Fix nested native broker commit contract --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/cmd/native_package_contract_test.go | 9 ++++ internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 22 +++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 19 ++++++++ .../tools/Test-ViiperUdeCtlTransaction.ps1 | 6 +++ native/udecx/tools/ViiperUdeCtl.cpp | 47 +++++++++++++------ 15 files changed, 101 insertions(+), 26 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 24518530..d3bfdd3a 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b', + '21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index d29e71fc..8163d7d6 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.12 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b') { + -DriverPackageVersion 0.1.0.13 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index e451d447..276c81a4 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.12", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.13", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index ddf7eb92..603a90c6 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.12", + "expectedDriverPackageVersion": "0.1.0.13", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 2e9c2664..d0a643f1 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -120,6 +120,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "--manifest-sha256", "manifest-installer-hash", "--broker-sha256", "--expected-inf-sha256", "--expected-sys-sha256", "--expected-cat-sha256", "--broker-token-sha256", "native-package-broker-commit", + "BuildBrokerCommitCommandLine(", "--expected-token-sha-256", + "--expected-broker-sha-256", "ParseBrokerCommitProof", "driverRollbackAuthorized", "CreatePipe(", "PROC_THREAD_ATTRIBUTE_HANDLE_LIST", "kMaximumBrokerProofBytes", "RollbackInstall(prior", "broker-reboot-boundary", @@ -139,6 +141,13 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("driver helper lost %q", fragment) } } + for _, obsolete := range []string{ + "--expected-token-sha256", "--expected-broker-sha256", + } { + if strings.Contains(helperSource, obsolete) { + t.Errorf("driver helper retained obsolete nested broker option %q", obsolete) + } + } if strings.Contains(helperSource, "UpdateDriverForPlugAndPlayDevicesW(") { t.Error("driver helper must bind only an exact selected preinstalled package with DiInstallDevice") } diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 88ff1f03..8e7c9618 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.12", + ExpectedDriverPackageVersion: "0.1.0.13", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 51b96385..d5698199 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -77,6 +77,10 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "Local test package lock SHA-256: $lockSha256", "$broker native-package-install --help", "$expectedBrokerFlags", + "$broker native-package-broker-commit --help", + "$expectedBrokerCommitFlags", + "'--expected-token-sha-256'", + "'--expected-broker-sha-256'", "$helper verify (Join-Path $driverDirectory 'ViiperUde.inf')", "result=success operation=verify changed=0 rebootRequired=0 rollback=not-needed exitCode=0", } { @@ -163,6 +167,24 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { packageCommand := read("internal", "cmd", "native_package.go") packageWindows := read("internal", "cmd", "native_package_windows.go") helperSource := read("native", "udecx", "tools", "ViiperUdeCtl.cpp") + for _, required := range []string{ + "BuildBrokerCommitCommandLine(", + `L" --expected-token-sha-256 "`, + `L" --expected-broker-sha-256 "`, + `L"self-test-broker-command"`, + } { + if !strings.Contains(helperSource, required) { + t.Fatalf("native helper omitted nested broker command contract %q", required) + } + } + for _, obsolete := range []string{ + `L" --expected-token-sha256 "`, + `L" --expected-broker-sha256 "`, + } { + if strings.Contains(helperSource, obsolete) { + t.Fatalf("native helper retained obsolete nested broker option %q", obsolete) + } + } for _, required := range []string{ `default:"production" enum:"production,local-test"`, `r.driverValidationMode != "production" && r.driverValidationMode != "local-test"`, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 899dca7a..b236e014 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.12" + DriverPackageVersion = "0.1.0.13" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 61dcf108..5ae918a8 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b" + const wantHex = "21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 76266493..95ae872a 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,8 +13,8 @@ ViiperUde 17.0 x64 - 08/12/2026 - 0.1.0.12 + 08/13/2026 + 0.1.0.13 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 5b479a2f..f3f5e20c 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.12" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.13" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 04c83f74..2ab05992 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/12/2026,0.1.0.12 +DriverVer=08/13/2026,0.1.0.13 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 2ec046e3..aa6348c4 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -196,6 +196,25 @@ if ($brokerHelpExitCode -ne 0 -or throw "Compiled local-test broker command contract is incompatible with the locked installer.`n$brokerHelpText" } +# The retained native helper launches this hidden broker command directly. +# Exercise its generated Kong option names so source-bound test artifacts +# cannot ship a helper/broker CLI mismatch that fails after driver mutation. +$brokerCommitHelpOutput = @(& $broker native-package-broker-commit --help 2>&1) +$brokerCommitHelpExitCode = $LASTEXITCODE +$brokerCommitHelpText = $brokerCommitHelpOutput -join [Environment]::NewLine +$expectedBrokerCommitFlags = @( + '--token-file', '--expected-token-sha-256', + '--expected-broker-sha-256', '--target-user-sid', + '--transaction-deadline-unix-ms' +) +if ($brokerCommitHelpExitCode -ne 0 -or + @($expectedBrokerCommitFlags | Where-Object { + $brokerCommitHelpText -notmatch [regex]::Escape($_) + }).Count -ne 0 -or + $brokerCommitHelpText -match '--expected-(?:token|broker)-sha256') { + throw "Compiled nested broker command contract is incompatible with the retained helper.`n$brokerCommitHelpText" +} + # Exercise the compiled helper's exact read-only SetupAPI/INF contract before # publishing an installer artifact. Static source checks cannot prove the # Windows API's two-call buffer-sizing behavior. diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 1d109604..6dee4975 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -57,6 +57,8 @@ $requiredContracts = [ordered]@{ 'staged broker hash binding' = '--broker-sha256' 'protected package token binding' = '--broker-token-sha256' 'nested package broker commit' = 'native-package-broker-commit' + 'nested broker expected token hash option' = '--expected-token-sha-256' + 'nested broker expected executable hash option' = '--expected-broker-sha-256' 'cooperative package deadline' = '--transaction-deadline-unix-ms' 'same-handle manifest binding' = 'Sha256Handle\(manifest\.get\(\)' 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' @@ -178,6 +180,10 @@ if ($source -match 'TerminateProcess\(') { throw 'ViiperUdeCtl must never hard-terminate the mutating broker transaction.' } +if ($source -match '--expected-(?:token|broker)-sha256') { + throw 'ViiperUdeCtl retained obsolete nested Kong SHA-256 option spelling.' +} + if ($source -match 'std::filesystem::copy_file') { throw 'Rollback packages must use the protected, write-through, verified exact-file copy path.' } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 01d95446..be70703f 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -2989,6 +2989,22 @@ std::wstring QuoteWindowsArgument(const std::wstring& value) { return quoted; } +std::wstring BuildBrokerCommitCommandLine(const InstallOptions& options) { + return QuoteWindowsArgument(options.brokerExecutable.wstring()) + + L" native-package-broker-commit --token-file " + + QuoteWindowsArgument(options.brokerToken.wstring()) + + L" --expected-token-sha-256 " + + QuoteWindowsArgument(std::wstring( + options.brokerTokenSha256.begin(), options.brokerTokenSha256.end())) + + L" --expected-broker-sha-256 " + + QuoteWindowsArgument(std::wstring( + options.brokerSha256.begin(), options.brokerSha256.end())) + + L" --target-user-sid " + + QuoteWindowsArgument(options.targetUserSid) + + L" --transaction-deadline-unix-ms " + + QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)); +} + struct BrokerCommitProof { bool success = false; bool changed = false; @@ -3156,19 +3172,7 @@ bool RunBrokerInstall( L"staged native broker does not match the installer-bound SHA-256"); } - std::wstring commandLine = QuoteWindowsArgument(options.brokerExecutable.wstring()) + - L" native-package-broker-commit --token-file " + - QuoteWindowsArgument(options.brokerToken.wstring()) + - L" --expected-token-sha256 " + - QuoteWindowsArgument(std::wstring( - options.brokerTokenSha256.begin(), options.brokerTokenSha256.end())) + - L" --expected-broker-sha256 " + - QuoteWindowsArgument(std::wstring( - options.brokerSha256.begin(), options.brokerSha256.end())) + - L" --target-user-sid " + - QuoteWindowsArgument(options.targetUserSid) + - L" --transaction-deadline-unix-ms " + - QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)); + std::wstring commandLine = BuildBrokerCommitCommandLine(options); std::vector mutableCommand(commandLine.begin(), commandLine.end()); mutableCommand.push_back(L'\0'); SECURITY_ATTRIBUTES inheritedSecurity{}; @@ -4813,6 +4817,21 @@ Outcome Status() { Outcome SelfTest() { Outcome outcome; + InstallOptions brokerCommandOptions; + brokerCommandOptions.brokerExecutable = LR"(C:\Program Files\VIIPER\viiper.exe)"; + brokerCommandOptions.brokerToken = LR"(C:\ProgramData\VIIPER\package.token)"; + brokerCommandOptions.brokerTokenSha256 = std::string(64, 'a'); + brokerCommandOptions.brokerSha256 = std::string(64, 'b'); + brokerCommandOptions.targetUserSid = L"S-1-5-21-1-2-3-1001"; + brokerCommandOptions.transactionDeadlineUnixMs = 123456789; + const std::wstring brokerCommandLine = + BuildBrokerCommitCommandLine(brokerCommandOptions); + if (brokerCommandLine.find(L" --expected-token-sha-256 ") == std::wstring::npos || + brokerCommandLine.find(L" --expected-broker-sha-256 ") == std::wstring::npos) { + SetError(&outcome.error, L"self-test-broker-command", ERROR_INVALID_DATA, + L"nested broker command does not match the compiled Kong CLI contract"); + return outcome; + } Version one{}; Version two{}; if (!ParseVersion(L"1.2.3.4", &one) || !ParseVersion(L"1.2.4.0", &two) || @@ -4914,7 +4933,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "6b904c660e130661cc64b0174b96f3442b79ccf9d177ff42b74b45a48cef912b") { + "21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From ca1132ce0d6b8e18867b628477f5ee3aed726bb2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 06:25:36 -0500 Subject: [PATCH 201/240] Harden native broker failure diagnostics --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 135 ++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 238 ++++++++++++++++-- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 8 + native/udecx/tools/ViiperUdeCtl.cpp | 213 +++++++++++++++- 14 files changed, 570 insertions(+), 46 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index d3bfdd3a..eb7d22e2 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5', + 'e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 8163d7d6..d014e1d9 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.13 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5') { + -DriverPackageVersion 0.1.0.14 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne 'e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 276c81a4..8f77320d 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.13", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.14", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 603a90c6..0ed06afa 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.13", + "expectedDriverPackageVersion": "0.1.0.14", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 8e7c9618..f2f8682b 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.13", + ExpectedDriverPackageVersion: "0.1.0.14", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index d5698199..decba833 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -2,7 +2,9 @@ package udecx import ( "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -131,6 +133,26 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "Restart, rerun this identical install command", "[switch]$PreflightOnly", "operation=local-test-preflight", + "ViiperLocalTestCertificateStore", + "CertAddEncodedCertificateToStore(", + "CertFindCertificateInStore(", + "CertDeleteCertificateFromStore(found)", + "CERT_STORE_ADD_NEW", + "CRYPT_E_NOT_FOUND", + "Get-ExactLocalTestTrustState", + "$addedStores.Add($storeName)", + "[Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly", + "action=verify-add result=present", + "action=verify-cleanup result=absent", + "LocalMachine\\$storeName trust cleanup failed during $cleanupAction.", + "ExactSpelling = true", + "[Parameter(Mandatory = $true)][int]$ProcessExitCode", + "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)", + "$proofExitCode -ne $ProcessExitCode", + "-Lines $output -ProcessExitCode $exitCode", + "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(", + "$certificateStoreOpenImport.ExactSpelling", + "does not bind the exact CertOpenStore entry point", } { if !strings.Contains(installer, required) { t.Fatalf("local-test installer omitted %q", required) @@ -158,12 +180,58 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "'--expected-cat-sha256'", "GetSecurityDescriptorBinaryForm", "BinaryLength", + "$store.Add($certificate)", + "$store.Remove(", } { if strings.Contains(installer, forbidden) { t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) } } + cleanupStart := strings.Index(installer, "function Remove-NewLocalTestTrust") + cleanupEnd := strings.Index(installer, "function Test-SettledLocalTestFailure") + if cleanupStart < 0 || cleanupEnd <= cleanupStart { + t.Fatal("local-test installer trust cleanup function is missing or malformed") + } + cleanup := installer[cleanupStart:cleanupEnd] + remove := strings.Index(cleanup, "[ViiperLocalTestCertificateStore]::Remove(") + verify := strings.LastIndex(cleanup, "Get-ExactLocalTestTrustState -StoreName $storeName") + absence := strings.Index(cleanup, "if ($cleanupState.ExactCount -ne 0)") + if remove < 0 || verify <= remove || absence <= verify { + t.Fatal("local-test installer does not verify persisted exact-certificate absence after native removal") + } + if strings.Count(cleanup, "catch {") != 1 || + strings.Index(cleanup, "$removalErrors.Add(") < strings.Index(cleanup, "catch {") { + t.Fatal("local-test installer does not independently aggregate per-store cleanup failures") + } + preflightStart := strings.Index(installer, "if ($PreflightOnly) {") + interopCompile := strings.Index(installer, "if (-not ('ViiperLocalTestCertificateStore' -as [type])) {") + interopVerify := strings.Index(installer, "$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod(") + preflightSuccess := strings.Index(installer, + "Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0'") + trustAddCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Add(") + trustRemoveCall := strings.Index(installer, "[ViiperLocalTestCertificateStore]::Remove(") + if preflightStart < 0 || interopCompile <= preflightStart || interopVerify <= interopCompile || + preflightSuccess <= interopVerify || trustAddCall <= preflightSuccess || + trustRemoveCall <= preflightSuccess { + t.Fatal("local-test preflight can return success before compiling and inspecting the exact certificate-store interop") + } + if strings.Contains(installer[preflightStart:interopCompile], "return") { + t.Fatal("local-test preflight can return before compiling the exact certificate-store interop") + } + settledStart := strings.Index(installer, "function Test-SettledLocalTestFailure") + settledEnd := strings.Index(installer, "$trustCommitted = $false") + if settledStart < 0 || settledEnd <= settledStart { + t.Fatal("local-test installer settled-failure predicate is missing or malformed") + } + settled := installer[settledStart:settledEnd] + parseExit := strings.Index(settled, "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)") + bindExit := strings.Index(settled, "$proofExitCode -ne $ProcessExitCode") + classify := strings.Index(settled, "$match.Groups['changed'].Value -ceq '0'") + if parseExit < 0 || bindExit <= parseExit || classify <= bindExit { + t.Fatal("local-test installer classifies settled proof before binding it to the observed child exit") + } + packageCommand := read("internal", "cmd", "native_package.go") packageWindows := read("internal", "cmd", "native_package_windows.go") helperSource := read("native", "udecx", "tools", "ViiperUdeCtl.cpp") @@ -224,6 +292,73 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { } } +func TestLocalTestSettledFailureRequiresObservedExitMatch(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell contract") + } + + root := filepath.Join("..", "..", "..") + installer, err := filepath.Abs(filepath.Join( + root, "native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1")) + if err != nil { + t.Fatalf("resolve local-test installer: %v", err) + } + powerShell := filepath.Join( + os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + if _, err := os.Stat(powerShell); err != nil { + t.Fatalf("locate Windows PowerShell: %v", err) + } + + const behaviorContract = ` +$ErrorActionPreference = 'Stop' +$source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw +$csharp = [regex]::Match( + $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@").Groups['source'].Value +if ([string]::IsNullOrEmpty($csharp)) { throw 'Embedded certificate-store source was not found.' } +Add-Type -Language CSharp -TypeDefinition $csharp +$openStore = [ViiperLocalTestCertificateStore].GetMethod( + 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') +$import = $openStore.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($import.Value -cne 'crypt32.dll' -or -not $import.ExactSpelling -or + $import.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { + throw 'CertOpenStore P/Invoke metadata does not name the exact native entry point.' +} +$start = $source.IndexOf('function Test-SettledLocalTestFailure') +$end = $source.IndexOf('$trustCommitted = $false', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Settled-failure predicate was not found.' } +Invoke-Expression $source.Substring($start, $end - $start) +$settled = @( + 'result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="broker-health"' +) +if (-not (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1)) { + throw 'Matching settled proof was rejected.' +} +$retainTrustOnFailure = $true +if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 4) { + $retainTrustOnFailure = $false +} +if (-not $retainTrustOnFailure) { + throw 'Mismatched proof exit incorrectly authorized trust removal.' +} +$preflight = @( + 'result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="preflight"' +) +if (-not (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 4)) { + throw 'Matching settled preflight proof was rejected.' +} +if (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 1) { + throw 'Mismatched preflight proof was accepted.' +} +` + command := exec.Command( + powerShell, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) + command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("settled-failure behavior contract failed: %v\n%s", err, output) + } +} + func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { root := filepath.Join("..", "..", "..", "native", "udecx", "tools") contents, err := os.ReadFile(filepath.Join(root, "Test-ViiperUdeSignedPackage.ps1")) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index b236e014..df87c905 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.13" + DriverPackageVersion = "0.1.0.14" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 5ae918a8..7b419955 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5" + const wantHex = "e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 95ae872a..f4656e98 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.13 + 0.1.0.14 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index f3f5e20c..6872133b 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.13" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.14" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 2ab05992..0e6f0248 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.13 +DriverVer=08/13/2026,0.1.0.14 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index caefa637..7e434588 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -438,28 +438,197 @@ if ($PreflightOnly) { -Path $preflightStage -ProgramDataRoot $preflightProgramDataRoot } } +} + +$certificateThumbprint = $certificate.Thumbprint +$expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) +if (-not ('ViiperLocalTestCertificateStore' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class ViiperLocalTestCertificateStore +{ + private const int CERT_STORE_PROV_SYSTEM_W = 10; + private const uint CERT_SYSTEM_STORE_LOCAL_MACHINE = 0x00020000; + private const uint CERT_STORE_OPEN_EXISTING_FLAG = 0x00004000; + private const uint CERT_STORE_MAXIMUM_ALLOWED_FLAG = 0x00001000; + private const uint CERT_ENCODING = 0x00010001; + private const uint CERT_STORE_ADD_NEW = 1; + private const uint CERT_FIND_EXISTING = 0x000d0000; + private const int CRYPT_E_NOT_FOUND = unchecked((int)0x80092004); + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true, + ExactSpelling = true)] + private static extern IntPtr CertOpenStore( + IntPtr provider, uint encoding, IntPtr cryptProvider, + uint flags, string storeName); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertAddEncodedCertificateToStore( + IntPtr store, uint encoding, byte[] certificate, uint length, + uint disposition, out IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertCreateCertificateContext( + uint encoding, byte[] certificate, uint length); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern IntPtr CertFindCertificateInStore( + IntPtr store, uint encoding, uint findFlags, uint findType, + IntPtr findParameter, IntPtr previousContext); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertDeleteCertificateFromStore(IntPtr context); + + [DllImport("crypt32.dll")] + private static extern bool CertFreeCertificateContext(IntPtr context); + + [DllImport("crypt32.dll", SetLastError = true)] + private static extern bool CertCloseStore(IntPtr store, uint flags); + + private static IntPtr Open(string storeName) + { + IntPtr store = CertOpenStore( + new IntPtr(CERT_STORE_PROV_SYSTEM_W), 0, IntPtr.Zero, + CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG | + CERT_STORE_MAXIMUM_ALLOWED_FLAG, + storeName); + if (store == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CertOpenStore"); + return store; + } + + public static void Add(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr context = IntPtr.Zero; + try + { + if (!CertAddEncodedCertificateToStore( + store, CERT_ENCODING, certificate, (uint)certificate.Length, + CERT_STORE_ADD_NEW, out context)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertAddEncodedCertificateToStore"); + } + finally + { + if (context != IntPtr.Zero) CertFreeCertificateContext(context); + CertCloseStore(store, 0); + } + } + + public static bool Remove(string storeName, byte[] certificate) + { + IntPtr store = Open(storeName); + IntPtr search = IntPtr.Zero; + try + { + search = CertCreateCertificateContext( + CERT_ENCODING, certificate, (uint)certificate.Length); + if (search == IntPtr.Zero) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertCreateCertificateContext"); + IntPtr found = CertFindCertificateInStore( + store, CERT_ENCODING, 0, CERT_FIND_EXISTING, search, IntPtr.Zero); + if (found == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == CRYPT_E_NOT_FOUND) return false; + throw new Win32Exception(error, "CertFindCertificateInStore"); + } + if (!CertDeleteCertificateFromStore(found)) + throw new Win32Exception( + Marshal.GetLastWin32Error(), "CertDeleteCertificateFromStore"); + return true; + } + finally + { + if (search != IntPtr.Zero) CertFreeCertificateContext(search); + CertCloseStore(store, 0); + } + } +} +'@ +} + +$certificateStoreOpenMethod = [ViiperLocalTestCertificateStore].GetMethod( + 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') +$certificateStoreOpenImport = $certificateStoreOpenMethod.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($certificateStoreOpenImport.Value -cne 'crypt32.dll' -or + -not $certificateStoreOpenImport.ExactSpelling -or + $certificateStoreOpenImport.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { + throw 'The local-test certificate-store interop does not bind the exact CertOpenStore entry point.' +} + +if ($PreflightOnly) { Write-Output 'result=success operation=local-test-preflight changed=0 rebootRequired=0 rollback=not-needed exitCode=0' return } -$expectedCertificateBytes = [Convert]::ToBase64String($certificate.RawData) +function Get-ExactLocalTestTrustState { + param([Parameter(Mandatory = $true)][string]$StoreName) + + $store = [Security.Cryptography.X509Certificates.X509Store]::new( + $StoreName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $matches = $null + try { + # Reopening the store read-only makes every verification a persisted-state + # postcondition rather than an observation through the mutating handle. + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + $matches = $store.Certificates.Find( + [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, + $certificateThumbprint, $false) + $exactMatches = @($matches | Where-Object { + [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes + }) + if ($matches.Count -ne $exactMatches.Count -or $exactMatches.Count -gt 1) { + throw "Certificate collision in LocalMachine\$StoreName." + } + return [pscustomobject]@{ ExactCount = [int]$exactMatches.Count } + } + finally { + if ($null -ne $matches) { + foreach ($match in $matches) { + $match.Dispose() + } + } + $store.Close() + } +} + $addedStores = [Collections.Generic.List[string]]::new() function Remove-NewLocalTestTrust { $removalErrors = [Collections.Generic.List[Exception]]::new() foreach ($storeName in $addedStores) { - $store = [Security.Cryptography.X509Certificates.X509Store]::new( - $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $cleanupAction = 'inspect-cleanup' try { - $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - @($store.Certificates | Where-Object { - [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes - }) | ForEach-Object { $store.Remove($_) } + $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName + $cleanupAction = 'remove' + if ($cleanupState.ExactCount -eq 1) { + $removed = [ViiperLocalTestCertificateStore]::Remove( + $storeName, $certificate.RawData) + $removeResult = if ($removed) { 'removed' } else { 'already-absent' } + } + else { + $removeResult = 'already-absent' + } + Write-Host "local-test-trust store=$storeName action=remove result=$removeResult" + + $cleanupAction = 'verify-cleanup' + $cleanupState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($cleanupState.ExactCount -ne 0) { + throw "Exact local-test certificate remained in LocalMachine\$storeName." + } + Write-Host "local-test-trust store=$storeName action=verify-cleanup result=absent" } catch { - $removalErrors.Add($_.Exception) - } - finally { - $store.Close() + Write-Host "local-test-trust store=$storeName action=$cleanupAction result=error" + $removalErrors.Add([InvalidOperationException]::new( + "LocalMachine\$storeName trust cleanup failed during $cleanupAction.", + $_.Exception)) } } if ($removalErrors.Count -ne 0) { @@ -470,7 +639,10 @@ function Remove-NewLocalTestTrust { } function Test-SettledLocalTestFailure { - param([Parameter(Mandatory = $true)][object[]]$Lines) + param( + [Parameter(Mandatory = $true)][object[]]$Lines, + [Parameter(Mandatory = $true)][int]$ProcessExitCode + ) $pattern = '(?m)^result=error operation=install changed=(?[01]) ' + 'rebootRequired=(?[01]) rollback=(?not-needed|succeeded|failed) ' + @@ -480,14 +652,19 @@ function Test-SettledLocalTestFailure { return $false } $match = $matches[0] + $proofExitCode = 0 + if (-not [int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode) -or + $proofExitCode -ne $ProcessExitCode) { + return $false + } return ($match.Groups['changed'].Value -ceq '0' -and $match.Groups['reboot'].Value -ceq '0' -and $match.Groups['rollback'].Value -ceq 'not-needed' -and - $match.Groups['exit'].Value -in @('1', '4')) -or + $proofExitCode -in @(1, 4)) -or ($match.Groups['changed'].Value -ceq '1' -and $match.Groups['reboot'].Value -ceq '0' -and $match.Groups['rollback'].Value -ceq 'succeeded' -and - $match.Groups['exit'].Value -ceq '1') + $proofExitCode -eq 1) } $trustCommitted = $false @@ -496,20 +673,30 @@ $stageDirectory = $null $programDataRoot = $null try { foreach ($storeName in @('Root', 'TrustedPublisher')) { - $store = [Security.Cryptography.X509Certificates.X509Store]::new( - $storeName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) + $trustAction = 'inspect-add' try { - $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - $present = @($store.Certificates | Where-Object { - [Convert]::ToBase64String($_.RawData) -ceq $expectedCertificateBytes - }).Count -ne 0 - if (-not $present) { - $store.Add($certificate) + $trustState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($trustState.ExactCount -eq 0) { + $trustAction = 'add' + [ViiperLocalTestCertificateStore]::Add( + $storeName, $certificate.RawData) $addedStores.Add($storeName) + Write-Host "local-test-trust store=$storeName action=add result=added" + + $trustAction = 'verify-add' + $trustState = Get-ExactLocalTestTrustState -StoreName $storeName + if ($trustState.ExactCount -ne 1) { + throw "Exact local-test certificate was not installed in LocalMachine\$storeName." + } + Write-Host "local-test-trust store=$storeName action=verify-add result=present" + } + else { + Write-Host "local-test-trust store=$storeName action=add result=preexisting" } } - finally { - $store.Close() + catch { + Write-Host "local-test-trust store=$storeName action=$trustAction result=error" + throw } } @@ -579,7 +766,8 @@ try { if ($exitCode -in @(0, 3010)) { $trustCommitted = $true } - elseif (Test-SettledLocalTestFailure -Lines $output) { + elseif (Test-SettledLocalTestFailure ` + -Lines $output -ProcessExitCode $exitCode) { $retainTrustOnFailure = $false } } diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 6dee4975..e2c0e186 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -41,6 +41,10 @@ $requiredContracts = [ordered]@{ 'broker health transaction' = 'RunBrokerInstall\(' 'canonical broker proof parser' = 'ParseBrokerCommitProof\(' 'bounded broker proof channel' = 'kMaximumBrokerProofBytes' + 'bounded sanitized broker diagnostic' = 'SanitizeBrokerDiagnostic\(' + 'separate nested application exit reporting' = 'nestedExitCode=' + 'broker failure Win32 mapping' = 'SetError\(error, phase, ERROR_INSTALL_FAILURE' + 'ambiguous broker diagnostic rejection' = 'diagnosticRejected = true' 'explicit inherited broker handles' = 'PROC_THREAD_ATTRIBUTE_HANDLE_LIST' 'indeterminate broker wait retention' = 'GetExitCodeProcess\(processHandle\.get\(\), &observedExit\)' 'production broker requirement' = 'broker-required' @@ -184,6 +188,10 @@ if ($source -match '--expected-(?:token|broker)-sha256') { throw 'ViiperUdeCtl retained obsolete nested Kong SHA-256 option spelling.' } +if ($source -match 'SetError\(error,\s*L"broker-health",\s*exitCode') { + throw 'Nested broker application exits must not be mislabeled as Win32 errors.' +} + if ($source -match 'std::filesystem::copy_file') { throw 'Rollback packages must use the protected, write-through, verified exact-file copy path.' } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index be70703f..8452a2ef 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +104,8 @@ constexpr uint64_t kBrokerRollbackCeilingMs = 3ULL * 60ULL * 1000ULL; constexpr uint64_t kDriverRollbackCeilingMs = 2ULL * 60ULL * 1000ULL; constexpr DWORD kCancelledIoDrainMs = 5000; constexpr size_t kMaximumBrokerProofBytes = 64U * 1024U; +constexpr size_t kMaximumBrokerDiagnosticCharacters = 1024U; +constexpr std::string_view kBrokerDiagnosticPrefix = "VIIPER: error: "; constexpr wchar_t kRollbackDirectorySecurity[] = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; constexpr wchar_t kRecoveryRecordSecurity[] = @@ -151,6 +154,7 @@ struct Error { DWORD code = ERROR_SUCCESS; std::wstring phase; std::wstring message; + std::optional nestedExitCode; std::wstring recoveryRecord; bool recoveryRecordWritten = false; DWORD recoveryRecordError = ERROR_SUCCESS; @@ -197,6 +201,7 @@ bool SetError(Error* error, const wchar_t* phase, DWORD code, std::wstring messa error->code = code; error->phase = phase; error->message = message.empty() ? FormatError(code) : std::move(message); + error->nestedExitCode.reset(); } SetLastError(code); return false; @@ -216,8 +221,11 @@ void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { << L" exitCode=" << static_cast(outcome.exitCode); if (!outcome.success) { stream << L" phase=" << std::quoted(outcome.error.phase) - << L" win32Error=" << outcome.error.code - << L" message=" << std::quoted(outcome.error.message); + << L" win32Error=" << outcome.error.code; + if (outcome.error.nestedExitCode) { + stream << L" nestedExitCode=" << *outcome.error.nestedExitCode; + } + stream << L" message=" << std::quoted(outcome.error.message); if (!outcome.error.recoveryRecord.empty()) { stream << L" recoveryRecord=" << std::quoted(outcome.error.recoveryRecord) << L" recoveryRecordWritten=" @@ -3011,8 +3019,50 @@ struct BrokerCommitProof { std::string rollback; DWORD exitCode = ERROR_GEN_FAILURE; bool driverRollbackAuthorized = false; + std::wstring diagnostic; }; +bool IsUnsafeBrokerDiagnosticCharacter(wchar_t value) { + const uint32_t codePoint = static_cast(value); + // The outer structured result is consumed by installers and logs. Preserve + // printable ASCII only; quotes and backslashes are escaped by std::quoted, + // while every control, direction mark, separator, and non-ASCII glyph is + // made visibly inert instead of being allowed to reshape that record. + return codePoint < 0x20U || codePoint > 0x7eU; +} + +bool SanitizeBrokerDiagnostic( + std::string_view payload, + std::wstring* diagnostic) { + static_assert(kMaximumBrokerDiagnosticCharacters > 3U); + if (diagnostic == nullptr || payload.empty() || + payload.size() > static_cast(std::numeric_limits::max())) { + return false; + } + const int payloadBytes = static_cast(payload.size()); + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, payload.data(), payloadBytes, nullptr, 0); + if (required <= 0) { + return false; + } + std::wstring converted(static_cast(required), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, payload.data(), payloadBytes, + converted.data(), required) != required) { + return false; + } + for (wchar_t& character : converted) { + if (IsUnsafeBrokerDiagnosticCharacter(character)) { + character = L'?'; + } + } + if (converted.size() > kMaximumBrokerDiagnosticCharacters) { + converted.resize(kMaximumBrokerDiagnosticCharacters - 3U); + converted.append(L"..."); + } + *diagnostic = std::move(converted); + return true; +} + bool ParseBrokerCommitProof( const std::string& output, DWORD processExitCode, @@ -3039,6 +3089,9 @@ bool ParseBrokerCommitProof( false, true, "failed", 3, false}, }}; std::optional parsed; + std::optional diagnostic; + bool diagnosticSeen = false; + bool diagnosticRejected = false; size_t cursor = 0; while (cursor < output.size()) { const size_t newline = output.find('\n', cursor); @@ -3070,6 +3123,22 @@ bool ParseBrokerCommitProof( match->driverRollbackAuthorized, }; } + if (line.starts_with(kBrokerDiagnosticPrefix)) { + if (!terminated || diagnosticSeen) { + diagnosticRejected = true; + diagnostic.reset(); + } else { + std::wstring sanitized; + if (SanitizeBrokerDiagnostic( + std::string_view(line).substr(kBrokerDiagnosticPrefix.size()), + &sanitized)) { + diagnostic = std::move(sanitized); + } else { + diagnosticRejected = true; + } + } + diagnosticSeen = true; + } if (!terminated) { break; } @@ -3079,10 +3148,32 @@ bool ParseBrokerCommitProof( return SetError(error, L"broker-proof", ERROR_INVALID_DATA, L"nested broker process exit and structured outcome are missing or inconsistent"); } + // Diagnostics are never transaction authority. Ambiguous, malformed, + // unterminated, or success-adjacent text is discarded; only the exact + // canonical result above controls changed/rollback classification. + if (!parsed->success && !diagnosticRejected && diagnostic) { + parsed->diagnostic = std::move(*diagnostic); + } *proof = std::move(*parsed); return true; } +bool SetBrokerCommitFailure(const BrokerCommitProof& proof, Error* error) { + std::wstring message = proof.driverRollbackAuthorized + ? L"nested broker transaction failed after proving a settled state" + : L"nested broker transaction failed with indeterminate service state"; + if (!proof.diagnostic.empty()) { + message.append(L"; nested diagnostic: "); + message.append(proof.diagnostic); + } + const wchar_t* phase = proof.changed ? L"broker-health" : L"broker-preflight"; + SetError(error, phase, ERROR_INSTALL_FAILURE, std::move(message)); + if (error != nullptr) { + error->nestedExitCode = proof.exitCode; + } + return false; +} + bool DrainBrokerProofPipe( HANDLE pipe, std::string* output, @@ -3329,10 +3420,7 @@ bool RunBrokerInstall( *driverRollbackAuthorized = proof.driverRollbackAuthorized; *brokerChanged = proof.changed; if (!proof.success) { - return SetError(error, L"broker-health", exitCode, - proof.driverRollbackAuthorized - ? L"nested broker transaction failed after proving a settled state" - : L"nested broker transaction failed with indeterminate service state"); + return SetBrokerCommitFailure(proof, error); } return true; } @@ -4933,7 +5021,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "21bc6734ba20f9a22601b75d8a737edf304d65aac59cfa1c1e33550b7e3602f5") { + "e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } @@ -5048,6 +5136,9 @@ Outcome SelfTest() { const std::string brokerSuccess = "result=success operation=native-package-broker-commit changed=0 " "rollback=not-needed exitCode=0\n"; + const std::string brokerPreflightFailure = + "result=error operation=native-package-broker-commit changed=0 " + "rollback=not-needed exitCode=4\n"; BrokerCommitProof brokerProof; Error brokerProofError; if (!ParseBrokerCommitProof( @@ -5062,9 +5153,7 @@ Outcome SelfTest() { brokerProof = {}; brokerProofError = {}; if (!ParseBrokerCommitProof( - "result=error operation=native-package-broker-commit changed=0 " - "rollback=not-needed exitCode=4\n", - 4, &brokerProof, &brokerProofError) || + brokerPreflightFailure, 4, &brokerProof, &brokerProofError) || brokerProof.success || brokerProof.changed || !brokerProof.driverRollbackAuthorized) { SetError(&outcome.error, L"self-test-broker-proof", ERROR_INVALID_DATA, @@ -5073,6 +5162,110 @@ Outcome SelfTest() { } brokerProof = {}; brokerProofError = {}; + const std::wstring expectedBrokerDiagnostic = + L"outer native package transaction mutex is not held"; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + "outer native package transaction mutex is not held\n", + 4, &brokerProof, &brokerProofError) || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized || + brokerProof.diagnostic != expectedBrokerDiagnostic) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"exact nested broker error diagnostic was rejected or changed proof authority"); + return outcome; + } + Error mappedBrokerError; + if (SetBrokerCommitFailure(brokerProof, &mappedBrokerError) || + mappedBrokerError.code != ERROR_INSTALL_FAILURE || + !mappedBrokerError.nestedExitCode || *mappedBrokerError.nestedExitCode != 4 || + mappedBrokerError.phase != L"broker-preflight" || + mappedBrokerError.message.find(expectedBrokerDiagnostic) == std::wstring::npos) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker application exit was not separated from the outer Win32 failure"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + const std::string unsafeBrokerDiagnostic = + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + "left\tmiddle\x01" + "right\x7f" + "\xe2\x80\xae" + "tail \"quoted\" \\ path\n"; + if (!ParseBrokerCommitProof( + unsafeBrokerDiagnostic, 4, &brokerProof, &brokerProofError) || + brokerProof.diagnostic != L"left?middle?right??tail \"quoted\" \\ path" || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker diagnostic controls were not sanitized without changing proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + const std::string oversizedBrokerDiagnostic( + kMaximumBrokerDiagnosticCharacters + 32U, 'x'); + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + oversizedBrokerDiagnostic + "\n", + 4, &brokerProof, &brokerProofError) || + brokerProof.diagnostic.size() != kMaximumBrokerDiagnosticCharacters || + !brokerProof.diagnostic.ends_with(L"...") || + brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"nested broker diagnostic was not deterministically capped"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + + std::string("\xc3\x28", 2) + "\n", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"malformed UTF-8 diagnostic changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + "first\n" + + std::string(kBrokerDiagnosticPrefix) + "second\n", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"ambiguous diagnostics changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerSuccess + std::string(kBrokerDiagnosticPrefix) + "contradiction\n", + ERROR_SUCCESS, &brokerProof, &brokerProofError) || + !brokerProof.success || brokerProof.changed || + brokerProof.driverRollbackAuthorized || !brokerProof.diagnostic.empty()) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"diagnostic text overrode a canonical broker success proof"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerPreflightFailure + std::string(kBrokerDiagnosticPrefix) + "unterminated", + 4, &brokerProof, &brokerProofError) || + !brokerProof.diagnostic.empty() || brokerProof.success || brokerProof.changed || + !brokerProof.driverRollbackAuthorized) { + SetError(&outcome.error, L"self-test-broker-diagnostic", ERROR_INVALID_DATA, + L"unterminated diagnostic changed canonical broker proof authority"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; if (!ParseBrokerCommitProof( "result=error operation=native-package-broker-commit changed=1 " "rollback=succeeded exitCode=1\n", From 9f0fac23cded531fda315f76af595c1136c3a45d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 08:12:01 -0500 Subject: [PATCH 202/240] Fix nested native transaction sealing --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/cmd/native_package_contract_test.go | 19 ++++++++ internal/cmd/native_package_windows.go | 46 +++++++++++++++---- internal/cmd/native_package_windows_test.go | 30 ++++++++++++ internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 40 ++++++++++++++-- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 7 ++- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 16 files changed, 141 insertions(+), 25 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index eb7d22e2..36b50f2e 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - 'e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab', + 'a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index d014e1d9..c26018af 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.14 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne 'e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab') { + -DriverPackageVersion 0.1.0.15 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne 'a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 8f77320d..dcd89374 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.14", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.15", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 0ed06afa..e9b290f5 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.14", + "expectedDriverPackageVersion": "0.1.0.15", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index d0a643f1..e61c38de 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -63,6 +63,25 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("Windows package orchestrator lost %q", fragment) } } + stageStart := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) stageCoordinationToken() error {") + stageEnd := strings.Index(windowsSource, + "func (t *windowsNativePackageTransaction) ensureManagedPackageDirectory() error {") + if stageStart < 0 || stageEnd <= stageStart { + t.Fatal("native package coordination-token implementation is missing or malformed") + } + stageToken := windowsSource[stageStart:stageEnd] + closeWriter := strings.Index(stageToken, "if err := windows.CloseHandle(handle); err != nil {") + reopenSealed := strings.Index(stageToken, "sealed, err := lockNativePackageInput(path)") + rehashSealed := strings.Index(stageToken, "sealedHash, err := hashNativePackageHandle(sealed)") + publishSealed := strings.Index(stageToken, "t.tokenHandle = sealed") + if closeWriter < 0 || reopenSealed <= closeWriter || rehashSealed <= reopenSealed || + publishSealed <= rehashSealed { + t.Fatal("native package coordination token is published before its write handle is sealed and revalidated") + } + if strings.Contains(stageToken, "t.tokenHandle = handle") { + t.Fatal("native package transaction retains a write-capable token handle across nested broker startup") + } requiredUninstallWindows := []string{ "acquireNamedNativePackageMutex(nativePackageMutexName", "acquireNativeInstallMutex(budget)", diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index 39296e69..ea6f2c60 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -920,31 +920,59 @@ func (t *windowsNativePackageTransaction) stageCoordinationToken() error { if err != nil { return fmt.Errorf("create protected package transaction token: %w", err) } - fail := func(failErr error) error { - windows.CloseHandle(handle) //nolint:errcheck + fail := func(owned windows.Handle, failErr error) error { + if owned != 0 { + windows.CloseHandle(owned) //nolint:errcheck + } _ = deleteNativePackageFile(path) return failErr } var written uint32 if err := windows.WriteFile(handle, content, &written, nil); err != nil { - return fail(err) + return fail(handle, err) } if written != uint32(len(content)) { - return fail(io.ErrShortWrite) + return fail(handle, io.ErrShortWrite) } if err := windows.FlushFileBuffers(handle); err != nil { - return fail(err) + return fail(handle, err) } if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { - return fail(err) + return fail(handle, err) } if err := requireSingleNativeFileLink(handle); err != nil { - return fail(err) + return fail(handle, err) } sum := sha256.Sum256(content) + // Seal the token before another process opens it. Windows share checks are + // symmetric: a new read-only open that specifies FILE_SHARE_READ still + // conflicts with this handle's existing GENERIC_WRITE access. Close the + // write-capable handle, reopen through the ordinary immutable-input path, + // and revalidate the exact bytes before publishing the path to the helper. + // The protected parent and token DACL exclude the unelevated race boundary; + // the retained read handle then prevents replacement for the transaction. + if err := windows.CloseHandle(handle); err != nil { + return fail(handle, fmt.Errorf("seal protected package transaction token: %w", err)) + } + handle = 0 + sealed, err := lockNativePackageInput(path) + if err != nil { + return fail(0, fmt.Errorf("reopen sealed package transaction token: %w", err)) + } + if err := validateNativeSecurityDescriptor(sealed, nativePackageTokenSDDL); err != nil { + return fail(sealed, fmt.Errorf("revalidate sealed package transaction token ACL: %w", err)) + } + sealedHash, err := hashNativePackageHandle(sealed) + if err != nil { + return fail(sealed, fmt.Errorf("rehash sealed package transaction token: %w", err)) + } + expectedHash := hex.EncodeToString(sum[:]) + if !strings.EqualFold(sealedHash, expectedHash) { + return fail(sealed, errors.New("sealed package transaction token changed during publication")) + } t.tokenPath = path - t.tokenSHA256 = hex.EncodeToString(sum[:]) - t.tokenHandle = handle + t.tokenSHA256 = expectedHash + t.tokenHandle = sealed return nil } diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index a015019f..7c476d01 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -8,10 +8,40 @@ import ( "testing" "time" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/mgr" ) +func TestNativePackageCoordinationTokenAllowsNestedImmutableRead(t *testing.T) { + requireNativeMutexAdministrator(t) + transaction := &windowsNativePackageTransaction{parent: t.TempDir()} + if err := transaction.stageCoordinationToken(); err != nil { + t.Fatalf("stage coordination token: %v", err) + } + t.Cleanup(func() { + if err := transaction.releaseCoordinationToken(); err != nil { + t.Errorf("release coordination token: %v", err) + } + }) + + // This is the exact access/share combination used by the nested broker. + // It failed live while the outer transaction retained a write-capable + // handle, even though both opens requested FILE_SHARE_READ. + nested, err := lockNativePackageInput(transaction.tokenPath) + if err != nil { + t.Fatalf("nested immutable token open: %v", err) + } + defer windows.CloseHandle(nested) //nolint:errcheck + hash, err := hashNativePackageHandle(nested) + if err != nil { + t.Fatalf("hash nested token handle: %v", err) + } + if hash != transaction.tokenSHA256 { + t.Fatalf("nested token hash = %s, want %s", hash, transaction.tokenSHA256) + } +} + func TestNativePackageRuntimePayloadExcludesCertificationPDB(t *testing.T) { want := []string{"ViiperUde.inf", "ViiperUde.sys", "ViiperUde.cat"} if !slices.Equal(nativePackageDriverFiles, want) { diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index f2f8682b..3629f67e 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.14", + ExpectedDriverPackageVersion: "0.1.0.15", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index decba833..47c07256 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -147,6 +147,7 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "LocalMachine\\$storeName trust cleanup failed during $cleanupAction.", "ExactSpelling = true", "[Parameter(Mandatory = $true)][int]$ProcessExitCode", + "[string]::Join([Environment]::NewLine, [string[]]$Lines)", "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)", "$proofExitCode -ne $ProcessExitCode", "-Lines $output -ProcessExitCode $exitCode", @@ -225,10 +226,16 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { t.Fatal("local-test installer settled-failure predicate is missing or malformed") } settled := installer[settledStart:settledEnd] + if strings.Contains(settled, "$Lines | Out-String") { + t.Fatal("local-test installer formats and host-wraps native settled-failure proof before parsing") + } + joinLines := strings.Index(settled, "[string]::Join([Environment]::NewLine, [string[]]$Lines)") + parseProof := strings.Index(settled, "[regex]::Matches($proofText, $pattern)") parseExit := strings.Index(settled, "[int]::TryParse($match.Groups['exit'].Value, [ref]$proofExitCode)") bindExit := strings.Index(settled, "$proofExitCode -ne $ProcessExitCode") classify := strings.Index(settled, "$match.Groups['changed'].Value -ceq '0'") - if parseExit < 0 || bindExit <= parseExit || classify <= bindExit { + if joinLines < 0 || parseProof <= joinLines || parseExit <= parseProof || + bindExit <= parseExit || classify <= bindExit { t.Fatal("local-test installer classifies settled proof before binding it to the observed child exit") } @@ -329,10 +336,37 @@ $end = $source.IndexOf('$trustCommitted = $false', $start) if ($start -lt 0 -or $end -le $start) { throw 'Settled-failure predicate was not found.' } Invoke-Expression $source.Substring($start, $end - $start) $settled = @( - 'result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="broker-health"' + 'VIIPER: error: install native driver and broker transaction: native driver helper failed with exit 1: exit status 1:', + ('result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1 ' + + 'phase="broker-preflight" win32Error=1603 nestedExitCode=4 ' + + 'message="nested broker transaction failed after proving a settled state; nested diagnostic: ' + + 'lock package transaction token: The process cannot access the file because it is being used by another process."') ) +if ($settled[1].Length -le 120) { + throw 'Settled proof fixture does not exceed the live host width.' +} if (-not (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1)) { - throw 'Matching settled proof was rejected.' + throw 'Matching long settled proof was rejected.' +} +$retainTrustOnFailure = $true +if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 1) { + $retainTrustOnFailure = $false +} +if ($retainTrustOnFailure) { + throw 'Matching long settled proof did not authorize trust removal.' +} +$cleanupCalls = 0 +$trustCommitted = $false +try { + throw 'simulated post-process transaction failure' +} +catch { + if (-not $trustCommitted -and -not $retainTrustOnFailure) { + $cleanupCalls++ + } +} +if ($cleanupCalls -ne 1) { + throw 'Settled rollback did not enter the trust-cleanup branch exactly once.' } $retainTrustOnFailure = $true if (Test-SettledLocalTestFailure -Lines $settled -ProcessExitCode 4) { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index df87c905..4368a211 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.14" + DriverPackageVersion = "0.1.0.15" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7b419955..26bfa040 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab" + const wantHex = "a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index f4656e98..ccba5edf 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.14 + 0.1.0.15 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 6872133b..41df4c45 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.14" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.15" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 0e6f0248..5e833571 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.14 +DriverVer=08/13/2026,0.1.0.15 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 7e434588..56375a0d 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -647,7 +647,12 @@ function Test-SettledLocalTestFailure { $pattern = '(?m)^result=error operation=install changed=(?[01]) ' + 'rebootRequired=(?[01]) rollback=(?not-needed|succeeded|failed) ' + 'exitCode=(?[0-9]+)(?: .*)?\r?$' - $matches = [regex]::Matches(($Lines | Out-String), $pattern) + # Out-String formats through the host and wraps long native proof lines at + # the current console width. Preserve the already-delimited child output + # byte-for-line instead: diagnostics may make the canonical proof much + # wider than the host while the rollback fields remain authoritative. + $proofText = [string]::Join([Environment]::NewLine, [string[]]$Lines) + $matches = [regex]::Matches($proofText, $pattern) if ($matches.Count -ne 1) { return $false } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 8452a2ef..be691fd1 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5021,7 +5021,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "e19b4fcd5a47dc55283e834e3718adc2965b822e192db0620666c835b6f276ab") { + "a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 353b51db792b14e2407c6c96498b7314a9efcbd0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 08:42:52 -0500 Subject: [PATCH 203/240] Create native broker credentials protected --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/cmd/native_package_contract_test.go | 25 ++++++ .../cmd/native_service_install_windows.go | 78 +++++++++++++++++-- .../native_service_install_windows_test.go | 47 +++++++++++ internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 14 files changed, 157 insertions(+), 17 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 36b50f2e..20658ff6 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - 'a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90', + '2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index c26018af..f39dc446 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.15 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne 'a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90') { + -DriverPackageVersion 0.1.0.16 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index dcd89374..6986013b 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.15", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.16", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index e9b290f5..0fa134d1 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.15", + "expectedDriverPackageVersion": "0.1.0.16", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index e61c38de..e17414c4 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -117,6 +117,31 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("%s lost the retained process-handle join", name) } } + credentialStart := strings.Index(serviceSource, + "func createProtectedNativeCredentialStagingFile(") + credentialEnd := strings.Index(serviceSource, + "func replaceFileAtomically(") + if credentialStart < 0 || credentialEnd <= credentialStart { + t.Fatal("protected native credential staging implementation is missing or malformed") + } + credentialStaging := serviceSource[credentialStart:credentialEnd] + for _, fragment := range []string{ + "nativeSecurityAttributes(sddl)", + "windows.CREATE_NEW", + "windows.FILE_FLAG_OPEN_REPARSE_POINT", + "windows.FILE_FLAG_WRITE_THROUGH", + "requireSingleNativeFileLink(handle)", + "validateNativeSecurityDescriptor(handle, sddl)", + } { + if !strings.Contains(credentialStaging, fragment) { + t.Errorf("native credential staging lost %q", fragment) + } + } + if strings.Contains(serviceSource, `os.CreateTemp(directory, ".viiper-key-*.tmp")`) || + strings.Contains(serviceSource, + "applyNativeACLToHandle(windows.Handle(temporary.Fd())") { + t.Fatal("native credential staging is created with a weak ACL before post-creation repair") + } if strings.Contains(uninstallWindowsSource, "scheduleNativePackageUninstallFileAtReboot(file.path)") { t.Error("native uninstall schedules a reusable canonical broker path for reboot deletion") diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index e165db03..6f64e550 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/hex" @@ -2182,11 +2183,12 @@ func readNativeCredentialReadOnly(userSID string) ([]byte, error) { func writeNativeCredentialAtomically(path string, contents []byte, userSID string) error { directory := filepath.Dir(path) - temporary, err := os.CreateTemp(directory, ".viiper-key-*.tmp") + temporary, temporaryPath, err := createProtectedNativeCredentialStagingFile( + directory, userSID, + ) if err != nil { return fmt.Errorf("create credential staging file: %w", err) } - temporaryPath := temporary.Name() cleanupTemporary := true defer func() { temporary.Close() //nolint:errcheck @@ -2194,9 +2196,6 @@ func writeNativeCredentialAtomically(path string, contents []byte, userSID strin os.Remove(temporaryPath) //nolint:errcheck } }() - if err := applyNativeACLToHandle(windows.Handle(temporary.Fd()), nativeCredentialFileSDDL(userSID)); err != nil { - return fmt.Errorf("protect credential staging file: %w", err) - } if _, err := temporary.Write(contents); err != nil { return fmt.Errorf("write credential staging file: %w", err) } @@ -2213,6 +2212,75 @@ func writeNativeCredentialAtomically(path string, contents []byte, userSID strin return nil } +func createProtectedNativeCredentialStagingFile( + directory, userSID string, +) (*os.File, string, error) { + sddl := nativeCredentialFileSDDL(userSID) + security, err := nativeSecurityAttributes(sddl) + if err != nil { + return nil, "", fmt.Errorf("build credential staging security descriptor: %w", err) + } + for attempt := 0; attempt < 8; attempt++ { + var suffix [16]byte + if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil { + return nil, "", fmt.Errorf("generate credential staging name: %w", err) + } + path := filepath.Join(directory, + ".viiper-key-"+hex.EncodeToString(suffix[:])+".tmp") + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, "", err + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + 0, + security, + windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_EXISTS) || + errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + continue + } + return nil, "", err + } + fail := func(failErr error) (*os.File, string, error) { + windows.CloseHandle(handle) //nolint:errcheck + _ = os.Remove(path) + return nil, "", failErr + } + attribute := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, + windows.FileAttributeTagInfo, + (*byte)(unsafe.Pointer(&attribute)), + uint32(unsafe.Sizeof(attribute)), + ); err != nil { + return fail(fmt.Errorf("inspect credential staging file: %w", err)) + } + if attribute.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY| + windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("credential staging path is not a regular file")) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(fmt.Errorf("reject hard-linked credential staging file: %w", err)) + } + if err := validateNativeSecurityDescriptor(handle, sddl); err != nil { + return fail(fmt.Errorf("validate credential staging file security: %w", err)) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + return fail(errors.New("wrap credential staging file handle")) + } + return file, path, nil + } + return nil, "", errors.New("credential staging name collisions exceeded retry budget") +} + func replaceFileAtomically(source, destination string) error { sourcePointer, err := windows.UTF16PtrFromString(source) if err != nil { diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go index 1edb9e61..de6079fd 100644 --- a/internal/cmd/native_service_install_windows_test.go +++ b/internal/cmd/native_service_install_windows_test.go @@ -26,6 +26,53 @@ import ( "golang.org/x/sys/windows/svc/mgr" ) +func TestNativeCredentialStagingIsProtectedAtCreation(t *testing.T) { + requireNativeMutexAdministrator(t) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatalf("query test user: %v", err) + } + userSID, err := validateNativeInstallingUserSID(user.User.Sid.String()) + if err != nil { + t.Fatalf("validate test user: %v", err) + } + directory := t.TempDir() + path := filepath.Join(directory, "credential.key") + contents := []byte("native-credential-contract") + if err := writeNativeCredentialAtomically(path, contents, userSID); err != nil { + t.Fatalf("write protected credential: %v", err) + } + actual, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read protected credential: %v", err) + } + if !slices.Equal(actual, contents) { + t.Fatalf("credential contents = %q, want %q", actual, contents) + } + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + t.Fatalf("open protected credential: %v", err) + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + t.Fatalf("credential link identity: %v", err) + } + if err := validateNativeSecurityDescriptor( + handle, nativeCredentialFileSDDL(userSID), + ); err != nil { + t.Fatalf("credential security: %v", err) + } + leftovers, err := filepath.Glob(filepath.Join(directory, ".viiper-key-*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(leftovers) != 0 { + t.Fatalf("credential staging residue: %v", leftovers) + } +} + func TestNativeBrokerServiceConfigurationIsExplicitAndEscaped(t *testing.T) { executable := `C:\Program Files\VIIPER\viiper.exe` credential := `C:\ProgramData\VIIPER\viiper key.txt` diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 3629f67e..6306ea2c 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.15", + ExpectedDriverPackageVersion: "0.1.0.16", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 4368a211..6c66c650 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.15" + DriverPackageVersion = "0.1.0.16" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 26bfa040..69171300 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90" + const wantHex = "2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index ccba5edf..886da0e4 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.15 + 0.1.0.16 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 41df4c45..477a0f78 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.15" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.16" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 5e833571..a7b29287 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.15 +DriverVer=08/13/2026,0.1.0.16 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index be691fd1..807e5bd2 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5021,7 +5021,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "a66fecc8ea05bd3e0b6a9e208a80cf9da7762dafa99fb8ecc3d65d4dd79efe90") { + "2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 9c4dcf5d1c84d4f3fc2bdb758a43fb717415790f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 09:22:20 -0500 Subject: [PATCH 204/240] Fix native lifecycle notification framing --- .github/scripts/Test-WorkflowSecurity.ps1 | 2 +- .github/workflows/native-ude.yml | 4 +- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 11 +++ .../udecx/live_validation_contract_test.go | 13 +++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 31 ++++++- native/udecx/README.md | 3 +- native/udecx/driver/Broker.c | 5 ++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Invoke-ViiperUdeLiveValidation.ps1 | 80 ++++++++++++++++--- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 16 files changed, 142 insertions(+), 23 deletions(-) diff --git a/.github/scripts/Test-WorkflowSecurity.ps1 b/.github/scripts/Test-WorkflowSecurity.ps1 index 20658ff6..0bd3d266 100644 --- a/.github/scripts/Test-WorkflowSecurity.ps1 +++ b/.github/scripts/Test-WorkflowSecurity.ps1 @@ -147,7 +147,7 @@ foreach ($requiredNativeGate in @( 'tags: ["v*.*.*"]', 'VIIPER_NATIVE_SOURCE_REVISION: ${{ github.sha }}', 'Get-ViiperUdeBuildIdentity.ps1', - '2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596', + '180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660', 'Test-ViiperUdeVersionMonotonicity.ps1', 'x64/Release/ViiperUde/ViiperUde.inf', 'inputs.upload_release_helper == true', diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index f39dc446..7d4aeebe 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -108,8 +108,8 @@ jobs: ./.github/scripts/Test-WorkflowSecurity.ps1 $identity = ./native/udecx/tools/Get-ViiperUdeBuildIdentity.ps1 ` -SourceRevision 0123456789abcdef0123456789abcdef01234567 ` - -DriverPackageVersion 0.1.0.16 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 - if ($identity -cne '2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596') { + -DriverPackageVersion 0.1.0.17 -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + if ($identity -cne '180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660') { throw "Native build-identity generator drifted: $identity" } $expected = ((Get-Content go.mod | Where-Object { $_ -match '^go\s+' } | Select-Object -First 1) -split '\s+')[1] diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 6986013b..05c2c029 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.16", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.17", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 0fa134d1..5a4f38ef 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.16", + "expectedDriverPackageVersion": "0.1.0.17", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 6306ea2c..0553c03d 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.16", + ExpectedDriverPackageVersion: "0.1.0.17", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 4eda19f0..150227fa 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -343,6 +343,17 @@ func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { "ViiperDispatchNotificationEvents(deviceContext->Controller);") } +func TestNativeLifecycleNotificationsPublishCanonicalEmptyTail(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + dispatch := normalizedContract(nativeCFunction(t, broker, "ViiperDispatchNotificationEvents")) + requireContractOrder(t, dispatch, + "operation->Header.Size = sizeof(*operation);", + "operation->DeviceSequence = event.DeviceSequence;", + "operation->IsoPacketsOffset = sizeof(*operation);", + "operation->PayloadOffset = sizeof(*operation);", + "WdfRequestSetInformation(dequeueRequest, sizeof(*operation));") +} + func TestNativeManualDequeueCancellationRetiresAccounting(t *testing.T) { controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index 44bd82c4..f48f2d9f 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -23,6 +23,7 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "-ProbeManifestPath is required", "-RestartRootDevice is required", "-DisposableTestMachine is required", + "-ManageInstalledBrokerService is required", "$Iterations -lt 3", "$MediaDurationSeconds -lt 180", "VIIPER_UDE_LIVE_MEDIA_SECONDS", @@ -55,6 +56,18 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "Win32_PnPEntity", "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", "$ownedRootDevices[0].PNPDeviceID", + "$ownedRootDevices[0].ConfigManagerErrorCode", + "$infName -cnotmatch '^oem[0-9]+\\.inf$'", + "$packageInfHash -cne $installedInfHash", + "if ($SignatureValidationMode -ne 'LocalTest')", + "$devnodes[0].IsSigned", + "$devnodes[0].Signer -notmatch '(?i)Microsoft'", + "Stop-Service -Name $brokerService.Name", + "Start-Service -Name $brokerService.Name", + "ServiceControllerStatus]::Stopped", + "ServiceControllerStatus]::Running", + "$ErrorActionPreference = 'Continue'", + "./internal/server/usb 2>&1", "Go reported success without executing required live test", } { if !strings.Contains(contract, required) { diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 6c66c650..1df0979f 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.16" + DriverPackageVersion = "0.1.0.17" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 69171300..7b410fae 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596" + const wantHex = "180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -305,6 +305,35 @@ func TestParseOperationRejectsMalformedCanonicalTail(t *testing.T) { } } +func TestParseOperationAcceptsCanonicalEmptyTail(t *testing.T) { + raw := make([]byte, OperationSize) + header, err := NewHeader(OperationSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 7) + binary.LittleEndian.PutUint64(raw[24:32], 9) + binary.LittleEndian.PutUint32(raw[32:36], 2) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationEndpointStart)) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + + op, err := ParseOperation(raw) + if err != nil { + t.Fatalf("ParseOperation canonical empty tail: %v", err) + } + if op.Token != 7 || op.DeviceID != 9 || op.Generation != 2 || + op.Kind != OperationEndpointStart || len(op.IsoPackets) != 0 || len(op.Payload) != 0 { + t.Fatalf("unexpected empty-tail operation: %+v", op) + } + + binary.LittleEndian.PutUint32(raw[72:76], 0) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ParseOperation zero ISO offset error=%v want=%v", err, ErrInvalidRange) + } +} + func TestParseDequeuedOperationRequiresExactBytesReturned(t *testing.T) { valid := dualSenseIsoOperationFixture(1, 4) if _, err := parseDequeuedOperation(valid, uint32(len(valid))); err != nil { diff --git a/native/udecx/README.md b/native/udecx/README.md index 03e2a29b..e61bb6f8 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -178,7 +178,8 @@ artifact supplies the source-bound evidence and probes for the real UdeCx test: -MediaProbePath C:\ViiperUdeLocalTest\ViiperUdeMediaProbe.exe ` -InputProbePath C:\ViiperUdeLocalTest\ViiperUdeInputProbe.exe ` -ProbeManifestPath C:\ViiperUdeLocalTest\ViiperUdeLiveProbes.manifest.json ` - -Iterations 10 -MediaDurationSeconds 30 -DisposableTestMachine + -Iterations 10 -MediaDurationSeconds 30 -DisposableTestMachine ` + -ManageInstalledBrokerService ``` Production uninstall is similarly owned by the signed installer. It calls diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index 1d3a3c33..cbb65d86 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -208,6 +208,11 @@ ViiperDispatchNotificationEvents( operation->EndpointMaxPacketSize = event.EndpointMaxPacketSize; operation->EndpointSequence = event.EndpointSequence; operation->DeviceSequence = event.DeviceSequence; + // Lifecycle and cancel notifications have an empty canonical tail. + // Keep both offsets at the first byte after the fixed header so the + // same strict parser contract applies to notifications and URBs. + operation->IsoPacketsOffset = sizeof(*operation); + operation->PayloadOffset = sizeof(*operation); WdfRequestSetInformation(dequeueRequest, sizeof(*operation)); InterlockedIncrement64(&controllerContext->NotificationEventsDelivered); WdfRequestComplete(dequeueRequest, STATUS_SUCCESS); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 886da0e4..f2576753 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.16 + 0.1.0.17 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 477a0f78..49d6e7f3 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.16" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.17" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index a7b29287..7e659f04 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.16 +DriverVer=08/13/2026,0.1.0.17 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 21c4fd4d..50b76502 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -32,6 +32,8 @@ param( [switch]$DisposableTestMachine, + [switch]$ManageInstalledBrokerService, + [ValidateRange(1, 300)] [int]$MediaDurationSeconds = 3, @@ -143,6 +145,9 @@ if ($ReleaseGate) { if (-not $DisposableTestMachine) { [void]$releaseGateFailures.Add('-DisposableTestMachine is required') } + if (-not $ManageInstalledBrokerService) { + [void]$releaseGateFailures.Add('-ManageInstalledBrokerService is required') + } if ($Iterations -lt 3) { [void]$releaseGateFailures.Add('-Iterations must be at least 3') } @@ -225,12 +230,31 @@ $devnodes = @(Get-CimInstance -ClassName Win32_PnPSignedDriver | Where-Object { if ($devnodes.Count -ne 1) { throw "Expected exactly one VIIPER UDE root devnode; found $($devnodes.Count)." } -if (-not [bool]$devnodes[0].IsSigned -or [string]::IsNullOrWhiteSpace([string]$devnodes[0].Signer)) { - throw "The installed VIIPER UDE devnode is not backed by a signed driver (Signer='$($devnodes[0].Signer)')." +if ([uint32]$ownedRootDevices[0].ConfigManagerErrorCode -ne 0) { + throw "The installed VIIPER UDE root devnode has PnP problem code '$($ownedRootDevices[0].ConfigManagerErrorCode)'." +} +$infName = [string]$devnodes[0].InfName +if ($infName -cnotmatch '^oem[0-9]+\.inf$') { + throw "The installed VIIPER UDE root devnode has an invalid OEM INF identity '$infName'." } -if ($SignatureValidationMode -ne 'LocalTest' -and - [string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { - throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." +$packageInfs = @(Get-ChildItem -LiteralPath $packageRoot -File -Filter 'ViiperUde.inf') +if ($packageInfs.Count -ne 1) { + throw "Expected exactly one signed-package INF; found $($packageInfs.Count)." +} +$installedInf = Join-Path (Join-Path $env:SystemRoot 'INF') $infName +$packageInfHash = (Get-FileHash -LiteralPath $packageInfs[0].FullName -Algorithm SHA256).Hash +$installedInfHash = (Get-FileHash -LiteralPath $installedInf -Algorithm SHA256).Hash +if ($packageInfHash -cne $installedInfHash) { + throw "The active VIIPER UDE devnode INF does not match the verified package (InfName='$infName')." +} +if ($SignatureValidationMode -ne 'LocalTest') { + if (-not [bool]$devnodes[0].IsSigned -or + [string]::IsNullOrWhiteSpace([string]$devnodes[0].Signer)) { + throw "The installed VIIPER UDE devnode is not backed by a signed driver (Signer='$($devnodes[0].Signer)')." + } + if ([string]$devnodes[0].Signer -notmatch '(?i)Microsoft') { + throw "The installed VIIPER UDE devnode is not backed by a Microsoft-signed driver (Signer='$($devnodes[0].Signer)')." + } } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -349,7 +373,26 @@ $oldGoToolchain = [Environment]::GetEnvironmentVariable('GOTOOLCHAIN', 'Process' $oldGoOS = [Environment]::GetEnvironmentVariable('GOOS', 'Process') $oldGoArch = [Environment]::GetEnvironmentVariable('GOARCH', 'Process') $oldCgoEnabled = [Environment]::GetEnvironmentVariable('CGO_ENABLED', 'Process') +$brokerService = Get-Service -Name 'VIIPERNativeBroker' -ErrorAction SilentlyContinue +if ($ManageInstalledBrokerService) { + if ($null -eq $brokerService) { + throw '-ManageInstalledBrokerService requires the installed VIIPERNativeBroker service.' + } + if ($brokerService.Status -ne [ServiceProcess.ServiceControllerStatus]::Running) { + throw "The installed VIIPERNativeBroker service must be running before validation; got '$($brokerService.Status)'." + } +} +elseif ($null -ne $brokerService -and + $brokerService.Status -eq [ServiceProcess.ServiceControllerStatus]::Running) { + throw 'VIIPERNativeBroker currently owns the controller. Pass -ManageInstalledBrokerService for a controlled stop/test/restart boundary.' +} try { + if ($ManageInstalledBrokerService) { + Stop-Service -Name $brokerService.Name -ErrorAction Stop + $brokerService.WaitForStatus( + [ServiceProcess.ServiceControllerStatus]::Stopped, + [TimeSpan]::FromSeconds(30)) + } $env:VIIPER_UDE_LIVE = '1' $env:VIIPER_UDE_LIVE_ITERATIONS = [string]$Iterations if ($null -ne $resolvedMediaProbe) { @@ -399,11 +442,19 @@ try { } $nativeIdentityLdflags = '-X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=' + $ExpectedSourceRevision.ToLowerInvariant() - $goTestOutput = @(& $go.Source test -v -count=1 -timeout "${timeoutMinutes}m" ` - -ldflags $nativeIdentityLdflags ` - -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ./internal/server/usb - ) - $goTestExitCode = $LASTEXITCODE + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $goTestOutput = @(& $go.Source test -v -count=1 -timeout "${timeoutMinutes}m" ` + -ldflags $nativeIdentityLdflags ` + -run '^TestNativeUDELive(ProductionControllers|OwnerCrashRecovery|RootRestartRecovery)$' ` + ./internal/server/usb 2>&1 + ) + $goTestExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $savedErrorActionPreference + } $goTestOutput | ForEach-Object { Write-Host $_ } if ($goTestExitCode -ne 0) { throw "Native UDE live validation failed with exit code $goTestExitCode." @@ -427,6 +478,15 @@ try { } } finally { + if ($ManageInstalledBrokerService) { + $brokerService.Refresh() + if ($brokerService.Status -ne [ServiceProcess.ServiceControllerStatus]::Running) { + Start-Service -Name $brokerService.Name -ErrorAction Stop + $brokerService.WaitForStatus( + [ServiceProcess.ServiceControllerStatus]::Running, + [TimeSpan]::FromSeconds(30)) + } + } [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE', $oldLive, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_ITERATIONS', $oldIterations, 'Process') [Environment]::SetEnvironmentVariable('VIIPER_UDE_LIVE_MEDIA_PROBE', $oldMediaProbe, 'Process') diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 807e5bd2..70839273 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5021,7 +5021,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "2fb3b07ba3eff0dfe961a588129bbdd5f6982f6261edd2ddfd5ab57686599596") { + "180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From dea5bdc4f8ec21bca755344f85e0d4717438745b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 11:48:46 -0500 Subject: [PATCH 205/240] Replace native root bus without reboot loop --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- .../native-udecx-package-install.md | 37 ++-- internal/cmd/native_package_contract_test.go | 16 ++ internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 9 +- native/udecx/tools/ViiperUdeCtl.cpp | 208 +++++++++++++----- 12 files changed, 203 insertions(+), 83 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 05c2c029..68ddef7d 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.17", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.18", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 5a4f38ef..7b8116f3 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.17", + "expectedDriverPackageVersion": "0.1.0.18", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index cf4b815d..0ee8fad6 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -62,7 +62,12 @@ of the source-provenance evidence without becoming a user-machine dependency. devnode and call `DiInstallDevice`; they never replace same-version Driver Store content. An absent or newer candidate uses `DiInstallDriverW` under the monotonic version policy. Same-version INF/SYS/CAT conflicts and implicit - downgrades fail before mutation. + downgrades fail before mutation. A newer package never updates a live root + bus in place: the helper removes only the captured exact owned devnode, + proves its child topology absent, stages the candidate, recreates the same + root instance ID, and binds the exact published candidate with + `DiInstallDevice`. The captured snapshot remains authoritative until broker + commit and recreates the prior identity/package on any failure. 4. After the exact binding is verified, the helper launches the immutable package broker's hidden `native-package-broker-commit` command while still holding the driver mutex and snapshot. That command reopens the token, @@ -212,15 +217,18 @@ registration cleanup is treated as VIIPER ownership authority. rollback continues. This is the documented [overlapped DeviceIoControl](https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol) and [CancelIoEx](https://learn.microsoft.com/windows/win32/fileio/cancelioex-func) lifetime rule. -- SetupAPI rollback preserves the captured root device instance ID. Per +- SetupAPI upgrade and rollback preserve the captured root device instance ID. + A newer package follows the same devnode-before-package lifecycle used for + exact removal so Windows does not treat the operation as an in-place update + of a loaded kernel bus. Per [`SetupDiCreateDeviceInfoW`](https://learn.microsoft.com/windows/win32/api/setupapi/nf-setupapi-setupdicreatedeviceinfow), forward creation passes the VIIPER-owned `VIIPERUDE` device name with `DICD_GENERATE_ID` and verifies the returned `ROOT\VIIPERUDE\####` identity. - Rollback omits `DICD_GENERATE_ID`, making `DeviceName` the complete captured - instance ID. It accepts only that namespace or the exact legacy + Upgrade recreation and rollback omit `DICD_GENERATE_ID`, making `DeviceName` + the complete captured instance ID. They accept only that namespace or the exact legacy `ROOT\USB\####` form produced when older builds incorrectly passed the USB - class name, after the existing service/package ownership proof. It then - verifies the restored identity, topology, and signed package hashes rather + class name, after the existing service/package ownership proof. The helper + then verifies the restored identity, topology, and signed package hashes rather than deleting every matching devnode and manufacturing a replacement. - ViGEmBus's root-enumerated bus architecture is used only as the lifecycle reference: the bus owns its exact child identities and separates user-mode @@ -239,13 +247,16 @@ scheduler migration cannot strand either global lock. ## Restart boundary -If Windows reports that driver activation requires a restart, the helper does -not start the broker or remove legacy ownership. It rolls the attempted driver -transaction back, the outer transaction restores the prior executable/service, -and preserves Windows `ERROR_SUCCESS_REBOOT_REQUIRED` (3010) through the Go -bootstrapper for the signed installer. After restart, the installer -retries the complete preflight and transaction from the beginning. No -cross-reboot journal is trusted as executable authority. +The normal newer-package path removes the captured exact root devnode before +staging and recreates its identity afterward; this avoids an in-place update of +the loaded bus and should commit without a restart. If Windows still reports +that either removal, package staging, or exact binding requires a restart, the +helper does not start the broker or remove legacy ownership. It rolls the +attempted driver transaction back, the outer transaction restores the prior +executable/service, and preserves Windows `ERROR_SUCCESS_REBOOT_REQUIRED` +(3010) through the Go bootstrapper for the signed installer. After restart, the +installer retries the complete preflight and transaction from the beginning. +No cross-reboot journal is trusted as executable authority. For removal, 3010 means the helper accepted the exact devnode/package removal but Windows needs a restart to finish it. The service and exact managed diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index e17414c4..9b33d982 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -199,6 +199,22 @@ func TestNativePackageProductionSourceContract(t *testing.T) { !strings.Contains(helperSource, "DiInstallDevice(") { t.Error("driver helper lost exact preinstalled-driver selection and DiInstallDevice binding") } + upgradeRemove := strings.Index(helperSource, `L"upgrade-deadline-before-device-removal"`) + upgradeAbsent := strings.Index(helperSource, "CaptureSnapshot(&afterRemoval") + upgradeStage := strings.Index(helperSource, "DiInstallDriverW(nullptr, candidate.infPath.c_str()") + upgradeIdentity := strings.Index(helperSource, "ExactRootRegistrationMode::Upgrade") + upgradeBind := -1 + if upgradeIdentity >= 0 { + if relative := strings.Index(helperSource[upgradeIdentity:], + "InstallPreinstalledDriverOnDevice("); relative >= 0 { + upgradeBind = upgradeIdentity + relative + } + } + if upgradeRemove < 0 || upgradeAbsent <= upgradeRemove || + upgradeStage <= upgradeAbsent || upgradeIdentity <= upgradeStage || + upgradeBind <= upgradeIdentity { + t.Error("driver upgrade no longer removes and proves the captured root absent before staging, exact-identity recreation, and binding") + } if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") } diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 0553c03d..31ff3c4c 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.17", + ExpectedDriverPackageVersion: "0.1.0.18", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 1df0979f..0d74c886 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.17" + DriverPackageVersion = "0.1.0.18" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7b410fae..295cd033 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660" + const wantHex = "55c5864bc1a8e3eff1eeac65935119f7aa821bfc76533ed3060a7d7131814a2e" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index f2576753..cd258ac3 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.17 + 0.1.0.18 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 49d6e7f3..a7453518 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.17" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.18" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 7e659f04..42051f1e 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.17 +DriverVer=08/13/2026,0.1.0.18 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index e2c0e186..697c347a 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -129,6 +129,9 @@ $requiredContracts = [ordered]@{ 'rollback root registration deadline' = 'rollback-deadline-before-root-registration' 'exact rollback devnode identity' = 'RegisterRootDeviceExact\(' 'rollback identity verification' = 'rollback-identity-verification' + 'upgrade devnode removal boundary' = 'upgrade-deadline-before-device-removal' + 'upgrade devnode absence verification' = 'upgrade-device-removal-verification' + 'exact upgrade devnode identity' = 'ExactRootRegistrationMode::Upgrade' 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' 'guarded downgrade' = '--allow-controlled-downgrade' } @@ -155,7 +158,11 @@ $orderedMutationContracts = [ordered]@{ 'first-time root creation uses the owned device name' = 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}kRootDeviceName[\s\S]{0,120}DICD_GENERATE_ID' 'registered devnode cleanup state survives post-registration validation' = - 'registeredAndVerified[\s\S]{0,300}createdHere = registrationSucceeded;[\s\S]{0,120}if \(registeredAndVerified\)' + 'bool registeredAndVerified = false;[\s\S]{0,1200}createdHere = registrationSucceeded;[\s\S]{0,160}if \(registeredAndVerified\)' + 'upgrade removes and proves the captured devnode absent before staging' = + 'upgrade-deadline-before-device-removal[\s\S]{0,800}CaptureSnapshot\(&afterRemoval[\s\S]{0,1800}DiInstallDriverW\(' + 'upgrade restores exact identity before exact package binding' = + 'DiInstallDriverW\([\s\S]{0,2200}prior\.devices\[0\]\.instanceId[\s\S]{0,300}ExactRootRegistrationMode::Upgrade[\s\S]{0,700}InstallPreinstalledDriverOnDevice\(' 'recovery journal is published and preservation armed before mutation' = 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' 'failed remove rollback preserves published evidence before return' = diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 70839273..d1b4ceab 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -2353,51 +2353,80 @@ bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId) { IsGeneratedRootInstanceIdForDeviceName(instanceId, kLegacyRootDeviceName); } +enum class ExactRootRegistrationMode { + Upgrade, + Rollback, +}; + bool RegisterRootDeviceExact( const GUID& classGuid, const std::wstring& instanceId, uint64_t transactionDeadlineUnixMs, + ExactRootRegistrationMode mode, + bool* mutationStarted, + bool* registrationSucceeded, DeviceInfoSet* set, SP_DEVINFO_DATA* data, Error* error) { + if (registrationSucceeded != nullptr) { + *registrationSucceeded = false; + } + const bool rollback = mode == ExactRootRegistrationMode::Rollback; if (!IsOwnedGeneratedRootInstanceId(instanceId)) { - return SetError(error, L"rollback-instance-id", ERROR_INVALID_DATA, + return SetError(error, rollback ? L"rollback-instance-id" : L"upgrade-instance-id", + ERROR_INVALID_DATA, L"captured root devnode identity is outside the VIIPER or legacy generated root namespace"); } *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); if (!*set) { - return SetLastErrorDetail(error, L"rollback-create-device-info-list"); + return SetLastErrorDetail(error, rollback + ? L"rollback-create-device-info-list" : L"upgrade-create-device-info-list"); } *data = SP_DEVINFO_DATA{}; data->cbSize = sizeof(*data); // With DICD_GENERATE_ID absent, SetupAPI treats DeviceName as the complete - // device instance ID. Rollback must never substitute a fresh ROOT instance. + // device instance ID. Upgrade and rollback must never substitute a fresh + // ROOT instance. if (!SetupDiCreateDeviceInfoW(set->get(), instanceId.c_str(), &classGuid, nullptr, nullptr, 0, data)) { - return SetLastErrorDetail(error, L"rollback-create-exact-root-devnode"); + return SetLastErrorDetail(error, rollback + ? L"rollback-create-exact-root-devnode" : L"upgrade-create-exact-root-devnode"); } const size_t idCharacters = std::size(kHardwareId) + 1; std::vector identifiers(idCharacters, L'\0'); std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, - L"rollback-deadline-before-root-properties", error)) { + rollback ? L"rollback-deadline-before-root-properties" + : L"upgrade-deadline-before-root-properties", error)) { return false; } MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { - return SetLastErrorDetail(error, L"rollback-set-root-hardware-id"); + return SetLastErrorDetail(error, rollback + ? L"rollback-set-root-hardware-id" : L"upgrade-set-root-hardware-id"); } if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, - L"rollback-deadline-before-root-registration", error)) { + rollback ? L"rollback-deadline-before-root-registration" + : L"upgrade-deadline-before-root-registration", error)) { return false; } MarkTransactionMutationStarted(); + if (mutationStarted != nullptr) { + *mutationStarted = true; + } if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { - return SetLastErrorDetail(error, L"rollback-register-exact-root-devnode"); + return SetLastErrorDetail(error, rollback + ? L"rollback-register-exact-root-devnode" : L"upgrade-register-exact-root-devnode"); + } + if (registrationSucceeded != nullptr) { + *registrationSucceeded = true; } return true; } @@ -2707,7 +2736,8 @@ bool RestorePriorBinding( return SetLastErrorDetail(error, L"rollback-inf-class"); } if (!RegisterRootDeviceExact(classGuid, expected.instanceId, - transactionDeadlineUnixMs, + transactionDeadlineUnixMs, ExactRootRegistrationMode::Rollback, + nullptr, nullptr, &target, &targetData, error)) { return false; } @@ -3511,67 +3541,121 @@ Outcome Install(const InstallOptions& options) { const bool driverMutation = disposition == CandidateDisposition::InstallRequired || topologyRepair; bool driverMutationStarted = false; + DeviceInfoSet created; + SP_DEVINFO_DATA createdData{}; + createdData.cbSize = sizeof(createdData); + bool createdHere = false; + bool registrationSucceeded = false; + GUID candidateClassGuid{}; + wchar_t candidateClassName[MAX_CLASS_NAME_LEN]{}; + const bool needsRootRegistration = + disposition == CandidateDisposition::InstallRequired || + (topologyRepair && prior.devices.empty()); + if (needsRootRegistration && + !SetupDiGetINFClassW(candidate.infPath.c_str(), &candidateClassGuid, + candidateClassName, MAX_CLASS_NAME_LEN, nullptr)) { + SetLastErrorDetail(&outcome.error, L"candidate-inf-class"); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (disposition == CandidateDisposition::InstallRequired) { - if (!CheckTransactionDeadline(options, - L"transaction-deadline-before-driver-install", &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; - } - driverMutationStarted = true; - outcome.changed = true; - BOOL installReboot = FALSE; - const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; - MarkTransactionMutationStarted(); - if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { - const DWORD installCode = GetLastError(); - const Error installError{installCode, L"install-driver-package", FormatError(installCode)}; - Error rollbackError; - bool rollbackReboot = false; - if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { - outcome.rollback = L"succeeded"; - outcome.rebootRequired = rollbackReboot; - outcome.error = installError; - return outcome; + // Updating a running root bus in place makes DiInstallDriverW report a + // reboot even though this helper immediately restores the old package. + // Remove only the exact captured VIIPER-owned devnode first, prove its + // topology is gone, then stage and bind the candidate to the same root + // identity. Snapshot rollback recreates the old exact identity/package + // if any later step fails. + if (!prior.devices.empty()) { + DeviceInfoSet replaced = OpenRootDevices(); + std::vector> replacements; + if (!replaced) { + SetLastErrorDetail(&outcome.error, L"upgrade-open-root-devices"); + } else if (!FindExactDevices(replaced.get(), &replacements, &outcome.error)) { + // Exact enumeration recorded the failure. + } else if (replacements.size() != 1 || + _wcsicmp(replacements[0].second.instanceId.c_str(), + prior.devices[0].instanceId.c_str()) != 0 || + _wcsicmp(replacements[0].second.publishedInf.c_str(), + prior.devices[0].publishedInf.c_str()) != 0 || + !(replacements[0].second.version == prior.devices[0].version)) { + SetError(&outcome.error, L"upgrade-root-identity", ERROR_REVISION_MISMATCH, + L"captured root devnode identity or package binding changed before replacement"); + } else { + bool removalReboot = false; + if (RemoveDevice(replaced.get(), replacements[0].first, + options.transactionDeadlineUnixMs, + L"upgrade-deadline-before-device-removal", + &driverMutationStarted, &removalReboot, &outcome.error)) { + outcome.rebootRequired = removalReboot; + if (removalReboot) { + SetError(&outcome.error, L"upgrade-device-removal-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"the exact prior root bus could not be removed synchronously; the captured binding will be restored before restart"); + } else { + Snapshot afterRemoval; + if (!CaptureSnapshot(&afterRemoval, &outcome.error)) { + // Exact inventory recorded the failure. + } else if (!afterRemoval.devices.empty()) { + SetError(&outcome.error, L"upgrade-device-removal-verification", + ERROR_DEVICE_IN_USE, + L"the exact prior root bus remained present after synchronous removal"); + } + } + } + outcome.changed = outcome.changed || driverMutationStarted; } - outcome.rollback = L"failed"; - outcome.rebootRequired = rollbackReboot; - outcome.error = std::move(rollbackError); - outcome.exitCode = ExitCode::RollbackFailed; - return outcome; } - outcome.rebootRequired = installReboot != FALSE; - if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { - // Exact Driver Store inventory recorded the failure. - } else if (options.production && !VerifyMicrosoftHardwareInfSigner( - publishedCandidate.infPath, &outcome.error)) { - // The staged package must retain its exact production HLK/WHCP policy. + + if (outcome.error.code == ERROR_SUCCESS && + CheckTransactionDeadline(options, + L"transaction-deadline-before-driver-install", &outcome.error)) { + BOOL installReboot = FALSE; + const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; + MarkTransactionMutationStarted(); + driverMutationStarted = true; + outcome.changed = true; + if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { + SetLastErrorDetail(&outcome.error, L"install-driver-package"); + } else { + outcome.rebootRequired = outcome.rebootRequired || installReboot != FALSE; + if (installReboot) { + SetError(&outcome.error, L"driver-package-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"Windows could not stage the candidate driver package without a restart; the captured binding will be restored first"); + } else if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { + // Exact Driver Store inventory recorded the failure. + } else if (options.production && !VerifyMicrosoftHardwareInfSigner( + publishedCandidate.infPath, &outcome.error)) { + // The staged package must retain its exact production HLK/WHCP policy. + } + } } } - DeviceInfoSet created; - SP_DEVINFO_DATA createdData{}; - createdData.cbSize = sizeof(createdData); - bool createdHere = false; - bool registrationSucceeded = false; - if (outcome.error.code == ERROR_SUCCESS && driverMutation && prior.devices.empty()) { - GUID classGuid{}; - wchar_t className[MAX_CLASS_NAME_LEN]{}; - if (!SetupDiGetINFClassW(candidate.infPath.c_str(), &classGuid, className, MAX_CLASS_NAME_LEN, nullptr)) { - SetLastErrorDetail(&outcome.error, L"candidate-inf-class"); + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + (prior.devices.empty() || disposition == CandidateDisposition::InstallRequired)) { + bool registeredAndVerified = false; + if (prior.devices.empty()) { + registeredAndVerified = RegisterRootDevice( + candidateClassGuid, options.transactionDeadlineUnixMs, + &driverMutationStarted, ®istrationSucceeded, + &created, &createdData, &outcome.error); } else { - const bool registeredAndVerified = RegisterRootDevice( - classGuid, options.transactionDeadlineUnixMs, + registeredAndVerified = RegisterRootDeviceExact( + candidateClassGuid, prior.devices[0].instanceId, + options.transactionDeadlineUnixMs, ExactRootRegistrationMode::Upgrade, &driverMutationStarted, ®istrationSucceeded, &created, &createdData, &outcome.error); - createdHere = registrationSucceeded; - if (registeredAndVerified) { - InstallPreinstalledDriverOnDevice( - created.get(), &createdData, publishedCandidate, - options.transactionDeadlineUnixMs, &driverMutationStarted, - &outcome.rebootRequired, &outcome.error); - } - outcome.changed = outcome.changed || driverMutationStarted; } + createdHere = registrationSucceeded; + if (registeredAndVerified) { + InstallPreinstalledDriverOnDevice( + created.get(), &createdData, publishedCandidate, + options.transactionDeadlineUnixMs, &driverMutationStarted, + &outcome.rebootRequired, &outcome.error); + } + outcome.changed = outcome.changed || driverMutationStarted; } if (outcome.error.code == ERROR_SUCCESS && topologyRepair && !prior.devices.empty()) { @@ -3626,6 +3710,8 @@ Outcome Install(const InstallOptions& options) { outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = installError; + outcome.exitCode = installError.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::Failure; return outcome; } outcome.rollback = L"failed"; @@ -5021,7 +5107,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "180003f7b141c8015c29e7b3dcb6d252601ca6e82e6cc43b4480db31e167a660") { + "55c5864bc1a8e3eff1eeac65935119f7aa821bfc76533ed3060a7d7131814a2e") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 164b1b4740bcfffc666457a350390b2bc964d41d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 12:27:58 -0500 Subject: [PATCH 206/240] Quiesce broker for native root bus upgrades --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- .../native-udecx-package-install.md | 72 +++- internal/cmd/native_package_contract_test.go | 17 +- .../cmd/native_package_process_windows.go | 43 +- internal/cmd/native_package_windows.go | 405 +++++++++++++++++- internal/cmd/native_package_windows_test.go | 150 +++++++ internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 11 + native/udecx/tools/ViiperUdeCtl.cpp | 170 +++++++- 15 files changed, 825 insertions(+), 59 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 68ddef7d..eb41250a 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.18", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.19", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 7b8116f3..8c75da18 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.18", + "expectedDriverPackageVersion": "0.1.0.19", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index 0ee8fad6..037503d8 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -50,7 +50,10 @@ of the source-provenance evidence without becoming a user-machine dependency. ## Commit order 1. Acquire the administrator-only machine package mutex and validate every - immutable input. + immutable input. Then acquire the broker-service mutex, inspect the exact + service/configuration/DACL/recovery/image/run-state, and retain a protected + hash snapshot of any trusted prior broker. This is the global + package-then-service lock order. 2. Create and hold a random one-time token below `%ProgramFiles%\VIIPER` with an administrator/SYSTEM-only DACL, and pass its installer-bound SHA-256 to `ViiperUdeCtl install`. The helper independently reopens and verifies the @@ -62,17 +65,27 @@ of the source-provenance evidence without becoming a user-machine dependency. devnode and call `DiInstallDevice`; they never replace same-version Driver Store content. An absent or newer candidate uses `DiInstallDriverW` under the monotonic version policy. Same-version INF/SYS/CAT conflicts and implicit - downgrades fail before mutation. A newer package never updates a live root - bus in place: the helper removes only the captured exact owned devnode, + downgrades fail before mutation. Only after classification proves a SetupAPI + mutation is required, the helper signals its inherited quiescence-request + event and waits for the outer transaction. The outer transaction stops only + a trusted formerly-running broker (or acknowledges an absent/already-stopped + trusted service) while retaining the broker-service mutex. Weak service + ownership aborts before driver mutation because it is not a safe rollback + source. A newer package never updates a live root bus in place: with the + trusted broker quiescent, the helper removes only the captured exact owned devnode, proves its child topology absent, stages the candidate, recreates the same root instance ID, and binds the exact published candidate with `DiInstallDevice`. The captured snapshot remains authoritative until broker commit and recreates the prior identity/package on any failure. -4. After the exact binding is verified, the helper launches the immutable - package broker's hidden `native-package-broker-commit` command while still - holding the driver mutex and snapshot. That command reopens the token, - requires its exact DACL/hash/path, proves the separate outer process still - owns the package mutex, then acquires the broker-service mutex. +4. After the exact binding is verified, the helper signals its inherited broker + handoff event. The outer transaction releases its protected prior-image and + SCM handles, then releases the broker-service mutex on the same pinned OS + thread. Only then does the helper launch the immutable package broker's + hidden `native-package-broker-commit` command while still holding the driver + mutex and snapshot. That command reopens the token, requires its exact + DACL/hash/path, proves the separate outer process still owns the package + mutex, then acquires the broker-service mutex. An exact driver no-op skips + service quiescence but uses the same handoff before broker health/repair. 5. The nested command first checks for a true no-op: canonical protected service/image/credential state, no live legacy owner, stable service PID, and authenticated `ping` with `Ready=true`, ABI 1.10, the exact capability mask, @@ -88,8 +101,13 @@ of the source-provenance evidence without becoming a user-machine dependency. fully settled child rollback authorizes the still-running helper to restore its captured driver packages/devnode. Crash, malformed/missing proof, exit 3, pipe/wait ambiguity, or an over-budget child leaves driver rollback - unauthorized and reports external reconciliation. USB/IP itself is never - directly removed by this transaction. + unauthorized and reports external reconciliation. If the outer transaction + stopped a trusted prior broker and the helper fails before handoff, it keeps + the service mutex and restores the exact snapshotted service/image/run-state + only after a settled driver proof. After handoff, it reacquires that mutex + and performs the same exact revalidation/restart only when the nested and + driver rollback proof is settled; indeterminate proof leaves the service + stopped. USB/IP itself is never directly removed by this transaction. The mutating broker process is never hard-terminated. The outer absolute four-minute deadline is passed through the helper into the nested broker, so it @@ -104,11 +122,14 @@ checked immediately before and after each mutating boundary; no new phase may start after expiry, and no process is killed mid-rollback. Before calling Go's `Cmd.Wait`, the outer transaction duplicates the exact -helper process handle with `SYNCHRONIZE`. It independently waits for that -process object to become signaled before releasing the package mutex or any -immutable input handle. A non-exit `Cmd.Wait` error is therefore still +helper process handle with `SYNCHRONIZE`. It waits on that retained process +object together with the two child-to-parent coordination events, and passes +only four unnamed, explicitly inherited events to the exact helper process. +The process signal has priority over stale event observations. The retained +process must become signaled before the package mutex or any immutable input +handle can unwind. A non-exit `Cmd.Wait` or event-wait error is therefore still indeterminate, but it can no longer let a live mutating helper escape the -transaction scope. +transaction scope or authorize an unsafe prior-service restart. ## Exact package removal @@ -237,19 +258,24 @@ registration cleanup is treated as VIIPER ownership authority. treats its service or driver store as installer-owned. Every public native install, repair, or uninstall takes locks in the same -machine-wide order: package mutex, then broker-service mutex. The nested helper -callback does not reacquire the package mutex (which would deadlock); the -protected one-time token and zero-time ownership check authorize that one -service transaction. The token is removed on commit or rollback and is inert -without a live outer mutex owner. Because Win32 mutexes are thread-owned, each -Go acquisition pins its goroutine to that OS thread until the matching release; +machine-wide order: package mutex, then broker-service mutex. During install, +the outer transaction retains both across any required broker stop and driver +replacement, then performs an explicit service-lock handoff to the nested +broker commit while continuing to own the package mutex. The nested callback +does not reacquire the package mutex (which would deadlock); the protected +one-time token and zero-time ownership check authorize that one service +transaction. The token is removed on commit or rollback and is inert without a +live outer mutex owner. Because Win32 mutexes are thread-owned, each Go +acquisition pins its goroutine to that OS thread until the matching release; scheduler migration cannot strand either global lock. ## Restart boundary -The normal newer-package path removes the captured exact root devnode before -staging and recreates its identity afterward; this avoids an in-place update of -the loaded bus and should commit without a restart. If Windows still reports +The normal newer-package path first quiesces the trusted native broker while +holding the service mutex, then removes the captured exact root devnode before +staging and recreates its identity afterward. This removes the broker's open +UdeCx ownership from the PnP boundary, avoids an in-place update of the loaded +bus, and should commit without a restart. If Windows still reports that either removal, package staging, or exact binding requires a restart, the helper does not start the broker or remove legacy ownership. It rolls the attempted driver transaction back, the outer transaction restores the prior diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 9b33d982..3e670e14 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -55,6 +55,11 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "nativePackageMutexHeldByAnotherOwner", "lockNativePackageDirectoryChain", "--broker-token-sha256", + "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", + "--broker-quiesce-abort-handle", "--broker-handoff-handle", + "AdditionalInheritedHandles", "coordinateDriverHelper(ctx", + "quiescePriorServiceForDriver", "releaseServiceForBrokerHandoff", + "restoreQuiescedPriorService", "driverHelperSettled", "nativePackageRebootRequiredError", "parseNativePackageInstallProof(text, processExitCode)", } @@ -113,9 +118,12 @@ func TestNativePackageProductionSourceContract(t *testing.T) { if strings.Contains(source, "command.Wait()") { t.Errorf("%s bypasses the retained process-handle join", name) } - if !strings.Contains(source, "waitNativePackageHelper(command)") { - t.Errorf("%s lost the retained process-handle join", name) - } + } + if !strings.Contains(windowsSource, "waitNativePackageHelperCoordinated(command") { + t.Error("package install lost the retained coordinated process-handle join") + } + if !strings.Contains(uninstallWindowsSource, "waitNativePackageHelper(command)") { + t.Error("package uninstall lost the retained process-handle join") } credentialStart := strings.Index(serviceSource, "func createProtectedNativeCredentialStagingFile(") @@ -179,6 +187,9 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "VerifyDriverCatalogMember(catalogPath, infPath", "LoadLibraryExW", "LOAD_LIBRARY_SEARCH_SYSTEM32", "GetProcAddress", "ValidateExactPackageDirectory", "Sha256Handle(manifest.get()", + "RequestBrokerQuiescence", "SignalBrokerHandoff", + "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", + "--broker-quiesce-abort-handle", "--broker-handoff-handle", } for _, fragment := range requiredHelper { if !strings.Contains(helperSource, fragment) { diff --git a/internal/cmd/native_package_process_windows.go b/internal/cmd/native_package_process_windows.go index 08df0b5b..e745b641 100644 --- a/internal/cmd/native_package_process_windows.go +++ b/internal/cmd/native_package_process_windows.go @@ -125,22 +125,42 @@ func (j *nativePackageProcessJoin) complete(commandWaitErr error) error { } func waitNativePackageHelper(command *exec.Cmd) error { - return waitNativePackageHelperWith( - command, - retainNativePackageProcessJoin, + return waitNativePackageHelperWith(command, retainNativePackageProcessJoin, + func() { time.Sleep(nativePackageProcessJoinRetry) }) +} + +// waitNativePackageHelperCoordinated retains the exact helper process while +// the outer package transaction services its inherited quiescence/handoff +// events. The callback must return only after the retained process is signaled +// or after a coordination anomaly; complete still joins the exact child before +// any package or service lock can unwind. +func waitNativePackageHelperCoordinated( + command *exec.Cmd, + coordinate func(windows.Handle) error, +) error { + join := retainNativePackageProcessJoinWithRetry( + command.Process, retainNativePackageProcessJoin, func() { time.Sleep(nativePackageProcessJoinRetry) }, ) + coordinationErr := coordinate(join.handle) + waitErr := join.complete(command.Wait()) + if coordinationErr != nil { + return &nativePackageProcessWaitIndeterminateError{ + cause: errors.Join(coordinationErr, waitErr), + } + } + return waitErr } -func waitNativePackageHelperWith( - command *exec.Cmd, +func retainNativePackageProcessJoinWithRetry( + process *os.Process, retain func(*os.Process) (*nativePackageProcessJoin, error), retry func(), -) error { +) *nativePackageProcessJoin { var join *nativePackageProcessJoin for join == nil { var err error - join, err = retain(command.Process) + join, err = retain(process) if err == nil { break } @@ -150,6 +170,15 @@ func waitNativePackageHelperWith( // lock and immutable input handle remains held. retry() } + return join +} + +func waitNativePackageHelperWith( + command *exec.Cmd, + retain func(*os.Process) (*nativePackageProcessJoin, error), + retry func(), +) error { + join := retainNativePackageProcessJoinWithRetry(command.Process, retain, retry) // A recovered pre-Wait duplication retry is not a transaction failure: the // exact handle was retained before Cmd.Wait and supplies the required join. return join.complete(command.Wait()) diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index ea6f2c60..ccae99d9 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -18,6 +18,7 @@ import ( "slices" "strconv" "strings" + "syscall" "time" "unsafe" @@ -47,6 +48,10 @@ type windowsNativePackageTransaction struct { nestedMutationStarted bool nestedRollbackSucceeded bool nestedServiceRollbackSettled bool + driverQuiesceRequested bool + driverBrokerHandoff bool + driverHelperSettled bool + driverCoordinationErr error programFiles string destination string @@ -58,6 +63,12 @@ type windowsNativePackageTransaction struct { service nativeManagedService serviceSnapshot nativePackageServiceSnapshot priorServiceExecutable string + priorExecutableSHA256 string + priorServiceConfig mgr.Config + priorServiceDACL string + priorServiceRecovery []mgr.RecoveryAction + priorServiceReset uint32 + priorServiceNonCrash bool priorExecutableRelease func() stoppedTrustedService bool @@ -338,10 +349,6 @@ func (t *windowsNativePackageTransaction) preflightNestedBrokerCommit() error { func (t *windowsNativePackageTransaction) InspectService( ctx context.Context, ) (nativePackageServiceSnapshot, error) { - if !t.nestedBrokerCommit { - t.serviceSnapshot = nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent} - return t.serviceSnapshot, nil - } budget := nativePackageTransactionTimeout if deadline, ok := ctx.Deadline(); ok { budget = time.Until(deadline) @@ -351,7 +358,7 @@ func (t *windowsNativePackageTransaction) InspectService( } release, err := acquireNativeInstallMutex(budget) if err != nil { - return nativePackageServiceSnapshot{}, fmt.Errorf("lock nested native broker transaction: %w", err) + return nativePackageServiceSnapshot{}, fmt.Errorf("lock native broker service transaction: %w", err) } t.releaseServiceMutex = release manager, err := mgr.Connect() @@ -424,14 +431,35 @@ func (t *windowsNativePackageTransaction) InspectService( if canonical { releaseExecutable, lockErr := lockNativePriorServiceExecutable(priorExecutable) if lockErr == nil { - disposition = nativePackageServiceTrusted - t.priorExecutableRelease = releaseExecutable - } else { + handle, openErr := lockNativePackageInput(priorExecutable) + if openErr == nil { + priorHash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr == nil && closeErr == nil { + disposition = nativePackageServiceTrusted + t.priorExecutableRelease = releaseExecutable + t.priorExecutableSHA256 = priorHash + } else { + releaseExecutable() + lockErr = errors.Join(hashErr, closeErr) + } + } else { + releaseExecutable() + lockErr = openErr + } + } + if lockErr != nil { // An exact service name/path with weak image ACLs is stale package // ownership, not a trustworthy rollback source. It is removed and // recreated; never "repair" its ACL while old handles may exist. t.logger.Warn("Replacing weak exact-owned native broker service image", "path", priorExecutable, "error", lockErr) + } else { + t.priorServiceConfig = config + t.priorServiceDACL = securityDescriptor + t.priorServiceRecovery = append([]mgr.RecoveryAction(nil), recovery...) + t.priorServiceReset = reset + t.priorServiceNonCrash = nonCrash } } t.serviceSnapshot = nativePackageServiceSnapshot{ @@ -445,7 +473,7 @@ func (t *windowsNativePackageTransaction) finalizeServiceInspection( ctx context.Context, snapshot nativePackageServiceSnapshot, ) (nativePackageServiceSnapshot, error) { - if snapshot.disposition == nativePackageServiceTrusted && snapshot.wasRunning { + if t.nestedBrokerCommit && snapshot.disposition == nativePackageServiceTrusted && snapshot.wasRunning { healthy, err := t.verifyExactBrokerHealth(ctx) if err != nil { if ctx.Err() != nil { @@ -601,8 +629,8 @@ func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Con } } } else { - if t.releaseServiceMutex != nil { - return errors.New("outer native package transaction unexpectedly holds the service mutex") + if t.releaseServiceMutex == nil { + return errors.New("outer native package transaction does not hold the service mutex") } if err := t.runDriverHelper(ctx); err != nil { return err @@ -695,12 +723,24 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE "nested native broker service rollback is unsettled; retaining staged and prior broker images and leaving the service stopped for external reconciliation")) return errors.Join(rollbackErrors...) } + if !t.nestedBrokerCommit && t.stoppedTrustedService && !t.driverHelperSettled { + rollbackErrors = append(rollbackErrors, errors.New( + "driver-helper or handoff proof is unsettled; leaving the prior trusted broker stopped for external reconciliation")) + return errors.Join(rollbackErrors...) + } + if !t.nestedBrokerCommit && t.stoppedTrustedService { + if err := t.restoreQuiescedPriorService(ctx); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("restore quiesced prior broker during outer rollback: %w", err)) + } + } restored := true if err := t.restoreBrokerExecutable(); err != nil { restored = false rollbackErrors = append(rollbackErrors, err) } - if t.stoppedTrustedService && t.service != nil && t.serviceSnapshot.wasRunning { + if t.nestedBrokerCommit && t.stoppedTrustedService && t.service != nil && + t.serviceSnapshot.wasRunning { if !restored { rollbackErrors = append(rollbackErrors, errors.New("refusing to restart prior native broker because its image was not restored")) @@ -822,6 +862,28 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e if proofErr != nil { return fmt.Errorf("validate native driver helper proof: %w: %s", proofErr, text) } + t.driverHelperSettled = proof.exitCode != 3 + if proof.success && !t.driverBrokerHandoff { + t.driverHelperSettled = false + return errors.New("native driver helper reported success without the broker service handoff") + } + if !proof.success && t.stoppedTrustedService && t.driverHelperSettled { + rollbackCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + restoreErr := t.restoreQuiescedPriorService(rollbackCtx) + cancel() + if restoreErr != nil { + return fmt.Errorf("restore trusted native broker after settled helper failure: %w", restoreErr) + } + } + if proof.success { + // The authenticated nested broker commit now owns the service run state. + t.stoppedTrustedService = false + } + if t.driverCoordinationErr != nil { + return fmt.Errorf("coordinate native broker quiescence: %w", t.driverCoordinationErr) + } if proof.exitCode == nativePackageRebootRequiredCode { return &nativePackageRebootRequiredError{cause: fmt.Errorf("%w: %s", err, text)} } @@ -832,6 +894,312 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e return nil } +type nativePackageDriverCoordination struct { + quiesceRequest windows.Handle + quiesceReady windows.Handle + quiesceAbort windows.Handle + brokerHandoff windows.Handle +} + +func newNativePackageDriverCoordination() (*nativePackageDriverCoordination, error) { + attributes := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + InheritHandle: 1, + } + coordination := &nativePackageDriverCoordination{} + create := func(target *windows.Handle, name string) error { + handle, err := windows.CreateEvent(attributes, 1, 0, nil) + if err != nil { + return fmt.Errorf("create inherited %s event: %w", name, err) + } + if handle == 0 { + return fmt.Errorf("create inherited %s event returned a null handle", name) + } + *target = handle + return nil + } + if err := create(&coordination.quiesceRequest, "broker quiesce request"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.quiesceReady, "broker quiesce ready"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.quiesceAbort, "broker quiesce abort"); err != nil { + coordination.close() + return nil, err + } + if err := create(&coordination.brokerHandoff, "broker handoff"); err != nil { + coordination.close() + return nil, err + } + return coordination, nil +} + +func (c *nativePackageDriverCoordination) close() { + if c == nil { + return + } + for _, handle := range []windows.Handle{ + c.brokerHandoff, c.quiesceAbort, c.quiesceReady, c.quiesceRequest, + } { + if handle != 0 { + windows.CloseHandle(handle) //nolint:errcheck + } + } + c.quiesceRequest = 0 + c.quiesceReady = 0 + c.quiesceAbort = 0 + c.brokerHandoff = 0 +} + +func (c *nativePackageDriverCoordination) inheritedHandles() []syscall.Handle { + return []syscall.Handle{ + syscall.Handle(c.quiesceRequest), syscall.Handle(c.quiesceReady), + syscall.Handle(c.quiesceAbort), syscall.Handle(c.brokerHandoff), + } +} + +func (c *nativePackageDriverCoordination) arguments() []string { + return []string{ + "--broker-quiesce-request-handle", strconv.FormatUint(uint64(c.quiesceRequest), 10), + "--broker-quiesce-ready-handle", strconv.FormatUint(uint64(c.quiesceReady), 10), + "--broker-quiesce-abort-handle", strconv.FormatUint(uint64(c.quiesceAbort), 10), + "--broker-handoff-handle", strconv.FormatUint(uint64(c.brokerHandoff), 10), + } +} + +func (t *windowsNativePackageTransaction) coordinateDriverHelper( + ctx context.Context, + process windows.Handle, + coordination *nativePackageDriverCoordination, +) error { + requestPending := true + handoffPending := true + for { + handles := []windows.Handle{process} + requestIndex := -1 + handoffIndex := -1 + if requestPending { + requestIndex = len(handles) + handles = append(handles, coordination.quiesceRequest) + } + if handoffPending { + handoffIndex = len(handles) + handles = append(handles, coordination.brokerHandoff) + } + status, err := windows.WaitForMultipleObjects(handles, false, windows.INFINITE) + if err != nil { + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("wait for driver-helper coordination event: %w", err) + } + index := int(status - windows.WAIT_OBJECT_0) + switch index { + case 0: + return nil + case requestIndex: + requestPending = false + t.driverQuiesceRequested = true + if quiesceErr := t.quiescePriorServiceForDriver(ctx); quiesceErr != nil { + t.driverCoordinationErr = quiesceErr + if signalErr := windows.SetEvent(coordination.quiesceAbort); signalErr != nil { + return errors.Join(quiesceErr, + fmt.Errorf("signal broker quiescence abort: %w", signalErr)) + } + continue + } + if signalErr := windows.SetEvent(coordination.quiesceReady); signalErr != nil { + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("signal broker quiescence readiness: %w", signalErr) + } + case handoffIndex: + handoffPending = false + if t.driverCoordinationErr != nil { + return errors.New("driver helper requested broker handoff after quiescence was aborted") + } + if handoffErr := t.releaseServiceForBrokerHandoff(); handoffErr != nil { + return handoffErr + } + default: + windows.SetEvent(coordination.quiesceAbort) //nolint:errcheck + return fmt.Errorf("unexpected driver-helper coordination wait status 0x%08x", status) + } + } +} + +func (t *windowsNativePackageTransaction) quiescePriorServiceForDriver(ctx context.Context) error { + if t.releaseServiceMutex == nil { + return errors.New("broker quiescence requires the held service mutex") + } + switch t.serviceSnapshot.disposition { + case nativePackageServiceAbsent: + return nil + case nativePackageServiceWeakExactOwned: + return errors.New("refusing to quiesce a weak exact-owned broker service before driver mutation") + case nativePackageServiceTrusted: + if t.service == nil { + return errors.New("trusted broker service snapshot has no live SCM handle") + } + if !t.serviceSnapshot.wasRunning { + return nil + } + // STOP is the parent transaction's mutation. Arm restoration before the + // control request so StopPending/timeouts cannot strand the prior broker. + t.stoppedTrustedService = true + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("quiesce trusted %s before root-bus mutation: %w", + NativeBrokerServiceName, err) + } + return nil + default: + return errors.New("broker quiescence received an unknown service disposition") + } +} + +func (t *windowsNativePackageTransaction) releaseServiceForBrokerHandoff() error { + if t.releaseServiceMutex == nil { + return errors.New("broker handoff requires the held service mutex") + } + if t.priorExecutableRelease != nil { + t.priorExecutableRelease() + t.priorExecutableRelease = nil + } + if t.service != nil { + if err := t.service.Close(); err != nil { + return fmt.Errorf("close prior broker service handle before handoff: %w", err) + } + t.service = nil + } + if t.manager != nil { + if err := t.manager.Close(); err != nil { + return fmt.Errorf("close prior SCM handle before handoff: %w", err) + } + t.manager = nil + } + t.releaseServiceMutex() + t.releaseServiceMutex = nil + t.driverBrokerHandoff = true + return nil +} + +func (t *windowsNativePackageTransaction) ensurePriorServiceForRestore( + ctx context.Context, +) error { + if t.releaseServiceMutex == nil { + budget := nativePackageRollbackTimeout + if deadline, ok := ctx.Deadline(); ok { + budget = time.Until(deadline) + if budget <= 0 { + return context.DeadlineExceeded + } + } + release, err := acquireNativeInstallMutex(budget) + if err != nil { + return fmt.Errorf("reacquire native broker service mutex for rollback: %w", err) + } + t.releaseServiceMutex = release + } + if t.manager == nil { + manager, err := mgr.Connect() + if err != nil { + return fmt.Errorf("reconnect to SCM for broker rollback: %w", err) + } + t.manager = &windowsNativeSCM{manager: manager} + } + if t.service == nil { + service, err := t.manager.OpenService(NativeBrokerServiceName) + if err != nil { + return fmt.Errorf("reopen prior %s for rollback: %w", NativeBrokerServiceName, err) + } + t.service = service + } + return nil +} + +func (t *windowsNativePackageTransaction) validatePriorServiceForRestart() error { + if t.serviceSnapshot.disposition != nativePackageServiceTrusted || + t.priorServiceExecutable == "" || t.priorExecutableSHA256 == "" { + return errors.New("prior broker snapshot is not a trusted restart source") + } + config, err := t.service.Config() + if err != nil { + return fmt.Errorf("query prior broker config for restart: %w", err) + } + if !nativeServiceConfigsEqual(config, t.priorServiceConfig) { + return errors.New("prior broker config changed before rollback restart") + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil || !strings.EqualFold(executable, t.priorServiceExecutable) { + return errors.New("prior broker executable changed before rollback restart") + } + dacl, err := t.service.SecurityDescriptor() + if err != nil { + return fmt.Errorf("query prior broker DACL for restart: %w", err) + } + if compareNativeSecurityDescriptorStrings(dacl, t.priorServiceDACL) != nil { + return errors.New("prior broker DACL changed before rollback restart") + } + recovery, err := t.service.RecoveryActions() + if err != nil { + return fmt.Errorf("query prior broker recovery actions for restart: %w", err) + } + reset, err := t.service.ResetPeriod() + if err != nil { + return fmt.Errorf("query prior broker recovery reset for restart: %w", err) + } + nonCrash, err := t.service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return fmt.Errorf("query prior broker recovery mode for restart: %w", err) + } + if !slices.Equal(recovery, t.priorServiceRecovery) || reset != t.priorServiceReset || + nonCrash != t.priorServiceNonCrash { + return errors.New("prior broker recovery policy changed before rollback restart") + } + if t.priorExecutableRelease == nil { + release, lockErr := lockNativePriorServiceExecutable(t.priorServiceExecutable) + if lockErr != nil { + return fmt.Errorf("relock protected prior broker executable: %w", lockErr) + } + t.priorExecutableRelease = release + } + handle, err := lockNativePackageInput(t.priorServiceExecutable) + if err != nil { + return fmt.Errorf("reopen protected prior broker executable: %w", err) + } + hash, hashErr := hashNativePackageHandle(handle) + closeErr := windows.CloseHandle(handle) + if hashErr != nil || closeErr != nil { + return fmt.Errorf("rehash protected prior broker executable: %w", + errors.Join(hashErr, closeErr)) + } + if !strings.EqualFold(hash, t.priorExecutableSHA256) { + return fmt.Errorf("prior broker executable SHA-256=%s expected=%s", + hash, t.priorExecutableSHA256) + } + return nil +} + +func (t *windowsNativePackageTransaction) restoreQuiescedPriorService(ctx context.Context) error { + if !t.stoppedTrustedService || !t.serviceSnapshot.wasRunning { + return nil + } + if err := t.ensurePriorServiceForRestore(ctx); err != nil { + return err + } + if err := t.validatePriorServiceForRestart(); err != nil { + return err + } + if err := reconcileNativePackageServiceRunning(ctx, t.service); err != nil { + return fmt.Errorf("restore prior trusted %s run state: %w", NativeBrokerServiceName, err) + } + if err := t.validatePriorServiceForRestart(); err != nil { + return fmt.Errorf("revalidate restarted prior broker: %w", err) + } + t.stoppedTrustedService = false + return nil +} + func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Context) (string, error) { deadline, ok := ctx.Deadline() if !ok || !deadline.After(time.Now()) { @@ -853,10 +1221,19 @@ func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Contex "--broker-token-sha256", t.tokenSHA256, "--target-user-sid", t.request.targetUserSID, } + coordination, err := newNativePackageDriverCoordination() + if err != nil { + return "", err + } + defer coordination.close() + arguments = append(arguments, coordination.arguments()...) // Do not use CommandContext: killing ViiperUdeCtl could interrupt its in-memory // DriverStore rollback or the broker's deferred SCM/credential rollback. command := exec.Command(t.request.driverHelper, arguments...) command.Dir = filepath.Dir(t.request.driverHelper) + command.SysProcAttr = &syscall.SysProcAttr{ + AdditionalInheritedHandles: coordination.inheritedHandles(), + } var output bytes.Buffer command.Stdout = &output command.Stderr = &output @@ -865,7 +1242,9 @@ func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Contex } // The helper owns the driver snapshot and nested broker rollback. Its // propagated absolute deadline is cooperative; never terminate it here. - err := waitNativePackageHelper(command) + err = waitNativePackageHelperCoordinated(command, func(process windows.Handle) error { + return t.coordinateDriverHelper(ctx, process, coordination) + }) text := strings.TrimSpace(output.String()) return text, err } diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index 7c476d01..e4673238 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -4,15 +4,165 @@ package cmd import ( "context" + "errors" "slices" "testing" "time" + "unsafe" "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/mgr" ) +func TestNativePackageDriverCoordinationUsesDistinctInheritedEvents(t *testing.T) { + t.Parallel() + coordination, err := newNativePackageDriverCoordination() + if err != nil { + t.Fatalf("create driver coordination: %v", err) + } + defer coordination.close() + seen := map[windows.Handle]bool{} + for _, handle := range []windows.Handle{ + coordination.quiesceRequest, coordination.quiesceReady, + coordination.quiesceAbort, coordination.brokerHandoff, + } { + if handle == 0 || seen[handle] { + t.Fatalf("coordination event handle=%d is null or duplicated", handle) + } + seen[handle] = true + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil || status != uint32(windows.WAIT_TIMEOUT) { + t.Fatalf("coordination event %d initial wait=(0x%x, %v), want timeout", + handle, status, err) + } + } + if len(coordination.inheritedHandles()) != 4 || len(coordination.arguments()) != 8 { + t.Fatal("driver coordination did not publish exactly four inherited handle arguments") + } + if unsafe.Sizeof(windows.Handle(0)) != unsafe.Sizeof(uintptr(0)) { + t.Fatal("Windows handle width no longer matches the decimal handoff contract") + } +} + +func TestNativePackageDriverCoordinationHoldsServiceMutexUntilBrokerHandoff(t *testing.T) { + t.Parallel() + coordination, err := newNativePackageDriverCoordination() + if err != nil { + t.Fatal(err) + } + defer coordination.close() + process, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(process) //nolint:errcheck + + released := make(chan struct{}) + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{disposition: nativePackageServiceAbsent}, + releaseServiceMutex: func() { + close(released) + }, + } + childDone := make(chan error, 1) + go func() { + if err := windows.SetEvent(coordination.quiesceRequest); err != nil { + childDone <- err + return + } + if status, err := windows.WaitForSingleObject(coordination.quiesceReady, 1000); err != nil || status != windows.WAIT_OBJECT_0 { + childDone <- errors.Join(err, errors.New("quiescence readiness was not signaled")) + return + } + select { + case <-released: + childDone <- errors.New("service mutex released before broker handoff") + return + default: + } + if err := windows.SetEvent(coordination.brokerHandoff); err != nil { + childDone <- err + return + } + select { + case <-released: + case <-time.After(time.Second): + childDone <- errors.New("service mutex remained held after broker handoff") + return + } + childDone <- windows.SetEvent(process) + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := transaction.coordinateDriverHelper(ctx, process, coordination); err != nil { + t.Fatalf("coordinate driver helper: %v", err) + } + if err := <-childDone; err != nil { + t.Fatal(err) + } + if !transaction.driverQuiesceRequested || !transaction.driverBrokerHandoff || + transaction.releaseServiceMutex != nil { + t.Fatalf("coordination state request=%v handoff=%v release=%v", + transaction.driverQuiesceRequested, transaction.driverBrokerHandoff, + transaction.releaseServiceMutex != nil) + } +} + +func TestNativePackageDriverQuiescenceStopsOnlyTrustedRunningService(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Running}} + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceTrusted, + wasRunning: true, + }, + service: service, + releaseServiceMutex: func() {}, + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("quiesce trusted service: %v", err) + } + if !transaction.stoppedTrustedService || service.status.State != svc.Stopped || + !slices.Equal(events, []string{"service-stop"}) { + t.Fatalf("trusted service state stopped=%v status=%d events=%v", + transaction.stoppedTrustedService, service.status.State, events) + } + + weak := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, + wasRunning: true, + }, + service: service, + releaseServiceMutex: func() {}, + } + if err := weak.quiescePriorServiceForDriver(context.Background()); err == nil { + t.Fatal("weak exact-owned service was quiesced as a trusted rollback source") + } +} + +func TestNativePackageOuterRollbackLeavesServiceStoppedOnUnsettledDriverProof(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Stopped}} + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceTrusted, + wasRunning: true, + }, + service: service, + stoppedTrustedService: true, + driverHelperSettled: false, + } + err := transaction.Rollback(context.Background()) + if err == nil || service.startCalls != 0 || len(events) != 0 { + t.Fatalf("unsettled outer rollback error=%v startCalls=%d events=%v", + err, service.startCalls, events) + } +} + func TestNativePackageCoordinationTokenAllowsNestedImmutableRead(t *testing.T) { requireNativeMutexAdministrator(t) transaction := &windowsNativePackageTransaction{parent: t.TempDir()} diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 31ff3c4c..bfc6fd0c 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.18", + ExpectedDriverPackageVersion: "0.1.0.19", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 0d74c886..cb712877 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.18" + DriverPackageVersion = "0.1.0.19" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 295cd033..1272ea53 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "55c5864bc1a8e3eff1eeac65935119f7aa821bfc76533ed3060a7d7131814a2e" + const wantHex = "50739ccf2b930a1cc8f52c1b484b6b3f08de2273b5014cf4c50c32a55e4cc962" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index cd258ac3..b163f391 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.18 + 0.1.0.19 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index a7453518..7e7a3921 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.18" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.19" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 42051f1e..b3adbeab 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.18 +DriverVer=08/13/2026,0.1.0.19 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 697c347a..29ae02b5 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -60,6 +60,13 @@ $requiredContracts = [ordered]@{ 'bool VerifyLocalTestPackageSigner\([\s\S]{0,220}Error\* error\) \{[\s\S]{0,1800}VerifyDriverCatalogMember\(catalogPath, infPath[\s\S]{0,180}infPath\.parent_path\(\) / kDriverFileName' 'staged broker hash binding' = '--broker-sha256' 'protected package token binding' = '--broker-token-sha256' + 'inherited broker quiescence request' = '--broker-quiesce-request-handle' + 'inherited broker quiescence readiness' = '--broker-quiesce-ready-handle' + 'inherited broker quiescence abort' = '--broker-quiesce-abort-handle' + 'inherited broker service handoff' = '--broker-handoff-handle' + 'driver mutation broker quiescence' = 'RequestBrokerQuiescence\(' + 'verified binding broker handoff' = 'SignalBrokerHandoff\(' + 'inherited event handle validation' = 'ParseInheritedEventHandle\(' 'nested package broker commit' = 'native-package-broker-commit' 'nested broker expected token hash option' = '--expected-token-sha-256' 'nested broker expected executable hash option' = '--expected-broker-sha-256' @@ -163,6 +170,10 @@ $orderedMutationContracts = [ordered]@{ 'upgrade-deadline-before-device-removal[\s\S]{0,800}CaptureSnapshot\(&afterRemoval[\s\S]{0,1800}DiInstallDriverW\(' 'upgrade restores exact identity before exact package binding' = 'DiInstallDriverW\([\s\S]{0,2200}prior\.devices\[0\]\.instanceId[\s\S]{0,300}ExactRootRegistrationMode::Upgrade[\s\S]{0,700}InstallPreinstalledDriverOnDevice\(' + 'broker quiescence precedes all classified driver mutation' = + 'if \(driverMutation && !options\.brokerExecutable\.empty\(\)[\s\S]{0,180}RequestBrokerQuiescence\([\s\S]{0,700}CandidateDisposition::InstallRequired' + 'broker handoff follows exact binding verification and precedes nested commit' = + 'VerifyInstalledBinding\([\s\S]{0,2200}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' 'recovery journal is published and preservation armed before mutation' = 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' 'failed remove rollback preserves published evidence before return' = diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index d1b4ceab..95d0a842 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -2868,6 +2868,10 @@ struct InstallOptions { std::string brokerTokenSha256; std::wstring targetUserSid; uint64_t transactionDeadlineUnixMs = 0; + HANDLE brokerQuiesceRequest = nullptr; + HANDLE brokerQuiesceReady = nullptr; + HANDLE brokerQuiesceAbort = nullptr; + HANDLE brokerHandoff = nullptr; }; uint64_t CurrentUnixMilliseconds() { @@ -2908,6 +2912,59 @@ bool ValidateTransactionDeadlineBudget(const InstallOptions& options, Error* err return true; } +bool RequestBrokerQuiescence(const InstallOptions& options, Error* error) { + if (options.brokerQuiesceRequest == nullptr || + options.brokerQuiesceReady == nullptr || + options.brokerQuiesceAbort == nullptr) { + return SetError(error, L"broker-quiescence-handles", ERROR_INVALID_HANDLE, + L"driver mutation requires the inherited broker quiescence handshake"); + } + if (!SetEvent(options.brokerQuiesceRequest)) { + return SetLastErrorDetail(error, L"broker-quiescence-request"); + } + const std::array responses{ + options.brokerQuiesceReady, options.brokerQuiesceAbort, + }; + for (;;) { + const uint64_t now = CurrentUnixMilliseconds(); + if (now >= options.transactionDeadlineUnixMs) { + return SetError(error, L"broker-quiescence-timeout", ERROR_TIMEOUT, + L"native broker did not prove quiescence before the package deadline"); + } + const DWORD waitMilliseconds = static_cast(std::min( + options.transactionDeadlineUnixMs - now, + std::numeric_limits::max() - 1ULL)); + const DWORD wait = WaitForMultipleObjects( + static_cast(responses.size()), responses.data(), FALSE, waitMilliseconds); + if (wait == WAIT_OBJECT_0) { + return true; + } + if (wait == WAIT_OBJECT_0 + 1) { + return SetError(error, L"broker-quiescence-aborted", ERROR_OPERATION_ABORTED, + L"the outer package transaction could not safely quiesce the native broker service"); + } + if (wait == WAIT_TIMEOUT) { + continue; + } + if (wait == WAIT_FAILED) { + return SetLastErrorDetail(error, L"broker-quiescence-wait"); + } + return SetError(error, L"broker-quiescence-wait", ERROR_INVALID_HANDLE, + L"broker quiescence wait returned an unexpected event state"); + } +} + +bool SignalBrokerHandoff(const InstallOptions& options, Error* error) { + if (options.brokerHandoff == nullptr) { + return SetError(error, L"broker-handoff-handle", ERROR_INVALID_HANDLE, + L"authenticated broker commit requires the inherited service-lock handoff"); + } + if (!SetEvent(options.brokerHandoff)) { + return SetLastErrorDetail(error, L"broker-handoff-signal"); + } + return true; +} + bool ValidateTransactionDeadlineBudget(uint64_t deadlineUnixMs, Error* error) { const uint64_t now = CurrentUnixMilliseconds(); if (deadlineUnixMs <= now || deadlineUnixMs - now > kMaximumTransactionDurationMs) { @@ -3559,6 +3616,17 @@ Outcome Install(const InstallOptions& options) { return outcome; } + // The service mutex remains owned by the outer package transaction. Ask it + // to stop only a trusted running broker after classification proves a + // driver mutation is necessary, then keep that mutex held across exact + // root replacement and binding verification. This prevents the broker from + // retaining a UdeCx handle that turns synchronous removal into a reboot. + if (driverMutation && !options.brokerExecutable.empty() && + !RequestBrokerQuiescence(options, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (disposition == CandidateDisposition::InstallRequired) { // Updating a running root bus in place makes DiInstallDriverW report a // reboot even though this helper immediately restores the old package. @@ -3734,6 +3802,7 @@ Outcome Install(const InstallOptions& options) { L"driver activation requires a restart; legacy ownership remains active and broker migration was not attempted"); } else if (!CheckTransactionDeadline( options, L"transaction-deadline-before-broker", &brokerError) || + !SignalBrokerHandoff(options, &brokerError) || !RunBrokerInstall( options, &driverRollbackAuthorized, &brokerChanged, &brokerError)) { // The broker command includes authenticated health verification and @@ -5419,6 +5488,47 @@ Outcome SelfTest() { return outcome; } +bool ParseInheritedEventHandle( + const wchar_t* value, + const wchar_t* name, + HANDLE* handle, + Error* error) { + const std::wstring text = value == nullptr ? L"" : value; + if (text.empty() || text.size() > 20 || + !std::all_of(text.begin(), text.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + std::wstring(name) + L" handle must contain only decimal digits"); + } + const wchar_t* begin = text.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || end != begin + text.size() || parsed == 0 || + parsed > static_cast(std::numeric_limits::max())) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle is outside the process handle range"); + } + const HANDLE candidate = reinterpret_cast(static_cast(parsed)); + if (candidate == INVALID_HANDLE_VALUE) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle is invalid"); + } + DWORD flags = 0; + if (!GetHandleInformation(candidate, &flags) || (flags & HANDLE_FLAG_INHERIT) == 0) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" handle was not explicitly inherited"); + } + const DWORD wait = WaitForSingleObject(candidate, 0); + if (wait != WAIT_TIMEOUT) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + std::wstring(name) + L" event must begin nonsignaled and waitable"); + } + *handle = candidate; + return true; +} + bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Error* error) { if (argc < 8) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); @@ -5437,6 +5547,10 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro bool brokerTokenHashSeen = false; bool targetUserSeen = false; bool transactionDeadlineSeen = false; + bool brokerQuiesceRequestSeen = false; + bool brokerQuiesceReadySeen = false; + bool brokerQuiesceAbortSeen = false; + bool brokerHandoffSeen = false; for (int index = 3; index < argc; ++index) { const std::wstring argument = argv[index]; if (_wcsicmp(argument.c_str(), L"--manifest") == 0 && index + 1 < argc && !manifestSeen) { @@ -5594,6 +5708,34 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro } options->transactionDeadlineUnixMs = static_cast(parsed); transactionDeadlineSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-request-handle") == 0 && + index + 1 < argc && !brokerQuiesceRequestSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce request", + &options->brokerQuiesceRequest, error)) { + return false; + } + brokerQuiesceRequestSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-ready-handle") == 0 && + index + 1 < argc && !brokerQuiesceReadySeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce ready", + &options->brokerQuiesceReady, error)) { + return false; + } + brokerQuiesceReadySeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-quiesce-abort-handle") == 0 && + index + 1 < argc && !brokerQuiesceAbortSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker quiesce abort", + &options->brokerQuiesceAbort, error)) { + return false; + } + brokerQuiesceAbortSeen = true; + } else if (_wcsicmp(argument.c_str(), L"--broker-handoff-handle") == 0 && + index + 1 < argc && !brokerHandoffSeen) { + if (!ParseInheritedEventHandle(argv[++index], L"broker handoff", + &options->brokerHandoff, error)) { + return false; + } + brokerHandoffSeen = true; } else { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, L"unknown, duplicate, or incomplete install option"); @@ -5603,9 +5745,23 @@ bool ParseInstallOptions(int argc, wchar_t** argv, InstallOptions* options, Erro !infHashSeen || !sysHashSeen || !catHashSeen || !transactionDeadlineSeen || brokerSeen != targetUserSeen || brokerSeen != brokerHashSeen || - brokerSeen != brokerTokenSeen || brokerSeen != brokerTokenHashSeen) { + brokerSeen != brokerTokenSeen || brokerSeen != brokerTokenHashSeen || + brokerSeen != brokerQuiesceRequestSeen || brokerSeen != brokerQuiesceReadySeen || + brokerSeen != brokerQuiesceAbortSeen || brokerSeen != brokerHandoffSeen) { return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, - L"manifest, its installer hash, source revision, validation mode, and exact INF/SYS/CAT hashes are required; broker executable, hashes, protected token, and target SID must be supplied together"); + L"manifest, its installer hash, source revision, validation mode, and exact INF/SYS/CAT hashes are required; broker executable, hashes, protected token, target SID, and inherited quiescence/handoff events must be supplied together"); + } + if (brokerSeen) { + const std::set coordinationHandles{ + reinterpret_cast(options->brokerQuiesceRequest), + reinterpret_cast(options->brokerQuiesceReady), + reinterpret_cast(options->brokerQuiesceAbort), + reinterpret_cast(options->brokerHandoff), + }; + if (coordinationHandles.size() != 4) { + return SetError(error, L"arguments", ERROR_INVALID_HANDLE, + L"broker quiescence and handoff require four distinct inherited events"); + } } return true; } @@ -5650,9 +5806,13 @@ void Usage() { L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms " L"[--allow-controlled-downgrade ] " - L"--broker-executable --broker-sha256 <64 hex> " - L"--broker-token --broker-token-sha256 <64 hex> " - L"--target-user-sid \n" + L"--broker-executable --broker-sha256 <64 hex> " + L"--broker-token --broker-token-sha256 <64 hex> " + L"--target-user-sid " + L"--broker-quiesce-request-handle " + L"--broker-quiesce-ready-handle " + L"--broker-quiesce-abort-handle " + L"--broker-handoff-handle \n" << L" ViiperUdeCtl.exe verify --manifest --manifest-sha256 <64 hex> " L"--source-revision <40-or-64 hex> --validation-mode " L"--expected-inf-sha256 <64 hex> --expected-sys-sha256 <64 hex> " From 27f06bbe3b87887fa9ba140f4c62901e8cbddfdb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 12:30:53 -0500 Subject: [PATCH 207/240] Update helper identity vector for driver 0.1.0.19 --- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 95d0a842..dc17431d 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5176,7 +5176,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "55c5864bc1a8e3eff1eeac65935119f7aa821bfc76533ed3060a7d7131814a2e") { + "50739ccf2b930a1cc8f52c1b484b6b3f08de2273b5014cf4c50c32a55e4cc962") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 8ab5b710cb8b69e5ea17a61626dc97c9152f911f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 13:05:43 -0500 Subject: [PATCH 208/240] Handle identical live probe manifests under strict mode --- internal/transport/udecx/live_validation_contract_test.go | 1 + native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index f48f2d9f..18bc05fe 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -33,6 +33,7 @@ func TestNativeLiveReleaseGateRequiresCompleteEvidence(t *testing.T) { "Driver Verifier must target only ViiperUde.sys", "Test-LiveProbeManifest", "sourceRevision", + "@(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count", "Get-FileHash -LiteralPath $path -Algorithm SHA256", "-ProbeManifestPath is required whenever a source-bound live probe is used", "[ValidateSet('LocalTest', 'ControlledTest', 'Production')]", diff --git a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 index 50b76502..c7122939 100644 --- a/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1 @@ -97,7 +97,7 @@ function Test-LiveProbeManifest { $actualNames = @($properties | ForEach-Object { $_.Name } | Sort-Object) $expectedNames = @($expected.Keys | Sort-Object) if ($actualNames.Count -ne $expectedNames.Count -or - (Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count -ne 0) { + @(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames).Count -ne 0) { throw "The native live-probe manifest must contain exactly: $($expectedNames -join ', ')." } From d28631845a047f72a44cb726712c52ac09ac6ddc Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 13:45:07 -0500 Subject: [PATCH 209/240] Acknowledge initial UdeCx endpoint publication --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 15 +++++++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Device.c | 8 ++++++++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 11 files changed, 32 insertions(+), 9 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index eb41250a..e65b349c 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.19", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.20", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 8c75da18..10a3aded 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.19", + "expectedDriverPackageVersion": "0.1.0.20", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index bfc6fd0c..abb79c75 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.19", + ExpectedDriverPackageVersion: "0.1.0.20", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 150227fa..a05c4cad 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -14,6 +14,21 @@ func TestNativeControllerNamesDeviceBeforeAssigningSecurity(t *testing.T) { "status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl);") } +func TestNativeDeviceInitializeDoesNotEnterResetProtocol(t *testing.T) { + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperEvtEndpointsConfigure")) + requireContractOrder(t, device, + "case UdecxEndpointsConfigureTypeDeviceInitialize:", + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;", + "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", + "status = ViiperBeginAcknowledgedDeviceReset(Device, Request);") + if strings.Count(device, "ViiperBeginAcknowledgedDeviceReset(Device, Request)") != 1 { + t.Fatal("initial endpoint publication can enter the post-enumeration reset protocol") + } +} + func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index cb712877..334d1c49 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.19" + DriverPackageVersion = "0.1.0.20" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 1272ea53..0be17ad2 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "50739ccf2b930a1cc8f52c1b484b6b3f08de2273b5014cf4c50c32a55e4cc962" + const wantHex = "e4a1be80b8498d1cd6870cc8c9a7ee1ec94502ada1012471297b6b716fd173ce" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index dd5d0285..a9aaa5d6 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -2291,6 +2291,14 @@ ViiperEvtEndpointsConfigure( switch (ConfigureParams->ConfigureType) { case UdecxEndpointsConfigureTypeDeviceInitialize: + // DeviceInitialize is UdeCx's endpoint-publication boundary, not a + // post-enumeration device reset. It can run more than once while the + // child is being initialized. Completing it synchronously avoids + // introducing a user-mode reset dependency before Windows can finish + // enumerating the child; endpoint START/PURGE and later configuration + // changes retain their existing state and media lifecycle handling. + WdfRequestComplete(Request, STATUS_SUCCESS); + return; case UdecxEndpointsConfigureTypeDeviceConfigurationChange: status = ViiperBeginAcknowledgedDeviceReset(Device, Request); break; diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index b163f391..3bd6e61b 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.19 + 0.1.0.20 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 7e7a3921..c4022c28 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.19" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.20" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index b3adbeab..f2ea9671 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.19 +DriverVer=08/13/2026,0.1.0.20 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index dc17431d..4fbdd45b 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5176,7 +5176,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "50739ccf2b930a1cc8f52c1b484b6b3f08de2273b5014cf4c50c32a55e4cc962") { + "e4a1be80b8498d1cd6870cc8c9a7ee1ec94502ada1012471297b6b716fd173ce") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From f96007b8706334c24422216d8879f34690e65499 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 14:13:39 -0500 Subject: [PATCH 210/240] Number UdeCx SuperSpeed ports globally --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 40 +++++++++++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Controller.c | 4 +- native/udecx/driver/Device.c | 7 +++- native/udecx/driver/ViiperUde.h | 5 +++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 13 files changed, 61 insertions(+), 13 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index e65b349c..a8288fcb 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.20", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.21", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 10a3aded..450d37f8 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.20", + "expectedDriverPackageVersion": "0.1.0.21", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index abb79c75..a91eddc1 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.20", + ExpectedDriverPackageVersion: "0.1.0.21", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index a05c4cad..a88bee6c 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -29,6 +29,46 @@ func TestNativeDeviceInitializeDoesNotEnterResetProtocol(t *testing.T) { } } +func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperCreateVirtualDevice")) + + for _, required := range []string{ + "#define VIIPER_UDE_USB20_PORT_COUNT VIIPER_UDE_MAX_DEVICES", + "#define VIIPER_UDE_USB30_PORT_COUNT VIIPER_UDE_MAX_DEVICES", + } { + if !strings.Contains(header, required) { + t.Fatalf("native controller topology lost %q", required) + } + } + for _, required := range []string{ + "udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_USB20_PORT_COUNT;", + "udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_USB30_PORT_COUNT;", + } { + if !strings.Contains(controller, required) { + t.Fatalf("native controller creation lost %q", required) + } + } + requireContractOrder(t, device, + "if (speed == UdecxUsbSuperSpeed)", + "plugOptions.Usb30PortNumber = (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1);", + "else", + "plugOptions.Usb20PortNumber = (USHORT)(slot + 1);") + + const usb20Ports = MaxDevices + for slot := 0; slot < MaxDevices; slot++ { + usb20Port := slot + 1 + usb30Port := usb20Ports + slot + 1 + if usb20Port < 1 || usb20Port > MaxDevices || + usb30Port < MaxDevices+1 || usb30Port > 2*MaxDevices { + t.Fatalf("slot %d maps to USB2=%d USB3=%d", slot, usb20Port, usb30Port) + } + } +} + func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 334d1c49..2ef4294e 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.20" + DriverPackageVersion = "0.1.0.21" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 0be17ad2..7e750db9 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "e4a1be80b8498d1cd6870cc8c9a7ee1ec94502ada1012471297b6b716fd173ce" + const wantHex = "a4d8b6a8422c49eb4a8e68db0ed6f741f765a3990cdaf2c08752fedfa3e185fb" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index de4c715b..1dcf832b 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -204,8 +204,8 @@ ViiperEvtDeviceAdd( } UDECX_WDF_DEVICE_CONFIG_INIT(&udeConfig, ViiperEvtQueryUsbCapability); - udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; - udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_MAX_DEVICES; + udeConfig.NumberOfUsb20Ports = (USHORT)VIIPER_UDE_USB20_PORT_COUNT; + udeConfig.NumberOfUsb30Ports = (USHORT)VIIPER_UDE_USB30_PORT_COUNT; status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig); if (!NT_SUCCESS(status)) { return status; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index a9aaa5d6..54207a4b 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -679,9 +679,12 @@ ViiperCreateVirtualDevice( UDECX_USB_DEVICE_PLUG_IN_OPTIONS_INIT(&plugOptions); if (speed == UdecxUsbSuperSpeed) { - plugOptions.Usb30PortNumber = slot + 1; + // UdeCx uses one controller-global namespace: USB 3 ports begin + // immediately after NumberOfUsb20Ports, not again at port one. + plugOptions.Usb30PortNumber = + (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1); } else { - plugOptions.Usb20PortNumber = slot + 1; + plugOptions.Usb20PortNumber = (USHORT)(slot + 1); } status = UdecxUsbDevicePlugIn(device, &plugOptions); if (!NT_SUCCESS(status)) { diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index ea857102..bde5a985 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -20,6 +20,11 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; #define VIIPER_UDE_MAX_INPUT_TRANSITIONS 256 #define VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES 65536 #define VIIPER_UDE_MANAGEMENT_SLOT_FLAG 0x80000000UL +// UdeCx numbers USB 3 ports after every USB 2 port on the controller. Keep +// the topology constants shared by controller creation and child plug-in so +// fixed slot-to-port identity cannot drift between those two boundaries. +#define VIIPER_UDE_USB20_PORT_COUNT VIIPER_UDE_MAX_DEVICES +#define VIIPER_UDE_USB30_PORT_COUNT VIIPER_UDE_MAX_DEVICES typedef enum VIIPER_UDE_PENDING_STATE { ViiperUdePendingEmpty = 0, diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 3bd6e61b..c824e2ab 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.20 + 0.1.0.21 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index c4022c28..aec9cb89 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.20" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.21" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index f2ea9671..9b657f71 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.20 +DriverVer=08/13/2026,0.1.0.21 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 4fbdd45b..17d949bc 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5176,7 +5176,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "e4a1be80b8498d1cd6870cc8c9a7ee1ec94502ada1012471297b6b716fd173ce") { + "a4d8b6a8422c49eb4a8e68db0ed6f741f765a3990cdaf2c08752fedfa3e185fb") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 20625762a4d5e8b8de65dbac6c06020afffe7066 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 17:33:55 -0500 Subject: [PATCH 211/240] Require pristine UdeCx runtime before upgrades --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/cmd/native_package.go | 5 +- internal/cmd/native_package_contract_test.go | 10 +- internal/cmd/native_package_test.go | 8 ++ internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 2 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 6 +- native/udecx/tools/ViiperUdeCtl.cpp | 122 +++++++++++++++++- 14 files changed, 152 insertions(+), 17 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index a8288fcb..87d5ab85 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.21", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.22", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 450d37f8..d902ea6b 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.21", + "expectedDriverPackageVersion": "0.1.0.22", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index cb9acc29..ff8803ae 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -114,7 +114,10 @@ func parseNativePackageInstallProof(output string, processExitCode int) (nativeP return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid success install outcome") } case nativePackageRebootRequiredCode: - if proof.success || !proof.changed || !proof.rebootRequired || proof.rollback != "succeeded" { + settledBeforeMutation := !proof.changed && proof.rollback == "not-needed" + settledAfterRollback := proof.changed && proof.rollback == "succeeded" + if proof.success || !proof.rebootRequired || + (!settledBeforeMutation && !settledAfterRollback) { return nativePackageInstallProof{}, errors.New("driver helper emitted an invalid reboot-boundary install outcome") } case 4: diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 3e670e14..c47376f4 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -188,6 +188,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "LoadLibraryExW", "LOAD_LIBRARY_SEARCH_SYSTEM32", "GetProcAddress", "ValidateExactPackageDirectory", "Sha256Handle(manifest.get()", "RequestBrokerQuiescence", "SignalBrokerHandoff", + "requirePristineRuntime", "IOCTL_VIIPER_UDE_QUERY_STATS", + "upgrade-runtime-reboot-boundary", "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", "--broker-quiesce-abort-handle", "--broker-handoff-handle", } @@ -211,6 +213,9 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Error("driver helper lost exact preinstalled-driver selection and DiInstallDevice binding") } upgradeRemove := strings.Index(helperSource, `L"upgrade-deadline-before-device-removal"`) + upgradeQuiesce := strings.Index(helperSource, "RequestBrokerQuiescence(options") + upgradePristine := strings.Index(helperSource, + "options.transactionDeadlineUnixMs, nullptr, &outcome.error, true") upgradeAbsent := strings.Index(helperSource, "CaptureSnapshot(&afterRemoval") upgradeStage := strings.Index(helperSource, "DiInstallDriverW(nullptr, candidate.infPath.c_str()") upgradeIdentity := strings.Index(helperSource, "ExactRootRegistrationMode::Upgrade") @@ -221,10 +226,11 @@ func TestNativePackageProductionSourceContract(t *testing.T) { upgradeBind = upgradeIdentity + relative } } - if upgradeRemove < 0 || upgradeAbsent <= upgradeRemove || + if upgradeQuiesce < 0 || upgradePristine <= upgradeQuiesce || + upgradeRemove <= upgradePristine || upgradeAbsent <= upgradeRemove || upgradeStage <= upgradeAbsent || upgradeIdentity <= upgradeStage || upgradeBind <= upgradeIdentity { - t.Error("driver upgrade no longer removes and proves the captured root absent before staging, exact-identity recreation, and binding") + t.Error("driver upgrade no longer quiesces the broker and proves a pristine runtime before removal, absence proof, staging, exact-identity recreation, and binding") } if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") diff --git a/internal/cmd/native_package_test.go b/internal/cmd/native_package_test.go index cfa4f60e..384d0da6 100644 --- a/internal/cmd/native_package_test.go +++ b/internal/cmd/native_package_test.go @@ -264,6 +264,10 @@ func TestNativePackageInstallProofFailsClosed(t *testing.T) { name: "reboot boundary", processExit: nativePackageRebootRequiredCode, wantReboot: true, output: `result=error operation=install changed=1 rebootRequired=1 rollback=succeeded exitCode=3010 phase="broker-reboot-boundary" win32Error=3010 message="restart required"` + "\n", }, + { + name: "pristine runtime reboot boundary", processExit: nativePackageRebootRequiredCode, wantReboot: true, + output: `result=error operation=install changed=0 rebootRequired=1 rollback=not-needed exitCode=3010 phase="upgrade-runtime-reboot-boundary" win32Error=3010 message="restart required"` + "\n", + }, { name: "settled failure", processExit: 1, output: "result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1\n", @@ -292,6 +296,10 @@ func TestNativePackageInstallProofFailsClosed(t *testing.T) { name: "unsafe reboot", processExit: nativePackageRebootRequiredCode, wantErr: true, output: "result=error operation=install changed=1 rebootRequired=1 rollback=failed exitCode=3010\n", }, + { + name: "unsafe pre-mutation reboot rollback", processExit: nativePackageRebootRequiredCode, wantErr: true, + output: "result=error operation=install changed=0 rebootRequired=1 rollback=succeeded exitCode=3010\n", + }, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index a91eddc1..a41dc7c6 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.21", + ExpectedDriverPackageVersion: "0.1.0.22", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 2ef4294e..a1573f90 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.21" + DriverPackageVersion = "0.1.0.22" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7e750db9..e098bd66 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "a4d8b6a8422c49eb4a8e68db0ed6f741f765a3990cdaf2c08752fedfa3e185fb" + const wantHex = "6e25b6972fd774d00cc3c081dfe2244fa6ad24ddf1551012c0297c741da849b5" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index c824e2ab..a93ae07b 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.21 + 0.1.0.22 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index aec9cb89..9390bd1a 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.21" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.22" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 9b657f71..6f7d09cd 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.21 +DriverVer=08/13/2026,0.1.0.22 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 56375a0d..16e27850 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -786,7 +786,7 @@ try { throw "Local VIIPER driver transaction failed with exit code $exitCode." } if ($exitCode -eq 3010) { - Write-Warning 'The attempted native transaction was safely rolled back and requires a reboot. Restart, rerun this identical install command, and proceed to live validation only after it returns exit 0.' + Write-Warning 'The native transaction stopped at a safe reboot boundary before mutation or after successful rollback. Restart, rerun this identical install command before creating another virtual device, and proceed to live validation only after it returns exit 0.' exit 3010 } } diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 29ae02b5..e20d1b0e 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -33,6 +33,8 @@ $requiredContracts = [ordered]@{ 'documented package install' = 'DiInstallDriverW\(' 'documented package removal' = 'DiUninstallDriverW\(' 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' + 'pristine upgrade statistics' = 'IOCTL_VIIPER_UDE_QUERY_STATS' + 'pristine upgrade reboot boundary' = 'upgrade-runtime-reboot-boundary' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' 'exact negotiated capability identity' = 'response\.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES' 'source-bound manifest identity' = 'driverBuildIdentity' @@ -172,8 +174,10 @@ $orderedMutationContracts = [ordered]@{ 'DiInstallDriverW\([\s\S]{0,2200}prior\.devices\[0\]\.instanceId[\s\S]{0,300}ExactRootRegistrationMode::Upgrade[\s\S]{0,700}InstallPreinstalledDriverOnDevice\(' 'broker quiescence precedes all classified driver mutation' = 'if \(driverMutation && !options\.brokerExecutable\.empty\(\)[\s\S]{0,180}RequestBrokerQuiescence\([\s\S]{0,700}CandidateDisposition::InstallRequired' + 'broker quiescence and pristine proof precede upgrade root removal' = + 'RequestBrokerQuiescence\([\s\S]{0,5000}&outcome\.error, true[\s\S]{0,5000}upgrade-deadline-before-device-removal' 'broker handoff follows exact binding verification and precedes nested commit' = - 'VerifyInstalledBinding\([\s\S]{0,2200}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' + 'VerifyInstalledBinding\([\s\S]{0,3000}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' 'recovery journal is published and preservation armed before mutation' = 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' 'failed remove rollback preserves published evidence before return' = diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 17d949bc..2e6cd956 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -2434,7 +2434,8 @@ bool RegisterRootDeviceExact( bool VerifyAbiHealth( uint64_t deadlineUnixMs, const std::string* expectedBuildIdentity, - Error* error) { + Error* error, + bool requirePristineRuntime = false) { DeviceInfoSet set(SetupDiGetClassDevsW( &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); if (!set) { @@ -2590,6 +2591,100 @@ bool VerifyAbiHealth( return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, L"loaded driver health response does not match the source-bound package identity"); } + if (requirePristineRuntime) { + VIIPER_UDE_STATS stats{}; + DWORD statsReturned = 0; + WinHandle statsEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!statsEvent) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats-event"); + } + OVERLAPPED statsOverlapped{}; + statsOverlapped.hEvent = statsEvent.get(); + const BOOL statsCompleted = DeviceIoControl( + device.get(), IOCTL_VIIPER_UDE_QUERY_STATS, + nullptr, 0, &stats, sizeof(stats), &statsReturned, &statsOverlapped); + if (!statsCompleted && GetLastError() != ERROR_IO_PENDING) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats"); + } + if (!statsCompleted) { + const uint64_t now = CurrentUnixMilliseconds(); + if (deadlineUnixMs <= now) { + const BOOL cancelled = CancelIoEx(device.get(), &statsOverlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if ((!cancelled && cancelError != ERROR_NOT_FOUND) || drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + !cancelled && cancelError != ERROR_NOT_FOUND + ? cancelError : ERROR_OPERATION_ABORTED, + L"expired pristine-runtime query could not be cancelled and drained safely"); + } + DWORD ignored = 0; + GetOverlappedResult(device.get(), &statsOverlapped, &ignored, FALSE); + return SetError(error, L"upgrade-pristine-stats-timeout", ERROR_TIMEOUT, + L"pristine-runtime query exceeded the package transaction deadline"); + } + const uint64_t remaining = deadlineUnixMs - now; + const DWORD waitMilliseconds = static_cast( + std::min(remaining, static_cast(MAXDWORD - 1))); + const DWORD wait = WaitForSingleObject(statsEvent.get(), waitMilliseconds); + if (wait == WAIT_TIMEOUT) { + const BOOL cancelled = CancelIoEx(device.get(), &statsOverlapped); + const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if (drain == WAIT_OBJECT_0) { + DWORD ignored = 0; + GetOverlappedResult(device.get(), &statsOverlapped, &ignored, FALSE); + } + if (!cancelled && cancelError != ERROR_NOT_FOUND) { + return SetError(error, L"upgrade-pristine-stats-cancel", cancelError, + L"timed-out pristine-runtime query could not be cancelled"); + } + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + ERROR_OPERATION_ABORTED, + L"timed-out pristine-runtime query did not drain safely"); + } + return SetError(error, L"upgrade-pristine-stats-timeout", ERROR_TIMEOUT, + L"pristine-runtime query exceeded the package transaction deadline"); + } + if (wait != WAIT_OBJECT_0) { + const DWORD waitError = GetLastError(); + CancelIoEx(device.get(), &statsOverlapped); + const DWORD drain = WaitForSingleObject(statsEvent.get(), kCancelledIoDrainMs); + if (drain != WAIT_OBJECT_0) { + return SetError(error, L"upgrade-pristine-stats-drain", + ERROR_OPERATION_ABORTED, + L"failed pristine-runtime wait could not be drained safely"); + } + SetLastError(waitError); + return SetLastErrorDetail(error, L"upgrade-pristine-stats-wait"); + } + if (!GetOverlappedResult( + device.get(), &statsOverlapped, &statsReturned, FALSE)) { + return SetLastErrorDetail(error, L"upgrade-pristine-stats-result"); + } + } + if (statsReturned != sizeof(stats) || stats.Header.Magic != VIIPER_UDE_MAGIC || + stats.Header.Major != VIIPER_UDE_ABI_MAJOR || + stats.Header.Minor != VIIPER_UDE_ABI_MINOR || + stats.Header.Size != sizeof(stats) || stats.Header.Flags != 0) { + return SetError(error, L"upgrade-pristine-stats", ERROR_REVISION_MISMATCH, + L"loaded driver returned an invalid pristine-runtime statistics record"); + } + if (stats.OperationsDequeued != 0 || stats.OperationsCompleted != 0 || + stats.OperationsCancelled != 0 || stats.OperationsPurged != 0 || + stats.LateCompletions != 0 || stats.InvalidMessages != 0 || + stats.QueueExhaustions != 0 || stats.IsoPackets != 0 || + stats.BytesToDevice != 0 || stats.BytesFromDevice != 0 || + stats.NotificationEvents != 0 || stats.NotificationEventOverflows != 0 || + stats.ActiveDevices != 0 || stats.PendingOperations != 0 || + stats.WaitingDequeues != 0 || stats.CleanupRetries != 0 || + stats.InputReportsSubmitted != 0 || stats.InputReportsCompleted != 0) { + return SetError(error, L"upgrade-runtime-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"the loaded native bus has serviced virtual-device work since boot; restart Windows and rerun the identical package command before creating another virtual device"); + } + } return true; } @@ -3627,7 +3722,24 @@ Outcome Install(const InstallOptions& options) { return outcome; } - if (disposition == CandidateDisposition::InstallRequired) { + // UdeCx child deletion is asynchronous. Older installed images could + // release their logical device slot before framework teardown settled, + // leaving DiUninstallDevice blocked indefinitely even though the broker + // reported an empty bus. Once the trusted broker is stopped, require a + // zero-lifetime-work runtime before replacing an existing root. A restart + // resets these counters and guarantees that no pre-upgrade child object + // can survive into root removal. + if (disposition == CandidateDisposition::InstallRequired && + !prior.devices.empty() && outcome.error.code == ERROR_SUCCESS && + !VerifyAbiHealth( + options.transactionDeadlineUnixMs, nullptr, &outcome.error, true)) { + if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { + outcome.rebootRequired = true; + } + } + + if (outcome.error.code == ERROR_SUCCESS && + disposition == CandidateDisposition::InstallRequired) { // Updating a running root bus in place makes DiInstallDriverW report a // reboot even though this helper immediately restores the old package. // Remove only the exact captured VIIPER-owned devnode first, prove its @@ -3789,7 +3901,9 @@ Outcome Install(const InstallOptions& options) { return outcome; } if (outcome.error.code != ERROR_SUCCESS) { - outcome.exitCode = ExitCode::PreflightRejected; + outcome.exitCode = outcome.rebootRequired && + outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED + ? ExitCode::RebootRequired : ExitCode::PreflightRejected; return outcome; } @@ -5176,7 +5290,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "a4d8b6a8422c49eb4a8e68db0ed6f741f765a3990cdaf2c08752fedfa3e185fb") { + "6e25b6972fd774d00cc3c081dfe2244fa6ad24ddf1551012c0297c741da849b5") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 11d104b3f2d3e20e04f0707fdf40e5594dd3f6e4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 17:36:24 -0500 Subject: [PATCH 212/240] Clean interrupted local test staging after reboot --- .../udecx/local_test_package_contract_test.go | 4 ++ .../tools/Install-ViiperUdeLocalTest.ps1 | 62 ++++++++++++++----- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 47c07256..c520d804 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -103,6 +103,7 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "[IO.Directory]::CreateDirectory($Path, $expectedSecurity)", "$directory.SetAccessControl($expectedSecurity)", + "Assert-ProtectedStagingDirectory", "$actualSecurity.AreAccessRulesProtected", "$actualSecurity.GetOwner([Security.Principal.SecurityIdentifier])", "$actualSecurity.GetAccessRules(", @@ -115,6 +116,9 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "[IO.FileOptions]::WriteThrough", "$lockByPath['viiper.exe']", "Remove-ProtectedStagingDirectory", + "Remove-PreBootProtectedStagingDirectories", + "[Environment]::TickCount64", + "$_.LastWriteTimeUtc -lt $bootBoundaryUtc", "Invoke-JoinedNativeProcess", "if (-not $process.Start())", "$Started.Value = $true", diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 16e27850..85e56a55 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -83,21 +83,14 @@ function Assert-ExactDirectoryEntries { } } -function Initialize-ProtectedStagingDirectory { +function Assert-ProtectedStagingDirectory { param([Parameter(Mandatory = $true)][string]$Path) - if (Test-Path -LiteralPath $Path) { - throw "Refusing to reuse local-test staging directory '$Path'." - } - $expectedSecurity = [Security.AccessControl.DirectorySecurity]::new() - $expectedSecurity.SetSecurityDescriptorSddlForm( - 'O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)', - [Security.AccessControl.AccessControlSections]::All) - $directory = [IO.Directory]::CreateDirectory($Path, $expectedSecurity) - if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Local-test staging directory is a reparse point: '$Path'." + $directory = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local-test staging directory is missing, not a directory, or a reparse point: '$Path'." } - $directory.SetAccessControl($expectedSecurity) $actualSecurity = $directory.GetAccessControl( [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Access) @@ -134,6 +127,24 @@ function Initialize-ProtectedStagingDirectory { } } +function Initialize-ProtectedStagingDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + if (Test-Path -LiteralPath $Path) { + throw "Refusing to reuse local-test staging directory '$Path'." + } + $expectedSecurity = [Security.AccessControl.DirectorySecurity]::new() + $expectedSecurity.SetSecurityDescriptorSddlForm( + 'O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)', + [Security.AccessControl.AccessControlSections]::All) + $directory = [IO.Directory]::CreateDirectory($Path, $expectedSecurity) + if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Local-test staging directory is a reparse point: '$Path'." + } + $directory.SetAccessControl($expectedSecurity) + Assert-ProtectedStagingDirectory -Path $Path +} + function Copy-ExactBrokerToProtectedStage { param( [Parameter(Mandatory = $true)][string]$SourcePath, @@ -203,11 +214,7 @@ function Remove-ProtectedStagingDirectory { [IO.Path]::GetFileName($fullPath) -notmatch '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$') { throw "Refusing unsafe local-test staging cleanup '$Path'." } - $directory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop - if (-not $directory.PSIsContainer -or - ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing unsafe local-test staging cleanup '$Path'." - } + Assert-ProtectedStagingDirectory -Path $fullPath $children = @(Get-ChildItem -LiteralPath $fullPath -Force) if ($children.Count -gt 1 -or ($children.Count -eq 1 -and @@ -221,6 +228,26 @@ function Remove-ProtectedStagingDirectory { [IO.Directory]::Delete($fullPath, $false) } +function Remove-PreBootProtectedStagingDirectories { + param([Parameter(Mandatory = $true)][string]$ProgramDataRoot) + + # A live sibling installer can own a same-boot staging directory before it + # acquires the nested package mutex. Only reclaim exact protected stages + # which predate this boot; Windows already terminated every possible owner. + $bootBoundaryUtc = [DateTime]::UtcNow.Subtract( + [TimeSpan]::FromMilliseconds([Environment]::TickCount64)) + $candidates = @(Get-ChildItem -LiteralPath $ProgramDataRoot -Force -Directory | + Where-Object { + $_.Name -match '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$' -and + $_.LastWriteTimeUtc -lt $bootBoundaryUtc + }) + foreach ($candidate in $candidates) { + Remove-ProtectedStagingDirectory ` + -Path $candidate.FullName -ProgramDataRoot $ProgramDataRoot + Write-Host "local-test-stage action=cleanup result=removed path=$($candidate.Name)" + } +} + function ConvertTo-WindowsProcessArgument { param([AllowEmptyString()][Parameter(Mandatory = $true)][string]$Value) @@ -728,6 +755,7 @@ try { ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "ProgramData is not a safe staging parent: '$programDataRoot'." } + Remove-PreBootProtectedStagingDirectories -ProgramDataRoot $programDataRoot $stageDirectory = Join-Path $programDataRoot ( 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) Initialize-ProtectedStagingDirectory -Path $stageDirectory From 77994024403b189f5fce55466b588d26ed3fb309 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 18:25:58 -0500 Subject: [PATCH 213/240] Validate local test installer on Windows PowerShell 5.1 --- .github/workflows/native-ude.yml | 19 +++++ _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/local_test_package_contract_test.go | 79 +++++++++++++++++-- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Install-ViiperUdeLocalTest.ps1 | 70 +++++++++++++++- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 12 files changed, 170 insertions(+), 16 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 7d4aeebe..24dd40e4 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -199,6 +199,25 @@ jobs: } } if ($failed) { throw "Native PowerShell parser gate failed" } + - name: Parse native PowerShell tooling with Windows PowerShell 5.1 + shell: powershell + run: | + if ($PSVersionTable.PSEdition -cne 'Desktop' -or + $PSVersionTable.PSVersion.Major -ne 5) { + throw "Expected Windows PowerShell 5.1, got $($PSVersionTable.PSVersion)." + } + $failed = $false + Get-ChildItem native/udecx/tools -File -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = $true + Write-Error "$($_.Name): $($errors -join [Environment]::NewLine)" + } + } + if ($failed) { throw "Windows PowerShell 5.1 parser gate failed" } - name: Expose WDK tools shell: pwsh run: | diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 87d5ab85..8a48c63e 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.22", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.23", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index d902ea6b..f0ebb3f0 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.22", + "expectedDriverPackageVersion": "0.1.0.23", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index a41dc7c6..ad15197a 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.22", + ExpectedDriverPackageVersion: "0.1.0.23", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index c520d804..0638ce56 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -117,7 +117,9 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "$lockByPath['viiper.exe']", "Remove-ProtectedStagingDirectory", "Remove-PreBootProtectedStagingDirectories", - "[Environment]::TickCount64", + "public static class ViiperWindowsUptime", + "public static extern ulong GetTickCount64();", + "Get-WindowsBootBoundaryUtc", "$_.LastWriteTimeUtc -lt $bootBoundaryUtc", "Invoke-JoinedNativeProcess", "if (-not $process.Start())", @@ -187,6 +189,8 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { "BinaryLength", "$store.Add($certificate)", "$store.Remove(", + "[Environment]::TickCount64", + "[Environment]::TickCount", } { if strings.Contains(installer, forbidden) { t.Fatalf("local-test elevated path retained unsafe dependency %q", forbidden) @@ -224,6 +228,16 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { if strings.Contains(installer[preflightStart:interopCompile], "return") { t.Fatal("local-test preflight can return before compiling the exact certificate-store interop") } + preflightCleanup := strings.Index(installer[preflightStart:preflightSuccess], + "Remove-PreBootProtectedStagingDirectories") + preflightOldAssertion := strings.Index(installer[preflightStart:preflightSuccess], + "Pre-boot protected staging cleanup did not remove its test directory.") + preflightCurrentAssertion := strings.Index(installer[preflightStart:preflightSuccess], + "Pre-boot protected staging cleanup removed a same-boot test directory.") + if preflightCleanup < 0 || preflightOldAssertion <= preflightCleanup || + preflightCurrentAssertion <= preflightOldAssertion { + t.Fatal("local-test preflight does not execute both sides of pre-boot staging cleanup") + } settledStart := strings.Index(installer, "function Test-SettledLocalTestFailure") settledEnd := strings.Index(installer, "$trustCommitted = $false") if settledStart < 0 || settledEnd <= settledStart { @@ -323,10 +337,12 @@ func TestLocalTestSettledFailureRequiresObservedExitMatch(t *testing.T) { const behaviorContract = ` $ErrorActionPreference = 'Stop' $source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw -$csharp = [regex]::Match( - $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@").Groups['source'].Value -if ([string]::IsNullOrEmpty($csharp)) { throw 'Embedded certificate-store source was not found.' } -Add-Type -Language CSharp -TypeDefinition $csharp +$csharpBlocks = @([regex]::Matches( + $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@") | + ForEach-Object { $_.Groups['source'].Value } | + Where-Object { $_ -match 'public static class ViiperLocalTestCertificateStore' }) +if ($csharpBlocks.Count -ne 1) { throw 'Embedded certificate-store source was not found exactly once.' } +Add-Type -Language CSharp -TypeDefinition $csharpBlocks[0] $openStore = [ViiperLocalTestCertificateStore].GetMethod( 'CertOpenStore', [Reflection.BindingFlags]'NonPublic,Static') $import = $openStore.GetCustomAttributes( @@ -335,6 +351,7 @@ if ($import.Value -cne 'crypt32.dll' -or -not $import.ExactSpelling -or $import.CharSet -ne [Runtime.InteropServices.CharSet]::Unicode) { throw 'CertOpenStore P/Invoke metadata does not name the exact native entry point.' } + $start = $source.IndexOf('function Test-SettledLocalTestFailure') $end = $source.IndexOf('$trustCommitted = $false', $start) if ($start -lt 0 -or $end -le $start) { throw 'Settled-failure predicate was not found.' } @@ -397,6 +414,58 @@ if (Test-SettledLocalTestFailure -Lines $preflight -ProcessExitCode 1) { } } +func TestLocalTestBootBoundaryRunsOnWindowsPowerShell51(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell contract") + } + + root := filepath.Join("..", "..", "..") + installer, err := filepath.Abs(filepath.Join( + root, "native", "udecx", "tools", "Install-ViiperUdeLocalTest.ps1")) + if err != nil { + t.Fatalf("resolve local-test installer: %v", err) + } + powerShell := filepath.Join( + os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + if _, err := os.Stat(powerShell); err != nil { + t.Fatalf("locate Windows PowerShell: %v", err) + } + + const behaviorContract = ` +$ErrorActionPreference = 'Stop' +$source = Get-Content -LiteralPath $env:VIIPER_INSTALLER_CONTRACT_PATH -Raw +$csharpBlocks = @([regex]::Matches( + $source, "(?s)Add-Type -Language CSharp -TypeDefinition @'\r?\n(?.*?)\r?\n'@") | + ForEach-Object { $_.Groups['source'].Value } | + Where-Object { $_ -match 'public static class ViiperWindowsUptime' }) +if ($csharpBlocks.Count -ne 1) { throw 'Embedded Windows-uptime source was not found exactly once.' } +Add-Type -Language CSharp -TypeDefinition $csharpBlocks[0] +$start = $source.IndexOf('function Get-WindowsBootBoundaryUtc') +$end = $source.IndexOf('function Remove-PreBootProtectedStagingDirectories', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Windows boot-boundary function was not found.' } +Invoke-Expression $source.Substring($start, $end - $start) +$before = [DateTime]::UtcNow +$boundary = Get-WindowsBootBoundaryUtc +$after = [DateTime]::UtcNow +$uptime = [TimeSpan]::FromMilliseconds([double][ViiperWindowsUptime]::GetTickCount64()) +$lower = $before.Subtract($uptime).AddSeconds(-2) +$upper = $after.Subtract($uptime).AddSeconds(2) +if ($boundary.Kind -ne [DateTimeKind]::Utc -or + $boundary -lt $lower -or $boundary -gt $upper) { + throw "Windows boot boundary was outside the native uptime interval: $boundary" +} +if ($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5) { + throw "Expected Windows PowerShell 5.1, got $($PSVersionTable.PSVersion)." +} +` + command := exec.Command( + powerShell, "-NoProfile", "-NonInteractive", "-Command", behaviorContract) + command.Env = append(os.Environ(), "VIIPER_INSTALLER_CONTRACT_PATH="+installer) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Windows PowerShell boot-boundary contract failed: %v\n%s", err, output) + } +} + func TestLocalTestValidationCannotWeakenProduction(t *testing.T) { root := filepath.Join("..", "..", "..", "native", "udecx", "tools") contents, err := os.ReadFile(filepath.Join(root, "Test-ViiperUdeSignedPackage.ps1")) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index a1573f90..74d1da58 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.22" + DriverPackageVersion = "0.1.0.23" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index e098bd66..448e0ee0 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "6e25b6972fd774d00cc3c081dfe2244fa6ad24ddf1551012c0297c741da849b5" + const wantHex = "c7b19790ddcf7c99af33b0e503591af2927ddc874c7655894a8d5e64015f0bff" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index a93ae07b..2eb14e29 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.22 + 0.1.0.23 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 9390bd1a..279bd3c6 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.22" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.23" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 6f7d09cd..9ede9785 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.22 +DriverVer=08/13/2026,0.1.0.23 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 index 85e56a55..29cdda24 100644 --- a/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 +++ b/native/udecx/tools/Install-ViiperUdeLocalTest.ps1 @@ -228,14 +228,45 @@ function Remove-ProtectedStagingDirectory { [IO.Directory]::Delete($fullPath, $false) } +if (-not ('ViiperWindowsUptime' -as [type])) { + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class ViiperWindowsUptime +{ + [DllImport("kernel32.dll", ExactSpelling = true)] + public static extern ulong GetTickCount64(); +} +'@ +} + +$uptimeMethod = [ViiperWindowsUptime].GetMethod( + 'GetTickCount64', [Reflection.BindingFlags]'Public,Static') +$uptimeImport = $uptimeMethod.GetCustomAttributes( + [Runtime.InteropServices.DllImportAttribute], $false)[0] +if ($uptimeMethod.ReturnType -ne [uint64] -or + $uptimeImport.Value -cne 'kernel32.dll' -or + -not $uptimeImport.ExactSpelling) { + throw 'The local-test installer does not bind the exact Windows uptime API.' +} + +function Get-WindowsBootBoundaryUtc { + # Windows PowerShell 5.1 runs on .NET Framework, whose Environment type + # does not expose TickCount64. Bind the native 64-bit uptime API directly + # so long-running systems cannot suffer Environment.TickCount wraparound. + $uptimeMilliseconds = [ViiperWindowsUptime]::GetTickCount64() + return [DateTime]::UtcNow.Subtract( + [TimeSpan]::FromMilliseconds([double]$uptimeMilliseconds)) +} + function Remove-PreBootProtectedStagingDirectories { param([Parameter(Mandatory = $true)][string]$ProgramDataRoot) # A live sibling installer can own a same-boot staging directory before it # acquires the nested package mutex. Only reclaim exact protected stages # which predate this boot; Windows already terminated every possible owner. - $bootBoundaryUtc = [DateTime]::UtcNow.Subtract( - [TimeSpan]::FromMilliseconds([Environment]::TickCount64)) + $bootBoundaryUtc = Get-WindowsBootBoundaryUtc $candidates = @(Get-ChildItem -LiteralPath $ProgramDataRoot -Force -Directory | Where-Object { $_.Name -match '^VIIPER\.LocalTestStage\.[0-9a-f]{32}$' -and @@ -451,6 +482,41 @@ if ($PreflightOnly) { ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "ProgramData is not a safe staging parent: '$preflightProgramDataRoot'." } + # Exercise both sides of the reboot-boundary cleanup under the exact + # inbox Windows PowerShell host used for installation. This regression + # path must execute before an artifact can be published. + $preflightOldStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + $preflightCurrentStage = Join-Path $preflightProgramDataRoot ( + 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) + try { + Initialize-ProtectedStagingDirectory -Path $preflightOldStage + Initialize-ProtectedStagingDirectory -Path $preflightCurrentStage + $preflightBootBoundaryUtc = Get-WindowsBootBoundaryUtc + [IO.Directory]::SetLastWriteTimeUtc( + $preflightOldStage, $preflightBootBoundaryUtc.AddSeconds(-1)) + [IO.Directory]::SetLastWriteTimeUtc( + $preflightCurrentStage, $preflightBootBoundaryUtc.AddSeconds(1)) + Remove-PreBootProtectedStagingDirectories ` + -ProgramDataRoot $preflightProgramDataRoot + if (Test-Path -LiteralPath $preflightOldStage) { + throw 'Pre-boot protected staging cleanup did not remove its test directory.' + } + if (-not (Test-Path -LiteralPath $preflightCurrentStage)) { + throw 'Pre-boot protected staging cleanup removed a same-boot test directory.' + } + } + finally { + foreach ($preflightCleanupStage in @( + $preflightOldStage, $preflightCurrentStage)) { + if (Test-Path -LiteralPath $preflightCleanupStage) { + Remove-ProtectedStagingDirectory ` + -Path $preflightCleanupStage ` + -ProgramDataRoot $preflightProgramDataRoot + } + } + } + $preflightStage = Join-Path $preflightProgramDataRoot ( 'VIIPER.LocalTestStage.' + [Guid]::NewGuid().ToString('N')) try { diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 2e6cd956..f760f9b4 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5290,7 +5290,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "6e25b6972fd774d00cc3c081dfe2244fa6ad24ddf1551012c0297c741da849b5") { + "c7b19790ddcf7c99af33b0e503591af2927ddc874c7655894a8d5e64015f0bff") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 1ca31105cff9a5c24550e1601fda07ba25671dc5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 21:57:53 -0500 Subject: [PATCH 214/240] Keep UdeCx root hub powered through enumeration --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 14 ++++++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Controller.c | 12 ++++++++++++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Test-ViiperUdeTargetCompatibility.ps1 | 4 ++++ native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 12 files changed, 39 insertions(+), 9 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 8a48c63e..dae95ca3 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.23", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.24", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index f0ebb3f0..3321aee8 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.23", + "expectedDriverPackageVersion": "0.1.0.24", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index ad15197a..aa851525 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.23", + ExpectedDriverPackageVersion: "0.1.0.24", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index a88bee6c..e77529e1 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -69,6 +69,20 @@ func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { } } +func TestNativeControllerEstablishesIdlePolicyBeforeUdeCxEmulation(t *testing.T) { + controller := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Controller.c"), + "ViiperEvtDeviceAdd")) + requireContractOrder(t, controller, + "status = WdfDeviceCreate(&DeviceInit, &attributes, &device);", + "WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT( &idleSettings, IdleCannotWakeFromS0);", + "status = WdfDeviceAssignS0IdleSettings(device, &idleSettings);", + "status = UdecxWdfDeviceAddUsbDeviceEmulation(device, &udeConfig);") + if strings.Contains(controller, "Start-Sleep") || strings.Contains(controller, "WdfTimer") { + t.Fatal("controller enumeration policy must not hide lifecycle races with timing workarounds") + } +} + func TestNativeBrokerDispatchUsesIndependentCursorAndEndpointFIFO(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 74d1da58..90a4b851 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.23" + DriverPackageVersion = "0.1.0.24" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 448e0ee0..e4b248f3 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "c7b19790ddcf7c99af33b0e503591af2927ddc874c7655894a8d5e64015f0bff" + const wantHex = "c2fee12b34725595496b259e38b2985ba1fad35ed76606c19091fd5564366058" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 1dcf832b..da8055e8 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -121,6 +121,7 @@ ViiperEvtDeviceAdd( WDF_OBJECT_ATTRIBUTES requestAttributes; WDF_FILEOBJECT_CONFIG fileConfig; UDECX_WDF_DEVICE_CONFIG udeConfig; + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS idleSettings; WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; VIIPER_UDE_CONTROLLER_CONTEXT *context; UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); @@ -178,6 +179,17 @@ ViiperEvtDeviceAdd( KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); + // UdeCx owns the controller's USB root-hub power policy. Establish the + // proven non-wakeable S0 idle contract before publishing emulation so a + // port connect cannot race an implicit hub-suspend transition. This is a + // cold controller-lifecycle setting; it adds no work to the input path. + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT( + &idleSettings, IdleCannotWakeFromS0); + status = WdfDeviceAssignS0IdleSettings(device, &idleSettings); + if (!NT_SUCCESS(status)) { + return status; + } + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = device; status = WdfWaitLockCreate(&attributes, &context->OwnerLock); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 2eb14e29..51e7c57f 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.23 + 0.1.0.24 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 279bd3c6..953b7351 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.23" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.24" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 9ede9785..8347bf81 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.23 +DriverVer=08/13/2026,0.1.0.24 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index d53b5e48..76fc3ba6 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -115,6 +115,10 @@ if ($controllerSource -notmatch 'FILE_AUTOGENERATED_DEVICE_NAME[\s\S]{0,300}?WdfDeviceInitAssignSDDLString\s*\(\s*DeviceInit') { throw 'The controller must name its device before assigning the broker-only SDDL.' } +if ($controllerSource -notmatch + 'WdfDeviceCreate\s*\([\s\S]{0,1500}?WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT\s*\(\s*&idleSettings\s*,\s*IdleCannotWakeFromS0\s*\)\s*;[\s\S]{0,300}?WdfDeviceAssignS0IdleSettings\s*\(\s*device\s*,\s*&idleSettings\s*\)[\s\S]{0,2500}?UdecxWdfDeviceAddUsbDeviceEmulation') { + throw 'The controller must establish non-wakeable S0 idle policy before publishing UdeCx emulation.' +} foreach ($requiredHeaderContract in @( 'EX_PUSH_LOCK DeviceLock;', diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index f760f9b4..237da4d3 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5290,7 +5290,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "c7b19790ddcf7c99af33b0e503591af2927ddc874c7655894a8d5e64015f0bff") { + "c2fee12b34725595496b259e38b2985ba1fad35ed76606c19091fd5564366058") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 065a0d4d5bd346c9b4e453b0992e5c798375465f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 22:15:55 -0500 Subject: [PATCH 215/240] Repair upgrades from a stopped UdeCx root --- _testing/e2e/latency_gate_windows_test.go | 2 +- docs/api/overview.md | 2 +- internal/cmd/native_package_contract_test.go | 7 +++++-- internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../udecx/tools/Test-ViiperUdeCtlTransaction.ps1 | 4 +++- native/udecx/tools/ViiperUdeCtl.cpp | 15 ++++++++++----- 11 files changed, 26 insertions(+), 16 deletions(-) diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index dae95ca3..4856273b 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -733,7 +733,7 @@ func TestValidatePingRequiresExpectedLoadedDriverIdentity(t *testing.T) { Server: "VIIPER", Version: "0.1.0", Transport: latency.TransportNativeUDE, Ready: &ready, NativeUDE: &viipertypes.NativeUDEInfo{ - ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.24", + ABIMajor: 1, ExpectedDriverPackageVersion: "0.1.0.25", LoadedDriverBuildIdentity: expected, }, } diff --git a/docs/api/overview.md b/docs/api/overview.md index 3321aee8..28d00886 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.24", + "expectedDriverPackageVersion": "0.1.0.25", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index c47376f4..782f8d7f 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -214,6 +214,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { } upgradeRemove := strings.Index(helperSource, `L"upgrade-deadline-before-device-removal"`) upgradeQuiesce := strings.Index(helperSource, "RequestBrokerQuiescence(options") + upgradeStartedGate := strings.Index(helperSource, + "!prior.devices.empty() && prior.devices[0].started &&") upgradePristine := strings.Index(helperSource, "options.transactionDeadlineUnixMs, nullptr, &outcome.error, true") upgradeAbsent := strings.Index(helperSource, "CaptureSnapshot(&afterRemoval") @@ -226,11 +228,12 @@ func TestNativePackageProductionSourceContract(t *testing.T) { upgradeBind = upgradeIdentity + relative } } - if upgradeQuiesce < 0 || upgradePristine <= upgradeQuiesce || + if upgradeQuiesce < 0 || upgradeStartedGate <= upgradeQuiesce || + upgradePristine <= upgradeStartedGate || upgradeRemove <= upgradePristine || upgradeAbsent <= upgradeRemove || upgradeStage <= upgradeAbsent || upgradeIdentity <= upgradeStage || upgradeBind <= upgradeIdentity { - t.Error("driver upgrade no longer quiesces the broker and proves a pristine runtime before removal, absence proof, staging, exact-identity recreation, and binding") + t.Error("driver upgrade no longer treats a stopped exact root as quiesced while requiring pristine runtime proof for a running root before removal, absence proof, staging, exact-identity recreation, and binding") } if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index aa851525..b78f8a97 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.24", + ExpectedDriverPackageVersion: "0.1.0.25", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 90a4b851..2550e2d5 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.24" + DriverPackageVersion = "0.1.0.25" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index e4b248f3..fc29d3cd 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "c2fee12b34725595496b259e38b2985ba1fad35ed76606c19091fd5564366058" + const wantHex = "b059e55ace1056eb432dc3601c43cdf8eb39c40ac89b997afe828218d3747aa2" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 51e7c57f..999af9d2 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.24 + 0.1.0.25 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 953b7351..da0baa4b 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.24" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.25" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 8347bf81..d7cc9854 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.24 +DriverVer=08/13/2026,0.1.0.25 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index e20d1b0e..6e6261c4 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -35,6 +35,8 @@ $requiredContracts = [ordered]@{ 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' 'pristine upgrade statistics' = 'IOCTL_VIIPER_UDE_QUERY_STATS' 'pristine upgrade reboot boundary' = 'upgrade-runtime-reboot-boundary' + 'stopped owned upgrade skips unavailable live ABI proof' = + 'CandidateDisposition::InstallRequired &&\s*!prior\.devices\.empty\(\) && prior\.devices\[0\]\.started &&' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' 'exact negotiated capability identity' = 'response\.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES' 'source-bound manifest identity' = 'driverBuildIdentity' @@ -173,7 +175,7 @@ $orderedMutationContracts = [ordered]@{ 'upgrade restores exact identity before exact package binding' = 'DiInstallDriverW\([\s\S]{0,2200}prior\.devices\[0\]\.instanceId[\s\S]{0,300}ExactRootRegistrationMode::Upgrade[\s\S]{0,700}InstallPreinstalledDriverOnDevice\(' 'broker quiescence precedes all classified driver mutation' = - 'if \(driverMutation && !options\.brokerExecutable\.empty\(\)[\s\S]{0,180}RequestBrokerQuiescence\([\s\S]{0,700}CandidateDisposition::InstallRequired' + 'if \(driverMutation && !options\.brokerExecutable\.empty\(\)[\s\S]{0,180}RequestBrokerQuiescence\([\s\S]{0,1200}CandidateDisposition::InstallRequired' 'broker quiescence and pristine proof precede upgrade root removal' = 'RequestBrokerQuiescence\([\s\S]{0,5000}&outcome\.error, true[\s\S]{0,5000}upgrade-deadline-before-device-removal' 'broker handoff follows exact binding verification and precedes nested commit' = diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 237da4d3..3f860271 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -3726,11 +3726,16 @@ Outcome Install(const InstallOptions& options) { // release their logical device slot before framework teardown settled, // leaving DiUninstallDevice blocked indefinitely even though the broker // reported an empty bus. Once the trusted broker is stopped, require a - // zero-lifetime-work runtime before replacing an existing root. A restart - // resets these counters and guarantees that no pre-upgrade child object - // can survive into root removal. + // zero-lifetime-work runtime before replacing a running root. A PnP-stopped + // exact owned root has no live UdeCx stack or ABI endpoint; its captured + // devnode/package identity is already the quiescence proof. Do not start an + // old driver solely to upgrade it. Removal, absence, and rollback checks + // below still guard the stopped-root transaction. For a running root, a + // restart resets these counters and guarantees that no pre-upgrade child + // object can survive into root removal. if (disposition == CandidateDisposition::InstallRequired && - !prior.devices.empty() && outcome.error.code == ERROR_SUCCESS && + !prior.devices.empty() && prior.devices[0].started && + outcome.error.code == ERROR_SUCCESS && !VerifyAbiHealth( options.transactionDeadlineUnixMs, nullptr, &outcome.error, true)) { if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { @@ -5290,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "c2fee12b34725595496b259e38b2985ba1fad35ed76606c19091fd5564366058") { + "b059e55ace1056eb432dc3601c43cdf8eb39c40ac89b997afe828218d3747aa2") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 51c0c21a4ac91a3e485295a1898e507032946896 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 22:46:05 -0500 Subject: [PATCH 216/240] Let UdeCx own the enumeration reset --- docs/api/overview.md | 2 +- docs/architecture/native-udecx.md | 17 +++++++----- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 17 ++++++++++++ .../udecx/live_validation_contract_test.go | 4 +++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/README.md | 3 ++- native/udecx/driver/Device.c | 26 ------------------- native/udecx/driver/ViiperUde.h | 1 - native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Invoke-ViiperUdePerformanceValidation.ps1 | 13 +++++++++- .../Test-ViiperUdeTargetCompatibility.ps1 | 5 ++++ native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 16 files changed, 59 insertions(+), 43 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 28d00886..8640e248 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -193,7 +193,7 @@ kept matched. "abiMajor": 1, "abiMinor": 10, "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.25", + "expectedDriverPackageVersion": "0.1.0.26", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", "maxDevices": 32, "maxDescriptorBytes": 262144, diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index f0a110ac..d3c4a73a 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -24,8 +24,9 @@ after transfer ordering, cancellation, teardown, and recovery are proven. evidence for manual-queue/cache ownership only, not for UDE completion IRQL. - Its controller contract also reports chained-MDL, high-speed, and SuperSpeed compatibility for a root controller with USB 2 and USB 3 ports. VIIPER - mirrors that capability set and explicitly forwards post-enumeration child - resets into the generation-owned lifecycle stream. + mirrors that capability set. UdeCx owns the mandatory post-enumeration child + reset; configuration replacement enters VIIPER's generation-owned lifecycle + stream after Windows has finished enumerating the child. - ViGEmBus provides the lifecycle north star: explicit protocol negotiation, handle-scoped ownership, bounded manual queues, cancel-safe requests, generation-aware target teardown, and synchronization per target rather than @@ -456,7 +457,8 @@ a wedged provider cannot retain the installer mutex indefinitely. stale reset publication or successor gate change. Reset never calls purge-complete or waits for a later start callback, matching UdeCx's distinct reset and purge contracts. -- Device reset closes direct input admission in the kernel callback and pauses +- Device configuration replacement closes direct input admission in the + kernel callback and pauses every user-mode publisher before controller state is cleared. Every endpoint first passes the same reset-specific driver-owned-request/rundown proof; if purge or removal wins, the actual reset request fails without publishing a @@ -466,9 +468,12 @@ a wedged provider cannot retain the installer mutex indefinitely. mode acknowledges them out of order. Admission and active publishers reopen only after a second exact-generation/object/epoch proof at acknowledgement, so no HID snapshot or late terminal callback can cross the reset boundary. - Post-enumeration reset, device initialization, and configuration replacement - share this one-child-at-a-time gate; concurrent reset transactions are - rejected instead of interleaving two controller resets. + Configuration replacement alone uses this one-child-at-a-time device gate; + concurrent reset transactions are rejected instead of interleaving two + controller resets. The mandatory post-enumeration reset stays entirely in + UdeCx, because an emulated descriptor has no backing physical reset to + coordinate and Windows cannot continue child enumeration while that reset is + waiting on user mode. Device initialization also completes synchronously. - The controller's default KMDF queue is parallel and completes interrupt-IN submissions directly. Mutation, broker, and lifecycle IOCTLs alone move to the serialized control queue. This removes a redundant KMDF forwarding and diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index b78f8a97..c5787590 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.25", + ExpectedDriverPackageVersion: "0.1.0.26", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index e77529e1..2bed5da0 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -29,6 +29,23 @@ func TestNativeDeviceInitializeDoesNotEnterResetProtocol(t *testing.T) { } } +func TestNativePostEnumerationResetDoesNotBlockEnumerationOnUserMode(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + + if strings.Contains(create, "callbacks.EvtUsbDeviceReset") || + strings.Contains(device, "ViiperEvtUsbDeviceReset") || + strings.Contains(header, "EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET") { + t.Fatal("post-enumeration reset can block child enumeration on a user-mode acknowledgement") + } + configure := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointsConfigure")) + if !strings.Contains(configure, + "case UdecxEndpointsConfigureTypeDeviceConfigurationChange: status = ViiperBeginAcknowledgedDeviceReset(Device, Request);") { + t.Fatal("configuration replacement lost its acknowledged device-reset boundary") + } +} + func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index 18bc05fe..e359fdb0 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -140,6 +140,10 @@ func TestNativePerformanceTraceCapturesAttributableCriticalPath(t *testing.T) { for _, required := range []string{ "[string]$ProbeManifestPath", "ProbeManifestPath = $ProbeManifestPath", + "[switch]$ManageInstalledBrokerService", + "$validationArguments.ManageInstalledBrokerService = $true", + "[int]$MediaDurationSeconds = 3", + "MediaDurationSeconds = $MediaDurationSeconds", "$profile = 'GeneralProfile.Verbose'", "GeneralProfile\\.Verbose\\.Memory", "@('DPC', 'Interrupt', 'WDFDPC', 'WDFInterrupt')", diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 2550e2d5..397fa4bf 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.25" + DriverPackageVersion = "0.1.0.26" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index fc29d3cd..e5d26aaf 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "b059e55ace1056eb432dc3601c43cdf8eb39c40ac89b997afe828218d3747aa2" + const wantHex = "f0cbf25f504adbec85a9b83d0c3e02dd7e3d9f4b829aabb8e183ba673f77baf4" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/README.md b/native/udecx/README.md index e61bb6f8..ed2d55a4 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -227,7 +227,8 @@ Microsoft-signed package with: -MediaDurationSeconds 30 ` -MediaProbePath .\native\udecx\x64\Release\ViiperUdeMediaProbe.exe ` -InputProbePath .\native\udecx\x64\Release\ViiperUdeInputProbe.exe ` - -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json + -ProbeManifestPath .\native\udecx\x64\Release\ViiperUdeLiveProbes.manifest.json ` + -ManageInstalledBrokerService ``` The command refuses an unsigned package, a package/service hash mismatch, a diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 54207a4b..9c221a7a 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -626,7 +626,6 @@ ViiperCreateVirtualDevice( UDECX_USB_DEVICE_CALLBACKS_INIT(&callbacks); callbacks.EvtUsbDeviceLinkPowerEntry = ViiperEvtUsbDeviceD0Entry; callbacks.EvtUsbDeviceLinkPowerExit = ViiperEvtUsbDeviceD0Exit; - callbacks.EvtUsbDeviceReset = ViiperEvtUsbDeviceReset; if (speed == UdecxUsbSuperSpeed) { callbacks.EvtUsbDeviceSetFunctionSuspendAndWake = ViiperEvtUsbDeviceSetFunctionSuspendAndWake; @@ -1172,31 +1171,6 @@ ViiperBeginAcknowledgedDeviceReset( return status; } -VOID -ViiperEvtUsbDeviceReset( - _In_ WDFDEVICE Controller, - _In_ UDECXUSBDEVICE Device, - _In_ WDFREQUEST Request, - _In_ BOOLEAN AllDevicesReset - ) -{ - NTSTATUS status; - - UNREFERENCED_PARAMETER(Controller); - if (AllDevicesReset) { - // The controller uses UdecxWdfDeviceResetActionResetEachUsbDevice, - // so UdeCx must deliver one callback per child. Accepting a controller- - // wide reset here would make the owner lose the affected generation. - WdfRequestComplete(Request, STATUS_NOT_SUPPORTED); - return; - } - - status = ViiperBeginAcknowledgedDeviceReset(Device, Request); - if (!NT_SUCCESS(status)) { - WdfRequestComplete(Request, status); - } -} - static NTSTATUS ViiperCreateEndpointQueue( diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index bde5a985..0039f9a3 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -324,7 +324,6 @@ EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; EVT_UDECX_USB_DEVICE_SET_FUNCTION_SUSPEND_AND_WAKE ViiperEvtUsbDeviceSetFunctionSuspendAndWake; -EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET ViiperEvtUsbDeviceReset; EVT_UDECX_USB_DEVICE_DEFAULT_ENDPOINT_ADD ViiperEvtDefaultEndpointAdd; EVT_UDECX_USB_DEVICE_ENDPOINT_ADD ViiperEvtEndpointAdd; EVT_UDECX_USB_DEVICE_ENDPOINTS_CONFIGURE ViiperEvtEndpointsConfigure; diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 999af9d2..77ad02be 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.25 + 0.1.0.26 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index da0baa4b..3c104545 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.25" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.26" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index d7cc9854..309b484b 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.25 +DriverVer=08/13/2026,0.1.0.26 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 index f772a96b..f8a24571 100644 --- a/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 +++ b/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1 @@ -34,7 +34,12 @@ param( [switch]$RestartRootDevice, - [switch]$DisposableTestMachine + [switch]$DisposableTestMachine, + + [switch]$ManageInstalledBrokerService, + + [ValidateRange(1, 300)] + [int]$MediaDurationSeconds = 3 ) Set-StrictMode -Version Latest @@ -118,6 +123,7 @@ $validationArguments = @{ MediaProbePath = $MediaProbePath InputProbePath = $InputProbePath ProbeManifestPath = $ProbeManifestPath + MediaDurationSeconds = $MediaDurationSeconds } if ($RequireDriverVerifier) { $validationArguments.RequireDriverVerifier = $true @@ -128,6 +134,9 @@ if ($RestartRootDevice) { if ($DisposableTestMachine) { $validationArguments.DisposableTestMachine = $true } +if ($ManageInstalledBrokerService) { + $validationArguments.ManageInstalledBrokerService = $true +} try { $startOutput = & $wprPath -start $profile -instancename $instanceName 2>&1 @@ -219,6 +228,8 @@ $evidence = [ordered]@{ inputProbeSha256 = (Get-FileHash -LiteralPath $InputProbePath -Algorithm SHA256).Hash.ToLowerInvariant() signatureValidationMode = $SignatureValidationMode iterations = $Iterations + mediaDurationSeconds = $MediaDurationSeconds + managesInstalledBrokerService = [bool]$ManageInstalledBrokerService analysisRequired = $true } $evidenceJson = $evidence | ConvertTo-Json -Depth 4 diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 76fc3ba6..e5367399 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -347,6 +347,11 @@ if (-not $deviceResetAdmissionMatch.Success -or 'InterlockedCompareExchange\s*\(\s*&endpointContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*else[\s\S]*ResetDeviceEpoch[\s\S]*deviceContext->ResetEpoch') { throw 'Device reset must advance its private epoch only after admission, and endpoint reset must capture that epoch atomically.' } +if ($deviceSource -match 'callbacks\.EvtUsbDeviceReset\s*=' -or + $deviceSource -match '(?m)^ViiperEvtUsbDeviceReset\s*\(' -or + $header -match 'EVT_UDECX_USB_DEVICE_POST_ENUMERATION_RESET') { + throw 'Post-enumeration reset must remain UdeCx-owned and must not wait on the user-mode lifecycle stream.' +} $managementSlotPinMatch = [regex]::Match( $brokerSource, '(?ms)^ViiperQueueAcknowledgedLifecycleEvent\s*\([^)]*\)\s*\{(?.*?)^\}') diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 3f860271..7c22f533 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "b059e55ace1056eb432dc3601c43cdf8eb39c40ac89b997afe828218d3747aa2") { + "f0cbf25f504adbec85a9b83d0c3e02dd7e3d9f4b829aabb8e183ba673f77baf4") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From ff9b795232fdd45606d0222cb898d7f18f4068b7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 23:30:44 -0500 Subject: [PATCH 217/240] Fix UdeCx endpoint queue lifecycle --- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 21 ++- ...river_endpoint_quiescence_contract_test.go | 125 ++++++++++++------ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Controller.c | 19 ++- native/udecx/driver/Device.c | 111 +++++++--------- native/udecx/driver/ViiperUde.h | 5 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 44 +++--- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 13 files changed, 182 insertions(+), 157 deletions(-) diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index c5787590..b82dfd10 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.26", + ExpectedDriverPackageVersion: "0.1.0.27", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 2bed5da0..25687198 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -735,19 +735,16 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. "InterlockedExchange(&endpointContext->Purging, TRUE);", "WdfSpinLockRelease(controllerContext->BrokerLock);", "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", - "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") - if strings.Contains(device, "WdfIoQueuePurge(") { - t.Fatal("UdeCx owns the associated endpoint queue; client code must not purge it") - } + "WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint);") createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) if !strings.Contains(createQueue, - "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") || - !strings.Contains(purge, "UdeCx owns and has already stopped the associated queue") { - t.Fatal("endpoint purge lost the UdeCx-owned associated-queue boundary") + "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") { + t.Fatal("endpoint purge lost its explicitly associated WDF queue") } - purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) - requireContractOrder(t, purgeWork, - "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", + purgeComplete := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointQueuePurged")) + requireContractOrder(t, purgeComplete, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "endpointContext->ActiveOperations", "ViiperInvalidateEndpointInputReport(endpoint);", "UdecxUsbEndpointPurgeComplete(endpoint);") resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) @@ -762,10 +759,8 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. "WdfSpinLockAcquire(controllerContext->BrokerLock);", "InterlockedExchange(&endpointContext->Purging, FALSE);", "WdfSpinLockRelease(controllerContext->BrokerLock);", + "WdfIoQueueStart(endpointContext->Queue);", "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart);") - if strings.Contains(device, "WdfIoQueueStart(") { - t.Fatal("UdeCx owns the associated endpoint queue; client code must not start it") - } cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) requireContractOrder(t, cleanup, diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index dbb0fc07..c34a4603 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -5,17 +5,16 @@ import ( "testing" ) -func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { +func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") - // UdeCx exclusively owns the associated endpoint queue's START/PURGE - // state. VIIPER may observe that queue, but must never mutate it. + // UdeCx delegates PURGE/START handling for the associated queue to the + // client. Only the asynchronous purge and matching start transitions are + // allowed; stop, drain, and synchronous purge create competing ownership. for _, mutation := range []string{ - "WdfIoQueuePurge(", "WdfIoQueuePurgeSynchronously(", - "WdfIoQueueStart(", "WdfIoQueueStop(", "WdfIoQueueStopSynchronously(", "WdfIoQueueDrain(", @@ -32,15 +31,13 @@ func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { } // A WDF callback can be delivered, then preempted before its first - // BrokerLock acquisition. Queue state closes that otherwise invisible + // BrokerLock acquisition. DriverNoRequests closes that otherwise invisible // window; ActiveOperations joins the callback's terminal DPC afterward. quiesce := normalizedContract(nativeCFunction(t, device, "ViiperWaitForEndpointQuiescence")) requireContractOrder(t, quiesce, "KeWaitForSingleObject( &endpointContext->OperationsDrained", "WdfSpinLockAcquire(controllerContext->BrokerLock);", "WdfIoQueueGetState(endpointContext->Queue, NULL, NULL);", - "WdfIoQueueAcceptRequests | WdfIoQueueDispatchRequests", - "WDF_IO_QUEUE_IDLE(queueState)", "WdfIoQueueDriverNoRequests", "endpointContext->ActiveOperations", "WdfSpinLockRelease(controllerContext->BrokerLock);", @@ -74,11 +71,22 @@ func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { "deviceContext->Purging", "pending->Request = Request;") - purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) - requireContractOrder(t, purgeWork, - "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", + purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) + requireContractOrder(t, purge, + "InterlockedExchange(&endpointContext->Purging, TRUE);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint);") + purgeComplete := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointQueuePurged")) + requireContractOrder(t, purgeComplete, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "endpointContext->ActiveOperations", "ViiperInvalidateEndpointInputReport(endpoint);", "UdecxUsbEndpointPurgeComplete(endpoint);") + start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) + requireContractOrder(t, start, + "InterlockedExchange(&endpointContext->Purging, FALSE);", + "WdfIoQueueStart(endpointContext->Queue);", + "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart);") resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) requireContractOrder(t, resetWork, "resetCurrent = ViiperQuiesceResetByIdentity(", @@ -94,17 +102,17 @@ func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { "ViiperInvalidateEndpointInputReport(endpoint);", "ViiperQueueAcknowledgedEndpointLifecycleEvent(") - controllerQuiesce := normalizedContract(nativeCFunction(t, device, "ViiperQuiesceControllerEndpoints")) + controllerQuiesce := normalizedContract(nativeCFunction(t, device, "ViiperDrainControllerEndpointOperations")) requireContractOrder(t, controllerQuiesce, "ViiperAcquireDeviceLockShared(controllerContext);", "deviceContext->Endpoints[endpointIndex]", - "ViiperWaitForEndpointQuiescence(endpoint, TRUE);", + "ViiperWaitForEndpointQuiescence(endpoint);", "ViiperReleaseDeviceLockShared(controllerContext);") cleanup := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceSelfManagedIoCleanup")) requireContractOrder(t, cleanup, "InterlockedExchange(&context->ShuttingDown, TRUE);", "ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED);", - "ViiperQuiesceControllerEndpoints(Device);", + "ViiperDrainControllerEndpointOperations(Device);", "ViiperDrainUrbCompletions(Device);", "context->PendingOperations", "context->PendingCompletions", @@ -154,7 +162,7 @@ func TestNativeResetQuiescenceIsExactGenerationAndFailClosed(t *testing.T) { "deviceContext->Endpoints[EndpointAddress]", "endpoint == ExpectedEndpoint", "endpointContext->Resetting", - "ViiperWaitForEndpointQuiescence(endpoint, FALSE);", + "ViiperWaitForEndpointQuiescence(endpoint);", "endpointContext->Resetting", "if (ReleaseGate)", "InterlockedExchange(&endpointContext->Resetting, FALSE);", @@ -581,17 +589,25 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { state.active-- state.driverOwned-- } - closeForPurgeOrShutdown := func(state *endpoint) { + closeForPurge := func(state *endpoint) { state.open = false state.queueAccepting = false state.queueDispatching = false - state.queued = 0 // UdeCx cancels requests it had not delivered. + state.queued = 0 // WdfIoQueuePurge cancels requests it had not delivered. } - terminallyQuiescent := func(state *endpoint) bool { + queuePurgeComplete := func(state *endpoint) bool { stopped := !state.queueAccepting && !state.queueDispatching queueIdle := state.queued == 0 && state.driverOwned == 0 return stopped && queueIdle && state.active == 0 } + closeForShutdown := func(state *endpoint) { + // The controller admission gate closes first. Queued host polls remain + // owned by UdeCx until PlugOutAndDelete requests endpoint PURGE. + state.open = false + } + driverQuiescent := func(state *endpoint) bool { + return state.driverOwned == 0 && state.active == 0 + } closeForReset := func(state *endpoint) { state.open = false state.resetOutstanding = true @@ -602,32 +618,53 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { return state.driverOwned == 0 && state.active == 0 } - for _, lifecycle := range []string{"purge", "shutdown"} { - state := endpoint{ - open: true, - queueAccepting: true, - queueDispatching: true, - queued: 1, - } - if !deliverByWDF(&state) { - t.Fatalf("%s: WDF did not deliver the pre-boundary callback", lifecycle) - } - // Preempt here: WDF owns the request but QueueUrb has not yet acquired - // BrokerLock or incremented ActiveOperations. - closeForPurgeOrShutdown(&state) - if terminallyQuiescent(&state) { - t.Fatalf("%s passed a WDF-delivered callback before rundown entry", lifecycle) - } - if resumeDeliveredCallback(&state) { - t.Fatalf("%s callback allocated/published after lifecycle closure", lifecycle) - } - if terminallyQuiescent(&state) { - t.Fatalf("%s passed the callback before terminal DPC completion", lifecycle) - } - runTerminalDPC(&state) - if !terminallyQuiescent(&state) || state.terminalDPCs != 1 { - t.Fatalf("%s failed stable stopped+idle+rundown proof: %+v", lifecycle, state) - } + purge := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + } + if !deliverByWDF(&purge) { + t.Fatal("purge: WDF did not deliver the pre-boundary callback") + } + closeForPurge(&purge) + if queuePurgeComplete(&purge) { + t.Fatal("purge passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&purge) { + t.Fatal("purge callback allocated/published after lifecycle closure") + } + if queuePurgeComplete(&purge) { + t.Fatal("purge completed before the terminal DPC") + } + runTerminalDPC(&purge) + if !queuePurgeComplete(&purge) || purge.terminalDPCs != 1 { + t.Fatalf("purge failed asynchronous queue completion proof: %+v", purge) + } + + shutdown := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + queued: 2, + } + if !deliverByWDF(&shutdown) { + t.Fatal("shutdown: WDF did not deliver the pre-boundary callback") + } + closeForShutdown(&shutdown) + if driverQuiescent(&shutdown) { + t.Fatal("shutdown passed a WDF-delivered callback before rundown entry") + } + if resumeDeliveredCallback(&shutdown) { + t.Fatal("shutdown callback allocated/published after lifecycle closure") + } + runTerminalDPC(&shutdown) + if !driverQuiescent(&shutdown) || !shutdown.queueDispatching || shutdown.queued != 1 { + t.Fatalf("shutdown waited on class-extension-owned queued polls: %+v", shutdown) + } + closeForPurge(&shutdown) // UdeCx callback after child consumption. + if !queuePurgeComplete(&shutdown) { + t.Fatalf("post-consumption endpoint purge did not complete: %+v", shutdown) } reset := endpoint{ diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 397fa4bf..8259c942 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.26" + DriverPackageVersion = "0.1.0.27" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index e5d26aaf..b6b6f8b3 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "f0cbf25f504adbec85a9b83d0c3e02dd7e3d9f4b829aabb8e183ba673f77baf4" + const wantHex = "7dd52de02f7ffc0250647b7ee6281ba70766d8cea1d8214c97ed161643efeb9c" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index da8055e8..51aa7b26 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -316,12 +316,11 @@ ViiperEvtDeviceSelfManagedIoCleanup( NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); - // KMDF purges non-power-managed queues before terminal self-managed - // cleanup. Prove each still-valid UdeCx endpoint queue is stopped and idle, - // and that its BrokerLock-owned rundown is zero, before consuming any UDE - // device handle. The shared device index held by this helper also prevents - // endpoint EvtCleanup from invalidating a queue during the observation. - ViiperQuiesceControllerEndpoints(Device); + // Close and join only operations already delivered into VIIPER. Queued host + // polls remain owned by the associated endpoint queues; PlugOutAndDelete + // causes UdeCx to issue PURGE, and that callback performs the required + // asynchronous WDF queue cancellation before acknowledging the extension. + ViiperDrainControllerEndpointOperations(Device); if (context->CompletionDpc != WDF_NO_HANDLE) { for (;;) { BOOLEAN stable; @@ -339,9 +338,9 @@ ViiperEvtDeviceSelfManagedIoCleanup( // reusable DPC only after its intrusive request list is empty. ViiperDrainUrbCompletions(Device); - // Endpoint queue-idle proof precedes this observation, so no UdeCx - // callback can newly enter rundown. Recheck all controller-owned - // terminal state under BrokerLock to join the final DPC handoff. + // Endpoint driver-operation proof precedes this observation, so no + // callback can newly enter rundown after ShuttingDown. Recheck all + // controller-owned terminal state to join the final DPC handoff. WdfSpinLockAcquire(context->BrokerLock); stable = InterlockedCompareExchange(&context->PendingOperations, 0, 0) == 0 && InterlockedCompareExchange(&context->PendingCompletions, 0, 0) == 0 && @@ -368,7 +367,7 @@ ViiperEvtDeviceSelfManagedIoCleanup( // restart of this same controller object. ViiperRetireManagementTombstonesForOwner(Device, WDF_NO_HANDLE); - // Only after every associated endpoint queue and completion owner is + // Only after every VIIPER-owned endpoint operation and completion owner is // quiescent may UdecxUsbDevicePlugOutAndDelete consume the child handles. // Deletion remains asynchronous; never use a consumed device handle or wait // for child EvtCleanup on this PnP worker. diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 9c221a7a..23ae0f76 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1231,8 +1231,9 @@ ViiperEvtEndpointCleanup( ViiperAcquireDeviceLockExclusive(controllerContext); // Microsoft permits no ordinary object access after EvtCleanup is called, // even when a WDF reference postpones destruction. UdeCx therefore owns - // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission, its - // work item drains ActiveOperations, and only then calls PurgeComplete. + // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission and + // asynchronously purges the associated WDF queue. Its completion callback + // acknowledges UdeCx only after all queued and driver-owned requests end. // Endpoint creation failure has no published users. Cleanup must never be // used as a late wait for an operation which can still access this context. NT_ASSERT(InterlockedCompareExchange( @@ -1305,14 +1306,6 @@ ViiperEvtEndpointAdd( endpointContext->Descriptor = descriptor; InitializeListHead(&endpointContext->AdmissionQueue); KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); - WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); - WDF_OBJECT_ATTRIBUTES_INIT(&attributes); - attributes.ParentObject = endpoint; - status = WdfWorkItemCreate( - &workItemConfig, &attributes, &endpointContext->PurgeWorkItem); - if (!NT_SUCCESS(status)) { - return status; - } WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem); WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = endpoint; @@ -1855,8 +1848,7 @@ _IRQL_requires_(PASSIVE_LEVEL) static VOID ViiperWaitForEndpointQuiescence( - _In_ UDECXUSBENDPOINT Endpoint, - _In_ BOOLEAN RequireStopped + _In_ UDECXUSBENDPOINT Endpoint ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); @@ -1872,8 +1864,6 @@ ViiperWaitForEndpointQuiescence( for (;;) { WDF_IO_QUEUE_STATE queueState; BOOLEAN quiescent; - BOOLEAN queueQuiescent; - BOOLEAN stopped; (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, @@ -1882,30 +1872,16 @@ ViiperWaitForEndpointQuiescence( FALSE, NULL); - // UdeCx exclusively owns START/PURGE state for the associated queue. - // Observe, but never mutate, that state. WDF_IO_QUEUE_IDLE proves both - // that no request remains queued and that every request already - // delivered to a callback has completed or been cancelled. Combined - // with the BrokerLock-owned rundown count, this closes the interval in - // which a delivered callback was preempted before it could increment - // ActiveOperations. + // WdfIoQueueDriverNoRequests closes the interval in which a callback + // was delivered and then preempted before its first BrokerLock + // acquisition. The BrokerLock-owned rundown joins that callback's + // terminal DPC. Queued host polls are intentionally allowed here: + // reset keeps the queue active, and terminal shutdown lets UdeCx issue + // the corresponding endpoint-purge callback after child consumption. WdfSpinLockAcquire(controllerContext->BrokerLock); queueState = WdfIoQueueGetState(endpointContext->Queue, NULL, NULL); - stopped = (queueState & - (WdfIoQueueAcceptRequests | WdfIoQueueDispatchRequests)) == 0; - // PURGE/removal owns a stopped queue and therefore requires full idle: - // no queued request and no driver-owned request. Endpoint RESET is a - // distinct asynchronous UdeCx contract; the queue may remain ready - // with an unconsumed interrupt poll, but UdeCx cannot resume endpoint - // I/O until its reset Request is completed. For that case, prove only - // that no request is currently delivered to a driver callback. The - // Resetting gate remains closed through a second proof at owner ack. - queueQuiescent = RequireStopped - ? stopped && WDF_IO_QUEUE_IDLE(queueState) - : (queueState & WdfIoQueueDriverNoRequests) != 0; - quiescent = queueQuiescent && - InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0) == 0 && - (!RequireStopped || stopped); + quiescent = (queueState & WdfIoQueueDriverNoRequests) != 0 && + InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0) == 0; WdfSpinLockRelease(controllerContext->BrokerLock); if (quiescent) { return; @@ -1920,7 +1896,7 @@ ViiperWaitForEndpointQuiescence( } VOID -ViiperQuiesceControllerEndpoints( +ViiperDrainControllerEndpointOperations( _In_ WDFDEVICE Controller ) { @@ -1929,12 +1905,11 @@ ViiperQuiesceControllerEndpoints( ULONG deviceIndex; PAGED_CODE(); - // On terminal removal KMDF purges non-power-managed queues before - // EvtDeviceSelfManagedIoCleanup. Hold the shared device index while - // observing every endpoint so EvtEndpointCleanup cannot invalidate a - // handle between lookup and the final queue/rundown proof. ShuttingDown is - // already set, so no direct-input or broker admission can reopen once the - // framework-owned queue is stopped and idle. + // Hold the shared device index while observing every endpoint so cleanup + // cannot invalidate a handle between lookup and the final driver-owned + // operation proof. ShuttingDown is already set, so no broker or direct + // input admission can reopen. UdeCx remains free to deliver its endpoint + // purge callbacks after PlugOutAndDelete consumes the child handles. ViiperAcquireDeviceLockShared(controllerContext); for (deviceIndex = 0; deviceIndex < VIIPER_UDE_MAX_DEVICES; ++deviceIndex) { UDECXUSBDEVICE device = controllerContext->Devices[deviceIndex]; @@ -1951,7 +1926,7 @@ ViiperQuiesceControllerEndpoints( UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; if (endpoint != WDF_NO_HANDLE) { - ViiperWaitForEndpointQuiescence(endpoint, TRUE); + ViiperWaitForEndpointQuiescence(endpoint); } } } @@ -2020,7 +1995,7 @@ ViiperQuiesceResetByIdentity( UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[endpointIndex]; if (endpoint != WDF_NO_HANDLE) { - ViiperWaitForEndpointQuiescence(endpoint, FALSE); + ViiperWaitForEndpointQuiescence(endpoint); } } // Revalidate the lifecycle gate after every queue/rundown proof. @@ -2062,7 +2037,7 @@ ViiperQuiesceResetByIdentity( if (!found) { break; } - ViiperWaitForEndpointQuiescence(endpoint, FALSE); + ViiperWaitForEndpointQuiescence(endpoint); WdfSpinLockAcquire(controllerContext->BrokerLock); found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && @@ -2185,19 +2160,29 @@ ViiperEvtEndpointResetWorkItem( } VOID -ViiperEvtEndpointPurgeWorkItem( - _In_ WDFWORKITEM WorkItem +ViiperEvtEndpointQueuePurged( + _In_ WDFQUEUE Queue, + _In_ WDFCONTEXT Context ) { - UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); + UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); PAGED_CODE(); - // UdeCx requires every request forwarded out of the endpoint queue to be - // completed before PurgeComplete. The shared completion DPC releases both - // broker and direct-input ownership only after the terminal UdeCx call. - ViiperWaitForEndpointQuiescence(endpoint, TRUE); - // The admission barrier is closed and all pre-boundary publishers have - // drained, so no cached state can be republished after this clear. + UNREFERENCED_PARAMETER(Queue); + // WDF invokes this only after queued cancellation and every request it + // delivered to the endpoint driver has completed. Join direct-input work + // admitted from the controller queue before PURGE closed its BrokerLock + // gate, then clear cached state before acknowledging UdeCx. No queue-state + // polling is needed: this callback is the framework's purge-complete fence. + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); ViiperInvalidateEndpointInputReport(endpoint); UdecxUsbEndpointPurgeComplete(endpoint); } @@ -2212,10 +2197,8 @@ ViiperEvtEndpointPurge( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); - // Serialize the admission gate with both pending-slot allocation and the - // direct input fast path. This makes OperationsDrained a reliable purge - // barrier instead of allowing a transfer to start after the work item has - // already observed the event as signaled. + // Serialize the admission gate with pending-slot allocation and direct + // input before the queue begins cancellation. WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Purging, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); @@ -2223,11 +2206,10 @@ ViiperEvtEndpointPurge( ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - // UdeCx owns and has already stopped the associated queue before PURGE; - // client drivers must not change that queue's state. Only callbacks already - // forwarded to our broker/direct paths remain, and each is covered by the - // ActiveOperations fence before this passive work item may report complete. - WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); + // UdeCx requires its client to stop dispatch, cancel queued requests, and + // acknowledge only after every driver-owned request has completed. The + // asynchronous queue callback is that framework-owned completion fence. + WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); } VOID @@ -2253,6 +2235,7 @@ ViiperEvtEndpointStart( } WdfSpinLockRelease(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0) { + WdfIoQueueStart(endpointContext->Queue); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); } } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 0039f9a3..86df271d 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -275,7 +275,6 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; WDFWAITLOCK InputLock; - WDFWORKITEM PurgeWorkItem; WDFWORKITEM ResetWorkItem; WDFREQUEST ResetRequest; KEVENT OperationsDrained; @@ -334,7 +333,7 @@ EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue; -EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; +EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; @@ -345,7 +344,7 @@ NTSTATUS ViiperInitializeBroker(_In_ WDFDEVICE Device); NTSTATUS ViiperCreateVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); NTSTATUS ViiperDestroyVirtualDevice(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); BOOLEAN ViiperDestroyOwnedDevices(_In_ WDFDEVICE Controller, _In_ WDFFILEOBJECT OwnerFile); -VOID ViiperQuiesceControllerEndpoints(_In_ WDFDEVICE Controller); +VOID ViiperDrainControllerEndpointOperations(_In_ WDFDEVICE Controller); BOOLEAN ViiperQuiesceResetByIdentity( _In_ WDFDEVICE Controller, _In_ ULONGLONG DeviceId, diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 77ad02be..5b98b7c3 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.26 + 0.1.0.27 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 3c104545..1b8b462a 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.26" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.27" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 309b484b..eadc7fef 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.26 +DriverVer=08/13/2026,0.1.0.27 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index e5367399..92133df3 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -294,16 +294,30 @@ $endpointQuiescenceMatch = [regex]::Match( '(?ms)^ViiperWaitForEndpointQuiescence\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $endpointQuiescenceMatch.Success -or $endpointQuiescenceMatch.Groups['body'].Value -notmatch - 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfIoQueueGetState\s*\(\s*endpointContext->Queue[\s\S]*WDF_IO_QUEUE_IDLE[\s\S]*WdfIoQueueDriverNoRequests[\s\S]*endpointContext->ActiveOperations[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*KeDelayExecutionThread') { - throw 'Endpoint quiescence must pair read-only WDF queue ownership with BrokerLock-owned rundown and a passive retry.' + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfIoQueueGetState\s*\(\s*endpointContext->Queue[\s\S]*WdfIoQueueDriverNoRequests[\s\S]*endpointContext->ActiveOperations[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*KeDelayExecutionThread' -or + $endpointQuiescenceMatch.Groups['body'].Value -match + 'WDF_IO_QUEUE_IDLE|WdfIoQueueAcceptRequests|WdfIoQueueDispatchRequests') { + throw 'Reset and terminal pre-consumption quiescence must join only driver-owned requests and BrokerLock-owned rundown.' } -$purgeWorkItemMatch = [regex]::Match( +$purgeQueueCallbackMatch = [regex]::Match( $deviceSource, - '(?ms)^VOID\s+ViiperEvtEndpointPurgeWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $purgeWorkItemMatch.Success -or - $purgeWorkItemMatch.Groups['body'].Value -notmatch - 'ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*,\s*TRUE\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete') { - throw 'Endpoint purge-complete must remain behind stopped+idle WDF queue and forwarded-URB rundown proof.' + '(?ms)^VOID\s+ViiperEvtEndpointQueuePurged\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointPurgeMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointPurge\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointStartMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointStart\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $purgeQueueCallbackMatch.Success -or + $purgeQueueCallbackMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*endpointContext->ActiveOperations[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)' -or + -not $endpointPurgeMatch.Success -or + $endpointPurgeMatch.Groups['body'].Value -notmatch + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*WdfIoQueuePurge\s*\(\s*endpointContext->Queue\s*,\s*ViiperEvtEndpointQueuePurged\s*,\s*Endpoint\s*\)' -or + -not $endpointStartMatch.Success -or + $endpointStartMatch.Groups['body'].Value -notmatch + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*WdfIoQueueStart\s*\(\s*endpointContext->Queue\s*\)[\s\S]*ViiperQueueEndpointLifecycleEvent') { + throw 'Endpoint PURGE/START must use the UdeCx-required asynchronous WDF queue lifecycle and keep admission closed until START.' } $resetWorkItemMatch = [regex]::Match( $deviceSource, @@ -313,16 +327,14 @@ if (-not $resetWorkItemMatch.Success -or 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetCurrent\s*\)[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Resetting\s*,\s*FALSE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperQueueAcknowledgedEndpointLifecycleEvent') { throw 'Endpoint reset publication must prove a live exact identity after DriverNoRequests/rundown and fail closed on removal.' } -foreach ($associatedQueueMutation in @( - 'WdfIoQueuePurge', +foreach ($forbiddenAssociatedQueueMutation in @( 'WdfIoQueuePurgeSynchronously', - 'WdfIoQueueStart', 'WdfIoQueueStop', 'WdfIoQueueStopSynchronously', 'WdfIoQueueDrain', 'WdfIoQueueDrainSynchronously')) { - if ($deviceSource -match ([regex]::Escape($associatedQueueMutation) + '\s*\(')) { - throw "UdeCx owns associated endpoint queue state; Device.c must not call $associatedQueueMutation." + if ($deviceSource -match ([regex]::Escape($forbiddenAssociatedQueueMutation) + '\s*\(')) { + throw "Associated endpoint queues must not use $forbiddenAssociatedQueueMutation." } } $resetIdentityMatch = [regex]::Match( @@ -330,7 +342,7 @@ $resetIdentityMatch = [regex]::Match( '(?ms)^BOOLEAN\s+ViiperQuiesceResetByIdentity\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $resetIdentityMatch.Success -or $resetIdentityMatch.Groups['body'].Value -notmatch - 'ViiperAcquireDeviceLockShared[\s\S]*device\s*!=\s*ExpectedDevice[\s\S]*DeviceId[\s\S]*Generation[\s\S]*ResetEpoch[\s\S]*ExpectedResetEpoch[\s\S]*Endpoints\[EndpointAddress\][\s\S]*endpoint\s*==\s*ExpectedEndpoint[\s\S]*ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*,\s*FALSE\s*\)[\s\S]*if\s*\(\s*ReleaseGate\s*\)[\s\S]*endpointContext->Resetting[\s\S]*ViiperReleaseDeviceLockShared') { + 'ViiperAcquireDeviceLockShared[\s\S]*device\s*!=\s*ExpectedDevice[\s\S]*DeviceId[\s\S]*Generation[\s\S]*ResetEpoch[\s\S]*ExpectedResetEpoch[\s\S]*Endpoints\[EndpointAddress\][\s\S]*endpoint\s*==\s*ExpectedEndpoint[\s\S]*ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*\)[\s\S]*if\s*\(\s*ReleaseGate\s*\)[\s\S]*endpointContext->Resetting[\s\S]*ViiperReleaseDeviceLockShared') { throw 'Reset acknowledgement must prove and release only an exact pinned device/endpoint generation and reset epoch.' } $deviceResetAdmissionMatch = [regex]::Match( @@ -415,8 +427,8 @@ $selfManagedCleanupMatch = [regex]::Match( '(?ms)^VOID\s+ViiperEvtDeviceSelfManagedIoCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $selfManagedCleanupMatch.Success -or $selfManagedCleanupMatch.Groups['body'].Value -notmatch - 'ViiperPurgeOwnerOperations[\s\S]*ViiperQuiesceControllerEndpoints[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*PendingOperations[\s\S]*PendingCompletions[\s\S]*CompletionQueue[\s\S]*CompletionDpcActive[\s\S]*ViiperBeginControllerShutdown') { - throw 'Terminal rundown must prove every UdeCx endpoint queue stopped+idle, join completion-DPC ownership, then consume children.' + 'ViiperPurgeOwnerOperations[\s\S]*ViiperDrainControllerEndpointOperations[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*PendingOperations[\s\S]*PendingCompletions[\s\S]*CompletionQueue[\s\S]*CompletionDpcActive[\s\S]*ViiperBeginControllerShutdown') { + throw 'Terminal rundown must join VIIPER-owned endpoint work and the completion DPC before asynchronously consuming children.' } $controllerShutdownMatch = [regex]::Match( $deviceSource, diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 7c22f533..4d0eca55 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "f0cbf25f504adbec85a9b83d0c3e02dd7e3d9f4b829aabb8e183ba673f77baf4") { + "7dd52de02f7ffc0250647b7ee6281ba70766d8cea1d8214c97ed161643efeb9c") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 5b69d90f77e5135835919378563dc6c5d79205a8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 13 Aug 2026 23:56:37 -0500 Subject: [PATCH 218/240] Repair noncanonical broker upgrades --- internal/cmd/native_package_contract_test.go | 1 + internal/cmd/native_package_windows.go | 64 +++++++++++++---- internal/cmd/native_package_windows_test.go | 74 +++++++++++++++++++- internal/server/api/handler/ping_test.go | 2 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 10 files changed, 129 insertions(+), 24 deletions(-) diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 782f8d7f..b6075d0b 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -59,6 +59,7 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "--broker-quiesce-abort-handle", "--broker-handoff-handle", "AdditionalInheritedHandles", "coordinateDriverHelper(ctx", "quiescePriorServiceForDriver", "releaseServiceForBrokerHandoff", + "removeWeakExactOwnedService", "restoreQuiescedPriorService", "driverHelperSettled", "nativePackageRebootRequiredError", "parseNativePackageInstallProof(text, processExitCode)", diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index ccae99d9..5a5c96e6 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -71,6 +71,8 @@ type windowsNativePackageTransaction struct { priorServiceNonCrash bool priorExecutableRelease func() stoppedTrustedService bool + weakServiceMutation bool + weakServiceRemoved bool temporaryPath string backupPath string @@ -570,19 +572,8 @@ func (t *windowsNativePackageTransaction) Prepare( // publish the canonical broker image. Any failure must prove rollback before // the still-running helper may touch its captured driver snapshot again. t.nestedMutationStarted = true - if t.service != nil && snapshot.disposition == nativePackageServiceWeakExactOwned { - if snapshot.wasRunning { - if err := stopNativeService(ctx, t.service, waitContext); err != nil { - return fmt.Errorf("stop weak exact-owned %s: %w", NativeBrokerServiceName, err) - } - } - if err := t.service.Delete(); err != nil && - !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { - return fmt.Errorf("delete weak exact-owned %s: %w", NativeBrokerServiceName, err) - } - t.service.Close() //nolint:errcheck - t.service = nil - if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + if snapshot.disposition == nativePackageServiceWeakExactOwned { + if err := t.removeWeakExactOwnedService(ctx); err != nil { return err } } @@ -1036,7 +1027,11 @@ func (t *windowsNativePackageTransaction) quiescePriorServiceForDriver(ctx conte case nativePackageServiceAbsent: return nil case nativePackageServiceWeakExactOwned: - return errors.New("refusing to quiesce a weak exact-owned broker service before driver mutation") + // A noncanonical exact-owned service is not rollback material. Remove it + // under the held service mutex before the helper mutates the root bus; the + // nested broker transaction will recreate the canonical service only after + // the driver has reached its protected handoff point. + return t.removeWeakExactOwnedService(ctx) case nativePackageServiceTrusted: if t.service == nil { return errors.New("trusted broker service snapshot has no live SCM handle") @@ -1057,6 +1052,47 @@ func (t *windowsNativePackageTransaction) quiescePriorServiceForDriver(ctx conte } } +func (t *windowsNativePackageTransaction) removeWeakExactOwnedService(ctx context.Context) error { + if t.releaseServiceMutex == nil { + return errors.New("weak broker removal requires the held service mutex") + } + if t.serviceSnapshot.disposition != nativePackageServiceWeakExactOwned { + return errors.New("weak broker removal received a non-weak service snapshot") + } + if t.weakServiceRemoved { + return nil + } + if t.service == nil { + return errors.New("weak exact-owned broker snapshot has no live SCM handle") + } + if t.manager == nil { + return errors.New("weak exact-owned broker snapshot has no live SCM manager") + } + + // The snapshot is deliberately excluded from rollback trust. Arm the + // fail-closed state before the first STOP/DELETE mutation: a partial failure + // must never restart or restore an image/configuration that was not proven. + t.weakServiceMutation = true + if t.serviceSnapshot.wasRunning { + if err := stopNativeService(ctx, t.service, waitContext); err != nil { + return fmt.Errorf("stop weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + } + if err := t.service.Delete(); err != nil && + !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + return fmt.Errorf("delete weak exact-owned %s: %w", NativeBrokerServiceName, err) + } + if err := t.service.Close(); err != nil { + return fmt.Errorf("close weak exact-owned %s after deletion: %w", NativeBrokerServiceName, err) + } + t.service = nil + if err := waitForNativePackageServiceDeletion(ctx, t.manager); err != nil { + return err + } + t.weakServiceRemoved = true + return nil +} + func (t *windowsNativePackageTransaction) releaseServiceForBrokerHandoff() error { if t.releaseServiceMutex == nil { return errors.New("broker handoff requires the held service mutex") diff --git a/internal/cmd/native_package_windows_test.go b/internal/cmd/native_package_windows_test.go index e4673238..d26f8de5 100644 --- a/internal/cmd/native_package_windows_test.go +++ b/internal/cmd/native_package_windows_test.go @@ -109,7 +109,7 @@ func TestNativePackageDriverCoordinationHoldsServiceMutexUntilBrokerHandoff(t *t } } -func TestNativePackageDriverQuiescenceStopsOnlyTrustedRunningService(t *testing.T) { +func TestNativePackageDriverQuiescenceStopsTrustedRunningService(t *testing.T) { t.Parallel() events := []string{} service := &fakeNativeService{events: &events, status: svc.Status{State: svc.Running}} @@ -130,16 +130,84 @@ func TestNativePackageDriverQuiescenceStopsOnlyTrustedRunningService(t *testing. transaction.stoppedTrustedService, service.status.State, events) } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("repeat trusted quiescence: %v", err) + } +} + +func TestNativePackageDriverQuiescenceRemovesWeakExactOwnedService(t *testing.T) { + t.Parallel() + for _, running := range []bool{false, true} { + running := running + t.Run(map[bool]string{false: "stopped", true: "running"}[running], func(t *testing.T) { + t.Parallel() + events := []string{} + state := svc.Stopped + if running { + state = svc.Running + } + service := &fakeNativeService{events: &events, status: svc.Status{State: state}} + manager := newFakeNativeSCM(service, &events) + transaction := &windowsNativePackageTransaction{ + serviceSnapshot: nativePackageServiceSnapshot{ + disposition: nativePackageServiceWeakExactOwned, + wasRunning: running, + }, + service: service, manager: manager, + releaseServiceMutex: func() {}, + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("quiesce weak service: %v", err) + } + want := []string{"service-delete", "service-open"} + if running { + want = append([]string{"service-stop"}, want...) + } + if !transaction.weakServiceMutation || !transaction.weakServiceRemoved || + transaction.service != nil || !service.deleted || !slices.Equal(events, want) { + t.Fatalf("weak service state mutation=%v removed=%v live=%v deleted=%v events=%v want=%v", + transaction.weakServiceMutation, transaction.weakServiceRemoved, + transaction.service != nil, service.deleted, events, want) + } + if err := transaction.quiescePriorServiceForDriver(context.Background()); err != nil { + t.Fatalf("repeat weak quiescence: %v", err) + } + if !slices.Equal(events, want) { + t.Fatalf("repeat weak quiescence mutated service again: events=%v want=%v", events, want) + } + }) + } +} + +func TestNativePackageDriverQuiescenceDoesNotRestoreWeakServiceOnDeleteFailure(t *testing.T) { + t.Parallel() + events := []string{} + service := &fakeNativeService{ + events: &events, status: svc.Status{State: svc.Running}, failDelete: errors.New("delete failed"), + } weak := &windowsNativePackageTransaction{ serviceSnapshot: nativePackageServiceSnapshot{ disposition: nativePackageServiceWeakExactOwned, wasRunning: true, }, - service: service, + service: service, manager: newFakeNativeSCM(service, &events), releaseServiceMutex: func() {}, } if err := weak.quiescePriorServiceForDriver(context.Background()); err == nil { - t.Fatal("weak exact-owned service was quiesced as a trusted rollback source") + t.Fatal("weak service delete failure was accepted") + } + if !weak.weakServiceMutation || weak.weakServiceRemoved || service.status.State != svc.Stopped || + service.deleted || !slices.Equal(events, []string{"service-stop", "service-delete"}) { + t.Fatalf("weak failure state mutation=%v removed=%v status=%d deleted=%v events=%v", + weak.weakServiceMutation, weak.weakServiceRemoved, service.status.State, + service.deleted, events) + } + if err := weak.Rollback(context.Background()); err != nil { + t.Fatalf("fail-closed weak rollback: %v", err) + } + if service.startCalls != 0 || service.status.State != svc.Stopped { + t.Fatalf("untrusted weak service was restarted: starts=%d status=%d", + service.startCalls, service.status.State) } } diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index b82dfd10..523bd27c 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.27", + ExpectedDriverPackageVersion: "0.1.0.28", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 8259c942..1c0dd7d1 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.27" + DriverPackageVersion = "0.1.0.28" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index b6b6f8b3..00c27d13 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "7dd52de02f7ffc0250647b7ee6281ba70766d8cea1d8214c97ed161643efeb9c" + const wantHex = "547a91c90017578afbe475221815e6eaa25f898eb07cabf26c21230e415cf1e5" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 5b98b7c3..11c0de9f 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/13/2026 - 0.1.0.27 + 0.1.0.28 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 1b8b462a..e8c9a395 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.27" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.28" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index eadc7fef..d7224776 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.27 +DriverVer=08/13/2026,0.1.0.28 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 4d0eca55..fbce97d7 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "7dd52de02f7ffc0250647b7ee6281ba70766d8cea1d8214c97ed161643efeb9c") { + "547a91c90017578afbe475221815e6eaa25f898eb07cabf26c21230e415cf1e5") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 2f3c38361f2112ee091a2a9a47f94b53db8411a6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 00:44:14 -0500 Subject: [PATCH 219/240] Start selected UdeCx endpoints without reset --- internal/server/api/handler/ping_test.go | 2 +- .../udecx/driver_dispatch_contract_test.go | 19 ++-- ...river_endpoint_quiescence_contract_test.go | 20 ----- internal/transport/udecx/host_test.go | 57 +++++++++--- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Device.c | 87 ++----------------- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 20 +++-- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 12 files changed, 82 insertions(+), 137 deletions(-) diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 523bd27c..56c84b7c 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -38,7 +38,7 @@ func TestPing(t *testing.T) { func TestPingReportsNegotiatedNativeBackend(t *testing.T) { want := &viipertypes.NativeUDEInfo{ ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, - ExpectedDriverPackageVersion: "0.1.0.28", + ExpectedDriverPackageVersion: "0.1.0.29", LoadedDriverBuildIdentity: strings.Repeat("a", 64), MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 25687198..77142ced 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -14,7 +14,7 @@ func TestNativeControllerNamesDeviceBeforeAssigningSecurity(t *testing.T) { "status = WdfDeviceInitAssignSDDLString(DeviceInit, &sddl);") } -func TestNativeDeviceInitializeDoesNotEnterResetProtocol(t *testing.T) { +func TestNativeConfigurationSelectionDoesNotEnterResetProtocol(t *testing.T) { device := normalizedContract(nativeCFunction(t, nativeContractSource(t, "native", "udecx", "driver", "Device.c"), "ViiperEvtEndpointsConfigure")) @@ -23,9 +23,12 @@ func TestNativeDeviceInitializeDoesNotEnterResetProtocol(t *testing.T) { "WdfRequestComplete(Request, STATUS_SUCCESS);", "return;", "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", - "status = ViiperBeginAcknowledgedDeviceReset(Device, Request);") - if strings.Count(device, "ViiperBeginAcknowledgedDeviceReset(Device, Request)") != 1 { - t.Fatal("initial endpoint publication can enter the post-enumeration reset protocol") + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;", + "case UdecxEndpointsConfigureTypeInterfaceSettingChange:") + if strings.Contains(device, "ViiperUdeOperationDeviceReset") || + strings.Contains(device, "ViiperBeginAcknowledgedDeviceReset") { + t.Fatal("dynamic endpoint configuration can enter the device-reset protocol") } } @@ -40,10 +43,10 @@ func TestNativePostEnumerationResetDoesNotBlockEnumerationOnUserMode(t *testing. t.Fatal("post-enumeration reset can block child enumeration on a user-mode acknowledgement") } configure := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointsConfigure")) - if !strings.Contains(configure, - "case UdecxEndpointsConfigureTypeDeviceConfigurationChange: status = ViiperBeginAcknowledgedDeviceReset(Device, Request);") { - t.Fatal("configuration replacement lost its acknowledged device-reset boundary") - } + requireContractOrder(t, configure, + "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", + "WdfRequestComplete(Request, STATUS_SUCCESS);", + "return;") } func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index c34a4603..19491b51 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -133,26 +133,6 @@ func TestNativeResetQuiescenceIsExactGenerationAndFailClosed(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") - deviceReset := normalizedContract(nativeCFunction(t, device, "ViiperBeginAcknowledgedDeviceReset")) - requireContractOrder(t, deviceReset, - "controllerContext->BrokerFaulted", - "InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE)", - "status = STATUS_DEVICE_BUSY;", - "} else {", - "InterlockedIncrement64(&deviceContext->ResetEpoch)", - "status = STATUS_SUCCESS;", - "if (!ViiperQuiesceResetByIdentity(", - "deviceContext->DeviceId", - "deviceContext->Generation", - "Device", - "resetEpoch", - "TRUE, FALSE))", - "WdfSpinLockAcquire(controllerContext->BrokerLock);", - "InterlockedExchange(&deviceContext->Resetting, FALSE);", - "WdfSpinLockRelease(controllerContext->BrokerLock);", - "return STATUS_DEVICE_NOT_READY;", - "ViiperQueueAcknowledgedDeviceLifecycleEvent(") - identityProof := normalizedContract(nativeCFunction(t, device, "ViiperQuiesceResetByIdentity")) requireContractOrder(t, identityProof, "ViiperAcquireDeviceLockShared(controllerContext);", diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index 052bdc19..a7837c8e 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -189,6 +189,25 @@ func (p *recordingProcessor) Lifecycle(_ context.Context, _ usb.Device, op Opera } func (p *recordingProcessor) Reset(_ usb.Device, identity DeviceIdentity) { p.resets <- identity } +type lifecycleOperationProcessor struct { + lifecycle chan Operation +} + +func (*lifecycleOperationProcessor) Process( + context.Context, usb.Device, Operation, +) (Completion, error) { + return Completion{}, nil +} + +func (p *lifecycleOperationProcessor) Lifecycle( + _ context.Context, _ usb.Device, op Operation, +) error { + p.lifecycle <- op + return nil +} + +func (*lifecycleOperationProcessor) Reset(usb.Device, DeviceIdentity) {} + type cancellableProcessor struct { started chan struct{} cancelled chan struct{} @@ -1534,10 +1553,7 @@ func TestHostRestartsInputPublisherAcrossD0WithoutResettingSequence(t *testing.T func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} - processor := &recordingProcessor{ - processed: make(chan uint64, 1), lifecycle: make(chan uint64, 3), - resets: make(chan DeviceIdentity, 1), - } + processor := &lifecycleOperationProcessor{lifecycle: make(chan Operation, 3)} host, _ := NewHost(driver, processor, 4) device := newInputPublisherTestDevice() identity, err := host.Register(context.Background(), 47, device) @@ -1562,15 +1578,25 @@ func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, Kind: OperationEndpointStart, } - select { - case <-processor.lifecycle: - case <-time.After(time.Second): - t.Fatal("D0 exit was not processed after the older sequence was retired") - } - select { - case sequence := <-processor.lifecycle: - t.Fatalf("superseded endpoint start reached lifecycle processor as sequence %d", sequence) - default: + deadline := time.After(time.Second) + d0ExitProcessed := false + for !d0ExitProcessed { + select { + case op := <-processor.lifecycle: + switch op.DeviceSequence { + case 1: + // A worker can finish dequeuing the older START before the + // already-delivered D0 barrier is announced centrally. Applying + // START and then D0-exit is safe; the invariant begins when exit + // processing finishes. + case 2: + d0ExitProcessed = true + default: + t.Fatalf("unexpected lifecycle sequence before D0 exit: %+v", op) + } + case <-deadline: + t.Fatal("D0 exit was not processed after the older sequence was retired") + } } device.reports <- []byte{1} select { @@ -1585,7 +1611,10 @@ func TestHostDoesNotResurrectInputFromPreD0ExitEndpointStart(t *testing.T) { Kind: OperationDeviceD0Entry, } select { - case <-processor.lifecycle: + case op := <-processor.lifecycle: + if op.Kind != OperationDeviceD0Entry || op.DeviceSequence != 3 { + t.Fatalf("unexpected lifecycle after D0 exit: %+v", op) + } case <-time.After(time.Second): t.Fatal("D0 entry was not processed") } diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 1c0dd7d1..72410e68 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.28" + DriverPackageVersion = "0.1.0.29" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 00c27d13..027a21d4 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "547a91c90017578afbe475221815e6eaa25f898eb07cabf26c21230e415cf1e5" + const wantHex = "2543cf51bfd48aa4b6639b13ebbf1911113df772ddc0ad504d4e0a183203526c" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 23ae0f76..9fa03cef 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1094,83 +1094,6 @@ ViiperEvtUsbDeviceSetFunctionSuspendAndWake( return STATUS_SUCCESS; } -static -NTSTATUS -ViiperBeginAcknowledgedDeviceReset( - _In_ UDECXUSBDEVICE Device, - _In_ WDFREQUEST Request - ) -{ - VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); - VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = - ViiperGetControllerContext(deviceContext->Controller); - NTSTATUS status; - ULONGLONG resetEpoch = 0; - - // Post-enumeration reset and device-configuration replacement are both - // asynchronous UdeCx reset boundaries. Close every client-owned admission - // path synchronously and permit only one reset transaction for a child. - // User mode stops and joins the publishers before acknowledging the - // operation; completion then reopens this exact kernel gate. - WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || - InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE || - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || - InterlockedCompareExchange(&deviceContext->Resetting, TRUE, FALSE) != FALSE) { - status = STATUS_DEVICE_BUSY; - } else { - resetEpoch = (ULONGLONG)InterlockedIncrement64(&deviceContext->ResetEpoch); - if (resetEpoch == 0) { - resetEpoch = (ULONGLONG)InterlockedIncrement64(&deviceContext->ResetEpoch); - } - status = STATUS_SUCCESS; - } - WdfSpinLockRelease(controllerContext->BrokerLock); - if (!NT_SUCCESS(status)) { - return STATUS_DEVICE_BUSY; - } - // Device callbacks are explicitly passive on the UDECXUSBDEVICE object. - // The unresolved asynchronous reset prevents UdeCx from resuming endpoint - // transfers, so a read-only DriverNoRequests sample plus endpoint rundown - // is stable until Request is completed. Join every endpoint before the - // reset is published to the owner; owner acknowledgement repeats this - // exact-generation proof immediately before completion. - if (!ViiperQuiesceResetByIdentity( - deviceContext->Controller, - deviceContext->DeviceId, - deviceContext->Generation, - Device, - WDF_NO_HANDLE, - resetEpoch, - 0, - TRUE, - FALSE)) { - // Removal/purge/fault won after this callback closed Resetting. Release - // only this callback-owned gate under the admission lock; the winning - // ShuttingDown/Purging/BrokerFaulted predicate remains closed. The - // caller owns the UdeCx request and completes the failed boundary. - WdfSpinLockAcquire(controllerContext->BrokerLock); - if ((ULONGLONG)InterlockedCompareExchange64( - &deviceContext->ResetEpoch, 0, 0) == resetEpoch) { - InterlockedExchange(&deviceContext->Resetting, FALSE); - } - WdfSpinLockRelease(controllerContext->BrokerLock); - return STATUS_DEVICE_NOT_READY; - } - ViiperInvalidateDeviceInputReports(Device); - status = ViiperQueueAcknowledgedDeviceLifecycleEvent( - Device, Request, ViiperUdeOperationDeviceReset); - if (!NT_SUCCESS(status)) { - WdfSpinLockAcquire(controllerContext->BrokerLock); - if ((ULONGLONG)InterlockedCompareExchange64( - &deviceContext->ResetEpoch, 0, 0) == resetEpoch) { - InterlockedExchange(&deviceContext->Resetting, FALSE); - } - WdfSpinLockRelease(controllerContext->BrokerLock); - } - return status; -} - static NTSTATUS ViiperCreateEndpointQueue( @@ -2260,8 +2183,14 @@ ViiperEvtEndpointsConfigure( WdfRequestComplete(Request, STATUS_SUCCESS); return; case UdecxEndpointsConfigureTypeDeviceConfigurationChange: - status = ViiperBeginAcknowledgedDeviceReset(Device, Request); - break; + // Selecting a configuration is the boundary that makes the newly + // created dynamic endpoint queues eligible for START. It is not a USB + // device reset. Holding this request for a user-mode reset round trip + // leaves every non-default endpoint in UdeCx's preceding PURGE state. + // Endpoint START/PURGE callbacks remain the authoritative ordered + // boundary for input, feedback, and media state. + WdfRequestComplete(Request, STATUS_SUCCESS); + return; case UdecxEndpointsConfigureTypeInterfaceSettingChange: status = ViiperQueueAcknowledgedInterfaceLifecycleEvent( Device, diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 11c0de9f..fdde6979 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,8 +13,8 @@ ViiperUde 17.0 x64 - 08/13/2026 - 0.1.0.28 + 08/14/2026 + 0.1.0.29 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index e8c9a395..6f62b924 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.28" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.29" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index d7224776..a8fa2323 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/13/2026,0.1.0.28 +DriverVer=08/14/2026,0.1.0.29 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 92133df3..b317a7ba 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -345,19 +345,23 @@ if (-not $resetIdentityMatch.Success -or 'ViiperAcquireDeviceLockShared[\s\S]*device\s*!=\s*ExpectedDevice[\s\S]*DeviceId[\s\S]*Generation[\s\S]*ResetEpoch[\s\S]*ExpectedResetEpoch[\s\S]*Endpoints\[EndpointAddress\][\s\S]*endpoint\s*==\s*ExpectedEndpoint[\s\S]*ViiperWaitForEndpointQuiescence\s*\(\s*endpoint\s*\)[\s\S]*if\s*\(\s*ReleaseGate\s*\)[\s\S]*endpointContext->Resetting[\s\S]*ViiperReleaseDeviceLockShared') { throw 'Reset acknowledgement must prove and release only an exact pinned device/endpoint generation and reset epoch.' } -$deviceResetAdmissionMatch = [regex]::Match( - $deviceSource, - '(?ms)^ViiperBeginAcknowledgedDeviceReset\s*\([^)]*\)\s*\{(?.*?)^\}') $endpointResetAdmissionMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperEvtEndpointReset\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $deviceResetAdmissionMatch.Success -or - $deviceResetAdmissionMatch.Groups['body'].Value -notmatch - 'BrokerFaulted[\s\S]*InterlockedCompareExchange\s*\(\s*&deviceContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*status\s*=\s*STATUS_DEVICE_BUSY[\s\S]*else[\s\S]*InterlockedIncrement64\s*\(\s*&deviceContext->ResetEpoch\s*\)[\s\S]*ViiperQuiesceResetByIdentity' -or - -not $endpointResetAdmissionMatch.Success -or +if (-not $endpointResetAdmissionMatch.Success -or $endpointResetAdmissionMatch.Groups['body'].Value -notmatch 'InterlockedCompareExchange\s*\(\s*&endpointContext->Resetting\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*else[\s\S]*ResetDeviceEpoch[\s\S]*deviceContext->ResetEpoch') { - throw 'Device reset must advance its private epoch only after admission, and endpoint reset must capture that epoch atomically.' + throw 'Endpoint reset must capture the device reset epoch atomically after admission.' +} +$endpointsConfigureMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointsConfigure\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointsConfigureMatch.Success -or + $endpointsConfigureMatch.Groups['body'].Value -notmatch + 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or + $endpointsConfigureMatch.Groups['body'].Value -match + 'ViiperBeginAcknowledgedDeviceReset|ViiperUdeOperationDeviceReset') { + throw 'Device configuration selection must complete directly so UdeCx can START the selected dynamic endpoints.' } if ($deviceSource -match 'callbacks\.EvtUsbDeviceReset\s*=' -or $deviceSource -match '(?m)^ViiperEvtUsbDeviceReset\s*\(' -or diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index fbce97d7..690ca3b3 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "547a91c90017578afbe475221815e6eaa25f898eb07cabf26c21230e415cf1e5") { + "2543cf51bfd48aa4b6639b13ebbf1911113df772ddc0ad504d4e0a183203526c") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From b43026584e2fe7ac32373a59ba1dd57986a8afcb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 08:15:14 -0500 Subject: [PATCH 220/240] Activate initially configured UdeCx endpoints --- .../udecx/driver_dispatch_contract_test.go | 25 +++++- ...river_endpoint_quiescence_contract_test.go | 12 ++- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Device.c | 81 +++++++++++++++---- native/udecx/driver/ViiperUde.h | 1 + native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 14 +++- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 11 files changed, 114 insertions(+), 31 deletions(-) diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index 77142ced..cd96aa7b 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -20,9 +20,13 @@ func TestNativeConfigurationSelectionDoesNotEnterResetProtocol(t *testing.T) { "ViiperEvtEndpointsConfigure")) requireContractOrder(t, device, "case UdecxEndpointsConfigureTypeDeviceInitialize:", + "ConfigureParams->EndpointsToConfigureCount", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE);", "WdfRequestComplete(Request, STATUS_SUCCESS);", "return;", "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", + "ConfigureParams->EndpointsToConfigureCount", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE);", "WdfRequestComplete(Request, STATUS_SUCCESS);", "return;", "case UdecxEndpointsConfigureTypeInterfaceSettingChange:") @@ -49,6 +53,16 @@ func TestNativePostEnumerationResetDoesNotBlockEnumerationOnUserMode(t *testing. "return;") } +func TestNativeInitialAttachOpensWorkingStateBeforePlugIn(t *testing.T) { + device := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperCreateVirtualDevice")) + requireContractOrder(t, device, + "InterlockedExchange(&deviceContext->InD0, TRUE);", + "ViiperClaimDeviceSlot(", + "UdecxUsbDevicePlugIn(device, &plugOptions);") +} + func TestNativeSuperSpeedPortsUseControllerGlobalNumbering(t *testing.T) { header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") @@ -758,12 +772,15 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. "ViiperInvalidateEndpointInputReport(endpoint);", "ViiperQueueAcknowledgedEndpointLifecycleEvent(") start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) - requireContractOrder(t, start, - "WdfSpinLockAcquire(controllerContext->BrokerLock);", + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint, TRUE);") { + t.Fatal("explicit endpoint START no longer performs the KMDF queue transition") + } + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, "InterlockedExchange(&endpointContext->Purging, FALSE);", - "WdfSpinLockRelease(controllerContext->BrokerLock);", + "endpointContext->StartAnnounced, TRUE, FALSE", "WdfIoQueueStart(endpointContext->Queue);", - "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart);") + "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);") cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) requireContractOrder(t, cleanup, diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index 19491b51..a961dedd 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -74,6 +74,7 @@ func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) requireContractOrder(t, purge, "InterlockedExchange(&endpointContext->Purging, TRUE);", + "InterlockedExchange(&endpointContext->StartAnnounced, FALSE);", "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", "WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint);") purgeComplete := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointQueuePurged")) @@ -83,10 +84,17 @@ func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { "ViiperInvalidateEndpointInputReport(endpoint);", "UdecxUsbEndpointPurgeComplete(endpoint);") start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) - requireContractOrder(t, start, + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint, TRUE);") { + t.Fatal("explicit UdeCx START no longer performs the queue-owning endpoint activation") + } + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, "InterlockedExchange(&endpointContext->Purging, FALSE);", + "endpointContext->StartAnnounced, TRUE, FALSE", + "if (StartQueue)", "WdfIoQueueStart(endpointContext->Queue);", - "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart);") + "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);", + "endpointContext->StartAnnounced, FALSE, TRUE") resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) requireContractOrder(t, resetWork, "resetCurrent = ViiperQuiesceResetByIdentity(", diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 72410e68..15239ed6 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.29" + DriverPackageVersion = "0.1.0.30" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 027a21d4..7e6cee3c 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "2543cf51bfd48aa4b6639b13ebbf1911113df772ddc0ad504d4e0a183203526c" + const wantHex = "ec059e2d6a603278aede029e73e628fa50b4f1ffe78c07275fea5671523fc0ec" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 9fa03cef..6ae2efb0 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -666,6 +666,11 @@ ViiperCreateVirtualDevice( deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; deviceContext->Speed = speed; deviceContext->MaxPendingOperations = input->MaxPendingOperations; + // A newly attached virtual USB device is already in working link state. + // UdeCx invokes LinkPowerEntry only when a later request resumes the child + // from low power, so waiting for that callback leaves the first selected + // endpoints permanently closed on systems which never suspend them. + InterlockedExchange(&deviceContext->InD0, TRUE); WdfObjectReference(ownerFile); InterlockedExchange(&deviceContext->OwnerReferenced, 1); @@ -2124,6 +2129,7 @@ ViiperEvtEndpointPurge( // input before the queue begins cancellation. WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Purging, TRUE); + InterlockedExchange(&endpointContext->StartAnnounced, FALSE); WdfSpinLockRelease(controllerContext->BrokerLock); InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperInvalidateEndpointInputReport(Endpoint); @@ -2135,32 +2141,62 @@ ViiperEvtEndpointPurge( WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); } +static VOID -ViiperEvtEndpointStart( - _In_ UDECXUSBENDPOINT Endpoint +ViiperActivateEndpoint( + _In_ UDECXUSBENDPOINT Endpoint, + _In_ BOOLEAN StartQueue ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN active = FALSE; + BOOLEAN announce = FALSE; + NTSTATUS status = STATUS_SUCCESS; - // UdeCx defines START as the boundary at which both the endpoint queue and - // any client-owned forwarded paths may resume. Open the kernel admission - // gate before publishing that boundary to user mode. Publishing first lets - // the newly started input publisher race back through SUBMIT_INPUT_REPORT - // while Purging is still true, consuming and discarding the first fresh - // sequence after resume. + // Device configuration is the hardware-selection boundary for dynamic + // endpoints. UdeCx does not issue a separate START callback for every + // newly selected endpoint on all supported Windows builds, so publish that + // selection before completing the configuration request. A later explicit + // START still owns the KMDF queue transition, but its user-mode activation + // is deduplicated by StartAnnounced. PURGE closes both gates together. InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); WdfSpinLockAcquire(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0) { + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0) { InterlockedExchange(&endpointContext->Purging, FALSE); + active = TRUE; + announce = InterlockedCompareExchange( + &endpointContext->StartAnnounced, TRUE, FALSE) == FALSE; } WdfSpinLockRelease(controllerContext->BrokerLock); - if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0) { + if (!active) { + return; + } + if (StartQueue) { WdfIoQueueStart(endpointContext->Queue); - (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointStart); } + if (announce) { + status = ViiperQueueEndpointLifecycleEvent( + Endpoint, ViiperUdeOperationEndpointStart); + if (!NT_SUCCESS(status)) { + // Permit a later explicit START to retry publication. Compare- + // exchange preserves a PURGE which may already have cleared the + // announcement while the notification path was being dispatched. + (VOID)InterlockedCompareExchange( + &endpointContext->StartAnnounced, FALSE, TRUE); + } + } +} + +VOID +ViiperEvtEndpointStart( + _In_ UDECXUSBENDPOINT Endpoint + ) +{ + ViiperActivateEndpoint(Endpoint, TRUE); } VOID @@ -2171,6 +2207,7 @@ ViiperEvtEndpointsConfigure( ) { NTSTATUS status = STATUS_SUCCESS; + ULONG endpointIndex; switch (ConfigureParams->ConfigureType) { case UdecxEndpointsConfigureTypeDeviceInitialize: @@ -2178,8 +2215,15 @@ ViiperEvtEndpointsConfigure( // post-enumeration device reset. It can run more than once while the // child is being initialized. Completing it synchronously avoids // introducing a user-mode reset dependency before Windows can finish - // enumerating the child; endpoint START/PURGE and later configuration - // changes retain their existing state and media lifecycle handling. + // enumerating the child. Announce only the exact endpoint handles UdeCx + // selected; this also covers Windows builds which do not follow a new + // dynamic endpoint with a separate START callback. + for (endpointIndex = 0; + endpointIndex < ConfigureParams->EndpointsToConfigureCount; + ++endpointIndex) { + ViiperActivateEndpoint( + ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE); + } WdfRequestComplete(Request, STATUS_SUCCESS); return; case UdecxEndpointsConfigureTypeDeviceConfigurationChange: @@ -2187,8 +2231,15 @@ ViiperEvtEndpointsConfigure( // created dynamic endpoint queues eligible for START. It is not a USB // device reset. Holding this request for a user-mode reset round trip // leaves every non-default endpoint in UdeCx's preceding PURGE state. - // Endpoint START/PURGE callbacks remain the authoritative ordered - // boundary for input, feedback, and media state. + // Publish the selected endpoints before completing this asynchronous + // configuration boundary. PURGE remains authoritative for release and + // a later explicit START performs only the KMDF queue transition. + for (endpointIndex = 0; + endpointIndex < ConfigureParams->EndpointsToConfigureCount; + ++endpointIndex) { + ViiperActivateEndpoint( + ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE); + } WdfRequestComplete(Request, STATUS_SUCCESS); return; case UdecxEndpointsConfigureTypeInterfaceSettingChange: diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 86df271d..8c66cd92 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -280,6 +280,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { KEVENT OperationsDrained; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; + volatile LONG StartAnnounced; volatile LONG Resetting; volatile LONG64 ResetDeviceEpoch; volatile LONG ActiveOperations; diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index fdde6979..e9d32794 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.29 + 0.1.0.30 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 6f62b924..6a848183 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.29" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.30" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index a8fa2323..e4d6a1a1 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.29 +DriverVer=08/14/2026,0.1.0.30 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index b317a7ba..73bf38b4 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -308,15 +308,21 @@ $endpointPurgeMatch = [regex]::Match( $endpointStartMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperEvtEndpointStart\s*\([^)]*\)\s*\{(?.*?)^\}') +$endpointActivateMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperActivateEndpoint\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $purgeQueueCallbackMatch.Success -or $purgeQueueCallbackMatch.Groups['body'].Value -notmatch 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*endpointContext->ActiveOperations[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)' -or -not $endpointPurgeMatch.Success -or $endpointPurgeMatch.Groups['body'].Value -notmatch - 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*WdfIoQueuePurge\s*\(\s*endpointContext->Queue\s*,\s*ViiperEvtEndpointQueuePurged\s*,\s*Endpoint\s*\)' -or + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->StartAnnounced\s*,\s*FALSE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*WdfIoQueuePurge\s*\(\s*endpointContext->Queue\s*,\s*ViiperEvtEndpointQueuePurged\s*,\s*Endpoint\s*\)' -or -not $endpointStartMatch.Success -or $endpointStartMatch.Groups['body'].Value -notmatch - 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*WdfIoQueueStart\s*\(\s*endpointContext->Queue\s*\)[\s\S]*ViiperQueueEndpointLifecycleEvent') { + 'ViiperActivateEndpoint\s*\(\s*Endpoint\s*,\s*TRUE\s*\)' -or + -not $endpointActivateMatch.Success -or + $endpointActivateMatch.Groups['body'].Value -notmatch + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*StartAnnounced[\s\S]*if\s*\(\s*StartQueue\s*\)[\s\S]*WdfIoQueueStart\s*\(\s*endpointContext->Queue\s*\)[\s\S]*ViiperQueueEndpointLifecycleEvent') { throw 'Endpoint PURGE/START must use the UdeCx-required asynchronous WDF queue lifecycle and keep admission closed until START.' } $resetWorkItemMatch = [regex]::Match( @@ -358,10 +364,10 @@ $endpointsConfigureMatch = [regex]::Match( '(?ms)^VOID\s+ViiperEvtEndpointsConfigure\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $endpointsConfigureMatch.Success -or $endpointsConfigureMatch.Groups['body'].Value -notmatch - 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or + 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*EndpointsToConfigureCount[\s\S]*ViiperActivateEndpoint\s*\([\s\S]*FALSE\s*\)[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or $endpointsConfigureMatch.Groups['body'].Value -match 'ViiperBeginAcknowledgedDeviceReset|ViiperUdeOperationDeviceReset') { - throw 'Device configuration selection must complete directly so UdeCx can START the selected dynamic endpoints.' + throw 'Device configuration selection must announce selected dynamic endpoints before completing directly.' } if ($deviceSource -match 'callbacks\.EvtUsbDeviceReset\s*=' -or $deviceSource -match '(?m)^ViiperEvtUsbDeviceReset\s*\(' -or diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 690ca3b3..df3c9c0a 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "2543cf51bfd48aa4b6639b13ebbf1911113df772ddc0ad504d4e0a183203526c") { + "ec059e2d6a603278aede029e73e628fa50b4f1ffe78c07275fea5671523fc0ec") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From e2dd76aa956faf29a0eda18df0722b9a345ded4a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 10:16:48 -0500 Subject: [PATCH 221/240] Complete native Xbox input initialization --- cmd/viiper/viiper.go | 2 +- device/xbox360/device.go | 105 ++++++++++++++++++---- device/xbox360/scheduled_input_test.go | 42 ++++++++- internal/cmd/service_windows.go | 13 ++- internal/cmd/service_windows_test.go | 46 +++++++++- internal/log/logging.go | 86 +++++++++++++++++- internal/log/logging_test.go | 58 ++++++++++++ internal/transport/udecx/host.go | 5 ++ internal/transport/udecx/host_test.go | 34 +++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- internal/updater/updater.go | 8 +- internal/updater/updater_test.go | 38 ++++++++ native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- usb/device.go | 13 +++ 17 files changed, 429 insertions(+), 31 deletions(-) create mode 100644 internal/log/logging_test.go diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index 471b6239..57590595 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -97,7 +97,7 @@ func findUserConfig(args []string) string { func setupRawLogger(cli *config.CLI, logger *slog.Logger, closeFiles *[]io.Closer) log.RawLogger { if cli.Log.RawFile != "" { // nolint - f, err := os.OpenFile(cli.Log.RawFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) // nolint + f, err := log.OpenBoundedFile(cli.Log.RawFile, 0o644) // nolint if err != nil { logger.Error("failed to open raw log file", "file", cli.Log.RawFile, "error", err) // nolint return log.NewRaw(nil) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index bae0fc39..b3bdab46 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "sync" "time" @@ -14,17 +15,32 @@ import ( ) type Xbox360 struct { - inputMu sync.RWMutex - inputState InputState - inputSignal chan struct{} - rumbleDispatchMu sync.Mutex - rumbleMu sync.Mutex - rumbleFunc func(XRumbleState) - rumbleState XRumbleState - rumbleSeen bool - descriptor usb.Descriptor + inputMu sync.RWMutex + inputState InputState + inputSignal chan struct{} + nativeInputMu sync.Mutex + nativeDataStage uint8 + nativeControlSent bool + rumbleDispatchMu sync.Mutex + rumbleMu sync.Mutex + rumbleFunc func(XRumbleState) + rumbleState XRumbleState + rumbleSeen bool + descriptor usb.Descriptor } +var nativeDataInitializationReports = [...][]byte{ + {0x01, 0x03, 0x0e}, + {0x02, 0x03, 0x00}, + {0x03, 0x03, 0x03}, + {0x08, 0x03, 0x00}, + {0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0xe4, 0xf2, 0xb3, 0xf8, + 0x49, 0xf3, 0xb0, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x03, 0x03}, +} + +var nativeControlInitializationReport = [...]byte{0x05, 0x03, 0x00} + type Xbox360CreateOptions struct { SubType *uint8 `json:"subType"` } @@ -129,29 +145,83 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out // ReadInterruptInput implements usb.InterruptInputDevice for the native UDE // input lane without changing the USB/IP report ownership contract. func (x *Xbox360) ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) { - return x.readInterruptInput(ctx, nil, ep, dst) + written, _, err := x.readInterruptInput(ctx, nil, ep, dst) + return written, err } func (x *Xbox360) ReadScheduledInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, ) (int, error) { + written, _, err := x.readInterruptInput(ctx, deadline, ep, dst) + return written, err +} + +func (x *Xbox360) ReadClassifiedScheduledInterruptInput( + ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, +) (int, bool, error) { return x.readInterruptInput(ctx, deadline, ep, dst) } +func (x *Xbox360) SupportsInterruptInputEndpoint(ep uint32) bool { + return ep == 1 || ep == 3 +} + +func (x *Xbox360) nativeInitializationReport(ep uint32, dst []byte) (int, bool, error) { + x.nativeInputMu.Lock() + defer x.nativeInputMu.Unlock() + + var report []byte + switch ep { + case 1: + if int(x.nativeDataStage) >= len(nativeDataInitializationReports) { + return 0, false, nil + } + report = nativeDataInitializationReports[x.nativeDataStage] + case 3: + if x.nativeControlSent { + return 0, false, nil + } + report = nativeControlInitializationReport[:] + default: + return 0, false, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) + } + if len(dst) < len(report) { + return 0, false, io.ErrShortBuffer + } + copy(dst, report) + if ep == 1 { + x.nativeDataStage++ + } else { + x.nativeControlSent = true + } + return len(report), true, nil +} + func (x *Xbox360) readInterruptInput( ctx context.Context, deadline <-chan time.Time, ep uint32, dst []byte, -) (int, error) { - if ep != 1 { - return 0, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) +) (int, bool, error) { + if !x.SupportsInterruptInputEndpoint(ep) { + return 0, false, fmt.Errorf("Xbox 360 interrupt-IN endpoint %d is unsupported", ep) } if deadline != nil && ctx.Err() != nil { - return 0, ctx.Err() + return 0, false, ctx.Err() + } + if written, transition, err := x.nativeInitializationReport(ep, dst); err != nil || transition { + return written, transition, err + } + if ep == 3 { + select { + case <-ctx.Done(): + return 0, false, ctx.Err() + case <-deadline: + return 0, false, context.DeadlineExceeded + } } inputReady := false select { case <-ctx.Done(): if deadline != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { - return 0, ctx.Err() + return 0, false, ctx.Err() } case <-deadline: case <-x.inputSignal: @@ -164,12 +234,13 @@ func (x *Xbox360) readInterruptInput( default: } } - return 0, ctx.Err() + return 0, false, ctx.Err() } x.inputMu.RLock() st := x.inputState x.inputMu.RUnlock() - return st.BuildReportInto(dst) + written, err := st.BuildReportInto(dst) + return written, inputReady, err } func (x *Xbox360) emitRumble(rumble XRumbleState) { diff --git a/device/xbox360/scheduled_input_test.go b/device/xbox360/scheduled_input_test.go index 80eae36c..93910cde 100644 --- a/device/xbox360/scheduled_input_test.go +++ b/device/xbox360/scheduled_input_test.go @@ -2,6 +2,7 @@ package xbox360 import ( "context" + "errors" "testing" "time" ) @@ -14,8 +15,18 @@ func TestScheduledInterruptInputPreservesXboxStateAndDeadlineReplay(t *testing.T state := *NewInputState() state.Buttons, state.LT, state.RX = 0x1234, 199, -4567 dev.UpdateInputState(state) - buffer := make([]byte, 20) + buffer := make([]byte, 32) never := make(chan time.Time) + for index, want := range nativeDataInitializationReports { + written, transition, readErr := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, 1, buffer) + if readErr != nil || !transition || written != len(want) { + t.Fatalf("initialization report %d=(%d, %v, %v)", index, written, transition, readErr) + } + if got := buffer[:written]; string(got) != string(want) { + t.Fatalf("initialization report %d=%x want=%x", index, got, want) + } + } if written, readErr := dev.ReadScheduledInterruptInput(context.Background(), never, 1, buffer); readErr != nil || written != 20 { t.Fatalf("event read=(%d, %v)", written, readErr) } @@ -46,3 +57,32 @@ func TestScheduledInterruptInputPreservesXboxStateAndDeadlineReplay(t *testing.T t.Fatalf("post-cancel state=%x", buffer) } } + +func TestNativeInterruptEndpointSelectionAndControlInitialization(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatal(err) + } + for endpoint := uint32(1); endpoint <= 4; endpoint++ { + want := endpoint == 1 || endpoint == 3 + if got := dev.SupportsInterruptInputEndpoint(endpoint); got != want { + t.Fatalf("endpoint %d support=%v want=%v", endpoint, got, want) + } + } + buffer := make([]byte, 32) + never := make(chan time.Time) + written, transition, err := dev.ReadClassifiedScheduledInterruptInput( + context.Background(), never, 3, buffer) + if err != nil || !transition || written != len(nativeControlInitializationReport) { + t.Fatalf("control initialization=(%d, %v, %v)", written, transition, err) + } + if got := buffer[:written]; string(got) != string(nativeControlInitializationReport[:]) { + t.Fatalf("control initialization=%x want=%x", got, nativeControlInitializationReport) + } + deadline := make(chan time.Time, 1) + deadline <- time.Now() + if _, _, err = dev.ReadClassifiedScheduledInterruptInput( + context.Background(), deadline, 3, buffer); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("idle control endpoint error=%v want deadline exceeded", err) + } +} diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go index 39846424..456237ba 100644 --- a/internal/cmd/service_windows.go +++ b/internal/cmd/service_windows.go @@ -22,7 +22,8 @@ const NativeBrokerServiceName = "VIIPERNativeBroker" const serviceStopTimeout = 30 * time.Second type nativeBrokerService struct { - run func(context.Context, func()) error + run func(context.Context, func()) error + logger *slog.Logger } func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error { @@ -44,7 +45,7 @@ func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error c.KeyFile = path } c.serviceMode = true - handler := &nativeBrokerService{run: func(ctx context.Context, ready func()) error { + handler := &nativeBrokerService{logger: logger, run: func(ctx context.Context, ready func()) error { c.ready = ready return c.StartServer(ctx, logger, rawLogger) }} @@ -87,6 +88,7 @@ func (s *nativeBrokerService) Execute( case err := <-done: changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} if err != nil { + s.logFailure(err) return true, 1 } return true, 3 @@ -109,6 +111,7 @@ Running: case err := <-done: changes <- svc.Status{State: svc.StopPending, WaitHint: 1_000} if err != nil { + s.logFailure(err) return true, 1 } return false, 0 @@ -125,6 +128,12 @@ Running: } } +func (s *nativeBrokerService) logFailure(err error) { + if err != nil && s.logger != nil { + s.logger.Error("VIIPER native broker stopped unexpectedly", "error", err) + } +} + func waitForServiceStop(done <-chan error) (bool, uint32) { timer := time.NewTimer(serviceStopTimeout) defer timer.Stop() diff --git a/internal/cmd/service_windows_test.go b/internal/cmd/service_windows_test.go index f8af979f..b8c6f7fd 100644 --- a/internal/cmd/service_windows_test.go +++ b/internal/cmd/service_windows_test.go @@ -3,9 +3,12 @@ package cmd import ( + "bytes" "context" "errors" + "log/slog" "path/filepath" + "strings" "testing" "time" @@ -106,14 +109,51 @@ func TestNativeServiceDoesNotReportRunningBeforeBrokerReady(t *testing.T) { } func TestNativeServiceReportsUnexpectedBrokerFailure(t *testing.T) { - handler := &nativeBrokerService{run: func(context.Context, func()) error { - return errors.New("broker failed") - }} + var records bytes.Buffer + handler := &nativeBrokerService{ + logger: slog.New(slog.NewTextHandler(&records, nil)), + run: func(context.Context, func()) error { + return errors.New("broker failed") + }, + } changes := make(chan svc.Status, 8) specific, code := handler.Execute(nil, make(chan svc.ChangeRequest), changes) if !specific || code != 1 { t.Fatalf("service result=(specific=%v code=%d), want service-specific failure 1", specific, code) } + if logged := records.String(); !strings.Contains(logged, "VIIPER native broker stopped unexpectedly") || + !strings.Contains(logged, "broker failed") { + t.Fatalf("service failure log=%q", logged) + } +} + +func TestNativeServiceLogsFailureAfterReportingRunning(t *testing.T) { + var records bytes.Buffer + release := make(chan struct{}) + handler := &nativeBrokerService{ + logger: slog.New(slog.NewTextHandler(&records, nil)), + run: func(_ context.Context, ready func()) error { + ready() + <-release + return errors.New("live transport failed") + }, + } + changes := make(chan svc.Status, 8) + result := make(chan uint32, 1) + go func() { + _, code := handler.Execute(nil, make(chan svc.ChangeRequest), changes) + result <- code + }() + waitForServiceState(t, changes, svc.StartPending) + waitForServiceState(t, changes, svc.Running) + close(release) + waitForServiceState(t, changes, svc.StopPending) + if code := <-result; code != 1 { + t.Fatalf("live service failure exit code=%d want=1", code) + } + if logged := records.String(); !strings.Contains(logged, "live transport failed") { + t.Fatalf("live service failure log=%q", logged) + } } func waitForServiceState(t *testing.T, changes <-chan svc.Status, want svc.State) { diff --git a/internal/log/logging.go b/internal/log/logging.go index 13f69dce..2b7ba059 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -7,13 +7,97 @@ package log import ( "context" + "errors" "fmt" "io" "log/slog" "os" "strings" + "sync" ) +const maxLogFileBytes int64 = 16 << 20 + +type boundedFile struct { + mu sync.Mutex + file *os.File + path string + mode os.FileMode + size int64 + maxBytes int64 +} + +func openBoundedFile(path string, mode os.FileMode, maxBytes int64) (*boundedFile, error) { + if maxBytes <= 0 { + return nil, errors.New("bounded log file size must be positive") + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, mode) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + return &boundedFile{ + file: file, path: path, mode: mode, size: info.Size(), maxBytes: maxBytes, + }, nil +} + +// OpenBoundedFile opens a durable append-only session log. Recovery restarts +// retain the previous failure, while a fixed-size wrap prevents unattended +// trace logging from consuming the machine's disk. +func OpenBoundedFile(path string, mode os.FileMode) (io.WriteCloser, error) { + return openBoundedFile(path, mode, maxLogFileBytes) +} + +func (f *boundedFile) Write(payload []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.file == nil { + return 0, os.ErrClosed + } + originalLength := len(payload) + if int64(len(payload)) > f.maxBytes { + payload = payload[len(payload)-int(f.maxBytes):] + } + if f.size+int64(len(payload)) > f.maxBytes { + if err := f.file.Close(); err != nil { + f.file = nil + return 0, err + } + file, err := os.OpenFile( + f.path, os.O_CREATE|os.O_TRUNC|os.O_APPEND|os.O_RDWR, f.mode) + if err != nil { + f.file = nil + return 0, err + } + f.file = file + f.size = 0 + } + written, err := f.file.Write(payload) + f.size += int64(written) + if err != nil { + return written, err + } + if written != len(payload) { + return written, io.ErrShortWrite + } + return originalLength, nil +} + +func (f *boundedFile) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.file == nil { + return nil + } + err := f.file.Close() + f.file = nil + return err +} + // LevelTrace defines a custom slog level below Debug for very verbose output. const LevelTrace slog.Level = -8 @@ -46,7 +130,7 @@ func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l >= slog.LevelError }, h: stderrHandler}) var closeFiles []io.Closer if logFile != "" { - f, err := os.OpenFile(logFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + f, err := OpenBoundedFile(logFile, 0o644) if err != nil { return nil, nil, err } diff --git a/internal/log/logging_test.go b/internal/log/logging_test.go new file mode 100644 index 00000000..32ca2c16 --- /dev/null +++ b/internal/log/logging_test.go @@ -0,0 +1,58 @@ +package log + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBoundedFileAppendsAcrossRecoveryRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker.log") + if err := os.WriteFile(path, []byte("first failure\n"), 0o600); err != nil { + t.Fatal(err) + } + file, err := openBoundedFile(path, 0o600, 1024) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + if _, err = file.Write([]byte("recovered\n")); err != nil { + t.Fatal(err) + } + if err = file.Close(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(contents); got != "first failure\nrecovered\n" { + t.Fatalf("appended log=%q", got) + } +} + +func TestBoundedFileWrapsBeforeDiskLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker.log") + file, err := openBoundedFile(path, 0o600, 16) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + if _, err = file.Write([]byte("old-record\n")); err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("new-record\n")); err != nil { + t.Fatal(err) + } + if err = file.Close(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(contents); got != "new-record\n" || strings.Contains(got, "old") { + t.Fatalf("wrapped log=%q", got) + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 411185a0..6d5a757d 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -223,9 +223,14 @@ func fastInputEndpoints(dev usb.Device) map[uint8]fastInputEndpoint { if dev == nil || dev.GetDescriptor() == nil { return result } + selector, restrictEndpoints := dev.(usb.InterruptInputEndpointSelector) for _, iface := range dev.GetDescriptor().Interfaces { for _, endpoint := range iface.Endpoints { if endpoint.BEndpointAddress&0x80 != 0 && endpoint.BMAttributes&0x03 == 0x03 { + if restrictEndpoints && !selector.SupportsInterruptInputEndpoint( + uint32(endpoint.BEndpointAddress&0x0f)) { + continue + } // USB 2.0 wMaxPacketSize uses bits 0..10 for bytes and bits // 11..12 for additional high-bandwidth transactions. Allocate // the complete service opportunity while enforcing the native diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index a7837c8e..c6d91b25 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -784,6 +784,14 @@ type staleDeadlineInputPublisherTestDevice struct { calls atomic.Int32 } +type selectedInputPublisherTestDevice struct { + *inputPublisherTestDevice +} + +func (*selectedInputPublisherTestDevice) SupportsInterruptInputEndpoint(endpoint uint32) bool { + return endpoint == 1 +} + type controlledInputAttempt struct { context.Context deadline time.Time @@ -830,6 +838,32 @@ func newInputPublisherTestDevice() *inputPublisherTestDevice { return &inputPublisherTestDevice{descriptor: *base, reports: make(chan []byte, 4)} } +func TestFastInputEndpointsHonorDeviceEndpointSelection(t *testing.T) { + base := newInputPublisherTestDevice() + base.descriptor.Interfaces[0].Endpoints = append( + base.descriptor.Interfaces[0].Endpoints, + usb.EndpointDescriptor{ + BEndpointAddress: 0x82, BMAttributes: 0x03, + WMaxPacketSize: 32, BInterval: 4, + }, + usb.EndpointDescriptor{ + BEndpointAddress: 0x84, BMAttributes: 0x03, + WMaxPacketSize: 32, BInterval: 16, + }, + ) + device := &selectedInputPublisherTestDevice{inputPublisherTestDevice: base} + endpoints := fastInputEndpoints(device) + if len(endpoints) != 1 { + t.Fatalf("selected fast-input endpoints=%v want only 0x81", endpoints) + } + if _, ok := endpoints[0x81]; !ok { + t.Fatalf("selected fast-input endpoints=%v missing 0x81", endpoints) + } + if unrestricted := fastInputEndpoints(base); len(unrestricted) != 3 { + t.Fatalf("compatibility fast-input endpoints=%v want all three", unrestricted) + } +} + func newDirectInputPublisherTestDevice() *directInputPublisherTestDevice { return &directInputPublisherTestDevice{ inputPublisherTestDevice: newInputPublisherTestDevice(), diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 15239ed6..016a1f05 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.30" + DriverPackageVersion = "0.1.0.31" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 7e6cee3c..dfd5b549 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "ec059e2d6a603278aede029e73e628fa50b4f1ffe78c07275fea5671523fc0ec" + const wantHex = "341c4a2187ae6e4944761851648ef5772005addf2994dbafd9743dc2fed91306" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index cce42ad7..e1189a1d 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -93,8 +93,14 @@ type release struct { } func CheckUpdate(currentVersion string, notify config.UpdateNotify) { + // Source-bound local validation binaries are intentionally not release + // channels. They must never open update UI or emit a false installer error + // while an elevated package transaction is still running. + if currentVersion == "dev" || strings.HasSuffix(currentVersion, "-local-test") { + return + } cur, ok := parseVersion(currentVersion) - if !ok && currentVersion != "dev" { + if !ok { slog.Error("failed to parse current version", "version", currentVersion) return } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 768acd0b..48380d67 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,8 +1,13 @@ package updater import ( + "log/slog" + "net/http" "strings" "testing" + "time" + + "github.com/Alia5/VIIPER/internal/config" ) func TestRuntimeURLsUseHbashtonRepository(t *testing.T) { @@ -24,6 +29,39 @@ func TestRuntimeURLsUseHbashtonRepository(t *testing.T) { } } +func TestLocalTestBuildSkipsReleaseNetworkAndParseErrors(t *testing.T) { + previousClient := client + t.Cleanup(func() { client = previousClient }) + transportCalled := make(chan struct{}, 1) + client = &http.Client{ + Timeout: time.Second, + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + transportCalled <- struct{}{} + return nil, nil + }), + } + previousLogger := slog.Default() + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + var records strings.Builder + slog.SetDefault(slog.New(slog.NewTextHandler(&records, nil))) + + CheckUpdate("0.1.0-local-test", config.UpdateNotifyStable) + select { + case <-transportCalled: + t.Fatal("local-test update check reached the network") + default: + } + if records.Len() != 0 { + t.Fatalf("local-test update log=%q", records.String()) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + func TestReleaseURLEscapesTag(t *testing.T) { t.Parallel() diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index e9d32794..539ec36d 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.30 + 0.1.0.31 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 6a848183..46ac34d2 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.30" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.31" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index e4d6a1a1..e918b940 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.30 +DriverVer=08/14/2026,0.1.0.31 PnpLockDown=1 [DestinationDirs] diff --git a/usb/device.go b/usb/device.go index a77ea7c2..e69f240e 100644 --- a/usb/device.go +++ b/usb/device.go @@ -43,6 +43,19 @@ type InterruptInputDevice interface { ReadInterruptInput(ctx context.Context, ep uint32, dst []byte) (int, error) } +// InterruptInputEndpointSelector lets a device restrict the interrupt-IN +// endpoints owned by the native producer lane. Descriptors may expose +// auxiliary interrupt pipes which intentionally remain pending until a +// protocol-specific event occurs. Starting a periodic state publisher for +// those pipes would invent traffic and can break device enumeration. +// +// ep is the endpoint number without the direction bit, matching +// ReadInterruptInput. Devices which do not implement this interface retain the +// compatibility behavior of publishing every interrupt-IN endpoint. +type InterruptInputEndpointSelector interface { + SupportsInterruptInputEndpoint(ep uint32) bool +} + // ScheduledInterruptInputDevice is the allocation-free deadline extension of // InterruptInputDevice. Native transports keep one reusable timer per active // endpoint and pass its channel here instead of creating a new timer-backed From 3aeb7df73208504cdd0f0937fe71d03820037124 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 10:20:13 -0500 Subject: [PATCH 222/240] Keep installer identity self-test in lockstep --- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index df3c9c0a..b553ea22 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "ec059e2d6a603278aede029e73e628fa50b4f1ffe78c07275fea5671523fc0ec") { + "341c4a2187ae6e4944761851648ef5772005addf2994dbafd9743dc2fed91306") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 9e25f1423cc889aa400d7c5ce8ae1d5003a652eb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 14:36:42 -0500 Subject: [PATCH 223/240] Add source-level UdeCx lifecycle diagnostics --- .github/workflows/native-ude.yml | 7 + internal/transport/udecx/client_windows.go | 13 + .../transport/udecx/client_windows_test.go | 1 + internal/transport/udecx/host.go | 88 +++++++ internal/transport/udecx/protocol.go | 138 +++++++++- .../transport/udecx/protocol_contract_test.go | 77 ++++-- internal/transport/udecx/protocol_test.go | 54 +++- native/udecx/driver/Broker.c | 12 + native/udecx/driver/Device.c | 139 +++++++++- native/udecx/driver/Ioctl.c | 80 +++++- native/udecx/driver/Trace.c | 63 +++++ native/udecx/driver/ViiperUde.h | 20 ++ native/udecx/driver/ViiperUde.vcxproj | 13 +- native/udecx/include/ViiperUdeProtocol.h | 104 +++++++- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/Copy-ViiperCrashDumps.ps1 | 109 ++++++++ .../tools/New-ViiperUdeAttestationPackage.ps1 | 4 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 6 +- .../tools/Set-ViiperCrashDiagnostics.ps1 | 248 ++++++++++++++++++ .../tools/Test-ViiperUdeDebugArtifacts.ps1 | 91 +++++++ .../tools/Test-ViiperUdeReleaseBundle.ps1 | 6 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 6 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 39 +++ native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 24 files changed, 1266 insertions(+), 56 deletions(-) create mode 100644 native/udecx/driver/Trace.c create mode 100644 native/udecx/tools/Copy-ViiperCrashDumps.ps1 create mode 100644 native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 create mode 100644 native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 24dd40e4..3a32a08f 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -244,6 +244,13 @@ jobs: -ProjectPath ./native/udecx/driver/ViiperUde.vcxproj -InfPath ./native/udecx/x64/Release/ViiperUde/ViiperUde.inf -RequireStampedInf + - name: Verify matching private line and type debug artifacts + shell: pwsh + run: >- + ./native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 + -SysPath ./native/udecx/x64/Release/ViiperUde.sys + -PdbPath ./native/udecx/x64/Release/ViiperUde.pdb + -MapPath ./native/udecx/x64/Release/ViiperUde.map - name: Build transactional root-devnode and live-media helpers shell: pwsh run: | diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index ef5ede5c..76bef049 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -38,6 +38,7 @@ const ( ioctlCompleteOperation = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 4) << 2) | methodInDirect ioctlQueryStats = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 5) << 2) | methodBuffered ioctlSubmitInputReport = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | ((ioctlBase + 6) << 2) | methodInDirect + ioctlQueryLifecycleTrace = (fileDeviceUnknown << 16) | (fileReadData << 14) | ((ioctlBase + 7) << 2) | methodBuffered completionPortCloseKey uintptr = ^uintptr(0) fileSkipCompletionPortOnSuccess byte = 0x1 requiredCapabilities = AdvertisedCapabilities @@ -704,6 +705,18 @@ func (c *Client) QueryStats(ctx context.Context) (Stats, error) { return ParseStats(buffer) } +func (c *Client) QueryLifecycleTrace(ctx context.Context) (LifecycleTrace, error) { + buffer := make([]byte, LifecycleTraceSize) + written, err := c.ioctl(ctx, ioctlQueryLifecycleTrace, nil, buffer) + if err != nil { + return LifecycleTrace{}, err + } + if written != LifecycleTraceSize { + return LifecycleTrace{}, ErrInvalidSize + } + return ParseLifecycleTrace(buffer) +} + func (c *Client) beginIO() (windows.Handle, error) { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index 1998721f..d7b37fbd 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -156,6 +156,7 @@ func TestIOCTLCodesMatchPackedHeader(t *testing.T) { "complete": {ioctlCompleteOperation, 0x22e411}, "stats": {ioctlQueryStats, 0x226414}, "input": {ioctlSubmitInputReport, 0x22e419}, + "trace": {ioctlQueryLifecycleTrace, 0x22641c}, } for name, pair := range wants { if pair.got != pair.want { diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 6d5a757d..3f53f9f5 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "math" "sync" "sync/atomic" @@ -42,6 +43,10 @@ type Driver interface { QueryStats(context.Context) (Stats, error) } +type LifecycleTraceDriver interface { + QueryLifecycleTrace(context.Context) (LifecycleTrace, error) +} + // InputReportDriver is an optional, version-negotiated extension used only // for interrupt-IN reports. Keeping it separate preserves the ordered broker // contract for control, output, feedback, audio, and lifecycle traffic. @@ -374,6 +379,7 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { } return err } + go h.observeLifecycleRemoval(identity) h.mu.Lock() if h.devices[identity.DeviceID] != entry { @@ -421,6 +427,88 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { return nil } +func (h *Host) observeLifecycleRemoval(identity DeviceIdentity) { + driver, ok := h.driver.(LifecycleTraceDriver) + if !ok { + return + } + seen := make(map[uint64]struct{}, LifecycleTraceCapacity) + for _, delay := range []time.Duration{0, 100 * time.Millisecond, 500 * time.Millisecond, 2 * time.Second, 5 * time.Second} { + if delay != 0 { + timer := time.NewTimer(delay) + <-timer.C + } + queryCtx, cancel := context.WithTimeout(context.Background(), completionTimeout) + trace, err := driver.QueryLifecycleTrace(queryCtx) + cancel() + if err != nil { + slog.Warn("native UDE lifecycle trace query failed", + "device_id", identity.DeviceID, "generation", identity.Generation, + "error", err) + return + } + for _, record := range trace.Records { + if record.DeviceID != identity.DeviceID || record.Generation != identity.Generation { + continue + } + if _, duplicate := seen[record.PublishedSequence]; duplicate { + continue + } + seen[record.PublishedSequence] = struct{}{} + slog.Info("native UDE lifecycle", + "sequence", record.PublishedSequence, + "qpc", record.TimestampQPC, + "qpc_frequency", trace.PerformanceFrequency, + "source", lifecycleTraceSourceName(record.Source), + "event", lifecycleTraceEventName(record.Event), + "line", record.Line, + "caller", fmt.Sprintf("%#x", record.Caller), + "cpu", record.Processor, + "irql", record.IRQL, + "device_id", record.DeviceID, + "generation", record.Generation, + "device_object", fmt.Sprintf("%#x", record.DeviceObject), + "endpoint_object", fmt.Sprintf("%#x", record.EndpointObject), + "endpoint", fmt.Sprintf("%#02x", record.EndpointAddress), + "status", fmt.Sprintf("%#08x", uint32(record.Status)), + "active_operations", record.ActiveOperations, + "pending_operations", record.PendingOperations, + "queue_state", fmt.Sprintf("%#08x", record.QueueState)) + } + } +} + +func lifecycleTraceSourceName(source uint8) string { + switch source { + case TraceSourceDevice: + return "Device.c" + case TraceSourceBroker: + return "Broker.c" + case TraceSourceController: + return "Controller.c" + default: + return fmt.Sprintf("source-%d", source) + } +} + +func lifecycleTraceEventName(event uint16) string { + names := [...]string{ + "", "create-begin", "device-create-returned", "device-slot-claimed", + "plug-in-begin", "plug-in-returned", "remove-claimed", + "management-abort-begin", "management-abort-end", "plug-out-begin", + "plug-out-returned", "endpoint-purge-begin", "endpoint-operations-purged", + "endpoint-queue-purge-requested", "endpoint-queue-purged", + "endpoint-drain-begin", "endpoint-drain-end", + "endpoint-purge-complete-begin", "endpoint-purge-complete-end", + "endpoint-cleanup-begin", "endpoint-cleanup-end", "device-cleanup-begin", + "device-cleanup-end", "controller-shutdown-begin", "controller-shutdown-end", + } + if int(event) < len(names) && names[event] != "" { + return names[event] + } + return fmt.Sprintf("event-%d", event) +} + func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { if h.input == nil { return diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 016a1f05..ff96cc57 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -16,25 +16,28 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 10 + ABIMinor uint16 = 11 // DriverPackageVersion is the native driver package version built and // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.31" + DriverPackageVersion = "0.1.0.32" BuildIdentitySize = sha256.Size - HeaderSize = 16 - NegotiateRequestSize = 32 - NegotiateResponseSize = 88 - DescriptorRecordSize = 16 - CreateDeviceSize = 56 - DeviceIdentitySize = 32 - IsoPacketSize = 16 - OperationSize = 104 - CompletionSize = 72 - InputReportSize = 48 - StatsSize = 144 + HeaderSize = 16 + NegotiateRequestSize = 32 + NegotiateResponseSize = 88 + DescriptorRecordSize = 16 + CreateDeviceSize = 56 + DeviceIdentitySize = 32 + IsoPacketSize = 16 + OperationSize = 104 + CompletionSize = 72 + InputReportSize = 48 + StatsSize = 144 + LifecycleTraceRecordSize = 80 + LifecycleTraceSize = 41008 + LifecycleTraceCapacity = 512 MaxDevices = 32 MaxDescriptorBytes = 256 * 1024 @@ -78,9 +81,44 @@ const ( CapabilityStreams CapabilityDeviceLifecycle CapabilityInputReports + CapabilityLifecycleTrace ) -const AdvertisedCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | CapabilityInputReports +const AdvertisedCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | + CapabilityInputReports | CapabilityLifecycleTrace + +const ( + TraceSourceDevice uint8 = iota + 1 + TraceSourceBroker + TraceSourceController +) + +const ( + TraceCreateBegin uint16 = iota + 1 + TraceDeviceCreateReturned + TraceDeviceSlotClaimed + TracePlugInBegin + TracePlugInReturned + TraceRemoveClaimed + TraceManagementAbortBegin + TraceManagementAbortEnd + TracePlugOutBegin + TracePlugOutReturned + TraceEndpointPurgeBegin + TraceEndpointOperationsPurged + TraceEndpointQueuePurgeRequested + TraceEndpointQueuePurged + TraceEndpointDrainBegin + TraceEndpointDrainEnd + TraceEndpointPurgeCompleteBegin + TraceEndpointPurgeCompleteEnd + TraceEndpointCleanupBegin + TraceEndpointCleanupEnd + TraceDeviceCleanupBegin + TraceDeviceCleanupEnd + TraceControllerShutdownBegin + TraceControllerShutdownEnd +) // nativeSourceRevision must be injected by the production build. Native // transport startup deliberately has no VCS/on-disk fallback: the broker and @@ -594,6 +632,78 @@ func ParseStats(src []byte) (Stats, error) { }, nil } +type LifecycleTraceRecord struct { + PublishedSequence uint64 + TimestampQPC uint64 + Caller uint64 + DeviceID uint64 + DeviceObject uint64 + EndpointObject uint64 + Generation uint32 + Line uint32 + Status int32 + ActiveOperations int32 + PendingOperations int32 + QueueState uint32 + Event uint16 + Processor uint16 + Source uint8 + IRQL uint8 + EndpointAddress uint8 +} + +type LifecycleTrace struct { + LatestSequence uint64 + PerformanceFrequency uint64 + Records []LifecycleTraceRecord +} + +func ParseLifecycleTrace(src []byte) (LifecycleTrace, error) { + h, err := ParseHeader(src) + if err != nil { + return LifecycleTrace{}, err + } + if h.Size != LifecycleTraceSize || len(src) != LifecycleTraceSize || + binary.LittleEndian.Uint32(src[36:40]) != LifecycleTraceRecordSize || + binary.LittleEndian.Uint32(src[40:44]) != LifecycleTraceCapacity || + binary.LittleEndian.Uint32(src[44:48]) != 0 { + return LifecycleTrace{}, ErrInvalidSize + } + recordCount := binary.LittleEndian.Uint32(src[32:36]) + if recordCount > LifecycleTraceCapacity { + return LifecycleTrace{}, ErrInvalidRange + } + trace := LifecycleTrace{ + LatestSequence: binary.LittleEndian.Uint64(src[16:24]), + PerformanceFrequency: binary.LittleEndian.Uint64(src[24:32]), + Records: make([]LifecycleTraceRecord, 0, recordCount), + } + for index := uint32(0); index < recordCount; index++ { + offset := 48 + int(index)*LifecycleTraceRecordSize + record := src[offset : offset+LifecycleTraceRecordSize] + trace.Records = append(trace.Records, LifecycleTraceRecord{ + PublishedSequence: binary.LittleEndian.Uint64(record[0:8]), + TimestampQPC: binary.LittleEndian.Uint64(record[8:16]), + Caller: binary.LittleEndian.Uint64(record[16:24]), + DeviceID: binary.LittleEndian.Uint64(record[24:32]), + DeviceObject: binary.LittleEndian.Uint64(record[32:40]), + EndpointObject: binary.LittleEndian.Uint64(record[40:48]), + Generation: binary.LittleEndian.Uint32(record[48:52]), + Line: binary.LittleEndian.Uint32(record[52:56]), + Status: int32(binary.LittleEndian.Uint32(record[56:60])), + ActiveOperations: int32(binary.LittleEndian.Uint32(record[60:64])), + PendingOperations: int32(binary.LittleEndian.Uint32(record[64:68])), + QueueState: binary.LittleEndian.Uint32(record[68:72]), + Event: binary.LittleEndian.Uint16(record[72:74]), + Processor: binary.LittleEndian.Uint16(record[74:76]), + Source: record[76], + IRQL: record[77], + EndpointAddress: record[78], + }) + } + return trace, nil +} + func (m Completion) wireLayout() (transferLength uint32, isoBytes int, total int, err error) { if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { return 0, 0, 0, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index b0626ea8..427cbda6 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -158,6 +158,38 @@ type contractStats struct { InputReportsCompleted uint64 } +type contractLifecycleTraceRecord struct { + PublishedSequence uint64 + TimestampQpc uint64 + Caller uint64 + DeviceId uint64 + DeviceObject uint64 + EndpointObject uint64 + Generation uint32 + Line uint32 + Status int32 + ActiveOperations int32 + PendingOperations int32 + QueueState uint32 + Event uint16 + Processor uint16 + Source uint8 + Irql uint8 + EndpointAddress uint8 + Reserved uint8 +} + +type contractLifecycleTrace struct { + Header contractHeader + LatestSequence uint64 + PerformanceFrequency uint64 + RecordCount uint32 + RecordSize uint32 + Capacity uint32 + Reserved uint32 + Records [LifecycleTraceCapacity]contractLifecycleTraceRecord +} + func nativeContractSource(t *testing.T, name ...string) string { t.Helper() parts := append([]string{"..", "..", ".."}, name...) @@ -219,6 +251,8 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), + "VIIPER_UDE_CAP_LIFECYCLE_TRACE": uint64(CapabilityLifecycleTrace), + "VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY": LifecycleTraceCapacity, } for name, want := range numbers { if got := cDefineNumber(t, header, name); got != want { @@ -227,17 +261,19 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { } types := map[string]reflect.Type{ - "HEADER": reflect.TypeOf(contractHeader{}), - "NEGOTIATE_REQUEST": reflect.TypeOf(contractNegotiateRequest{}), - "NEGOTIATE_RESPONSE": reflect.TypeOf(contractNegotiateResponse{}), - "DESCRIPTOR_RECORD": reflect.TypeOf(contractDescriptorRecord{}), - "CREATE_DEVICE": reflect.TypeOf(contractCreateDevice{}), - "DEVICE_IDENTITY": reflect.TypeOf(contractDeviceIdentity{}), - "ISO_PACKET": reflect.TypeOf(contractISOPacket{}), - "OPERATION": reflect.TypeOf(contractOperation{}), - "COMPLETION": reflect.TypeOf(contractCompletion{}), - "INPUT_REPORT": reflect.TypeOf(contractInputReport{}), - "STATS": reflect.TypeOf(contractStats{}), + "HEADER": reflect.TypeOf(contractHeader{}), + "NEGOTIATE_REQUEST": reflect.TypeOf(contractNegotiateRequest{}), + "NEGOTIATE_RESPONSE": reflect.TypeOf(contractNegotiateResponse{}), + "DESCRIPTOR_RECORD": reflect.TypeOf(contractDescriptorRecord{}), + "CREATE_DEVICE": reflect.TypeOf(contractCreateDevice{}), + "DEVICE_IDENTITY": reflect.TypeOf(contractDeviceIdentity{}), + "ISO_PACKET": reflect.TypeOf(contractISOPacket{}), + "OPERATION": reflect.TypeOf(contractOperation{}), + "COMPLETION": reflect.TypeOf(contractCompletion{}), + "INPUT_REPORT": reflect.TypeOf(contractInputReport{}), + "STATS": reflect.TypeOf(contractStats{}), + "LIFECYCLE_TRACE_RECORD": reflect.TypeOf(contractLifecycleTraceRecord{}), + "LIFECYCLE_TRACE": reflect.TypeOf(contractLifecycleTrace{}), } wantSizes := map[string]uintptr{ "HEADER": HeaderSize, "NEGOTIATE_REQUEST": NegotiateRequestSize, @@ -245,6 +281,8 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "CREATE_DEVICE": CreateDeviceSize, "DEVICE_IDENTITY": DeviceIdentitySize, "ISO_PACKET": IsoPacketSize, "OPERATION": OperationSize, "COMPLETION": CompletionSize, "INPUT_REPORT": InputReportSize, "STATS": StatsSize, + "LIFECYCLE_TRACE_RECORD": LifecycleTraceRecordSize, + "LIFECYCLE_TRACE": LifecycleTraceSize, } sizePattern := regexp.MustCompile(`static_assert\(sizeof\(VIIPER_UDE_([A-Z_]+)\) == ([0-9]+),`) seenSizes := make(map[string]bool) @@ -323,7 +361,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { if !strings.Contains(header, `#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "`+DriverPackageVersion+`"`) { t.Fatalf("C driver package version does not match Go %q", DriverPackageVersion) } - advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\)`).MatchString(header) + advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\s*\|\s*VIIPER_UDE_CAP_LIFECYCLE_TRACE\)`).MatchString(header) if !advertised { t.Fatal("C advertised capability identity tuple does not match Go") } @@ -488,13 +526,14 @@ func verifyGUIDAndIOCTLContract(t *testing.T, header string) { access string } specs := map[string]ioctlSpec{ - "NEGOTIATE": {"ioctlNegotiate", 0, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, - "CREATE_DEVICE": {"ioctlCreateDevice", 1, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, - "DESTROY_DEVICE": {"ioctlDestroyDevice", 2, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, - "DEQUEUE_OPERATION": {"ioctlDequeueOperation", 3, "METHOD_OUT_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, - "COMPLETE_OPERATION": {"ioctlCompleteOperation", 4, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, - "QUERY_STATS": {"ioctlQueryStats", 5, "METHOD_BUFFERED", "FILE_READ_DATA"}, - "SUBMIT_INPUT_REPORT": {"ioctlSubmitInputReport", 6, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "NEGOTIATE": {"ioctlNegotiate", 0, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "CREATE_DEVICE": {"ioctlCreateDevice", 1, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DESTROY_DEVICE": {"ioctlDestroyDevice", 2, "METHOD_BUFFERED", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "DEQUEUE_OPERATION": {"ioctlDequeueOperation", 3, "METHOD_OUT_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "COMPLETE_OPERATION": {"ioctlCompleteOperation", 4, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "QUERY_STATS": {"ioctlQueryStats", 5, "METHOD_BUFFERED", "FILE_READ_DATA"}, + "SUBMIT_INPUT_REPORT": {"ioctlSubmitInputReport", 6, "METHOD_IN_DIRECT", "FILE_READ_DATA | FILE_WRITE_DATA"}, + "QUERY_LIFECYCLE_TRACE": {"ioctlQueryLifecycleTrace", 7, "METHOD_BUFFERED", "FILE_READ_DATA"}, } pattern := regexp.MustCompile(`(?m)^#define IOCTL_VIIPER_UDE_([A-Z_]+) CTL_CODE\(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE \+ ([0-9]+), (METHOD_[A-Z_]+), ([^)]+)\)$`) seen := make(map[string]bool) diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index dfd5b549..e224babb 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "341c4a2187ae6e4944761851648ef5772005addf2994dbafd9743dc2fed91306" + const wantHex = "96ecacd1be08c28c77429c56c0b6a39e593c59cc60481746d7ec2b268545f1dd" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -525,6 +525,51 @@ func TestIdentityAndStatsLayout(t *testing.T) { } } +func TestParseLifecycleTracePreservesDebugState(t *testing.T) { + raw := make([]byte, LifecycleTraceSize) + h, _ := NewHeader(LifecycleTraceSize) + putHeader(raw, h) + binary.LittleEndian.PutUint64(raw[16:24], 23) + binary.LittleEndian.PutUint64(raw[24:32], 10_000_000) + binary.LittleEndian.PutUint32(raw[32:36], 1) + binary.LittleEndian.PutUint32(raw[36:40], LifecycleTraceRecordSize) + binary.LittleEndian.PutUint32(raw[40:44], LifecycleTraceCapacity) + + record := raw[48 : 48+LifecycleTraceRecordSize] + binary.LittleEndian.PutUint64(record[0:8], 23) + binary.LittleEndian.PutUint64(record[8:16], 1_234_567) + binary.LittleEndian.PutUint64(record[16:24], 0xfffff80212345678) + binary.LittleEndian.PutUint64(record[24:32], 41) + binary.LittleEndian.PutUint64(record[32:40], 0xffff808000001000) + binary.LittleEndian.PutUint64(record[40:48], 0xffff808000002000) + binary.LittleEndian.PutUint32(record[48:52], 7) + binary.LittleEndian.PutUint32(record[52:56], 2225) + binary.LittleEndian.PutUint32(record[56:60], 0xc0000184) + binary.LittleEndian.PutUint32(record[60:64], 2) + binary.LittleEndian.PutUint32(record[64:68], 3) + binary.LittleEndian.PutUint32(record[68:72], 4) + binary.LittleEndian.PutUint16(record[72:74], TraceEndpointPurgeCompleteEnd) + binary.LittleEndian.PutUint16(record[74:76], 9) + record[76], record[77], record[78] = TraceSourceDevice, 0, 0x84 + + trace, err := ParseLifecycleTrace(raw) + if err != nil { + t.Fatal(err) + } + if trace.LatestSequence != 23 || trace.PerformanceFrequency != 10_000_000 || len(trace.Records) != 1 { + t.Fatalf("unexpected lifecycle trace header: %+v", trace) + } + got := trace.Records[0] + if got.PublishedSequence != 23 || got.TimestampQPC != 1_234_567 || + got.Caller != 0xfffff80212345678 || got.DeviceID != 41 || got.Generation != 7 || + got.Line != 2225 || uint32(got.Status) != 0xc0000184 || got.ActiveOperations != 2 || + got.PendingOperations != 3 || got.QueueState != 4 || + got.Event != TraceEndpointPurgeCompleteEnd || got.Processor != 9 || + got.Source != TraceSourceDevice || got.IRQL != 0 || got.EndpointAddress != 0x84 { + t.Fatalf("unexpected lifecycle trace record: %+v", got) + } +} + func FuzzParseOperation(f *testing.F) { f.Add([]byte{}) valid := make([]byte, OperationSize) @@ -578,11 +623,18 @@ func FuzzProtocolDecoders(f *testing.F) { h, _ = NewHeader(len(stats)) putHeader(stats, h) f.Add(stats) + trace := make([]byte, LifecycleTraceSize) + h, _ = NewHeader(len(trace)) + putHeader(trace, h) + binary.LittleEndian.PutUint32(trace[36:40], LifecycleTraceRecordSize) + binary.LittleEndian.PutUint32(trace[40:44], LifecycleTraceCapacity) + f.Add(trace) f.Fuzz(func(t *testing.T, raw []byte) { _, _ = ParseHeader(raw) _, _ = ParseNegotiateResponse(raw) _, _ = ParseStats(raw) _, _ = ParseOperation(raw) + _, _ = ParseLifecycleTrace(raw) }) } diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index cbb65d86..a495bfdf 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -2759,6 +2759,13 @@ ViiperAbortDeviceManagementOperations( _In_ NTSTATUS Status ) { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_MANAGEMENT_ABORT_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, Device, WDF_NO_HANDLE, 0, Status, + deviceContext->PendingOperations, 0); // Device removal has already closed Purging and retired the DeviceLock // table entry. Complete every still-published management request while // the UDE handle is valid, then release the slot's exact device/endpoint @@ -2766,6 +2773,11 @@ ViiperAbortDeviceManagementOperations( // helper also stably joins a slot already owned by a completing kernel // callback, including file cleanup racing the serialized control queue. ViiperAbortManagementOperationsMatching(Controller, Device, Status); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_MANAGEMENT_ABORT_END, deviceContext->DeviceId, + deviceContext->Generation, Device, WDF_NO_HANDLE, 0, Status, + deviceContext->PendingOperations, 0); } VOID diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 6ae2efb0..9fbc2962 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -616,6 +616,10 @@ ViiperCreateVirtualDevice( if (!NT_SUCCESS(status)) { return status; } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_CREATE_BEGIN, + input->DeviceId, input->Generation, WDF_NO_HANDLE, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); deviceInit = UdecxUsbDeviceInitAllocate(controller); if (deviceInit == NULL) { @@ -652,6 +656,10 @@ ViiperCreateVirtualDevice( // explicit instead of relying on the current UdeCx call context. attributes.ExecutionLevel = WdfExecutionLevelPassive; status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED, input->DeviceId, + input->Generation, device, WDF_NO_HANDLE, 0, status, 0, 0); if (!NT_SUCCESS(status)) { UdecxUsbDeviceInitFree(deviceInit); goto ExitAdmission; @@ -680,6 +688,10 @@ ViiperCreateVirtualDevice( goto ExitAdmission; } deviceContext->Slot = slot; + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); UDECX_USB_DEVICE_PLUG_IN_OPTIONS_INIT(&plugOptions); if (speed == UdecxUsbSuperSpeed) { @@ -690,7 +702,15 @@ ViiperCreateVirtualDevice( } else { plugOptions.Usb20PortNumber = (USHORT)(slot + 1); } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_IN_BEGIN, + deviceContext->DeviceId, deviceContext->Generation, device, WDF_NO_HANDLE, + 0, STATUS_SUCCESS, 0, 0); status = UdecxUsbDevicePlugIn(device, &plugOptions); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_PLUG_IN_RETURNED, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, status, 0, 0); if (!NT_SUCCESS(status)) { ViiperReleaseDeviceSlot(controllerContext, device, slot); WdfObjectDelete(device); @@ -797,8 +817,20 @@ ViiperDestroyVirtualDevice( if (!NT_SUCCESS(status)) { goto ExitAdmission; } + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_REMOVE_CLAIMED, + input->DeviceId, input->Generation, device, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_OUT_BEGIN, + input->DeviceId, input->Generation, device, WDF_NO_HANDLE, 0, + STATUS_SUCCESS, 0, 0); status = UdecxUsbDevicePlugOutAndDelete(device); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_PLUG_OUT_RETURNED, input->DeviceId, input->Generation, + device, WDF_NO_HANDLE, 0, status, 0, 0); if (!NT_SUCCESS(status)) { // PlugOutAndDelete consumes the UDE handle even when it reports a // failure. The request was nevertheless accepted at our ABI boundary; @@ -881,6 +913,11 @@ ViiperBeginControllerShutdown( PAGED_CODE(); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_BEGIN, 0, 0, WDF_NO_HANDLE, + WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + // Revoke all table handles in one transaction. PlugOutAndDelete can invoke // asynchronous UdeCx cleanup, so no controller lock may be held across it. ViiperAcquireDeviceLockExclusive(controllerContext); @@ -905,14 +942,30 @@ ViiperBeginControllerShutdown( for (index = 0; index < deviceCount; ++index) { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]); if (deviceContext->Plugged) { + ULONGLONG deviceId = deviceContext->DeviceId; + ULONG generation = deviceContext->Generation; + NTSTATUS status; + // A successful call starts UdeCx-owned asynchronous deletion. If // UdeCx rejects the request during controller removal, ordinary // parent teardown still owns and deletes the child object. - (VOID)UdecxUsbDevicePlugOutAndDelete(devices[index]); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_PLUG_OUT_BEGIN, deviceId, generation, + devices[index], WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + status = UdecxUsbDevicePlugOutAndDelete(devices[index]); + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_PLUG_OUT_RETURNED, deviceId, generation, + devices[index], WDF_NO_HANDLE, 0, status, 0, 0); } else { WdfObjectDelete(devices[index]); } } + VIIPER_TRACE_LIFECYCLE( + Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END, 0, 0, WDF_NO_HANDLE, + WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); } VOID @@ -930,6 +983,11 @@ ViiperEvtVirtualDeviceCleanup( return; } controllerContext = ViiperGetControllerContext(deviceContext->Controller); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CLEANUP_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + deviceContext->PendingOperations, 0); // Lifecycle notification admission reads OwnerFile while holding // BrokerLock. Revoke both that admission and the reference which pins the @@ -952,6 +1010,11 @@ ViiperEvtVirtualDeviceCleanup( if (ownerFile != WDF_NO_HANDLE) { WdfObjectDereference(ownerFile); } + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_DEVICE_CLEANUP_END, deviceContext->DeviceId, + deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + deviceContext->PendingOperations, 0); } static @@ -1156,6 +1219,11 @@ ViiperEvtEndpointCleanup( } controllerContext = ViiperGetControllerContext(deviceContext->Controller); address = endpointContext->Descriptor.bEndpointAddress; + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, address, + STATUS_SUCCESS, endpointContext->ActiveOperations, 0); ViiperAcquireDeviceLockExclusive(controllerContext); // Microsoft permits no ordinary object access after EvtCleanup is called, // even when a WDF reference postpones destruction. UdeCx therefore owns @@ -1180,6 +1248,11 @@ ViiperEvtEndpointCleanup( deviceContext->RetiredEndpoints[address] = TRUE; } ViiperReleaseDeviceLockExclusive(controllerContext); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_END, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, address, + STATUS_SUCCESS, endpointContext->ActiveOperations, 0); } NTSTATUS @@ -1438,7 +1511,8 @@ ViiperEvtFastInputQueueReady( { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); - VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request = WDF_NO_HANDLE; @@ -2095,24 +2169,63 @@ ViiperEvtEndpointQueuePurged( { UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = + ViiperGetDeviceContext(endpointContext->Device); + WDFDEVICE controller = deviceContext->Controller; + UDECXUSBDEVICE device = endpointContext->Device; + ULONGLONG deviceId = deviceContext->DeviceId; + ULONG generation = deviceContext->Generation; + UCHAR endpointAddress = endpointContext->Descriptor.bEndpointAddress; PAGED_CODE(); UNREFERENCED_PARAMETER(Queue); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); // WDF invokes this only after queued cancellation and every request it // delivered to the endpoint driver has completed. Join direct-input work // admitted from the controller queue before PURGE closed its BrokerLock // gate, then clear cached state before acknowledging UdeCx. No queue-state // polling is needed: this callback is the framework's purge-complete fence. + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, Executive, KernelMode, FALSE, NULL); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); NT_ASSERT(InterlockedCompareExchange( &endpointContext->ActiveOperations, 0, 0) == 0); ViiperInvalidateEndpointInputReport(endpoint); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); UdecxUsbEndpointPurgeComplete(endpoint); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END, deviceId, generation, + device, endpoint, endpointAddress, STATUS_SUCCESS, 0, 0); } VOID @@ -2125,6 +2238,14 @@ ViiperEvtEndpointPurge( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + // Serialize the admission gate with pending-slot allocation and direct // input before the queue begins cancellation. WdfSpinLockAcquire(controllerContext->BrokerLock); @@ -2134,11 +2255,25 @@ ViiperEvtEndpointPurge( InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_OPERATIONS_PURGED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); // UdeCx requires its client to stop dispatch, cancel queued requests, and // acknowledge only after every driver-owned request has completed. The // asynchronous queue callback is that framework-owned completion fence. WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, Endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); } static diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index d70dbd75..114a4215 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -58,7 +58,7 @@ ViiperHandleNegotiate( input->ClientNonce == 0 || input->Reserved != 0 || (input->RequestedCapabilities & ~(VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS)) != 0) { + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE)) != 0) { return STATUS_INVALID_PARAMETER; } if (input->Header.Major != VIIPER_UDE_ABI_MAJOR || @@ -167,6 +167,81 @@ ViiperHandleQueryStats( return STATUS_SUCCESS; } +static +NTSTATUS +ViiperHandleQueryLifecycleTrace( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request + ) +{ + NTSTATUS status; + VIIPER_UDE_LIFECYCLE_TRACE *output; + VIIPER_UDE_CONTROLLER_CONTEXT *context; + VIIPER_UDE_FILE_CONTEXT *fileContext; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + LARGE_INTEGER frequency; + ULONGLONG latestSequence; + ULONGLONG firstSequence; + ULONGLONG sequence; + + if (fileObject == WDF_NO_HANDLE) { + return STATUS_INVALID_HANDLE; + } + fileContext = ViiperGetFileContext(fileObject); + if (InterlockedCompareExchange(&fileContext->Negotiated, 0, 0) == 0 || + InterlockedCompareExchange(&fileContext->Closing, 0, 0) != 0) { + return STATUS_INVALID_DEVICE_STATE; + } + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + context = ViiperGetControllerContext(WdfIoQueueGetDevice(Queue)); + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->RecordSize = sizeof(output->Records[0]); + output->Capacity = VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY; + (VOID)KeQueryPerformanceCounter(&frequency); + output->PerformanceFrequency = (ULONGLONG)frequency.QuadPart; + + latestSequence = (ULONGLONG)ViiperReadCounter( + &context->LifecycleTraceSequence); + output->LatestSequence = latestSequence; + firstSequence = latestSequence > VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY + ? latestSequence - VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY + 1 + : 1; + for (sequence = firstSequence; sequence <= latestSequence; ++sequence) { + VIIPER_UDE_LIFECYCLE_TRACE_RECORD *source = + &context->LifecycleTrace[ + (sequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + ULONGLONG publishedBefore = (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + ULONGLONG publishedAfter; + + if (publishedBefore != sequence) { + continue; + } + KeMemoryBarrier(); + RtlCopyMemory( + &output->Records[output->RecordCount], source, sizeof(*source)); + KeMemoryBarrier(); + publishedAfter = (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + if (publishedAfter == sequence && + output->Records[output->RecordCount].PublishedSequence == sequence) { + ++output->RecordCount; + } + } + + WdfRequestSetInformation(Request, sizeof(*output)); + return STATUS_SUCCESS; +} + VOID ViiperEvtIoDeviceControlRoute( _In_ WDFQUEUE Queue, @@ -233,6 +308,9 @@ ViiperEvtIoDeviceControl( case IOCTL_VIIPER_UDE_QUERY_STATS: status = ViiperHandleQueryStats(Queue, Request); break; + case IOCTL_VIIPER_UDE_QUERY_LIFECYCLE_TRACE: + status = ViiperHandleQueryLifecycleTrace(Queue, Request); + break; case IOCTL_VIIPER_UDE_CREATE_DEVICE: status = ViiperCreateVirtualDevice(Queue, Request); break; diff --git a/native/udecx/driver/Trace.c b/native/udecx/driver/Trace.c new file mode 100644 index 00000000..958e226a --- /dev/null +++ b/native/udecx/driver/Trace.c @@ -0,0 +1,63 @@ +#include + +#include "ViiperUde.h" + +#pragma intrinsic(_ReturnAddress) + +VOID +ViiperTraceLifecycle( + _In_ WDFDEVICE Controller, + _In_ UCHAR Source, + _In_ USHORT Event, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ UCHAR EndpointAddress, + _In_ NTSTATUS Status, + _In_ LONG ActiveOperations, + _In_ ULONG QueueState, + _In_ ULONG Line + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD *record; + PROCESSOR_NUMBER processorNumber; + LARGE_INTEGER timestamp; + ULONGLONG sequence; + + controllerContext = ViiperGetControllerContext(Controller); + sequence = (ULONGLONG)InterlockedIncrement64( + &controllerContext->LifecycleTraceSequence); + record = &controllerContext->LifecycleTrace[ + (sequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + + (VOID)InterlockedExchange64((volatile LONG64 *)&record->PublishedSequence, 0); + KeMemoryBarrier(); + + timestamp = KeQueryPerformanceCounter(NULL); + KeGetCurrentProcessorNumberEx(&processorNumber); + record->TimestampQpc = (ULONGLONG)timestamp.QuadPart; + record->Caller = (ULONGLONG)(ULONG_PTR)_ReturnAddress(); + record->DeviceId = DeviceId; + record->DeviceObject = (ULONGLONG)(ULONG_PTR)Device; + record->EndpointObject = (ULONGLONG)(ULONG_PTR)Endpoint; + record->Generation = Generation; + record->Line = Line; + record->Status = Status; + record->ActiveOperations = ActiveOperations; + record->PendingOperations = InterlockedCompareExchange( + &controllerContext->PendingOperations, 0, 0); + record->QueueState = QueueState; + record->Event = Event; + record->Processor = (VIIPER_UDE_UINT16)( + processorNumber.Group * MAXIMUM_PROC_PER_GROUP + processorNumber.Number); + record->Source = Source; + record->Irql = (VIIPER_UDE_UINT8)KeGetCurrentIrql(); + record->EndpointAddress = EndpointAddress; + record->Reserved = 0; + + KeMemoryBarrier(); + (VOID)InterlockedExchange64( + (volatile LONG64 *)&record->PublishedSequence, (LONG64)sequence); +} diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 8c66cd92..4826d5eb 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -180,6 +180,9 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 IsoPackets; volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; + volatile LONG64 LifecycleTraceSequence; + DECLSPEC_ALIGN(8) VIIPER_UDE_LIFECYCLE_TRACE_RECORD + LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; // Sorted by DeviceId and protected by DeviceLock. The input producer uses // a shared binary lookup while lifecycle mutations retain exclusive access // to the physical UDE port table below. @@ -398,6 +401,23 @@ VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); _IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationCompleted(_In_ UDECXUSBENDPOINT Endpoint); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); +VOID ViiperTraceLifecycle( + _In_ WDFDEVICE Controller, + _In_ UCHAR Source, + _In_ USHORT Event, + _In_ ULONGLONG DeviceId, + _In_ ULONG Generation, + _In_opt_ UDECXUSBDEVICE Device, + _In_opt_ UDECXUSBENDPOINT Endpoint, + _In_ UCHAR EndpointAddress, + _In_ NTSTATUS Status, + _In_ LONG ActiveOperations, + _In_ ULONG QueueState, + _In_ ULONG Line); +#define VIIPER_TRACE_LIFECYCLE(Controller, Source, Event, DeviceId, Generation, Device, Endpoint, EndpointAddress, Status, ActiveOperations, QueueState) \ + ViiperTraceLifecycle((Controller), (Source), (Event), (DeviceId), (Generation), \ + (Device), (Endpoint), (EndpointAddress), (Status), (ActiveOperations), \ + (QueueState), __LINE__) NTSTATUS ViiperQueueEndpointLifecycleEvent( _In_ UDECXUSBENDPOINT Endpoint, _In_ VIIPER_UDE_OPERATION_KIND Kind); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 539ec36d..dc5d5b3c 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.31 + 0.1.0.32 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -59,10 +59,16 @@ $(IntDir);..\include;%(AdditionalIncludeDirectories) POOL_ZERO_DOWN_LEVEL_SUPPORT;%(PreprocessorDefinitions) true + ProgramDatabase + false + /Zo %(AdditionalOptions) %(AdditionalDependencies);usbd.lib - /PDBALTPATH:%_PDB% %(AdditionalOptions) + /DEBUG:FULL /PDBALTPATH:%_PDB% %(AdditionalOptions) + true + true + true certHash @@ -74,6 +80,7 @@ + @@ -104,6 +111,6 @@ - + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 46ac34d2..bd4458f8 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,8 +35,8 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(10) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.31" +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(11) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.32" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ @@ -69,9 +69,41 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_CAP_STREAMS VIIPER_UDE_UINT32_C(0x00000002) #define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) #define VIIPER_UDE_CAP_INPUT_REPORTS VIIPER_UDE_UINT32_C(0x00000008) +#define VIIPER_UDE_CAP_LIFECYCLE_TRACE VIIPER_UDE_UINT32_C(0x00000010) #define VIIPER_UDE_ADVERTISED_CAPABILITIES \ (VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | \ - VIIPER_UDE_CAP_INPUT_REPORTS) + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE) + +#define VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY VIIPER_UDE_UINT32_C(512) + +#define VIIPER_UDE_TRACE_SOURCE_DEVICE 1 +#define VIIPER_UDE_TRACE_SOURCE_BROKER 2 +#define VIIPER_UDE_TRACE_SOURCE_CONTROLLER 3 + +#define VIIPER_UDE_TRACE_CREATE_BEGIN 1 +#define VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED 2 +#define VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED 3 +#define VIIPER_UDE_TRACE_PLUG_IN_BEGIN 4 +#define VIIPER_UDE_TRACE_PLUG_IN_RETURNED 5 +#define VIIPER_UDE_TRACE_REMOVE_CLAIMED 6 +#define VIIPER_UDE_TRACE_MANAGEMENT_ABORT_BEGIN 7 +#define VIIPER_UDE_TRACE_MANAGEMENT_ABORT_END 8 +#define VIIPER_UDE_TRACE_PLUG_OUT_BEGIN 9 +#define VIIPER_UDE_TRACE_PLUG_OUT_RETURNED 10 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_BEGIN 11 +#define VIIPER_UDE_TRACE_ENDPOINT_OPERATIONS_PURGED 12 +#define VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED 13 +#define VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED 14 +#define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN 15 +#define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END 16 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN 17 +#define VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END 18 +#define VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_BEGIN 19 +#define VIIPER_UDE_TRACE_ENDPOINT_CLEANUP_END 20 +#define VIIPER_UDE_TRACE_DEVICE_CLEANUP_BEGIN 21 +#define VIIPER_UDE_TRACE_DEVICE_CLEANUP_END 22 +#define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_BEGIN 23 +#define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END 24 #if defined(_WIN32) #define VIIPER_UDE_IOCTL_BASE 0x900 @@ -82,6 +114,7 @@ typedef int32_t VIIPER_UDE_INT32; #define IOCTL_VIIPER_UDE_COMPLETE_OPERATION CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 4, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_VIIPER_UDE_QUERY_STATS CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 5, METHOD_BUFFERED, FILE_READ_DATA) #define IOCTL_VIIPER_UDE_SUBMIT_INPUT_REPORT CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 6, METHOD_IN_DIRECT, FILE_READ_DATA | FILE_WRITE_DATA) +#define IOCTL_VIIPER_UDE_QUERY_LIFECYCLE_TRACE CTL_CODE(FILE_DEVICE_UNKNOWN, VIIPER_UDE_IOCTL_BASE + 7, METHOD_BUFFERED, FILE_READ_DATA) #endif #pragma pack(push, 1) @@ -251,6 +284,38 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT64 InputReportsCompleted; } VIIPER_UDE_STATS; +typedef struct VIIPER_UDE_LIFECYCLE_TRACE_RECORD { + VIIPER_UDE_UINT64 PublishedSequence; + VIIPER_UDE_UINT64 TimestampQpc; + VIIPER_UDE_UINT64 Caller; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT64 DeviceObject; + VIIPER_UDE_UINT64 EndpointObject; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Line; + VIIPER_UDE_INT32 Status; + VIIPER_UDE_INT32 ActiveOperations; + VIIPER_UDE_INT32 PendingOperations; + VIIPER_UDE_UINT32 QueueState; + VIIPER_UDE_UINT16 Event; + VIIPER_UDE_UINT16 Processor; + VIIPER_UDE_UINT8 Source; + VIIPER_UDE_UINT8 Irql; + VIIPER_UDE_UINT8 EndpointAddress; + VIIPER_UDE_UINT8 Reserved; +} VIIPER_UDE_LIFECYCLE_TRACE_RECORD; + +typedef struct VIIPER_UDE_LIFECYCLE_TRACE { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 LatestSequence; + VIIPER_UDE_UINT64 PerformanceFrequency; + VIIPER_UDE_UINT32 RecordCount; + VIIPER_UDE_UINT32 RecordSize; + VIIPER_UDE_UINT32 Capacity; + VIIPER_UDE_UINT32 Reserved; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; +} VIIPER_UDE_LIFECYCLE_TRACE; + #pragma pack(pop) #if defined(__cplusplus) @@ -265,6 +330,8 @@ static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI dri static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); +static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); +static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L _Static_assert(sizeof(VIIPER_UDE_HEADER) == 16, "VIIPER_UDE_HEADER ABI drift"); _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_REQUEST ABI drift"); @@ -277,6 +344,8 @@ _Static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI dr _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); _Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); #endif /* @@ -297,6 +366,8 @@ typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 104) typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 48) ? 1 : -1]; typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 144) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_RECORD_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008) ? 1 : -1]; /* * A same-size field reorder is just as destructive as a size change but would @@ -416,4 +487,31 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, CleanupRetries, 124); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsSubmitted, 128); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsCompleted, 136); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, PublishedSequence, 0); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, TimestampQpc, 8); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Caller, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, DeviceId, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, DeviceObject, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, EndpointObject, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Generation, 48); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Line, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Status, 56); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, ActiveOperations, 60); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, PendingOperations, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, QueueState, 68); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Event, 72); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Processor, 74); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Source, 76); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Irql, 77); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, EndpointAddress, 78); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, Reserved, 79); + +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, LatestSequence, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, PerformanceFrequency, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordCount, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordSize, 36); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Capacity, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Reserved, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Records, 48); + #undef VIIPER_UDE_ASSERT_OFFSET diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index e918b940..a689e3fb 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.31 +DriverVer=08/14/2026,0.1.0.32 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Copy-ViiperCrashDumps.ps1 b/native/udecx/tools/Copy-ViiperCrashDumps.ps1 new file mode 100644 index 00000000..84fabc1d --- /dev/null +++ b/native/udecx/tools/Copy-ViiperCrashDumps.ps1 @@ -0,0 +1,109 @@ +[CmdletBinding()] +param( + [string]$Destination, + + [ValidateRange(0, 100)] + [int]$MaxMiniDumps = 5, + + [ValidatePattern('^S-1-(?:\d+-){1,14}\d+$')] + [string]$GrantReadToSID, + + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Crash-dump collection requires an elevated PowerShell session.' +} + +if ([string]::IsNullOrWhiteSpace($Destination)) { + $Destination = Join-Path $env:ProgramData 'Viiper\crash-dumps' +} +$destinationPath = [IO.Path]::GetFullPath($Destination) +$windowsPath = [IO.Path]::GetFullPath($env:SystemRoot).TrimEnd('\') +if ($destinationPath.TrimEnd('\') -eq $windowsPath -or + $destinationPath.StartsWith("$windowsPath\System32", + [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing unsafe crash-dump destination '$destinationPath'." +} + +$sources = @() +$memoryDump = Join-Path $env:SystemRoot 'MEMORY.DMP' +if (Test-Path -LiteralPath $memoryDump -PathType Leaf) { + $sources += Get-Item -LiteralPath $memoryDump +} +if ($MaxMiniDumps -gt 0) { + $miniDumpDirectory = Join-Path $env:SystemRoot 'Minidump' + if (Test-Path -LiteralPath $miniDumpDirectory -PathType Container) { + $sources += @(Get-ChildItem -LiteralPath $miniDumpDirectory -File -Filter '*.dmp' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First $MaxMiniDumps) + } +} +if ($sources.Count -eq 0) { + throw 'Windows has no MEMORY.DMP or minidump files to collect.' +} + +$requiredBytes = [uint64](($sources | Measure-Object Length -Sum).Sum) +$destinationRoot = [IO.Path]::GetPathRoot($destinationPath) +$drive = Get-CimInstance Win32_LogicalDisk -Filter ` + "DeviceID='$($destinationRoot.TrimEnd('\'))'" -ErrorAction Stop +$safetyBytes = 2GB +if ([uint64]$drive.FreeSpace -lt ($requiredBytes + $safetyBytes)) { + throw "Destination volume needs $([Math]::Ceiling(($requiredBytes + $safetyBytes) / 1GB)) GB free to preserve dumps with safety headroom." +} + +New-Item -ItemType Directory -Path $destinationPath -Force | Out-Null +$manifestFiles = @() +foreach ($source in $sources) { + $destinationFile = Join-Path $destinationPath $source.Name + if (Test-Path -LiteralPath $destinationFile -PathType Leaf) { + $sourceHash = (Get-FileHash -LiteralPath $source.FullName -Algorithm SHA256).Hash + $destinationHash = (Get-FileHash -LiteralPath $destinationFile -Algorithm SHA256).Hash + if ($sourceHash -eq $destinationHash) { + $copied = Get-Item -LiteralPath $destinationFile + } + elseif (-not $Force) { + throw "Destination '$destinationFile' exists with different content; pass -Force to replace it." + } + else { + Copy-Item -LiteralPath $source.FullName -Destination $destinationFile -Force + $copied = Get-Item -LiteralPath $destinationFile + } + } + else { + Copy-Item -LiteralPath $source.FullName -Destination $destinationFile + $copied = Get-Item -LiteralPath $destinationFile + } + $manifestFiles += [ordered]@{ + name = $copied.Name + source = $source.FullName + length = [uint64]$copied.Length + lastWriteUtc = $copied.LastWriteTimeUtc.ToString('o') + sha256 = (Get-FileHash -LiteralPath $copied.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +if (-not [string]::IsNullOrWhiteSpace($GrantReadToSID)) { + $aclOutput = (& icacls.exe $destinationPath /grant "*$GrantReadToSID`:(OI)(CI)RX" /T /C 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "Could not grant dump-directory read access to '$GrantReadToSID'.`n$aclOutput" + } +} + +$manifest = [ordered]@{ + schema = 1 + machine = $env:COMPUTERNAME + collectedUtc = [DateTime]::UtcNow.ToString('o') + files = $manifestFiles +} +$manifestPath = Join-Path $destinationPath 'crash-dumps.json' +[IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 6), + [Text.UTF8Encoding]::new($false)) +Write-Host "Collected $($manifestFiles.Count) crash dump(s) in '$destinationPath'." +Write-Host "Manifest: $manifestPath" diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index fe28a6ed..110159fb 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -81,8 +81,8 @@ if ($versionNodes.Count -ne 1) { } $driverPackageVersion = $versionNodes[0].InnerText.Trim() $driverABIMajor = 1 -$driverABIMinor = 10 -$driverCapabilities = [uint32]13 +$driverABIMinor = 11 +$driverCapabilities = [uint32]29 $driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $SourceRevision ` -DriverPackageVersion $driverPackageVersion ` diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index aa6348c4..4a230cb2 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -106,7 +106,7 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $source = $SourceRevision.ToLowerInvariant() $buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $source -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + -ABIMajor 1 -ABIMinor 11 -Capabilities 29 $manifest = [ordered]@{ schema = 2 @@ -117,8 +117,8 @@ $manifest = [ordered]@{ sourceRevision = $source driverPackageVersion = $driverVersion driverABIMajor = 1 - driverABIMinor = 10 - driverCapabilities = '0x0000000d' + driverABIMinor = 11 + driverCapabilities = '0x0000001d' driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 files = @( diff --git a/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 new file mode 100644 index 00000000..fd195e91 --- /dev/null +++ b/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1 @@ -0,0 +1,248 @@ +[CmdletBinding()] +param( + [ValidateSet('Status', 'Enable', 'Restore')] + [string]$Mode = 'Status', + + [ValidateSet('Complete', 'Kernel', 'Automatic')] + [string]$DumpType = 'Complete', + + [string]$StatePath, + + [switch]$AcknowledgeDiskUse +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$crashControlPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' +$memoryManagementPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' +if ([string]::IsNullOrWhiteSpace($StatePath)) { + $StatePath = Join-Path $env:ProgramData 'Viiper\diagnostics\crash-policy-backup.json' +} + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Crash-diagnostic configuration requires an elevated PowerShell session.' + } +} + +function Get-RegistryValueSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$Names + ) + + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + $presentNames = @($key.GetValueNames()) + $values = [ordered]@{} + foreach ($name in $Names) { + $present = $presentNames -contains $name + $values[$name] = [ordered]@{ + present = $present + kind = if ($present) { $key.GetValueKind($name).ToString() } else { $null } + value = if ($present) { + $key.GetValue($name, $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } + else { $null } + } + } + return $values +} + +function Set-RegistryValueFromSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)]$Snapshot + ) + + if (-not [bool]$Snapshot.present) { + Remove-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction SilentlyContinue + return + } + $propertyType = switch ([string]$Snapshot.kind) { + 'DWord' { 'DWord' } + 'QWord' { 'QWord' } + 'String' { 'String' } + 'ExpandString' { 'ExpandString' } + 'MultiString' { 'MultiString' } + 'Binary' { 'Binary' } + default { throw "Unsupported saved registry kind '$($Snapshot.kind)' for '$Name'." } + } + $value = $Snapshot.value + if ($propertyType -eq 'MultiString') { + $value = @($value | ForEach-Object { [string]$_ }) + } + New-ItemProperty -LiteralPath $Path -Name $Name -Value $value ` + -PropertyType $propertyType -Force | Out-Null +} + +function Write-StateFile { + param([Parameter(Mandatory = $true)]$State) + + $fullPath = [IO.Path]::GetFullPath($StatePath) + $directory = Split-Path -Parent $fullPath + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $temporary = "$fullPath.tmp" + [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + [IO.File]::Replace($temporary, $fullPath, $null, $true) +} + +function Write-NewStateFile { + param([Parameter(Mandatory = $true)]$State) + + $fullPath = [IO.Path]::GetFullPath($StatePath) + $directory = Split-Path -Parent $fullPath + New-Item -ItemType Directory -Path $directory -Force | Out-Null + if (Test-Path -LiteralPath $fullPath) { + throw "Crash-diagnostic backup already exists at '$fullPath'; restore it before replacing policy." + } + $temporary = "$fullPath.tmp" + [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporary -Destination $fullPath +} + +function Get-CurrentStatus { + $computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $pageUsage = @(Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue) + $crash = Get-RegistryValueSnapshot -Path $crashControlPath -Names @( + 'CrashDumpEnabled', 'DumpFile', 'AlwaysKeepMemoryDump', 'Overwrite') + $paging = Get-ItemProperty -LiteralPath $memoryManagementPath -ErrorAction Stop + [ordered]@{ + crashDumpEnabled = if ($crash.CrashDumpEnabled.present) { + [int]$crash.CrashDumpEnabled.value + } else { 0 } + dumpFile = if ($crash.DumpFile.present) { + [string]$crash.DumpFile.value + } else { '' } + alwaysKeepMemoryDump = if ($crash.AlwaysKeepMemoryDump.present) { + [int]$crash.AlwaysKeepMemoryDump.value + } else { 0 } + overwrite = if ($crash.Overwrite.present) { + [int]$crash.Overwrite.value + } else { 0 } + automaticManagedPagefile = [bool]$computer.AutomaticManagedPagefile + pagingFiles = @($paging.PagingFiles) + totalPhysicalMemoryBytes = [uint64]$computer.TotalPhysicalMemory + pagefiles = @($pageUsage | ForEach-Object { + [ordered]@{ + name = [string]$_.Name + allocatedMB = [uint32]$_.AllocatedBaseSize + currentUsageMB = [uint32]$_.CurrentUsage + peakUsageMB = [uint32]$_.PeakUsage + } + }) + policyBackup = [IO.Path]::GetFullPath($StatePath) + policyBackupPresent = Test-Path -LiteralPath $StatePath -PathType Leaf + } +} + +if ($Mode -eq 'Status') { + Get-CurrentStatus | ConvertTo-Json -Depth 6 + return +} + +Assert-Administrator + +$crashNames = @('CrashDumpEnabled', 'DumpFile', 'AlwaysKeepMemoryDump', + 'Overwrite', 'LogEvent', 'AutoReboot', 'FilterPages') +$memoryNames = @('PagingFiles') + +if ($Mode -eq 'Restore') { + $stateFile = Resolve-Path -LiteralPath $StatePath -ErrorAction Stop + $state = Get-Content -LiteralPath $stateFile.Path -Raw | ConvertFrom-Json + if ([int]$state.schema -ne 1 -or [string]$state.machine -cne $env:COMPUTERNAME) { + throw 'Crash-diagnostic backup schema or machine identity does not match this machine.' + } + foreach ($name in $crashNames) { + Set-RegistryValueFromSnapshot -Path $crashControlPath -Name $name ` + -Snapshot $state.crashControl.$name + } + foreach ($name in $memoryNames) { + Set-RegistryValueFromSnapshot -Path $memoryManagementPath -Name $name ` + -Snapshot $state.memoryManagement.$name + } + $state | Add-Member -NotePropertyName restoredUtc -NotePropertyValue ` + ([DateTime]::UtcNow.ToString('o')) -Force + Write-StateFile -State $state + Write-Host 'The prior crash-dump and pagefile policy is restored. Restart Windows to apply pagefile changes.' + return +} + +if (-not $AcknowledgeDiskUse) { + throw 'Enabling full crash diagnostics can reserve substantial disk space. Pass -AcknowledgeDiskUse.' +} + +$computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop +$physicalMB = [uint64][Math]::Ceiling( + [double]$computer.TotalPhysicalMemory / 1MB) +$requiredPagefileMB = $physicalMB + 300 +$dumpTypeValue = switch ($DumpType) { + 'Complete' { 1 } + 'Kernel' { 2 } + 'Automatic' { 7 } +} +$systemDrive = $env:SystemDrive.TrimEnd('\') +$logicalDisk = Get-CimInstance Win32_LogicalDisk -Filter ` + "DeviceID='$systemDrive'" -ErrorAction Stop +if ($DumpType -eq 'Complete') { + $requiredFreeBytes = ([uint64]$requiredPagefileMB * 2MB) + 10GB + if ([uint64]$logicalDisk.FreeSpace -lt $requiredFreeBytes) { + throw "Complete-dump policy needs at least $([Math]::Ceiling($requiredFreeBytes / 1GB)) GB free on $systemDrive for pagefile, dump, and safety headroom." + } +} + +$state = [ordered]@{ + schema = 1 + machine = $env:COMPUTERNAME + capturedUtc = [DateTime]::UtcNow.ToString('o') + requestedDumpType = $DumpType + totalPhysicalMemoryBytes = [uint64]$computer.TotalPhysicalMemory + crashControl = Get-RegistryValueSnapshot -Path $crashControlPath -Names $crashNames + memoryManagement = Get-RegistryValueSnapshot -Path $memoryManagementPath -Names $memoryNames +} +Write-NewStateFile -State $state + +try { + New-ItemProperty -LiteralPath $crashControlPath -Name CrashDumpEnabled ` + -Value $dumpTypeValue -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name DumpFile ` + -Value '%SystemRoot%\MEMORY.DMP' -PropertyType ExpandString -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name AlwaysKeepMemoryDump ` + -Value 1 -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name Overwrite ` + -Value 1 -PropertyType DWord -Force | Out-Null + New-ItemProperty -LiteralPath $crashControlPath -Name LogEvent ` + -Value 1 -PropertyType DWord -Force | Out-Null + Remove-ItemProperty -LiteralPath $crashControlPath -Name FilterPages ` + -ErrorAction SilentlyContinue + if ($DumpType -eq 'Complete') { + $pagingFile = "$systemDrive\pagefile.sys $requiredPagefileMB $requiredPagefileMB" + New-ItemProperty -LiteralPath $memoryManagementPath -Name PagingFiles ` + -Value @($pagingFile) -PropertyType MultiString -Force | Out-Null + } +} +catch { + foreach ($name in $crashNames) { + Set-RegistryValueFromSnapshot -Path $crashControlPath -Name $name ` + -Snapshot $state.crashControl[$name] + } + foreach ($name in $memoryNames) { + Set-RegistryValueFromSnapshot -Path $memoryManagementPath -Name $name ` + -Snapshot $state.memoryManagement[$name] + } + throw +} + +Write-Host "$DumpType memory dumps are enabled at %SystemRoot%\MEMORY.DMP." +if ($DumpType -eq 'Complete') { + Write-Host "The next boot will reserve a $requiredPagefileMB MB system-drive pagefile." +} +Write-Host "Original policy backup: $([IO.Path]::GetFullPath($StatePath))" +Write-Host 'Restart Windows before fault injection so the pagefile and dump policy are active.' diff --git a/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 new file mode 100644 index 00000000..bb2fc8e9 --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 @@ -0,0 +1,91 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SysPath, + + [Parameter(Mandatory = $true)] + [string]$PdbPath, + + [Parameter(Mandatory = $true)] + [string]$MapPath, + + [string]$SymChkPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ExactArtifact { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = Get-Item -LiteralPath $Path -ErrorAction Stop + if (-not $resolved.PSIsContainer -and $resolved.Name -ceq $ExpectedName -and + $resolved.Length -gt 0) { + return $resolved + } + throw "Expected non-empty '$ExpectedName' artifact at '$Path'." +} + +function Resolve-SymChk { + param([string]$ExplicitPath) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + return (Resolve-Path -LiteralPath $ExplicitPath -ErrorAction Stop).Path + } + $command = Get-Command symchk.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $command) { + return $command.Source + } + $candidate = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Debuggers\x64\symchk.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + throw 'symchk.exe is required to prove that the SYS and full private line PDB match.' +} + +$sys = Resolve-ExactArtifact -Path $SysPath -ExpectedName 'ViiperUde.sys' +$pdb = Resolve-ExactArtifact -Path $PdbPath -ExpectedName 'ViiperUde.pdb' +$map = Resolve-ExactArtifact -Path $MapPath -ExpectedName 'ViiperUde.map' +$symchk = Resolve-SymChk -ExplicitPath $SymChkPath + +$embeddedImageText = [Text.Encoding]::ASCII.GetString( + [IO.File]::ReadAllBytes($sys.FullName)) +if ($embeddedImageText -notmatch '(?:^|\x00)ViiperUde\.pdb(?:\x00|$)' -or + $embeddedImageText -match '(?i)[A-Z]:\\[^\x00]{0,512}ViiperUde\.pdb') { + throw 'The driver image must embed only the relocatable ViiperUde.pdb basename.' +} + +$savedErrorActionPreference = $ErrorActionPreference +try { + # Windows PowerShell 5.1 wraps native stderr as non-terminating ErrorRecord + # objects even when the native tool succeeds. Preserve that diagnostic text + # and judge the tool only by its exit code and matching-symbol report. + $ErrorActionPreference = 'Continue' + $symbolOutput = (& $symchk /v $sys.FullName /s $pdb.DirectoryName 2>&1 | + ForEach-Object { $_.ToString() } | Out-String) + $symbolExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $savedErrorActionPreference +} +if ($symbolExitCode -ne 0 -or + $symbolOutput -notmatch '(?im)private symbols & lines' -or + $symbolOutput -notmatch '(?im)PDB Matched:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Line numbers:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Type Info:\s+TRUE') { + throw "The driver debug artifacts are not a matching private source/line/type set.`n$symbolOutput" +} + +$mapText = Get-Content -LiteralPath $map.FullName -Raw +foreach ($symbol in @('ViiperTraceLifecycle', 'ViiperEvtEndpointQueuePurged', + 'ViiperEvtEndpointPurge', 'ViiperBeginControllerShutdown')) { + if ($mapText -notmatch ('\b' + [regex]::Escape($symbol) + '\b')) { + throw "The driver link map does not contain required lifecycle symbol '$symbol'." + } +} + +Write-Host "VIIPER UDE debug artifacts match and contain private symbols, line tables, type information, and lifecycle map symbols." diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index 02d8010c..fd1c6bf3 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -156,12 +156,12 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + -ABIMajor 1 -ABIMinor 11 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or [string]$manifest.driverPackageVersion -cne $driverVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 10 -or - [string]$manifest.driverCapabilities -cne '0x0000000d' -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 11 -or + [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or -not [bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'HLK/WHCP') { diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 5bf2825e..576843e4 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -675,12 +675,12 @@ $driverPackageVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverPackageVersion ` - -ABIMajor 1 -ABIMinor 10 -Capabilities 13 + -ABIMajor 1 -ABIMinor 11 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 10 -or - [string]$manifest.driverCapabilities -cne '0x0000000d' -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 11 -or + [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' } diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 73bf38b4..d200f124 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -57,6 +57,30 @@ if ($driverVersion -notmatch '^\d+\.\d+\.\d+\.\d+$') { throw "ViiperUdeDriverVersion must be a four-part numeric version; found '$driverVersion'." } +$compileDefinition = $project.SelectSingleNode( + '//msb:ItemDefinitionGroup/msb:ClCompile', $namespace) +$linkDefinition = $project.SelectSingleNode( + '//msb:ItemDefinitionGroup/msb:Link', $namespace) +if ($null -eq $compileDefinition -or + $compileDefinition.DebugInformationFormat -cne 'ProgramDatabase' -or + $compileDefinition.SupportJustMyCode -cne 'false' -or + $compileDefinition.AdditionalOptions -notmatch '(?:^|\s)/Zo(?:\s|$)') { + throw 'Every native build must emit optimized source/line/type information with /Zi and /Zo.' +} +if ($null -eq $linkDefinition -or + $linkDefinition.GenerateDebugInformation -cne 'true' -or + $linkDefinition.GenerateMapFile -cne 'true' -or + $linkDefinition.MapExports -cne 'true' -or + $linkDefinition.AdditionalOptions -notmatch '(?:^|\s)/DEBUG:FULL(?:\s|$)' -or + $linkDefinition.AdditionalOptions -notmatch '/PDBALTPATH:%_PDB%') { + throw 'Every native build must emit a full matching PDB and link map with a relocatable image PDB path.' +} +$traceCompileItems = @($project.SelectNodes( + "//msb:ClCompile[@Include='Trace.c']", $namespace)) +if ($traceCompileItems.Count -ne 1) { + throw "Expected exactly one Trace.c compile item; found $($traceCompileItems.Count)." +} + $infItems = @($project.SelectNodes('//msb:Inf', $namespace)) if ($infItems.Count -ne 1) { throw "Expected exactly one INF project item; found $($infItems.Count)." @@ -105,10 +129,25 @@ $header = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'ViiperUde. $controllerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Controller.c') -Raw $deviceSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Device.c') -Raw $brokerSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Broker.c') -Raw +$traceSource = Get-Content -LiteralPath (Join-Path $driverSourceDirectory 'Trace.c') -Raw $allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter '*.c' | Sort-Object -Property FullName | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" +foreach ($requiredTraceContract in @( + 'DECLSPEC_ALIGN(8) VIIPER_UDE_LIFECYCLE_TRACE_RECORD', + 'LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', + 'volatile LONG64 LifecycleTraceSequence;', + '#define VIIPER_TRACE_LIFECYCLE')) { + if (-not $header.Contains($requiredTraceContract)) { + throw "Missing bounded lifecycle-flight-recorder contract: $requiredTraceContract" + } +} +if ($traceSource -notmatch 'InterlockedIncrement64[\s\S]*KeQueryPerformanceCounter[\s\S]*_ReturnAddress[\s\S]*KeGetCurrentIrql[\s\S]*InterlockedExchange64' -or + $traceSource -match 'ExAllocatePool|WdfMemoryCreate|WdfSpinLockAcquire|WdfWaitLockAcquire|KeWaitForSingleObject') { + throw 'Lifecycle tracing must remain preallocated, nonblocking, timestamped, and source-addressable.' +} + if ($controllerSource -notmatch 'WdfDeviceInitSetCharacteristics\s*\(\s*DeviceInit\s*,\s*FILE_DEVICE_SECURE_OPEN\s*\|\s*FILE_AUTOGENERATED_DEVICE_NAME\s*,\s*FALSE\s*\)\s*;' -or $controllerSource -notmatch diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b553ea22..b7cd3947 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5295,7 +5295,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "341c4a2187ae6e4944761851648ef5772005addf2994dbafd9743dc2fed91306") { + "96ecacd1be08c28c77429c56c0b6a39e593c59cc60481746d7ec2b268545f1dd") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 16fb390b5b318b6af7c8d29b16370dcbb5512bf0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 14:48:58 -0500 Subject: [PATCH 224/240] Accept pinned WDK catalog output casing --- native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 4a230cb2..8213eaf1 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -52,7 +52,7 @@ $inputs = [ordered]@{ 'ViiperUde.inf' = Resolve-ExactInput $InfPath 'ViiperUde.inf' 'ViiperUde.sys' = Resolve-ExactInput $SysPath 'ViiperUde.sys' 'ViiperUde.pdb' = Resolve-ExactInput $PdbPath 'ViiperUde.pdb' - 'ViiperUde.cat' = Resolve-ExactInput $CatalogPath 'ViiperUde.cat' + 'ViiperUde.cat' = Resolve-ExactInput $CatalogPath 'viiperude.cat' } $helper = Resolve-ExactInput $HelperPath 'ViiperUdeCtl.exe' $broker = Resolve-ExactInput $BrokerPath 'viiper.exe' From 1c4e7b17567c731e3b0f27d31f7e1e47866b8885 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 14:54:26 -0500 Subject: [PATCH 225/240] Report packaged ABI from manifest --- native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 8213eaf1..25dfe27d 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -267,6 +267,6 @@ if ($preflightExitCode -ne 0 -or Write-Host "Created compact source-bound local test package at '$output'." Write-Host "Source: $source" -Write-Host "Driver: $driverVersion / ABI 1.10 / $buildIdentity" +Write-Host "Driver: $driverVersion / ABI $($manifest.driverABIMajor).$($manifest.driverABIMinor) / $buildIdentity" Write-Host "Test signer certificate SHA-256: $certificateSha256" Write-Host "Local test package lock SHA-256: $lockSha256" From 77993478e10b03760b14cade7e90a928ab7091f6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 15:08:51 -0500 Subject: [PATCH 226/240] Validate local test signatures without trust prompts --- .../tools/Test-ViiperUdeSignedPackage.ps1 | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 576843e4..10ddfbfa 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -556,6 +556,38 @@ function Get-BoundedAuthenticodeSignature { } } +function Test-ExpectedLocalTestTrustFailure { + param( + [Parameter(Mandatory = $true)]$Result, + [Parameter(Mandatory = $true)][string]$ExpectedCertificateThumbprint, + [Parameter(Mandatory = $true)][string]$TargetPath, + [string]$CatalogPath + ) + + if ($Result.ExitCode -ne 1 -or + $ExpectedCertificateThumbprint -cnotmatch '^[0-9A-F]{40}$') { + return $false + } + $evidence = ([string]$Result.StandardOutput) + "`n" + + ([string]$Result.StandardError) + $rootTrustError = '(?ims)^SignTool Error: A certificate chain processed, but terminated in a root\s*\r?\n\s*certificate which is not trusted by the trust provider\.\s*$' + if (@([regex]::Matches($evidence, $rootTrustError)).Count -ne 1 -or + @([regex]::Matches($evidence, + '(?im)^\s*SHA1 hash:\s*' + [regex]::Escape($ExpectedCertificateThumbprint) + '\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, '(?im)^Number of warnings:\s*0\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, '(?im)^Number of errors:\s*1\s*$')).Count -ne 1 -or + @([regex]::Matches($evidence, + '(?im)^Verifying:\s*' + [regex]::Escape($TargetPath) + '\s*$')).Count -ne 1) { + return $false + } + if (-not [string]::IsNullOrWhiteSpace($CatalogPath) -and + @([regex]::Matches($evidence, + '(?im)^File is signed in catalog:\s*' + [regex]::Escape($CatalogPath) + '\s*$')).Count -ne 1) { + return $false + } + return $true +} + function Assert-DriverSignature { param( [Parameter(Mandatory = $true)] @@ -565,12 +597,16 @@ function Assert-DriverSignature { [ValidateSet('LocalTest', 'ControlledTest', 'Production')] [string]$Mode, - [string]$ExpectedLocalTestCertificateSha256 + [string]$ExpectedLocalTestCertificateSha256, + + [switch]$AllowUntrustedLocalTestRoot ) $signature = Get-BoundedAuthenticodeSignature -Path $Path try { - if ($signature.Status -cne 'Valid') { + if ($signature.Status -cne 'Valid' -and + -not ($Mode -eq 'LocalTest' -and $AllowUntrustedLocalTestRoot -and + $signature.Status -ceq 'UnknownError')) { throw "'$Path' does not have a valid Authenticode signature (status '$($signature.Status)')." } if ($null -eq $signature.SignerCertificate) { @@ -616,6 +652,7 @@ if (-not (Get-Item -LiteralPath $root.Path).PSIsContainer) { $localTestCertificate = $null $localTestCertificateSha256 = $null +$localTestCertificateThumbprint = $null if ($ValidationMode -eq 'LocalTest') { if ([string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { throw '-LocalTestCertificatePath is required for LocalTest validation.' @@ -629,6 +666,33 @@ if ($ValidationMode -eq 'LocalTest') { } $localTestCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($resolvedCertificate) $localTestCertificateSha256 = Get-CertificateSha256 -Certificate $localTestCertificate + $localTestCertificateThumbprint = $localTestCertificate.Thumbprint.ToUpperInvariant() + $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() + try { + $chain.ChainPolicy.RevocationMode = + [Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + $chain.ChainPolicy.VerificationFlags = + [Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority + $chainValid = $chain.Build($localTestCertificate) + $chainStatuses = @($chain.ChainStatus) + $onlyExpectedTrustStatus = $chainStatuses.Count -eq 0 -or + ($chainStatuses.Count -eq 1 -and + $chainStatuses[0].Status -eq + [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::UntrustedRoot) + $ekuOids = Get-CertificateEkuOids -Certificate $localTestCertificate + if (-not $chainValid -or -not $onlyExpectedTrustStatus -or + $chain.ChainElements.Count -ne 1 -or + $localTestCertificate.Subject -cne $localTestCertificate.Issuer -or + $localTestCertificate.NotBefore -gt [DateTime]::Now -or + $localTestCertificate.NotAfter -lt [DateTime]::Now -or + -not $ekuOids.Contains('1.3.6.1.5.5.7.3.3') -or + $localTestCertificateThumbprint -cnotmatch '^[0-9A-F]{40}$') { + throw 'The local-test certificate is not a current, self-issued, code-signing certificate with a valid self-chain.' + } + } + finally { + $chain.Dispose() + } } elseif (-not [string]::IsNullOrWhiteSpace($LocalTestCertificatePath)) { throw '-LocalTestCertificatePath is valid only with -ValidationMode LocalTest.' @@ -728,7 +792,8 @@ foreach ($name in @('ViiperUde.inf', 'ViiperUde.pdb')) { foreach ($name in @('ViiperUde.cat', 'ViiperUde.sys')) { Assert-DriverSignature -Path $files[$name] -Mode $ValidationMode ` - -ExpectedLocalTestCertificateSha256 $localTestCertificateSha256 + -ExpectedLocalTestCertificateSha256 $localTestCertificateSha256 ` + -AllowUntrustedLocalTestRoot:$RequireLocalTestToolchainValidation } $requireExternalTools = $ValidationMode -ne 'LocalTest' -or $RequireLocalTestToolchainValidation if ($requireExternalTools) { @@ -738,7 +803,11 @@ if ($requireExternalTools) { $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` -Arguments @('verify', $policy, '/v', $files[$name]) ` -Operation "SignTool signature validation for '$name'" - if ($exitCode.ExitCode -ne 0) { + $expectedUntrustedRoot = $ValidationMode -eq 'LocalTest' -and + (Test-ExpectedLocalTestTrustFailure -Result $exitCode ` + -ExpectedCertificateThumbprint $localTestCertificateThumbprint ` + -TargetPath $files[$name]) + if ($exitCode.ExitCode -ne 0 -and -not $expectedUntrustedRoot) { throw "Signature policy validation failed for '$name' with exit code $($exitCode.ExitCode)." } } @@ -747,7 +816,11 @@ if ($requireExternalTools) { $exitCode = Invoke-BoundedValidationTool -FilePath $signTool.Source ` -Arguments @('verify', $policy, '/v', '/c', $files['ViiperUde.cat'], $files[$name]) ` -Operation "SignTool catalog membership validation for '$name'" - if ($exitCode.ExitCode -ne 0) { + $expectedUntrustedRoot = $ValidationMode -eq 'LocalTest' -and + (Test-ExpectedLocalTestTrustFailure -Result $exitCode ` + -ExpectedCertificateThumbprint $localTestCertificateThumbprint ` + -TargetPath $files[$name] -CatalogPath $files['ViiperUde.cat']) + if ($exitCode.ExitCode -ne 0 -and -not $expectedUntrustedRoot) { throw "'$name' is not a verified member of the exact catalog (exit code $($exitCode.ExitCode))." } } From 04063349ad2ba3472d03320ec5030766361324b0 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 15:13:22 -0500 Subject: [PATCH 227/240] Verify local test packages before trust install --- native/udecx/tools/ViiperUdeCtl.cpp | 57 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b7cd3947..b7c455d5 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -1299,6 +1299,7 @@ bool LoadWinTrustFunction( bool VerifyDriverCatalogMember( const std::filesystem::path& catalogPath, const std::filesystem::path& memberPath, + bool allowUntrustedLocalTestRoot, Error* error) { WinHandle member(CreateFileW(memberPath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); @@ -1397,7 +1398,9 @@ bool VerifyDriverCatalogMember( trust.dwStateAction = WTD_STATEACTION_CLOSE; WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &action, &trust); releasePolicy(); - if (status != ERROR_SUCCESS) { + if (status != ERROR_SUCCESS && + !(allowUntrustedLocalTestRoot && + status == static_cast(CERT_E_UNTRUSTEDROOT))) { return SetError(error, L"catalog-member-policy", static_cast(status), L"package file is not a valid member of the exact trusted driver catalog"); } @@ -1408,27 +1411,13 @@ bool VerifyLocalTestPackageSigner( const std::filesystem::path& infPath, std::string_view expectedCertificateSha256, Error* error) { - SP_INF_SIGNER_INFO_W signer{}; - signer.cbSize = sizeof(signer); - if (!SetupVerifyInfFileW(infPath.c_str(), nullptr, &signer)) { - const DWORD code = GetLastError(); - if (code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER) { - return SetError(error, L"inf-local-test-signature", code); - } - } - if (signer.CatalogFile[0] == L'\0' || signer.DigitalSigner[0] == L'\0') { - return SetError(error, L"inf-local-test-signature", ERROR_INVALID_DATA, - L"local test INF did not report a catalog and signer"); - } - const std::filesystem::path reportedCatalogPath = signer.CatalogFile; - if (_wcsicmp(reportedCatalogPath.filename().c_str(), kCatalogName) != 0) { - return SetError(error, L"inf-local-test-signature", ERROR_INVALID_DATA, - L"local test INF did not report the exact VIIPER catalog"); - } + // InspectInfContract already pins CatalogFile to kCatalogName. Verify the + // exact member hashes and signer directly so a clean packaging machine + // does not need to trust the disposable WDK certificate first. const std::filesystem::path catalogPath = infPath.parent_path() / kCatalogName; - if (!VerifyDriverCatalogMember(catalogPath, infPath, error) || + if (!VerifyDriverCatalogMember(catalogPath, infPath, true, error) || !VerifyDriverCatalogMember(catalogPath, - infPath.parent_path() / kDriverFileName, error)) { + infPath.parent_path() / kDriverFileName, true, error)) { return false; } @@ -1520,9 +1509,9 @@ bool VerifyMicrosoftHardwareInfSigner( DWORD encoding = 0; HCERTSTORE store = nullptr; HCRYPTMSG message = nullptr; - if (!VerifyDriverCatalogMember(catalogPath, infPath, error) || + if (!VerifyDriverCatalogMember(catalogPath, infPath, false, error) || !VerifyDriverCatalogMember(catalogPath, - packageInfPath.parent_path() / kDriverFileName, error)) { + packageInfPath.parent_path() / kDriverFileName, false, error)) { return false; } if (!CryptQueryObject(CERT_QUERY_OBJECT_FILE, catalogPath.c_str(), @@ -1604,6 +1593,7 @@ bool VerifyMicrosoftHardwareInfSigner( bool LoadOwnedPackage( const std::filesystem::path& rawPath, bool requireOwned, + bool allowUntrustedLocalTestRoot, PackageInfo* package, bool* owned, Error* error) { @@ -1631,8 +1621,12 @@ bool LoadOwnedPackage( return true; } std::filesystem::path catalogPath; - if (!VerifyInfSignature(path, &catalogPath, error)) { - return false; + if (allowUntrustedLocalTestRoot) { + catalogPath = path.parent_path() / kCatalogName; + } else { + if (!VerifyInfSignature(path, &catalogPath, error)) { + return false; + } } std::filesystem::path packageInfPath = path; if (_wcsicmp(path.filename().c_str(), L"ViiperUde.inf") != 0 && @@ -1765,7 +1759,8 @@ bool EnumerateOwnedPackages(std::vector* packages, Error* error) { PackageInfo package; bool owned = false; Error packageError; - if (!LoadOwnedPackage(infDirectory / data.cFileName, false, &package, &owned, &packageError)) { + if (!LoadOwnedPackage(infDirectory / data.cFileName, false, false, + &package, &owned, &packageError)) { FindClose(rawFind); *error = std::move(packageError); return false; @@ -2080,7 +2075,8 @@ bool CaptureSnapshot(Snapshot* snapshot, Error* error) { } bool owned = false; PackageInfo package; - if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, &package, &owned, error)) { + if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, false, + &package, &owned, error)) { return false; } package.publishedName = device.publishedInf; @@ -2145,7 +2141,8 @@ bool RemoveAllExactDevices( } PackageInfo package; bool owned = false; - if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, &package, &owned, error) || !owned) { + if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, false, + &package, &owned, error) || !owned) { return false; } } @@ -3090,7 +3087,8 @@ bool ValidateCandidateInputs( return false; } bool owned = false; - if (!LoadOwnedPackage(lockedInfPath, true, candidate, &owned, error) || !owned || + if (!LoadOwnedPackage(lockedInfPath, true, options.localTest, + candidate, &owned, error) || !owned || (options.production && !VerifyMicrosoftHardwareInfSigner(lockedInfPath, error))) { return false; } @@ -4524,7 +4522,8 @@ bool BackupPackages( } PackageInfo verified; bool owned = false; - if (!LoadOwnedPackage(backupInf, true, &verified, &owned, error) || !owned) { + if (!LoadOwnedPackage(backupInf, true, false, + &verified, &owned, error) || !owned) { return false; } if (!(verified.version == packages[index].version) || From 3a660e849ef44306760d634fd044ca7072ae94e8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 15:25:10 -0500 Subject: [PATCH 228/240] Support pristine upgrades from ABI 1.10 --- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 8 +- native/udecx/tools/ViiperUdeCtl.cpp | 210 ++++++++++++------ 2 files changed, 145 insertions(+), 73 deletions(-) diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 6e6261c4..38305b67 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -38,7 +38,13 @@ $requiredContracts = [ordered]@{ 'stopped owned upgrade skips unavailable live ABI proof' = 'CandidateDisposition::InstallRequired &&\s*!prior\.devices\.empty\(\) && prior\.devices\[0\]\.started &&' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' - 'exact negotiated capability identity' = 'response\.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES' + 'exact negotiated capability identity' = 'response\.Capabilities != negotiatedCapabilities' + 'known previous ABI negotiation' = 'kPreviousAbiMinor = 10' + 'known previous ABI capabilities' = 'kPreviousAbiCapabilities' + 'previous ABI pristine-upgrade boundary' = 'IsPreviousAbiRetryEligible\(' + 'previous ABI invalid-parameter retry only' = + 'error\.code == ERROR_INVALID_PARAMETER[\s\S]{0,180}abi-negotiate-result' + 'previous ABI stats header validation' = 'stats\.Header\.Minor != negotiatedMinor' 'source-bound manifest identity' = 'driverBuildIdentity' 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' 'install rollback' = 'RollbackInstall\(' diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b7c455d5..63408275 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -88,6 +88,10 @@ constexpr wchar_t kServiceName[] = L"ViiperUde"; constexpr wchar_t kProviderName[] = L"VIIPER Project"; constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; +constexpr VIIPER_UDE_UINT16 kPreviousAbiMinor = 10; +constexpr VIIPER_UDE_UINT32 kPreviousAbiCapabilities = + VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | + VIIPER_UDE_CAP_INPUT_REPORTS; constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; @@ -2428,76 +2432,29 @@ bool RegisterRootDeviceExact( return true; } -bool VerifyAbiHealth( +bool IssueAbiNegotiation( + HANDLE device, uint64_t deadlineUnixMs, - const std::string* expectedBuildIdentity, - Error* error, - bool requirePristineRuntime = false) { - DeviceInfoSet set(SetupDiGetClassDevsW( - &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); - if (!set) { - return SetLastErrorDetail(error, L"abi-interface-enumeration"); - } - std::wstring interfacePath; - size_t exactCount = 0; - for (DWORD index = 0;; ++index) { - SP_DEVICE_INTERFACE_DATA interfaceData{}; - interfaceData.cbSize = sizeof(interfaceData); - if (!SetupDiEnumDeviceInterfaces(set.get(), nullptr, &kViiperInterfaceGuid, index, &interfaceData)) { - if (GetLastError() != ERROR_NO_MORE_ITEMS) { - return SetLastErrorDetail(error, L"abi-interface-enumeration"); - } - break; - } - SP_DEVINFO_DATA deviceData{}; - deviceData.cbSize = sizeof(deviceData); - DWORD required = 0; - SetupDiGetDeviceInterfaceDetailW( - set.get(), &interfaceData, nullptr, 0, &required, &deviceData); - if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { - return SetLastErrorDetail(error, L"abi-interface-detail"); - } - std::vector buffer(required); - auto* detail = reinterpret_cast(buffer.data()); - detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); - if (!SetupDiGetDeviceInterfaceDetailW( - set.get(), &interfaceData, detail, required, nullptr, &deviceData)) { - return SetLastErrorDetail(error, L"abi-interface-detail"); - } - if (!HasExactHardwareId(set.get(), deviceData)) { - continue; - } - std::wstring service; - if (!ReadService(set.get(), deviceData, &service, error)) { - return false; - } - if (_wcsicmp(service.c_str(), kServiceName) != 0) { - return SetError(error, L"abi-interface-ownership", ERROR_ACCESS_DENIED); - } - ++exactCount; - interfacePath = detail->DevicePath; - } - if (exactCount != 1) { - return SetError(error, L"abi-interface-count", - exactCount == 0 ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); - } - WinHandle device(CreateFileW(interfacePath.c_str(), GENERIC_READ | GENERIC_WRITE, - 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr)); - if (!device) { - return SetLastErrorDetail(error, L"abi-interface-open", - L"native broker interface is unavailable or still owned by another process"); + VIIPER_UDE_UINT16 abiMinor, + VIIPER_UDE_UINT32 requestedCapabilities, + VIIPER_UDE_NEGOTIATE_RESPONSE* response, + VIIPER_UDE_UINT64* clientNonce, + DWORD* returnedBytes, + Error* error) { + if (response == nullptr || clientNonce == nullptr || returnedBytes == nullptr) { + return SetError(error, L"abi-negotiate-arguments", ERROR_INVALID_PARAMETER); } LARGE_INTEGER counter{}; QueryPerformanceCounter(&counter); VIIPER_UDE_NEGOTIATE_REQUEST request{}; request.Header.Magic = VIIPER_UDE_MAGIC; request.Header.Major = VIIPER_UDE_ABI_MAJOR; - request.Header.Minor = VIIPER_UDE_ABI_MINOR; + request.Header.Minor = abiMinor; request.Header.Size = sizeof(request); request.ClientNonce = static_cast(counter.QuadPart) ^ GetTickCount64(); if (request.ClientNonce == 0) request.ClientNonce = 1; - request.RequestedCapabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; - VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + request.RequestedCapabilities = requestedCapabilities; + *response = VIIPER_UDE_NEGOTIATE_RESPONSE{}; DWORD returned = 0; WinHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (!event) { @@ -2505,15 +2462,15 @@ bool VerifyAbiHealth( } OVERLAPPED overlapped{}; overlapped.hEvent = event.get(); - const BOOL completed = DeviceIoControl(device.get(), IOCTL_VIIPER_UDE_NEGOTIATE, - &request, sizeof(request), &response, sizeof(response), &returned, &overlapped); + const BOOL completed = DeviceIoControl(device, IOCTL_VIIPER_UDE_NEGOTIATE, + &request, sizeof(request), response, sizeof(*response), &returned, &overlapped); if (!completed && GetLastError() != ERROR_IO_PENDING) { return SetLastErrorDetail(error, L"abi-negotiate"); } if (!completed) { const uint64_t now = CurrentUnixMilliseconds(); if (deadlineUnixMs <= now) { - const BOOL cancelled = CancelIoEx(device.get(), &overlapped); + const BOOL cancelled = CancelIoEx(device, &overlapped); const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); if ((!cancelled && cancelError != ERROR_NOT_FOUND) || drain != WAIT_OBJECT_0) { @@ -2523,7 +2480,7 @@ bool VerifyAbiHealth( L"expired native ABI negotiation could not be cancelled and drained safely"); } DWORD ignored = 0; - GetOverlappedResult(device.get(), &overlapped, &ignored, FALSE); + GetOverlappedResult(device, &overlapped, &ignored, FALSE); return SetError(error, L"abi-negotiate-timeout", ERROR_TIMEOUT, L"native ABI negotiation exceeded the package transaction deadline"); } @@ -2532,12 +2489,12 @@ bool VerifyAbiHealth( std::min(remaining, static_cast(MAXDWORD - 1))); const DWORD wait = WaitForSingleObject(event.get(), waitMilliseconds); if (wait == WAIT_TIMEOUT) { - const BOOL cancelled = CancelIoEx(device.get(), &overlapped); + const BOOL cancelled = CancelIoEx(device, &overlapped); const DWORD cancelError = cancelled ? ERROR_SUCCESS : GetLastError(); const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); if (drain == WAIT_OBJECT_0) { DWORD ignored = 0; - GetOverlappedResult(device.get(), &overlapped, &ignored, FALSE); + GetOverlappedResult(device, &overlapped, &ignored, FALSE); } if (!cancelled && cancelError != ERROR_NOT_FOUND) { return SetError(error, L"abi-negotiate-cancel", cancelError, @@ -2552,7 +2509,7 @@ bool VerifyAbiHealth( } if (wait != WAIT_OBJECT_0) { const DWORD waitError = GetLastError(); - CancelIoEx(device.get(), &overlapped); + CancelIoEx(device, &overlapped); const DWORD drain = WaitForSingleObject(event.get(), kCancelledIoDrainMs); if (drain != WAIT_OBJECT_0) { return SetError(error, L"abi-negotiate-drain", ERROR_OPERATION_ABORTED, @@ -2561,10 +2518,103 @@ bool VerifyAbiHealth( SetLastError(waitError); return SetLastErrorDetail(error, L"abi-negotiate-wait"); } - if (!GetOverlappedResult(device.get(), &overlapped, &returned, FALSE)) { + if (!GetOverlappedResult(device, &overlapped, &returned, FALSE)) { return SetLastErrorDetail(error, L"abi-negotiate-result"); } } + *clientNonce = request.ClientNonce; + *returnedBytes = returned; + return true; +} + +bool IsPreviousAbiRetryEligible( + bool requirePristineRuntime, + const std::string* expectedBuildIdentity, + const Error& error) { + return requirePristineRuntime && expectedBuildIdentity == nullptr && + error.code == ERROR_INVALID_PARAMETER && + (error.phase == L"abi-negotiate" || + error.phase == L"abi-negotiate-result"); +} + +bool VerifyAbiHealth( + uint64_t deadlineUnixMs, + const std::string* expectedBuildIdentity, + Error* error, + bool requirePristineRuntime = false) { + DeviceInfoSet set(SetupDiGetClassDevsW( + &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); + if (!set) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); + } + std::wstring interfacePath; + size_t exactCount = 0; + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interfaceData{}; + interfaceData.cbSize = sizeof(interfaceData); + if (!SetupDiEnumDeviceInterfaces(set.get(), nullptr, &kViiperInterfaceGuid, index, &interfaceData)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, L"abi-interface-enumeration"); + } + break; + } + SP_DEVINFO_DATA deviceData{}; + deviceData.cbSize = sizeof(deviceData); + DWORD required = 0; + SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, nullptr, 0, &required, &deviceData); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + std::vector buffer(required); + auto* detail = reinterpret_cast(buffer.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + if (!SetupDiGetDeviceInterfaceDetailW( + set.get(), &interfaceData, detail, required, nullptr, &deviceData)) { + return SetLastErrorDetail(error, L"abi-interface-detail"); + } + if (!HasExactHardwareId(set.get(), deviceData)) { + continue; + } + std::wstring service; + if (!ReadService(set.get(), deviceData, &service, error)) { + return false; + } + if (_wcsicmp(service.c_str(), kServiceName) != 0) { + return SetError(error, L"abi-interface-ownership", ERROR_ACCESS_DENIED); + } + ++exactCount; + interfacePath = detail->DevicePath; + } + if (exactCount != 1) { + return SetError(error, L"abi-interface-count", + exactCount == 0 ? ERROR_DEVICE_NOT_AVAILABLE : ERROR_DUPLICATE_SERVICE_NAME); + } + WinHandle device(CreateFileW(interfacePath.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, nullptr)); + if (!device) { + return SetLastErrorDetail(error, L"abi-interface-open", + L"native broker interface is unavailable or still owned by another process"); + } + VIIPER_UDE_UINT16 negotiatedMinor = VIIPER_UDE_ABI_MINOR; + VIIPER_UDE_UINT32 negotiatedCapabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + VIIPER_UDE_UINT64 clientNonce = 0; + DWORD returned = 0; + if (!IssueAbiNegotiation(device.get(), deadlineUnixMs, negotiatedMinor, + negotiatedCapabilities, &response, &clientNonce, &returned, error)) { + if (!IsPreviousAbiRetryEligible( + requirePristineRuntime, expectedBuildIdentity, *error)) { + return false; + } + *error = Error{}; + negotiatedMinor = kPreviousAbiMinor; + negotiatedCapabilities = kPreviousAbiCapabilities; + if (!IssueAbiNegotiation(device.get(), deadlineUnixMs, negotiatedMinor, + negotiatedCapabilities, &response, &clientNonce, &returned, error)) { + return false; + } + } std::string loadedBuildIdentity; loadedBuildIdentity.reserve(VIIPER_UDE_BUILD_IDENTITY_BYTES * 2); static constexpr char digits[] = "0123456789abcdef"; @@ -2574,10 +2624,10 @@ bool VerifyAbiHealth( } if (returned != sizeof(response) || response.Header.Magic != VIIPER_UDE_MAGIC || response.Header.Major != VIIPER_UDE_ABI_MAJOR || - response.Header.Minor != VIIPER_UDE_ABI_MINOR || + response.Header.Minor != negotiatedMinor || response.Header.Size != sizeof(response) || response.Header.Flags != 0 || - response.ClientNonce != request.ClientNonce || response.DriverNonce == 0 || - response.Capabilities != VIIPER_UDE_ADVERTISED_CAPABILITIES || + response.ClientNonce != clientNonce || response.DriverNonce == 0 || + response.Capabilities != negotiatedCapabilities || response.MaxDevices != VIIPER_UDE_MAX_DEVICES || response.MaxDescriptorBytes != VIIPER_UDE_MAX_DESCRIPTOR_BYTES || response.MaxTransferBytes != VIIPER_UDE_MAX_TRANSFER_BYTES || @@ -2663,7 +2713,7 @@ bool VerifyAbiHealth( } if (statsReturned != sizeof(stats) || stats.Header.Magic != VIIPER_UDE_MAGIC || stats.Header.Major != VIIPER_UDE_ABI_MAJOR || - stats.Header.Minor != VIIPER_UDE_ABI_MINOR || + stats.Header.Minor != negotiatedMinor || stats.Header.Size != sizeof(stats) || stats.Header.Flags != 0) { return SetError(error, L"upgrade-pristine-stats", ERROR_REVISION_MISMATCH, L"loaded driver returned an invalid pristine-runtime statistics record"); @@ -5300,6 +5350,22 @@ Outcome SelfTest() { } return outcome; } + Error previousAbiError; + previousAbiError.code = ERROR_INVALID_PARAMETER; + previousAbiError.phase = L"abi-negotiate-result"; + if (!IsPreviousAbiRetryEligible(true, nullptr, previousAbiError) || + IsPreviousAbiRetryEligible(false, nullptr, previousAbiError) || + IsPreviousAbiRetryEligible(true, &buildIdentity, previousAbiError)) { + SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, + L"previous-ABI retry escaped the pristine upgrade-only boundary"); + return outcome; + } + previousAbiError.phase = L"abi-negotiate-timeout"; + if (IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { + SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, + L"previous-ABI retry accepted a non-version negotiation failure"); + return outcome; + } JsonValue value; std::string message; if (!JsonParser(R"({"schema":1,"files":[]})").Parse(&value, &message) || From dc9ce3a548960e32eb54c43745d78ca165bd35d3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 15:30:17 -0500 Subject: [PATCH 229/240] Update local test signature contract --- .../udecx/local_test_package_contract_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/transport/udecx/local_test_package_contract_test.go b/internal/transport/udecx/local_test_package_contract_test.go index 0638ce56..c21f0f36 100644 --- a/internal/transport/udecx/local_test_package_contract_test.go +++ b/internal/transport/udecx/local_test_package_contract_test.go @@ -300,8 +300,19 @@ func TestLocalTestPackageUsesFullTransactionalNativeBackend(t *testing.T) { t.Fatal("native helper still treats a successful SetupGetStringFieldW size query as failure") } if strings.Count(helperSource, - "code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER") != 2 { - t.Fatal("native helper does not recognize SetupAPI's exact trusted-Authenticode success classification") + "code != ERROR_AUTHENTICODE_TRUSTED_PUBLISHER") != 1 { + t.Fatal("native helper does not retain SetupAPI's exact trusted-Authenticode classification for installed packages") + } + for _, required := range []string{ + "bool allowUntrustedLocalTestRoot", + "allowUntrustedLocalTestRoot &&", + "status == static_cast(CERT_E_UNTRUSTEDROOT)", + "VerifyDriverCatalogMember(catalogPath, infPath, true, error)", + "VerifyDriverCatalogMember(catalogPath, infPath, false, error)", + } { + if !strings.Contains(helperSource, required) { + t.Fatalf("native helper omitted scoped pre-trust catalog policy %q", required) + } } if strings.Contains(helperSource, "ERROR_AUTHENTICODE_TRUST_NOT_ESTABLISHED") { From 07700497274c690de9f69a6380e87e5a07e50fa8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 18:00:56 -0500 Subject: [PATCH 230/240] Fix precise ISO clock output pointer --- .../udecx/driver_iso_contract_test.go | 25 +++++++++++++++++++ internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Broker.c | 3 ++- native/udecx/driver/ViiperUde.vcxproj | 2 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 8 files changed, 33 insertions(+), 7 deletions(-) diff --git a/internal/transport/udecx/driver_iso_contract_test.go b/internal/transport/udecx/driver_iso_contract_test.go index 9f8b4533..020ba6db 100644 --- a/internal/transport/udecx/driver_iso_contract_test.go +++ b/internal/transport/udecx/driver_iso_contract_test.go @@ -154,3 +154,28 @@ func TestNativeDriverRejectedExplicitIsoReservationDoesNotAdvanceTail(t *testing t.Fatalf("out-of-range explicit reservation advanced tail to %d", tail) } } + +func TestNativeDriverPreciseIsoClockSuppliesRequiredQpcOutput(t *testing.T) { + source := nativeDriverBrokerSource(t) + start := strings.Index(source, "ViiperReserveIsoStartFrame(") + if start < 0 { + t.Fatal("native ISO reservation helper is missing") + } + end := strings.Index(source[start:], "ViiperCopyTransferBuffer(") + if end < 0 { + t.Fatal("native ISO reservation helper boundary is missing") + } + reservation := source[start : start+end] + + for _, required := range []string{ + "ULONGLONG qpcTimestamp;", + "KeQueryInterruptTimePrecise(&qpcTimestamp)", + } { + if !strings.Contains(reservation, required) { + t.Fatalf("native precise ISO clock is missing %q", required) + } + } + if strings.Contains(reservation, "KeQueryInterruptTimePrecise(NULL)") { + t.Fatal("native precise ISO clock passes a null mandatory QPC output pointer") + } +} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index ff96cc57..15c032d1 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.32" + DriverPackageVersion = "0.1.0.33" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index e224babb..cc4266c0 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "96ecacd1be08c28c77429c56c0b6a39e593c59cc60481746d7ec2b268545f1dd" + const wantHex = "037546fe63e5507cadf58c7b151f096fa52a533ce4cda4397040bf0b748e347d" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index a495bfdf..d4a0264a 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -1400,6 +1400,7 @@ ViiperReserveIsoStartFrame( ) { LONG64 observed; + ULONGLONG qpcTimestamp; ULONG currentFrame; LONG requestedDelta; ULONG startFrame; @@ -1407,7 +1408,7 @@ ViiperReserveIsoStartFrame( ULONG span; span = ViiperIsoFrameSpan(EndpointContext, PacketCount); - currentFrame = (ULONG)(KeQueryInterruptTimePrecise(NULL) / 10000ULL); + currentFrame = (ULONG)(KeQueryInterruptTimePrecise(&qpcTimestamp) / 10000ULL); if ((TransferFlags & USBD_START_ISO_TRANSFER_ASAP) == 0) { // An explicit URB is valid only in the future 1024-frame window. Do // not let a rejected request advance the shared endpoint tail: doing diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index dc5d5b3c..14f6854b 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.32 + 0.1.0.33 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index bd4458f8..a2e333b8 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(11) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.32" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.33" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index a689e3fb..6abf063b 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.32 +DriverVer=08/14/2026,0.1.0.33 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 63408275..bef25f87 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5344,7 +5344,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "96ecacd1be08c28c77429c56c0b6a39e593c59cc60481746d7ec2b268545f1dd") { + "037546fe63e5507cadf58c7b151f096fa52a533ce4cda4397040bf0b748e347d") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 27fb398e496bc8aff7f485696ca25fc45b88aef2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 19:49:59 -0500 Subject: [PATCH 231/240] Harden native UDE lifecycle and diagnostics --- .github/workflows/native-ude.yml | 117 +++- .../udecx/driver_dispatch_contract_test.go | 100 ++- ...river_endpoint_quiescence_contract_test.go | 285 ++++++++- .../udecx/driver_lifecycle_contract_test.go | 600 +++++++++++++++++- internal/transport/udecx/host.go | 12 +- internal/transport/udecx/protocol.go | 2 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/driver/Broker.c | 57 +- native/udecx/driver/Controller.c | 23 +- native/udecx/driver/Device.c | 581 ++++++++++++----- native/udecx/driver/Ioctl.c | 3 + native/udecx/driver/ViiperUde.h | 27 +- native/udecx/driver/ViiperUde.vcxproj | 7 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../udecx/tools/New-ViiperUdeDebugBundle.ps1 | 219 +++++++ .../tools/Test-ViiperUdeDebugArtifacts.ps1 | 134 +++- .../tools/Test-ViiperUdeStaticAnalysis.ps1 | 44 ++ .../Test-ViiperUdeTargetCompatibility.ps1 | 185 +++++- native/udecx/tools/ViiperUdeCtl.cpp | 2 +- 20 files changed, 2121 insertions(+), 283 deletions(-) create mode 100644 native/udecx/tools/New-ViiperUdeDebugBundle.ps1 create mode 100644 native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 3a32a08f..9480b632 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -259,27 +259,35 @@ jobs: $devCmd = Join-Path $vs "Common7\Tools\VsDevCmd.bat" $source = (Resolve-Path "native\udecx\tools\ViiperUdeCtl.cpp").Path $outputDir = Join-Path $PWD "native\udecx\x64\Release" + $intermediateDir = Join-Path $env:RUNNER_TEMP "viiper-native-symbol-objects" New-Item -ItemType Directory -Force $outputDir | Out-Null + New-Item -ItemType Directory -Force $intermediateDir | Out-Null $output = Join-Path $outputDir "ViiperUdeCtl.exe" - $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /DUNICODE /D_UNICODE `"$source`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib" + $helperPdb = Join-Path $outputDir "ViiperUdeCtl.pdb" + $helperObj = Join-Path $intermediateDir "ViiperUdeCtl.obj" + $command = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /DUNICODE /D_UNICODE `"$source`" /Fo:`"$helperObj`" /Fe:`"$output`" /link Setupapi.lib Newdev.lib Cfgmgr32.lib Advapi32.lib /DEBUG:FULL /PDB:`"$helperPdb`" /PDBALTPATH:ViiperUdeCtl.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" cmd.exe /d /s /c $command - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output)) { throw "ViiperUdeCtl build failed" } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $output) -or -not (Test-Path $helperPdb)) { throw "ViiperUdeCtl full-symbol build failed" } & $output self-test if ($LASTEXITCODE -ne 0) { throw "ViiperUdeCtl self-test failed" } $mediaSource = (Resolve-Path "native\udecx\tools\ViiperUdeMediaProbe.cpp").Path $mediaOutput = Join-Path $outputDir "ViiperUdeMediaProbe.exe" - $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib" + $mediaPdb = Join-Path $outputDir "ViiperUdeMediaProbe.pdb" + $mediaObj = Join-Path $intermediateDir "ViiperUdeMediaProbe.obj" + $mediaCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /D_WIN32_WINNT=0x0A00 `"$mediaSource`" /Fo:`"$mediaObj`" /Fe:`"$mediaOutput`" /link Ole32.lib Ksuser.lib /DEBUG:FULL /PDB:`"$mediaPdb`" /PDBALTPATH:ViiperUdeMediaProbe.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" cmd.exe /d /s /c $mediaCommand - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaOutput)) { throw "ViiperUdeMediaProbe build failed" } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaOutput) -or -not (Test-Path $mediaPdb)) { throw "ViiperUdeMediaProbe full-symbol build failed" } $mediaSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-media-smoke.snapshot" & $mediaOutput snapshot $mediaSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $mediaSnapshot)) { throw "ViiperUdeMediaProbe endpoint snapshot smoke test failed" } Remove-Item -LiteralPath $mediaSnapshot -Force $inputSource = (Resolve-Path "native\udecx\tools\ViiperUdeInputProbe.cpp").Path $inputOutput = Join-Path $outputDir "ViiperUdeInputProbe.exe" - $inputCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /MT /D_WIN32_WINNT=0x0A00 `"$inputSource`" /Fe:`"$inputOutput`" /link Setupapi.lib Hid.lib" + $inputPdb = Join-Path $outputDir "ViiperUdeInputProbe.pdb" + $inputObj = Join-Path $intermediateDir "ViiperUdeInputProbe.obj" + $inputCommand = "`"$devCmd`" -arch=x64 -host_arch=x64 && cl.exe /nologo /std:c++20 /EHsc /W4 /WX /O2 /Z7 /MT /D_WIN32_WINNT=0x0A00 `"$inputSource`" /Fo:`"$inputObj`" /Fe:`"$inputOutput`" /link Setupapi.lib Hid.lib /DEBUG:FULL /PDB:`"$inputPdb`" /PDBALTPATH:ViiperUdeInputProbe.pdb /INCREMENTAL:NO /OPT:REF /OPT:ICF" cmd.exe /d /s /c $inputCommand - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputOutput)) { throw "ViiperUdeInputProbe build failed" } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputOutput) -or -not (Test-Path $inputPdb)) { throw "ViiperUdeInputProbe full-symbol build failed" } $inputSnapshot = Join-Path $env:RUNNER_TEMP "viiper-ude-input-smoke.snapshot" & $inputOutput snapshot $inputSnapshot if ($LASTEXITCODE -ne 0 -or -not (Test-Path $inputSnapshot)) { throw "ViiperUdeInputProbe HID snapshot smoke test failed" } @@ -295,6 +303,13 @@ jobs: $probeManifestPath = Join-Path $outputDir 'ViiperUdeLiveProbes.manifest.json' $probeManifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $probeManifestPath -Encoding utf8NoBOM if (-not (Test-Path -LiteralPath $probeManifestPath -PathType Leaf)) { throw "Live-probe manifest was not created" } + ./native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 ` + -SysPath native/udecx/x64/Release/ViiperUde.sys ` + -PdbPath native/udecx/x64/Release/ViiperUde.pdb ` + -MapPath native/udecx/x64/Release/ViiperUde.map ` + -HelperPath $output -HelperPdbPath $helperPdb ` + -MediaProbePath $mediaOutput -MediaProbePdbPath $mediaPdb ` + -InputProbePath $inputOutput -InputProbePdbPath $inputPdb - name: Build source-bound native broker shell: pwsh run: | @@ -302,11 +317,85 @@ jobs: $env:CGO_ENABLED = '0' $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') go build -tags release -trimpath ` - -ldflags "-s -w -X main.Version=0.1.0-local-test -X main.Commit=$env:GITHUB_SHA -X main.Date=$buildDate -X github.com/Alia5/VIIPER/internal/codegen/common.Version=0.1.0-local-test -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA" ` + -ldflags "-X main.Version=0.1.0-local-test -X main.Commit=$env:GITHUB_SHA -X main.Date=$buildDate -X github.com/Alia5/VIIPER/internal/codegen/common.Version=0.1.0-local-test -X github.com/Alia5/VIIPER/internal/transport/udecx.nativeSourceRevision=$env:GITHUB_SHA" ` -o $output ./cmd/viiper if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { throw 'Source-bound native broker build failed.' } + $nmPatterns = @( + ' main\.main(?:\.abi0)?$', + ' github\.com/Alia5/VIIPER/internal/transport/udecx\.\(\*Host\)\.runInputPublisher(?:\.abi0)?$' + ) + $nmMatches = @(& go tool nm $output 2>&1 | Select-String -Pattern $nmPatterns) + $nmExitCode = $LASTEXITCODE + $nmText = $nmMatches -join [Environment]::NewLine + if ($nmExitCode -ne 0 -or + @($nmPatterns | Where-Object { $nmText -notmatch $_ }).Count -ne 0) { + throw 'Source-bound native broker is missing required Go/DWARF hot-path symbols.' + } + $brokerAscii = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($output)) + $dwarfSectionPatterns = @( + '\.(?:z)?debug_info(?:\x00|$)', + '\.(?:z)?debug_line(?:\x00|$)', + '\.(?:z)?debug_abbrev(?:\x00|$)' + ) + if (@($dwarfSectionPatterns | Where-Object { $brokerAscii -notmatch $_ }).Count -ne 0) { + throw 'Source-bound native broker is missing embedded Go DWARF sections.' + } + $helpOutput = (& $output --help 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or + $helpOutput -notmatch [regex]::Escape("Version: 0.1.0-local-test ($env:GITHUB_SHA)") -or + $helpOutput -notmatch [regex]::Escape($buildDate)) { + throw 'Retaining Go DWARF changed or removed the source-bound broker version metadata.' + } + $buildInfoPath = "$output.buildinfo.txt" + $buildInfo = (& go version -m $output 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or + $buildInfo -notmatch ('(?m)^\s*build\s+vcs\.revision=' + [regex]::Escape($env:GITHUB_SHA) + '\s*$')) { + throw 'The broker Go build information is not bound to the workflow source revision.' + } + [IO.File]::WriteAllText($buildInfoPath, $buildInfo, [Text.UTF8Encoding]::new($false)) + $brokerItem = Get-Item -LiteralPath $output + $buildManifest = [ordered]@{ + schema = 1 + sourceRevision = $env:GITHUB_SHA.ToLowerInvariant() + version = '0.1.0-local-test' + commit = $env:GITHUB_SHA.ToLowerInvariant() + buildDate = $buildDate + goVersion = (go env GOVERSION) + trimpath = $true + embeddedDwarf = $true + embeddedDwarfSections = @('debug_info', 'debug_line', 'debug_abbrev') + binary = [ordered]@{ + name = $brokerItem.Name + length = $brokerItem.Length + sha256 = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + } + buildInfoSha256 = (Get-FileHash -LiteralPath $buildInfoPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + $buildManifestPath = "$output.build.json" + [IO.File]::WriteAllText($buildManifestPath, + ($buildManifest | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) + - name: Compose exact source-bound debug bundle + if: ${{ inputs.upload_release_helper == true || inputs.upload_artifacts == true }} + shell: pwsh + run: >- + ./native/udecx/tools/New-ViiperUdeDebugBundle.ps1 + -RepositoryRoot . + -SourceRevision $env:GITHUB_SHA + -DriverImagePath native/udecx/x64/Release/ViiperUde.sys + -DriverPdbPath native/udecx/x64/Release/ViiperUde.pdb + -DriverMapPath native/udecx/x64/Release/ViiperUde.map + -BrokerPath native/udecx/x64/Release/viiper.exe + -BrokerBuildInfoPath native/udecx/x64/Release/viiper.exe.buildinfo.txt + -BrokerBuildManifestPath native/udecx/x64/Release/viiper.exe.build.json + -HelperPath native/udecx/x64/Release/ViiperUdeCtl.exe + -HelperPdbPath native/udecx/x64/Release/ViiperUdeCtl.pdb + -MediaProbePath native/udecx/x64/Release/ViiperUdeMediaProbe.exe + -MediaProbePdbPath native/udecx/x64/Release/ViiperUdeMediaProbe.pdb + -InputProbePath native/udecx/x64/Release/ViiperUdeInputProbe.exe + -InputProbePdbPath native/udecx/x64/Release/ViiperUdeInputProbe.pdb + -OutputDirectory native/udecx/x64/Release/ViiperUdeDebug - name: Upload source-bound native live probes if: ${{ inputs.upload_release_helper == true }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -314,7 +403,9 @@ jobs: name: ViiperUdeLiveProbes-windows-amd64-${{ github.sha }} path: | native/udecx/x64/Release/ViiperUdeMediaProbe.exe + native/udecx/x64/Release/ViiperUdeMediaProbe.pdb native/udecx/x64/Release/ViiperUdeInputProbe.exe + native/udecx/x64/Release/ViiperUdeInputProbe.pdb native/udecx/x64/Release/ViiperUdeLiveProbes.manifest.json if-no-files-found: error retention-days: 30 @@ -323,7 +414,17 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ViiperUdeCtl-windows-amd64-${{ github.sha }} - path: native/udecx/x64/Release/ViiperUdeCtl.exe + path: | + native/udecx/x64/Release/ViiperUdeCtl.exe + native/udecx/x64/Release/ViiperUdeCtl.pdb + if-no-files-found: error + retention-days: 30 + - name: Upload exact source-bound debug bundle + if: ${{ inputs.upload_release_helper == true || inputs.upload_artifacts == true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ViiperUdeDebug-windows-amd64-${{ github.sha }} + path: native/udecx/x64/Release/ViiperUdeDebug/** if-no-files-found: error retention-days: 30 - name: Validate testing-only Hardware Dev Center CAB structure diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index cd96aa7b..f19007e0 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -21,12 +21,12 @@ func TestNativeConfigurationSelectionDoesNotEnterResetProtocol(t *testing.T) { requireContractOrder(t, device, "case UdecxEndpointsConfigureTypeDeviceInitialize:", "ConfigureParams->EndpointsToConfigureCount", - "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE);", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex]);", "WdfRequestComplete(Request, STATUS_SUCCESS);", "return;", "case UdecxEndpointsConfigureTypeDeviceConfigurationChange:", "ConfigureParams->EndpointsToConfigureCount", - "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE);", + "ViiperActivateEndpoint( ConfigureParams->EndpointsToConfigure[endpointIndex]);", "WdfRequestComplete(Request, STATUS_SUCCESS);", "return;", "case UdecxEndpointsConfigureTypeInterfaceSettingChange:") @@ -350,8 +350,8 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { "WdfWaitLockAcquire(endpointContext->InputLock, NULL);", "for (;;)", "WdfIoQueueRetrieveNextRequest(Queue, &request)", - "ViiperPrepareCachedInputUrb(endpoint, request);", - "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);", + "ViiperPrepareCachedInputUrb( endpoint, request, &directInputBytes, &directInputSequence);", + "ViiperCompleteRetrievedInputUrb( endpoint, request, completionStatus, directInputBytes, directInputSequence);", "WdfWaitLockRelease(endpointContext->InputLock);", "ViiperEndpointOperationCompleted(endpoint);") if strings.Count(ready, "ViiperEndpointOperationStarted(endpoint);") != 2 { @@ -368,6 +368,40 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { } } +func TestNativeDirectInputStatsCommitAfterTerminalUdeCxCompletion(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + + prepare := normalizedContract(nativeCFunction(t, device, "ViiperPrepareCachedInputUrb")) + if strings.Contains(prepare, "BytesFromDevice") || + strings.Contains(prepare, "InputReportsCompleted") { + t.Fatal("cached input preparation reports completion before the terminal UdeCx call") + } + requireContractOrder(t, prepare, + "*BytesPrepared = 0;", + "*SequencePrepared = 0;", + "UdecxUrbSetBytesCompleted(Request, reportLength);", + "*BytesPrepared = reportLength;", + "*SequencePrepared = reportSequence;") + + queueCompletion := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrbCompletion")) + requireContractOrder(t, queueCompletion, + "requestContext->DirectInputBytes = DirectInputBytes;", + "requestContext->DirectInputSequence = DirectInputSequence;", + "requestContext->CompletionQueued = TRUE;") + + dpc := normalizedContract(nativeCFunction(t, broker, "ViiperEvtCompletionDpc")) + requireContractOrder(t, dpc, + "directInputBytes = requestContext->DirectInputBytes;", + "directInputSequence = requestContext->DirectInputSequence;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "UdecxUrbCompleteWithNtStatus(request, completionStatus);", + "UdecxUrbComplete(request, usbdStatus);", + "directInputSequence != 0", + "InterlockedAdd64(&controllerContext->BytesFromDevice, directInputBytes);", + "InterlockedIncrement64(&controllerContext->InputReportsCompleted);") +} + func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") if !strings.Contains(header, "#define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01") { @@ -385,11 +419,11 @@ func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { "WdfIoQueueRetrieveNextRequest(endpointContext->Queue") ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) requireContractOrder(t, ready, - "ViiperPrepareCachedInputUrb(endpoint, request);", + "ViiperPrepareCachedInputUrb( endpoint, request, &directInputBytes, &directInputSequence);", "&endpointContext->CachedDeliveryPending", "&endpointContext->InputTransitionCount", "&endpointContext->InputSnapshotPending", - "ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus);") + "ViiperCompleteRetrievedInputUrb( endpoint, request, completionStatus, directInputBytes, directInputSequence);") prepare := normalizedContract(nativeCFunction(t, device, "ViiperPrepareCachedInputUrb")) requireContractOrder(t, prepare, "if (InterlockedCompareExchange(&endpointContext->InputTransitionCount", @@ -623,9 +657,9 @@ func TestNativeFastInputUsesSharedIndexedLifetimeAdmission(t *testing.T) { "ViiperReleaseDeviceLockShared(controllerContext);", "WdfWaitLockAcquire(endpointContext->InputLock, NULL);") requireContractOrder(t, submit, - "ViiperPrepareCachedInputUrb(endpoint, urbRequest);", + "ViiperPrepareCachedInputUrb( endpoint, urbRequest, &directInputBytes, &directInputSequence);", "WdfWaitLockRelease(endpointContext->InputLock);", - "ViiperCompleteRetrievedInputUrb(endpoint, urbRequest, status);") + "ViiperCompleteRetrievedInputUrb( endpoint, urbRequest, status, directInputBytes, directInputSequence);") requireContractOrder(t, submit, "endpointContext->LastInputSequence", "RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength);", @@ -750,20 +784,45 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. requireContractOrder(t, purge, "WdfSpinLockAcquire(controllerContext->BrokerLock);", "InterlockedExchange(&endpointContext->Purging, TRUE);", + "InterlockedExchange(&endpointContext->StartAnnounced, FALSE);", + "outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding);", + "enqueueWorkItem = InterlockedCompareExchange( &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE;", "WdfSpinLockRelease(controllerContext->BrokerLock);", "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", - "WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint);") + "if (enqueueWorkItem)", + "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") createQueue := normalizedContract(nativeCFunction(t, device, "ViiperCreateEndpointQueue")) if !strings.Contains(createQueue, "UdecxUsbEndpointSetWdfIoQueue(Endpoint, endpointContext->Queue);") { t.Fatal("endpoint purge lost its explicitly associated WDF queue") } - purgeComplete := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointQueuePurged")) - requireContractOrder(t, purgeComplete, - "KeWaitForSingleObject( &endpointContext->OperationsDrained", - "endpointContext->ActiveOperations", + if strings.Contains(device, "WdfIoQueuePurge(") || + strings.Contains(device, "WdfIoQueueStart(") { + t.Fatal("UdeCx-associated endpoint queue state is client-mutated") + } + purgeQuiescence := normalizedContract(nativeCFunction( + t, device, "ViiperWaitForEndpointPurgeQuiescence")) + requireContractOrder(t, purgeQuiescence, + "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", + "endpointContext->PurgeOutstanding", + "endpointContext->Purging", + "!WDF_IO_QUEUE_READY(queueState)", + "WdfIoQueueDriverNoRequests", + "driverRequests == 0", + "endpointContext->ActiveOperations") + for _, forbidden := range []string{"WDF_IO_QUEUE_IDLE", "queuedRequests == 0"} { + if strings.Contains(purgeQuiescence, forbidden) { + t.Fatalf("endpoint PURGE incorrectly waits on UdeCx-owned queue state %q", forbidden) + } + } + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "ViiperWaitForEndpointPurgeQuiescence( endpoint, &queueState, &queuedRequests, &driverRequests);", "ViiperInvalidateEndpointInputReport(endpoint);", - "UdecxUsbEndpointPurgeComplete(endpoint);") + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);", + "UdecxUsbEndpointPurgeComplete(endpoint);", + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) requireContractOrder(t, resetWork, "resetCurrent = ViiperQuiesceResetByIdentity(", @@ -772,20 +831,25 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. "ViiperInvalidateEndpointInputReport(endpoint);", "ViiperQueueAcknowledgedEndpointLifecycleEvent(") start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) - if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint, TRUE);") { - t.Fatal("explicit endpoint START no longer performs the KMDF queue transition") + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint);") { + t.Fatal("explicit endpoint START no longer opens VIIPER endpoint admission") } activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) requireContractOrder(t, activate, + "endpointContext->PurgeOutstanding", "InterlockedExchange(&endpointContext->Purging, FALSE);", "endpointContext->StartAnnounced, TRUE, FALSE", - "WdfIoQueueStart(endpointContext->Queue);", "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);") + if strings.Contains(activate, "PurgeWorkerActive") { + t.Fatal("final synchronous START is incorrectly coupled to worker callback return") + } cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointCleanup")) requireContractOrder(t, cleanup, "ViiperAcquireDeviceLockExclusive(controllerContext);", "endpointContext->ActiveOperations", + "endpointContext->PurgeOutstanding", + "endpointContext->PurgeWorkerActive", "ViiperInvalidateEndpointInputReport(endpoint);", "deviceContext->Endpoints[address] = WDF_NO_HANDLE;", "ViiperReleaseDeviceLockExclusive(controllerContext);") @@ -891,7 +955,7 @@ func TestNativeDeviceAndBrokerLockOrderNeverReverses(t *testing.T) { requireContractOrder(t, virtualCleanup, "WdfSpinLockAcquire(controllerContext->BrokerLock);", "WdfSpinLockRelease(controllerContext->BrokerLock);", - "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);") + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);") management := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteManagementOperation")) firstRelease := strings.Index(management, "WdfSpinLockRelease(ControllerContext->BrokerLock);") diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index a961dedd..af9eb3ec 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -5,16 +5,18 @@ import ( "testing" ) -func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { +func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") - // UdeCx delegates PURGE/START handling for the associated queue to the - // client. Only the asynchronous purge and matching start transitions are - // allowed; stop, drain, and synchronous purge create competing ownership. + // UdeCx exclusively owns the associated endpoint queue's START/PURGE + // state. VIIPER may observe that queue, but must never mutate it. for _, mutation := range []string{ + "WdfIoQueuePurge(", "WdfIoQueuePurgeSynchronously(", + "WdfIoQueueStart(", "WdfIoQueueStop(", "WdfIoQueueStopSynchronously(", "WdfIoQueueDrain(", @@ -44,6 +46,31 @@ func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { "if (quiescent)", "return;", "KeDelayExecutionThread(") + purgeQuiescence := normalizedContract(nativeCFunction( + t, device, "ViiperWaitForEndpointPurgeQuiescence")) + requireContractOrder(t, purgeQuiescence, + "KeWaitForSingleObject( &endpointContext->OperationsDrained", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", + "endpointContext->PurgeOutstanding", + "endpointContext->Purging", + "!WDF_IO_QUEUE_READY(queueState)", + "WdfIoQueueDriverNoRequests", + "driverRequests == 0", + "endpointContext->ActiveOperations", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (quiescent)", + "return;", + "KeDelayExecutionThread(") + for _, forbidden := range []string{ + "WDF_IO_QUEUE_IDLE", + "WDF_IO_QUEUE_PURGED", + "queuedRequests == 0", + } { + if strings.Contains(purgeQuiescence, forbidden) { + t.Fatalf("purge quiescence incorrectly waits on UdeCx-owned queue state %q", forbidden) + } + } queueUrb := normalizedContract(nativeCFunction(t, broker, "ViiperQueueUrb")) requireContractOrder(t, queueUrb, @@ -73,28 +100,81 @@ func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { purge := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurge")) requireContractOrder(t, purge, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", "InterlockedExchange(&endpointContext->Purging, TRUE);", "InterlockedExchange(&endpointContext->StartAnnounced, FALSE);", + "outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding);", + "enqueueWorkItem = InterlockedCompareExchange( &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", - "WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint);") - purgeComplete := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointQueuePurged")) - requireContractOrder(t, purgeComplete, - "KeWaitForSingleObject( &endpointContext->OperationsDrained", - "endpointContext->ActiveOperations", + "ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge);", + "if (enqueueWorkItem)", + "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + if strings.Contains(purge, "ViiperInvalidateEndpointInputReport") { + t.Fatal("DISPATCH-level endpoint PURGE must defer wait-lock-backed input invalidation to its passive work item") + } + enqueueEnd := strings.Index(purge, "WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);") + if enqueueEnd < 0 || strings.Contains(purge[enqueueEnd+len("WdfWorkItemEnqueue(endpointContext->PurgeWorkItem);"):], "endpointContext") { + t.Fatal("PURGE work-item enqueue must remain the callback's final endpoint-context access") + } + purgeWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointPurgeWorkItem")) + requireContractOrder(t, purgeWork, + "WdfWorkItemGetParentObject(WorkItem)", + "for (;;)", + "endpointContext->PurgeOutstanding", + "endpointContext->PurgeWorkerActive", + "ViiperWaitForEndpointPurgeQuiescence( endpoint, &queueState, &queuedRequests, &driverRequests);", "ViiperInvalidateEndpointInputReport(endpoint);", - "UdecxUsbEndpointPurgeComplete(endpoint);") + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "UdecxUsbEndpointPurgeComplete(endpoint);", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "endpointContext->PurgeOutstanding", + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") + decrement := strings.Index(purgeWork, + "remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding);") + complete := strings.Index(purgeWork, "UdecxUsbEndpointPurgeComplete(endpoint);") + workerRelease := strings.LastIndex(purgeWork, + "InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE);") + if decrement < 0 || complete <= decrement || workerRelease <= complete { + t.Fatal("PURGE worker must decrement before completion and retain worker ownership through synchronous callbacks") + } + if !strings.Contains(header, "WDFWORKITEM PurgeWorkItem;") || + !strings.Contains(header, "volatile LONG PurgeOutstanding;") || + !strings.Contains(header, "volatile LONG PurgeWorkerActive;") || + !strings.Contains(header, "EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem;") { + t.Fatal("endpoint context lost its counted passive PURGE worker state") + } + endpointAdd := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointAdd")) + requireContractOrder(t, endpointAdd, + "KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE);", + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = endpoint;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &endpointContext->PurgeWorkItem);") + requireContractOrder(t, endpointAdd, + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = endpoint;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &endpointContext->ResetWorkItem);") start := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointStart")) - if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint, TRUE);") { - t.Fatal("explicit UdeCx START no longer performs the queue-owning endpoint activation") + if !strings.Contains(start, "ViiperActivateEndpoint(Endpoint);") { + t.Fatal("explicit UdeCx START no longer opens the VIIPER endpoint admission gate") } activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) requireContractOrder(t, activate, + "endpointContext->PurgeOutstanding", "InterlockedExchange(&endpointContext->Purging, FALSE);", "endpointContext->StartAnnounced, TRUE, FALSE", - "if (StartQueue)", - "WdfIoQueueStart(endpointContext->Queue);", "ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart);", "endpointContext->StartAnnounced, FALSE, TRUE") + if strings.Contains(activate, "PurgeWorkerActive") { + t.Fatal("synchronous final START must not wait for the still-executing PURGE worker to return") + } + reset := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointReset")) + if strings.Contains(reset, "ViiperInvalidateEndpointInputReport") { + t.Fatal("DISPATCH-level endpoint RESET must defer wait-lock-backed input invalidation to its passive work item") + } resetWork := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointResetWorkItem")) requireContractOrder(t, resetWork, "resetCurrent = ViiperQuiesceResetByIdentity(", @@ -137,6 +217,136 @@ func TestNativeEndpointQuiescenceUsesUdeCxRequiredQueueLifecycle(t *testing.T) { } } +func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing.T) { + type purgeState struct { + purging bool + queueReady bool + driverNoRequest bool + driverRequests int + activeOperations int + queuedHostPolls int + outstanding int + workerActive bool + enqueues int + callbacks int + completions int + } + + beginPurge := func(state *purgeState) { + state.purging = true + // The class extension closes dispatch before invoking the callback. + state.queueReady = false + state.callbacks++ + state.outstanding++ + if !state.workerActive { + state.workerActive = true + state.enqueues++ + } + } + start := func(state *purgeState) bool { + // START is allowed immediately after the final counter decrement, even + // though the completing worker remains active until the callback returns. + if state.outstanding != 0 { + return false + } + state.purging = false + state.queueReady = true + return true + } + quiescent := func(state *purgeState) bool { + // queuedHostPolls is intentionally excluded: those requests remain owned + // by UdeCx while delivery is stopped. + return state.outstanding > 0 && state.purging && !state.queueReady && + state.driverNoRequest && state.driverRequests == 0 && + state.activeOperations == 0 + } + completeOne := func(state *purgeState, duringComplete func()) bool { + if !state.workerActive || !quiescent(state) { + return false + } + // This is the source contract's decrement-before-PurgeComplete boundary. + state.outstanding-- + state.completions++ + if duringComplete != nil { + duringComplete() + } + if state.outstanding == 0 { + state.workerActive = false + } + return true + } + + state := purgeState{driverNoRequest: true, queuedHostPolls: 7} + beginPurge(&state) + beginPurge(&state) + if state.outstanding != 2 || state.enqueues != 1 || !state.workerActive { + t.Fatalf("overlapping PURGE callbacks were not coalesced onto one counted worker: %+v", state) + } + if !completeOne(&state, func() { + if start(&state) { + t.Fatal("non-final PURGE completion admitted a synchronous START") + } + if !state.workerActive || state.outstanding != 1 { + t.Fatalf("worker ownership/count changed before non-final completion returned: %+v", state) + } + }) { + t.Fatal("first counted PURGE did not complete after driver quiescence") + } + if !completeOne(&state, func() { + if !start(&state) { + t.Fatal("final counter decrement did not admit synchronous START") + } + if !state.workerActive { + t.Fatal("worker ownership was released before synchronous completion callbacks") + } + beginPurge(&state) // reentrant from PurgeComplete + if state.enqueues != 1 || state.outstanding != 1 || !state.purging { + t.Fatalf("reentrant PURGE was lost or redundantly enqueued: %+v", state) + } + }) { + t.Fatal("second counted PURGE did not complete") + } + if !state.workerActive || state.outstanding != 1 { + t.Fatalf("worker did not retain a reentrant PURGE: %+v", state) + } + if !completeOne(&state, func() { + if !start(&state) || !state.workerActive { + t.Fatalf("final reentrant completion did not expose the intended START boundary: %+v", state) + } + }) { + t.Fatal("reentrant PURGE did not complete") + } + if state.outstanding != 0 || state.workerActive || state.enqueues != 1 || + state.completions != state.callbacks || state.queuedHostPolls != 7 { + t.Fatalf("counted worker lost a callback or consumed UdeCx-owned polls: %+v", state) + } + + for _, test := range []struct { + name string + state purgeState + want bool + }{ + {name: "stopped with queued host polls", state: purgeState{ + purging: true, driverNoRequest: true, outstanding: 1, queuedHostPolls: 99}, want: true}, + {name: "ready queue", state: purgeState{ + purging: true, queueReady: true, driverNoRequest: true, outstanding: 1}}, + {name: "framework callback delivered", state: purgeState{ + purging: true, driverNoRequest: false, outstanding: 1}}, + {name: "driver request held", state: purgeState{ + purging: true, driverNoRequest: true, driverRequests: 1, outstanding: 1}}, + {name: "VIIPER operation held", state: purgeState{ + purging: true, driverNoRequest: true, activeOperations: 1, outstanding: 1}}, + {name: "no outstanding callback", state: purgeState{ + purging: true, driverNoRequest: true}}, + {name: "START reopened gate", state: purgeState{ + driverNoRequest: true, outstanding: 1}}, + } { + if got := quiescent(&test.state); got != test.want { + t.Fatalf("%s: quiescent=%v want %v: %+v", test.name, got, test.want, test.state) + } + } +} + func TestNativeResetQuiescenceIsExactGenerationAndFailClosed(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") @@ -546,6 +756,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { queueDispatching bool queued int driverOwned int + driverNoRequests bool active int terminalDPCs int resetOutstanding bool @@ -558,6 +769,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { } state.queued-- state.driverOwned++ + state.driverNoRequests = false return true } resumeDeliveredCallback := func(state *endpoint) bool { @@ -576,17 +788,19 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { state.terminalDPCs++ state.active-- state.driverOwned-- + state.driverNoRequests = state.driverOwned == 0 } - closeForPurge := func(state *endpoint) { + udeCxBeginPurge := func(state *endpoint) { state.open = false - state.queueAccepting = false + // UdeCx owns this transition. A stopped+idle queue may still accept + // and retain host requests (0x0d), while dispatch remains closed until + // START. VIIPER must not consume or wait on those queued requests. state.queueDispatching = false - state.queued = 0 // WdfIoQueuePurge cancels requests it had not delivered. } queuePurgeComplete := func(state *endpoint) bool { - stopped := !state.queueAccepting && !state.queueDispatching - queueIdle := state.queued == 0 && state.driverOwned == 0 - return stopped && queueIdle && state.active == 0 + queueReady := state.queueAccepting && state.queueDispatching + return !queueReady && state.driverNoRequests && + state.driverOwned == 0 && state.active == 0 && !state.open } closeForShutdown := func(state *endpoint) { // The controller admission gate closes first. Queued host polls remain @@ -611,11 +825,12 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { queueAccepting: true, queueDispatching: true, queued: 2, + driverNoRequests: true, } if !deliverByWDF(&purge) { t.Fatal("purge: WDF did not deliver the pre-boundary callback") } - closeForPurge(&purge) + udeCxBeginPurge(&purge) if queuePurgeComplete(&purge) { t.Fatal("purge passed a WDF-delivered callback before rundown entry") } @@ -626,8 +841,28 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { t.Fatal("purge completed before the terminal DPC") } runTerminalDPC(&purge) - if !queuePurgeComplete(&purge) || purge.terminalDPCs != 1 { - t.Fatalf("purge failed asynchronous queue completion proof: %+v", purge) + if !queuePurgeComplete(&purge) || purge.terminalDPCs != 1 || + !purge.queueAccepting || purge.queueDispatching || purge.queued != 1 { + t.Fatalf("purge consumed queued host polls or failed driver-rundown proof: %+v", purge) + } + + // Direct input is admitted through the controller queue, so endpoint queue + // state alone cannot make PURGE complete. ActiveOperations is the second + // half of the proof and is sampled under the same BrokerLock. + direct := endpoint{ + open: true, + queueAccepting: true, + queueDispatching: true, + driverNoRequests: true, + active: 1, + } + udeCxBeginPurge(&direct) + if queuePurgeComplete(&direct) { + t.Fatal("purge passed direct input while the associated queue was idle") + } + direct.active-- + if !queuePurgeComplete(&direct) { + t.Fatalf("purge did not complete after direct input rundown: %+v", direct) } shutdown := endpoint{ @@ -635,6 +870,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { queueAccepting: true, queueDispatching: true, queued: 2, + driverNoRequests: true, } if !deliverByWDF(&shutdown) { t.Fatal("shutdown: WDF did not deliver the pre-boundary callback") @@ -650,7 +886,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { if !driverQuiescent(&shutdown) || !shutdown.queueDispatching || shutdown.queued != 1 { t.Fatalf("shutdown waited on class-extension-owned queued polls: %+v", shutdown) } - closeForPurge(&shutdown) // UdeCx callback after child consumption. + udeCxBeginPurge(&shutdown) // UdeCx callback after child consumption. if !queuePurgeComplete(&shutdown) { t.Fatalf("post-consumption endpoint purge did not complete: %+v", shutdown) } @@ -660,6 +896,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { queueAccepting: true, queueDispatching: true, queued: 2, + driverNoRequests: true, } if !deliverByWDF(&reset) { t.Fatal("reset: WDF did not deliver the pre-boundary callback") diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index 06f136d9..e2c1fc06 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -126,22 +126,47 @@ func TestKernelOwnerCleanupJoinsFiniteMutationRundown(t *testing.T) { } } -func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { +func TestKernelDelayedCleanupReservesPhysicalPortAndCannotRevokeSuccessor(t *testing.T) { device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") - if strings.Contains(device+header, "RemovingSlots") { - t.Fatal("logical slot reuse still depends on asynchronous child cleanup") + for _, required := range []string{ + "ULONGLONG PortReservationEpochs[VIIPER_UDE_MAX_DEVICES];", + "BOOLEAN PortReserved[VIIPER_UDE_MAX_DEVICES];", + "volatile LONG ReservedPorts;", + "ULONGLONG PortReservation;", + } { + if !strings.Contains(header, required) { + t.Fatalf("physical port reservation contract missing %q", required) + } } claim := normalizedContract(nativeCFunction(t, device, "ViiperClaimDeviceSlot")) requireContractOrder(t, claim, "if (current == WDF_NO_HANDLE)", + "if (!ControllerContext->PortReserved[index] && freeSlot == VIIPER_UDE_MAX_DEVICES)", "freeSlot = index;", - "ControllerContext->Devices[freeSlot] = Device;") + "ControllerContext->PortReserved[freeSlot] = TRUE;", + "InterlockedIncrement(&ControllerContext->ReservedPorts);", + "ControllerContext->Devices[freeSlot] = Device;", + "*PortReservation = reservation;") release := normalizedContract(nativeCFunction(t, device, "ViiperReleaseDeviceSlot")) requireContractOrder(t, release, + "ControllerContext->PortReservationEpochs[Slot] == PortReservation", "if (ControllerContext->Devices[Slot] == Device)", - "ControllerContext->Devices[Slot] = WDF_NO_HANDLE;") + "ControllerContext->Devices[Slot] = WDF_NO_HANDLE;", + "ControllerContext->PortReserved[Slot] = FALSE;", + "InterlockedDecrement(&ControllerContext->ReservedPorts);", + "NT_ASSERT(remaining >= 0);") + + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + controllerCleanup := normalizedContract(nativeCFunction( + t, controller, "ViiperEvtControllerCleanup")) + requireContractOrder(t, controllerCleanup, + "context->ActiveDevices", + "context->ReservedPorts", + "context->InputDeviceCount == 0", + "for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index)", + "NT_ASSERT(!context->PortReserved[index]);") remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) requireContractOrder(t, remove, @@ -149,10 +174,13 @@ func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { "ControllerContext->Devices[index] = WDF_NO_HANDLE;", "ViiperRetireActiveDevice(ControllerContext, deviceContext);", "*Device = current;") + if strings.Contains(remove, "PortReserved") { + t.Fatal("logical removal releases the physical port before framework cleanup") + } cleanup := normalizedContract(nativeCFunction(t, device, "ViiperEvtVirtualDeviceCleanup")) requireContractOrder(t, cleanup, "InterlockedExchange(&deviceContext->OwnerReferenced, 0)", - "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);", + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);", "ViiperRetireActiveDevice(controllerContext, deviceContext);", "WdfObjectDereference(ownerFile);") @@ -164,6 +192,557 @@ func TestKernelDelayedCleanupCannotBlockOrRevokeReusedSlot(t *testing.T) { } } +func TestKernelPlugInPublishesCleanupAccountingBeforeUdeCxExposure(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + + claim := normalizedContract(nativeCFunction(t, device, "ViiperClaimDeviceSlot")) + requireContractOrder(t, claim, + "ViiperAcquireDeviceLockExclusive(ControllerContext);", + "deviceContext->Slot = freeSlot;", + "deviceContext->PortReservation = reservation;", + "deviceContext->Plugged = TRUE;", + "ControllerContext->PortReserved[freeSlot] = TRUE;", + "ControllerContext->Devices[freeSlot] = Device;", + "InterlockedIncrement(&ControllerContext->ActiveDevices);", + "InterlockedExchange(&deviceContext->ActiveCounted, 1);", + "ViiperReleaseDeviceLockExclusive(ControllerContext);") + + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "deviceId = input->DeviceId;", + "ViiperClaimDeviceSlot( controllerContext, device, deviceId, &slot, &portReservation);", + "status = UdecxUsbDevicePlugIn(device, &plugOptions);", + "if (!NT_SUCCESS(status))", + "ViiperReleaseDeviceSlot(controllerContext, device, slot, portReservation);", + "ViiperRetireActiveDevice(controllerContext, deviceContext);", + "WdfObjectDelete(device);", + "goto ExitAdmission;") + + plugIn := "status = UdecxUsbDevicePlugIn(device, &plugOptions);" + postPlugIn := create[strings.Index(create, plugIn)+len(plugIn):] + for _, forbidden := range []string{ + "deviceContext->Plugged", + "deviceContext->ActiveCounted", + "controllerContext->ActiveDevices", + } { + if strings.Contains(postPlugIn, forbidden) { + t.Fatalf("PlugIn publication still mutates %q after UdeCx exposure: %s", + forbidden, postPlugIn) + } + } + failureBoundary := strings.Index(postPlugIn, "if (!NT_SUCCESS(status))") + if failureBoundary < 0 { + t.Fatal("PlugIn failure rollback is missing") + } + if strings.Contains(postPlugIn[:failureBoundary], "deviceContext->") { + t.Fatalf("PlugIn return tracing accesses context after UdeCx exposure: %s", + postPlugIn[:failureBoundary]) + } +} + +type modeledPortDevice struct { + slot int + token uint64 + active bool +} + +type modeledPortController struct { + devices [4]*modeledPortDevice + epochs [4]uint64 + reserved [4]bool + active int + reservedPorts int +} + +func (controller *modeledPortController) claim(device *modeledPortDevice) bool { + for slot := range controller.devices { + if controller.devices[slot] != nil || controller.reserved[slot] { + continue + } + controller.epochs[slot]++ + if controller.epochs[slot] == 0 { + controller.epochs[slot]++ + } + device.slot = slot + device.token = controller.epochs[slot] + device.active = true + controller.reserved[slot] = true + controller.reservedPorts++ + controller.devices[slot] = device + controller.active++ + return true + } + return false +} + +func (controller *modeledPortController) retire(device *modeledPortDevice) { + if !device.active { + return + } + device.active = false + controller.active-- +} + +func (controller *modeledPortController) logicalRemove(device *modeledPortDevice) { + if device.slot >= 0 && device.slot < len(controller.devices) && + controller.devices[device.slot] == device { + controller.devices[device.slot] = nil + controller.retire(device) + } +} + +func (controller *modeledPortController) release( + device *modeledPortDevice, + slot int, + token uint64, +) { + if slot < 0 || slot >= len(controller.devices) || token == 0 || + !controller.reserved[slot] || controller.epochs[slot] != token { + return + } + if controller.devices[slot] == device { + controller.devices[slot] = nil + } + controller.reserved[slot] = false + controller.reservedPorts-- +} + +func (controller *modeledPortController) cleanup(device *modeledPortDevice) { + controller.release(device, device.slot, device.token) + controller.retire(device) +} + +func TestPortReservationAndActiveAccountingModel(t *testing.T) { + t.Run("normal remove holds the exact port until cleanup", func(t *testing.T) { + controller := new(modeledPortController) + first := &modeledPortDevice{slot: -1} + second := &modeledPortDevice{slot: -1} + if !controller.claim(first) { + t.Fatal("first claim failed") + } + controller.logicalRemove(first) + if controller.active != 0 || !controller.reserved[first.slot] || + controller.reservedPorts != 1 { + t.Fatalf("logical remove lost teardown state: active=%d reserved=%v ports=%d", + controller.active, controller.reserved[first.slot], controller.reservedPorts) + } + if !controller.claim(second) || second.slot == first.slot { + t.Fatalf("successor reused reserved port: first=%d second=%d", + first.slot, second.slot) + } + controller.cleanup(first) + controller.cleanup(first) + if controller.active != 1 || controller.reservedPorts != 1 || + controller.reserved[first.slot] || + controller.devices[second.slot] != second { + t.Fatalf("exact cleanup disturbed successor: active=%d ports=%d first_reserved=%v", + controller.active, controller.reservedPorts, controller.reserved[first.slot]) + } + }) + + t.Run("failed PlugIn rollback cannot revoke its successor", func(t *testing.T) { + controller := new(modeledPortController) + failed := &modeledPortDevice{slot: -1} + successor := &modeledPortDevice{slot: -1} + if !controller.claim(failed) { + t.Fatal("failed-device claim failed") + } + failedSlot, failedToken := failed.slot, failed.token + controller.release(failed, failedSlot, failedToken) + controller.retire(failed) + if controller.reservedPorts != 0 { + t.Fatalf("failed PlugIn release leaked %d reserved ports", controller.reservedPorts) + } + if !controller.claim(successor) || successor.slot != failedSlot || + successor.token == failedToken { + t.Fatalf("successor identity did not advance: failed=(%d,%d) successor=(%d,%d)", + failedSlot, failedToken, successor.slot, successor.token) + } + controller.cleanup(failed) + if controller.active != 1 || controller.reservedPorts != 1 || + !controller.reserved[successor.slot] || + controller.devices[successor.slot] != successor { + t.Fatal("late failed-device cleanup revoked the successor") + } + }) + + t.Run("controller shutdown retires logic before exact physical cleanup", func(t *testing.T) { + controller := new(modeledPortController) + devices := []*modeledPortDevice{{slot: -1}, {slot: -1}, {slot: -1}} + for _, device := range devices { + if !controller.claim(device) { + t.Fatal("shutdown fixture claim failed") + } + } + for _, device := range devices { + controller.logicalRemove(device) + } + if controller.active != 0 || controller.reservedPorts != len(devices) { + t.Fatalf("logical shutdown state active=%d reserved=%d", + controller.active, controller.reservedPorts) + } + for _, device := range devices { + if !controller.reserved[device.slot] { + t.Fatalf("shutdown released port %d before cleanup", device.slot) + } + controller.cleanup(device) + } + if controller.active != 0 || controller.reservedPorts != 0 { + t.Fatalf("terminal cleanup state active=%d reserved=%d", + controller.active, controller.reservedPorts) + } + }) + + t.Run("unexpected and invalid cleanup is idempotent", func(t *testing.T) { + controller := new(modeledPortController) + device := &modeledPortDevice{slot: -1} + if !controller.claim(device) { + t.Fatal("unexpected-cleanup fixture claim failed") + } + controller.release(device, -1, device.token) + controller.release(device, device.slot, 0) + controller.release(device, device.slot, device.token+1) + if controller.active != 1 || controller.reservedPorts != 1 || + !controller.reserved[device.slot] { + t.Fatal("invalid token mutated live reservation") + } + controller.cleanup(device) + controller.cleanup(device) + if controller.active != 0 || controller.reservedPorts != 0 || + controller.reserved[device.slot] || + controller.devices[device.slot] != nil { + t.Fatalf("unexpected cleanup was not idempotent: active=%d", controller.active) + } + }) +} + +func TestControllerRestartPreservesOutstandingPortReservationEpochs(t *testing.T) { + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + deviceAdd := normalizedContract(nativeCFunction(t, controller, "ViiperEvtDeviceAdd")) + if !strings.Contains(deviceAdd, "RtlZeroMemory(context, sizeof(*context));") { + t.Fatal("controller context is not initialized exactly at device creation") + } + selfManagedInit := nativeCFunction(t, controller, "ViiperEvtDeviceSelfManagedIoInit") + for _, forbidden := range []string{"RtlZeroMemory", "PortReservationEpochs", "PortReserved"} { + if strings.Contains(selfManagedInit, forbidden) { + t.Fatalf("same-object restart resets outstanding reservation state %q: %s", + forbidden, selfManagedInit) + } + } +} + +func TestDispatchLevelPowerAndResetCallbacksDeferPassiveInvalidation(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + + for _, required := range []string{ + "WDFWORKITEM D0ExitWorkItem;", + "volatile LONG D0ExitPending;", + "EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem;", + } { + if !strings.Contains(header, required) { + t.Fatalf("device power deferral contract missing %q", required) + } + } + + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtUsbDeviceD0ExitWorkItem);", + "workItemConfig.AutomaticSerialization = WdfFalse;", + "attributes.ParentObject = device;", + "WdfWorkItemCreate( &workItemConfig, &attributes, &deviceContext->D0ExitWorkItem);", + "ViiperClaimDeviceSlot(", + "UdecxUsbDevicePlugIn(device, &plugOptions);") + + d0Exit := normalizedContract(nativeCFunction(t, device, "ViiperEvtUsbDeviceD0Exit")) + requireContractOrder(t, d0Exit, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->InD0, FALSE);", + "controllerContext->ShuttingDown", + "deviceContext->Purging", + "status = STATUS_SUCCESS;", + "&deviceContext->D0ExitPending, TRUE, FALSE", + "status = STATUS_DEVICE_BUSY;", + "WdfWorkItemEnqueue(deviceContext->D0ExitWorkItem);", + "status = STATUS_PENDING;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "return status;") + assertNoDispatchLevelWaits(t, "D0 exit", d0Exit) + + reset := normalizedContract(nativeCFunction(t, device, "ViiperEvtEndpointReset")) + requireContractOrder(t, reset, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "&endpointContext->Resetting, TRUE, FALSE", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY);", + "endpointContext->ResetRequest = Request;", + "WdfWorkItemEnqueue(endpointContext->ResetWorkItem);") + assertNoDispatchLevelWaits(t, "endpoint reset", reset) + + d0Work := normalizedContract(nativeCFunction( + t, device, "ViiperEvtUsbDeviceD0ExitWorkItem")) + requireContractOrder(t, d0Work, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "ViiperInvalidateDeviceInputReports(device);", + "ViiperQueueDeviceLifecycleEvent( device, ViiperUdeOperationDeviceD0Exit);", + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "InterlockedExchange(&deviceContext->D0ExitPending, FALSE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);") + completion := "UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);" + afterCompletion := d0Work[strings.Index(d0Work, completion)+len(completion):] + for _, forbidden := range []string{ + "deviceContext", "controllerContext", "ViiperGet", "Wdf", "VIIPER_TRACE", + } { + if strings.Contains(afterCompletion, forbidden) { + t.Fatalf("D0-exit work item accesses the device after UdeCx completion via %q: %s", + forbidden, afterCompletion) + } + } + + flush := normalizedContract(nativeCFunction(t, device, "ViiperFlushD0ExitWorkItem")) + requireContractOrder(t, flush, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "WdfWorkItemFlush(deviceContext->D0ExitWorkItem);", + "deviceContext->D0ExitPending") + if strings.Contains(flush, "if (InterlockedCompareExchange") { + t.Fatal("teardown conditionally skips the D0-exit flush after the pending flag clears") + } + + destroy := normalizedContract(nativeCFunction(t, device, "ViiperDestroyVirtualDevice")) + requireContractOrder(t, destroy, + "ViiperBeginRemoveDevice(", + "ViiperFlushD0ExitWorkItem(device);", + "ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED);", + "UdecxUsbDevicePlugOutAndDelete(device);") + destroyOwned := normalizedContract(nativeCFunction(t, device, "ViiperDestroyOwnedDevices")) + requireContractOrder(t, destroyOwned, + "ViiperBeginRemoveDevice(", + "ViiperFlushD0ExitWorkItem(device);", + "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", + "UdecxUsbDevicePlugOutAndDelete(device)") + controllerShutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) + requireContractOrder(t, controllerShutdown, + "devices[deviceCount++] = device;", + "ViiperReleaseDeviceLockExclusive(controllerContext);", + "ViiperFlushD0ExitWorkItem(devices[index]);", + "UdecxUsbDevicePlugOutAndDelete(devices[index]);") + + resetWork := normalizedContract(nativeCFunction( + t, device, "ViiperEvtEndpointResetWorkItem")) + requireContractOrder(t, resetWork, + "NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);", + "ViiperQuiesceResetByIdentity(", + "if (!resetCurrent)", + "ViiperInvalidateEndpointInputReport(endpoint);", + "ViiperQueueAcknowledgedEndpointLifecycleEvent(") + + d0Entry := normalizedContract(nativeCFunction(t, device, "ViiperEvtUsbDeviceD0Entry")) + requireContractOrder(t, d0Entry, + "WdfSpinLockAcquire(controllerContext->BrokerLock);", + "deviceContext->Purging", + "deviceContext->D0ExitPending", + "status = STATUS_DEVICE_BUSY;", + "InterlockedExchange(&deviceContext->InD0, TRUE);", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry);") + assertNoDispatchLevelWaits(t, "D0 entry", d0Entry) + + activate := normalizedContract(nativeCFunction(t, device, "ViiperActivateEndpoint")) + requireContractOrder(t, activate, + "deviceContext->Purging", + "deviceContext->InD0", + "deviceContext->D0ExitPending", + "InterlockedExchange(&endpointContext->Purging, FALSE);") +} + +func assertNoDispatchLevelWaits(t *testing.T, name, body string) { + t.Helper() + for _, forbidden := range []string{ + "ViiperInvalidateEndpointInputReport", + "ViiperInvalidateDeviceInputReports", + "ViiperAcquireDeviceLock", + "WdfWaitLockAcquire", + "KeWaitForSingleObject", + "KeDelayExecutionThread", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("%s performs passive-only work through %q: %s", name, forbidden, body) + } + } +} + +type modeledDevicePower struct { + inD0 bool + exitPending bool + cacheValid bool + workEnqueues int + exitCompletions int +} + +func (device *modeledDevicePower) beginExit() bool { + if device.exitPending { + return false + } + device.exitPending = true + device.inD0 = false + device.workEnqueues++ + return true +} + +func (device *modeledDevicePower) completeExitWork() bool { + if !device.exitPending { + return false + } + device.cacheValid = false + device.exitPending = false + device.exitCompletions++ + return true +} + +func (device *modeledDevicePower) enterD0() bool { + if device.exitPending { + return false + } + device.inD0 = true + return true +} + +func (device *modeledDevicePower) canAdmitInputOrStart() bool { + return device.inD0 && !device.exitPending +} + +func TestAsyncD0ExitStateModelRejectsReentrancyAndStaleInput(t *testing.T) { + device := &modeledDevicePower{inD0: true, cacheValid: true} + if !device.canAdmitInputOrStart() || !device.beginExit() { + t.Fatal("initial D0 exit was not admitted") + } + if device.beginExit() || device.enterD0() || device.canAdmitInputOrStart() { + t.Fatal("pending D0 exit admitted a duplicate exit, D0 entry, input, or START") + } + if device.workEnqueues != 1 || device.exitCompletions != 0 || !device.cacheValid { + t.Fatalf("DISPATCH phase mutated passive cache state: %+v", *device) + } + if !device.completeExitWork() || device.cacheValid || device.exitPending || + device.exitCompletions != 1 { + t.Fatalf("passive completion did not clear cache and close one transition: %+v", *device) + } + if device.completeExitWork() || !device.enterD0() || !device.canAdmitInputOrStart() { + t.Fatal("completion duplicated or D0 entry failed to reopen admission") + } + if device.workEnqueues != 1 || device.exitCompletions != 1 { + t.Fatalf("one D0-exit callback did not map to one async completion: %+v", *device) + } +} + +func TestAsyncD0ExitTeardownFlushesPastPendingFlagBoundary(t *testing.T) { + type powerTeardown struct { + inD0 bool + purging bool + shuttingDown bool + exitPending bool + workQueued bool + workRunning bool + completionCalls int + flushCalls int + handleConsumed bool + } + beginExit := func(state *powerTeardown) string { + state.inD0 = false + if state.shuttingDown || state.purging { + return "success" + } + if state.exitPending { + return "busy" + } + state.exitPending = true + state.workQueued = true + return "pending" + } + startWorker := func(state *powerTeardown) bool { + if !state.workQueued || state.workRunning { + return false + } + state.workQueued = false + state.workRunning = true + return true + } + completePowerTransition := func(state *powerTeardown) bool { + if !state.workRunning || !state.exitPending { + return false + } + // The real worker clears this immediately before the UdeCx completion. + // It is still executing and using the device until it returns. + state.exitPending = false + state.completionCalls++ + return true + } + returnWorker := func(state *powerTeardown) bool { + if !state.workRunning || state.exitPending { + return false + } + state.workRunning = false + return true + } + flush := func(state *powerTeardown) { + state.flushCalls++ + if state.workQueued { + if !startWorker(state) || !completePowerTransition(state) { + t.Fatal("flush could not run a queued D0-exit worker") + } + } + if state.workRunning && !returnWorker(state) { + t.Fatal("flush returned before the active D0-exit worker") + } + } + consume := func(state *powerTeardown) bool { + if state.workQueued || state.workRunning || state.exitPending { + return false + } + state.handleConsumed = true + return true + } + + // Power exit wins admission, then removal closes the gate. Teardown must + // drain the already-queued callback before consuming the UdeCx handle. + queued := powerTeardown{inD0: true} + if status := beginExit(&queued); status != "pending" { + t.Fatalf("D0 exit did not win its BrokerLock boundary: %s", status) + } + queued.purging = true + flush(&queued) + if !consume(&queued) || queued.completionCalls != 1 || queued.flushCalls != 1 { + t.Fatalf("teardown consumed before its queued power completion returned: %+v", queued) + } + + // The worker can clear D0ExitPending just before its completion call. A + // conditional flag check would miss this still-running callback; an + // unconditional work-item flush joins it. + running := powerTeardown{inD0: true} + if beginExit(&running) != "pending" || !startWorker(&running) || + !completePowerTransition(&running) || running.exitPending || !running.workRunning { + t.Fatalf("model did not reach the cleared-flag/running-worker boundary: %+v", running) + } + running.purging = true + flush(&running) + if !consume(&running) || running.workRunning || running.completionCalls != 1 { + t.Fatalf("unconditional flush failed to join the post-flag callback tail: %+v", running) + } + + // Removal can win first. A later D0-exit callback closes InD0 but completes + // synchronously and must not enqueue work against the soon-consumed handle. + teardownFirst := powerTeardown{inD0: true, purging: true} + if status := beginExit(&teardownFirst); status != "success" || + teardownFirst.inD0 || teardownFirst.workQueued || teardownFirst.exitPending { + t.Fatalf("teardown-owned D0 exit did not finish synchronously: %+v status=%s", + teardownFirst, status) + } + flush(&teardownFirst) + if !consume(&teardownFirst) || teardownFirst.completionCalls != 0 { + t.Fatalf("teardown-first path scheduled an unowned async completion: %+v", teardownFirst) + } +} + func TestKernelStaleChildCannotNotifySuccessorOwner(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") @@ -219,7 +798,7 @@ func TestKernelStaleChildCannotNotifySuccessorOwner(t *testing.T) { "ownerFile = deviceContext->OwnerFile;", "deviceContext->OwnerFile = WDF_NO_HANDLE;", "WdfSpinLockRelease(controllerContext->BrokerLock);", - "ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot);", + "ViiperReleaseDeviceSlot( controllerContext, device, deviceContext->Slot, deviceContext->PortReservation);", "if (ownerFile != WDF_NO_HANDLE)", "WdfObjectDereference(ownerFile);") } @@ -241,6 +820,7 @@ func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { requireContractOrder(t, destroyOwned, "deviceContext = ViiperGetDeviceContext(device);", "plugged = deviceContext->Plugged;", + "ViiperFlushD0ExitWorkItem(device);", "ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED);", "if (plugged)", "UdecxUsbDevicePlugOutAndDelete(device)", @@ -249,7 +829,11 @@ func TestKernelNeverUsesConsumedUDEDeviceHandle(t *testing.T) { shutdown := normalizedContract(nativeCFunction(t, device, "ViiperBeginControllerShutdown")) requireContractOrder(t, shutdown, "VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]);", - "if (deviceContext->Plugged)", + "BOOLEAN plugged = deviceContext->Plugged;", + "ULONGLONG deviceId = deviceContext->DeviceId;", + "ULONG generation = deviceContext->Generation;", + "ViiperFlushD0ExitWorkItem(devices[index]);", + "if (plugged)", "UdecxUsbDevicePlugOutAndDelete(devices[index]);") assertNoConsumedHandleUse(t, shutdown, "UdecxUsbDevicePlugOutAndDelete(devices[index]);", "} else {") diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 3f53f9f5..46ee7451 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -623,6 +623,12 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p classifiedReader, classified := entry.device.(usb.ClassifiedScheduledInterruptInputDevice) var reportBuffer []byte var deadlineTimer *time.Timer + var retryTimer *time.Timer + defer func() { + if retryTimer != nil { + stopInputDeadlineTimer(retryTimer) + } + }() if direct { reportBuffer = make([]byte, publisher.reportSize) if scheduled && publisher.interval > 0 { @@ -737,7 +743,11 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p if retryInterval <= 0 { retryInterval = time.Millisecond } - retryTimer := time.NewTimer(retryInterval) + if retryTimer == nil { + retryTimer = time.NewTimer(retryInterval) + } else { + retryTimer.Reset(retryInterval) + } select { case <-publisher.submitCtx.Done(): stopInputDeadlineTimer(retryTimer) diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 15c032d1..d8aed4d1 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.33" + DriverPackageVersion = "0.1.0.34" BuildIdentitySize = sha256.Size HeaderSize = 16 diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index cc4266c0..02e4519e 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "037546fe63e5507cadf58c7b151f096fa52a533ce4cda4397040bf0b748e347d" + const wantHex = "7d769fa2edc36556a5d7f5c63d855625ada9bbc6236ea8cf73892b4b41499293" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index d4a0264a..a1edd766 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -36,7 +36,9 @@ ViiperCompleteUnownedUrb( 0, Status, USBD_STATUS_INTERNAL_HC_ERROR, - TRUE); + TRUE, + 0, + 0); if (!queued) { NT_ASSERT(FALSE); } @@ -550,7 +552,9 @@ ViiperQueueUrbCompletion( _In_ ULONGLONG Token, _In_ NTSTATUS Status, _In_ USBD_STATUS UsbdStatus, - _In_ BOOLEAN CompleteWithNtStatus + _In_ BOOLEAN CompleteWithNtStatus, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence ) { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); @@ -574,6 +578,8 @@ ViiperQueueUrbCompletion( requestContext->CompletionStatus = Status; requestContext->CompletionUsbdStatus = UsbdStatus; requestContext->CompleteWithNtStatus = CompleteWithNtStatus; + requestContext->DirectInputBytes = DirectInputBytes; + requestContext->DirectInputSequence = DirectInputSequence; requestContext->CompletionQueued = TRUE; if (InterlockedCompareExchange(&controllerContext->PendingCompletions, 0, 0) == 0) { KeClearEvent(&controllerContext->CompletionOperationsDrained); @@ -603,6 +609,11 @@ ViiperEvtCompletionDpc( WDFDEVICE controller = (WDFDEVICE)WdfDpcGetParentObject(Dpc); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); + // The authored UDE host-compatibility contract requires terminal URB + // completion at DISPATCH_LEVEL and on a separate DPC when processing began + // synchronously. Keep this boundary even though generated per-function + // documentation has carried conflicting IRQL metadata: the shipped class- + // extension helpers are nonpaged wrappers which complete at caller IRQL. NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL); for (;;) { @@ -613,6 +624,8 @@ ViiperEvtCompletionDpc( NTSTATUS completionStatus = STATUS_SUCCESS; USBD_STATUS usbdStatus = USBD_STATUS_SUCCESS; BOOLEAN completeWithNtStatus = FALSE; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; BOOLEAN ownershipReleased = FALSE; PLIST_ENTRY entry; VIIPER_UDE_REQUEST_CONTEXT *requestContext; @@ -634,6 +647,8 @@ ViiperEvtCompletionDpc( completionStatus = requestContext->CompletionStatus; usbdStatus = requestContext->CompletionUsbdStatus; completeWithNtStatus = requestContext->CompleteWithNtStatus; + directInputBytes = requestContext->DirectInputBytes; + directInputSequence = requestContext->DirectInputSequence; requestContext->CompletionRequest = WDF_NO_HANDLE; requestContext->CompletionQueued = FALSE; if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { @@ -656,6 +671,16 @@ ViiperEvtCompletionDpc( UdecxUrbComplete(request, usbdStatus); } + if (directInputBytes != 0 && + !completeWithNtStatus && + usbdStatus == USBD_STATUS_SUCCESS && + directInputSequence != 0) { + InterlockedAdd64(&controllerContext->BytesFromDevice, directInputBytes); + InterlockedIncrement64(&controllerContext->InputReportsCompleted); + } else if (directInputBytes != 0) { + NT_ASSERT(FALSE); + } + WdfSpinLockAcquire(controllerContext->BrokerLock); if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS && ViiperSlotMatches(&controllerContext->PendingSlots[slot], request, token) && @@ -693,7 +718,7 @@ ViiperDrainUrbCompletions( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); for (;;) { BOOLEAN drained; @@ -1226,7 +1251,9 @@ ViiperEvtUrbCanceledOnQueue( 0, STATUS_CANCELLED, USBD_STATUS_CANCELED, - TRUE); + TRUE, + 0, + 0); if (!queued) { NT_ASSERT(FALSE); } @@ -1276,7 +1303,9 @@ ViiperEvtUrbCancel( token, STATUS_CANCELLED, USBD_STATUS_CANCELED, - TRUE); + TRUE, + 0, + 0); if (notifyOwner) { ViiperDispatchNotificationEvents(controller); } @@ -1637,9 +1666,9 @@ ViiperSerializeOperation( if (urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER && urb->UrbHeader.Function != URB_FUNCTION_CONTROL_TRANSFER_EX) { // Windows can supply stale or inconsistent direction bits in - // TransferFlags (usbip-win2 observes this for bulk URBs). The endpoint - // descriptor is authoritative for every non-control pipe; only a - // control setup packet owns its direction. Normalize both ABI fields + // TransferFlags for bulk URBs. The endpoint descriptor is authoritative + // for every non-control pipe; only a control setup packet owns its + // direction. Normalize both ABI fields // together so user mode never rejects or inverts an otherwise valid // media/output transfer. directionIn = (endpointContext->Descriptor.bEndpointAddress & @@ -1779,7 +1808,9 @@ ViiperQueueOwnedCompletion( Token, completionStatus, completionUsbdStatus, - completionWithNtStatus); + completionWithNtStatus, + 0, + 0); } return queued; } @@ -1850,7 +1881,9 @@ ViiperRemovePublishingRequest( Token, Status, USBD_STATUS_CANCELED, - TRUE); + TRUE, + 0, + 0); if (notifyOwner) { ViiperDispatchNotificationEvents(controller); } @@ -2179,7 +2212,9 @@ ViiperQueueUrb( token, STATUS_CANCELLED, USBD_STATUS_CANCELED, - TRUE); + TRUE, + 0, + 0); InterlockedIncrement64(&controllerContext->OperationsCancelled); // MarkCancelableEx can reject a request before it ever reaches // dispatch. Retiring that admission exposes the next endpoint diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 51aa7b26..35686d2b 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -15,12 +15,18 @@ DEFINE_GUID( VIIPER_UDE_INTERFACE_GUID_DATA4_6, VIIPER_UDE_INTERFACE_GUID_DATA4_7); +static +BOOLEAN +ViiperFinishOwnerCleanup( + _In_ WDFDEVICE Device, + _In_ WDFFILEOBJECT OwnerFile + ); + #ifdef ALLOC_PRAGMA #pragma alloc_text(PAGE, ViiperEvtDeviceAdd) #pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoInit) -#pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoCleanup) +#pragma alloc_text(PAGE, ViiperFinishOwnerCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) -#pragma alloc_text(PAGE, ViiperEvtFileCleanup) #pragma alloc_text(PAGE, ViiperEvtFileClose) #pragma alloc_text(PAGE, ViiperCreateQueues) #endif @@ -232,6 +238,7 @@ ViiperEvtControllerCleanup( ) { VIIPER_UDE_CONTROLLER_CONTEXT *context; + ULONG index; context = ViiperGetControllerContext((WDFDEVICE)ControllerObject); // Every active operation belongs in SelfManagedIoCleanup, while the @@ -245,8 +252,12 @@ ViiperEvtControllerCleanup( NT_ASSERT(InterlockedCompareExchange(&context->ActiveOwnerAdmissions, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->ActiveFileCleanups, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->ActiveDevices, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange(&context->ReservedPorts, 0, 0) == 0); NT_ASSERT(InterlockedCompareExchange(&context->OwnerReferenced, 0, 0) == 0); NT_ASSERT(context->InputDeviceCount == 0); + for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { + NT_ASSERT(!context->PortReserved[index]); + } } NTSTATUS @@ -270,7 +281,7 @@ ViiperEvtDeviceSelfManagedIoCleanup( WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; BOOLEAN releaseOwner = FALSE; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // Close every user/UdeCx admission path before draining work that already // crossed the boundary. Interlocked operations also provide the ordering @@ -318,8 +329,8 @@ ViiperEvtDeviceSelfManagedIoCleanup( ViiperPurgeOwnerOperations(Device, STATUS_DEVICE_REMOVED); // Close and join only operations already delivered into VIIPER. Queued host // polls remain owned by the associated endpoint queues; PlugOutAndDelete - // causes UdeCx to issue PURGE, and that callback performs the required - // asynchronous WDF queue cancellation before acknowledging the extension. + // causes UdeCx to stop those queues and issue PURGE. That callback drains + // only VIIPER-owned forwarded work before acknowledging the extension. ViiperDrainControllerEndpointOperations(Device); if (context->CompletionDpc != WDF_NO_HANDLE) { for (;;) { @@ -452,7 +463,7 @@ ViiperEvtFileCleanup( BOOLEAN cleanupAdmitted = FALSE; LONG remainingCleanups; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); device = WdfFileObjectGetDevice(FileObject); context = ViiperGetControllerContext(device); fileContext = ViiperGetFileContext(FileObject); diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 9fbc2962..ddeac0f6 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1,10 +1,9 @@ /* * Dynamic UdeCx device and endpoint lifecycle. * - * The endpoint creation and purge order follows the documented UdeCx contract - * and the permissively licensed usbip-win2 implementation identified in - * THIRD_PARTY_NOTICES.md. VIIPER-specific ownership, identity, and broker - * semantics are implemented here. + * The endpoint creation and purge order follows the documented UdeCx contract. + * VIIPER-specific ownership, identity, and broker semantics are implemented + * here. */ #include "ViiperUde.h" @@ -13,10 +12,8 @@ #pragma alloc_text(PAGE, ViiperCreateVirtualDevice) #pragma alloc_text(PAGE, ViiperDestroyVirtualDevice) #pragma alloc_text(PAGE, ViiperDestroyOwnedDevices) -#pragma alloc_text(PAGE, ViiperBeginControllerShutdown) #pragma alloc_text(PAGE, ViiperEvtEndpointAdd) #pragma alloc_text(PAGE, ViiperEvtDefaultEndpointAdd) -#pragma alloc_text(PAGE, ViiperEvtVirtualDeviceCleanup) #pragma alloc_text(PAGE, ViiperEvtEndpointCleanup) #endif @@ -39,23 +36,25 @@ ViiperValidateDescriptorChain( _In_ UCHAR ExpectedType ) { - ULONG offset = 0; + const UCHAR *cursor = Descriptor; + ULONG remaining = Length; - if (Length < 2 || Descriptor[1] != ExpectedType) { + if (remaining < 2 || cursor[1] != ExpectedType) { return FALSE; } - while (offset < Length) { + while (remaining != 0) { ULONG itemLength; - if (Length - offset < 2) { + if (remaining < 2) { return FALSE; } - itemLength = Descriptor[offset]; - if (itemLength < 2 || itemLength > Length - offset) { + itemLength = cursor[0]; + if (itemLength < 2 || itemLength > remaining) { return FALSE; } - offset += itemLength; + cursor += itemLength; + remaining -= itemLength; } - return offset == Length; + return TRUE; } static @@ -504,9 +503,11 @@ ViiperClaimDeviceSlot( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ UDECXUSBDEVICE Device, _In_ ULONGLONG DeviceId, - _Out_ ULONG *Slot + _Out_ ULONG *Slot, + _Out_ ULONGLONG *PortReservation ) { + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); ULONG index; ULONG freeSlot = VIIPER_UDE_MAX_DEVICES; NTSTATUS status = STATUS_INSUFFICIENT_RESOURCES; @@ -519,7 +520,8 @@ ViiperClaimDeviceSlot( for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index) { UDECXUSBDEVICE current = ControllerContext->Devices[index]; if (current == WDF_NO_HANDLE) { - if (freeSlot == VIIPER_UDE_MAX_DEVICES) { + if (!ControllerContext->PortReserved[index] && + freeSlot == VIIPER_UDE_MAX_DEVICES) { freeSlot = index; } continue; @@ -534,8 +536,25 @@ ViiperClaimDeviceSlot( if (freeSlot != VIIPER_UDE_MAX_DEVICES) { status = ViiperInsertInputDeviceLocked(ControllerContext, Device); if (NT_SUCCESS(status)) { + ULONGLONG reservation = ++ControllerContext->PortReservationEpochs[freeSlot]; + if (reservation == 0) { + reservation = ++ControllerContext->PortReservationEpochs[freeSlot]; + } + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->ActiveCounted, 0, 0) == 0); + deviceContext->Slot = freeSlot; + deviceContext->PortReservation = reservation; + // Publish a complete lifecycle record before PlugIn can expose + // the object to UdeCx. For a claimed object, Plugged means that + // successful exposure must be unwound with PlugOutAndDelete. + deviceContext->Plugged = TRUE; + ControllerContext->PortReserved[freeSlot] = TRUE; + InterlockedIncrement(&ControllerContext->ReservedPorts); ControllerContext->Devices[freeSlot] = Device; + InterlockedIncrement(&ControllerContext->ActiveDevices); + InterlockedExchange(&deviceContext->ActiveCounted, 1); *Slot = freeSlot; + *PortReservation = reservation; } } @@ -549,15 +568,24 @@ VOID ViiperReleaseDeviceSlot( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ UDECXUSBDEVICE Device, - _In_ ULONG Slot + _In_ ULONG Slot, + _In_ ULONGLONG PortReservation ) { ViiperAcquireDeviceLockExclusive(ControllerContext); - if (Slot < VIIPER_UDE_MAX_DEVICES) { + if (Slot < VIIPER_UDE_MAX_DEVICES && PortReservation != 0 && + ControllerContext->PortReserved[Slot] && + ControllerContext->PortReservationEpochs[Slot] == PortReservation) { if (ControllerContext->Devices[Slot] == Device) { ViiperRemoveInputDeviceLocked(ControllerContext, Device); ControllerContext->Devices[Slot] = WDF_NO_HANDLE; } + ControllerContext->PortReserved[Slot] = FALSE; + { + LONG remaining = InterlockedDecrement(&ControllerContext->ReservedPorts); + NT_ASSERT(remaining >= 0); + (VOID)remaining; + } } ViiperReleaseDeviceLockExclusive(ControllerContext); } @@ -578,6 +606,25 @@ ViiperRetireActiveDevice( NT_ASSERT(remaining >= 0); } +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperFlushD0ExitWorkItem( + _In_ UDECXUSBDEVICE Device + ) +{ + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + NT_ASSERT(deviceContext->D0ExitWorkItem != WDF_NO_HANDLE); + // Flush unconditionally: the worker clears D0ExitPending immediately + // before its final UdeCx completion call, so a false flag does not prove + // that the callback has returned and stopped using the device handle. + WdfWorkItemFlush(deviceContext->D0ExitWorkItem); + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->D0ExitPending, 0, 0) == 0); +} + NTSTATUS ViiperCreateVirtualDevice( _In_ WDFQUEUE Queue, @@ -594,10 +641,14 @@ ViiperCreateVirtualDevice( UDECX_USB_DEVICE_STATE_CHANGE_CALLBACKS callbacks; UDECX_USB_DEVICE_SPEED speed; WDF_OBJECT_ATTRIBUTES attributes; + WDF_WORKITEM_CONFIG workItemConfig; UDECXUSBDEVICE device = WDF_NO_HANDLE; VIIPER_UDE_DEVICE_CONTEXT *deviceContext; UDECX_USB_DEVICE_PLUG_IN_OPTIONS plugOptions; ULONG slot; + ULONG generation; + ULONGLONG deviceId; + ULONGLONG portReservation; PAGED_CODE(); status = WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength); @@ -608,6 +659,8 @@ ViiperCreateVirtualDevice( InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } + deviceId = input->DeviceId; + generation = input->Generation; speed = ViiperMapSpeed(input->Speed); if (speed == (UDECX_USB_DEVICE_SPEED)0) { return STATUS_NOT_SUPPORTED; @@ -618,7 +671,7 @@ ViiperCreateVirtualDevice( } VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_CREATE_BEGIN, - input->DeviceId, input->Generation, WDF_NO_HANDLE, WDF_NO_HANDLE, 0, + deviceId, generation, WDF_NO_HANDLE, WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); deviceInit = UdecxUsbDeviceInitAllocate(controller); @@ -649,17 +702,15 @@ ViiperCreateVirtualDevice( WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VIIPER_UDE_DEVICE_CONTEXT); attributes.ParentObject = controller; attributes.EvtCleanupCallback = ViiperEvtVirtualDeviceCleanup; - // UdeCx permits its USB-device callbacks at <= DISPATCH_LEVEL, while - // endpoint creation and the DeviceLock snapshot used by power/reset - // callbacks are PASSIVE_LEVEL-only. KMDF controller objects otherwise - // default to dispatch execution, so make the child callback contract - // explicit instead of relying on the current UdeCx call context. + // Keep ordinary WDF cleanup and child-owned callbacks passive. This object + // attribute does not narrow the documented <= DISPATCH_LEVEL contract of + // UdeCx state callbacks; those paths remain independently dispatch-safe. attributes.ExecutionLevel = WdfExecutionLevelPassive; status = UdecxUsbDeviceCreate(&deviceInit, &attributes, &device); VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED, input->DeviceId, - input->Generation, device, WDF_NO_HANDLE, 0, status, 0, 0); + VIIPER_UDE_TRACE_DEVICE_CREATE_RETURNED, deviceId, + generation, device, WDF_NO_HANDLE, 0, status, 0, 0); if (!NT_SUCCESS(status)) { UdecxUsbDeviceInitFree(deviceInit); goto ExitAdmission; @@ -669,8 +720,8 @@ ViiperCreateVirtualDevice( RtlZeroMemory(deviceContext, sizeof(*deviceContext)); deviceContext->Controller = controller; deviceContext->OwnerFile = ownerFile; - deviceContext->DeviceId = input->DeviceId; - deviceContext->Generation = input->Generation; + deviceContext->DeviceId = deviceId; + deviceContext->Generation = generation; deviceContext->Slot = VIIPER_UDE_MAX_DEVICES; deviceContext->Speed = speed; deviceContext->MaxPendingOperations = input->MaxPendingOperations; @@ -682,16 +733,28 @@ ViiperCreateVirtualDevice( WdfObjectReference(ownerFile); InterlockedExchange(&deviceContext->OwnerReferenced, 1); - status = ViiperClaimDeviceSlot(controllerContext, device, input->DeviceId, &slot); + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtUsbDeviceD0ExitWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &deviceContext->D0ExitWorkItem); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(device); + goto ExitAdmission; + } + + status = ViiperClaimDeviceSlot( + controllerContext, device, deviceId, &slot, &portReservation); if (!NT_SUCCESS(status)) { WdfObjectDelete(device); goto ExitAdmission; } - deviceContext->Slot = slot; VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED, deviceContext->DeviceId, - deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + VIIPER_UDE_TRACE_DEVICE_SLOT_CLAIMED, deviceId, + generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0), slot); UDECX_USB_DEVICE_PLUG_IN_OPTIONS_INIT(&plugOptions); if (speed == UdecxUsbSuperSpeed) { @@ -704,22 +767,20 @@ ViiperCreateVirtualDevice( } VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_IN_BEGIN, - deviceContext->DeviceId, deviceContext->Generation, device, WDF_NO_HANDLE, + deviceId, generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); status = UdecxUsbDevicePlugIn(device, &plugOptions); VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_PLUG_IN_RETURNED, deviceContext->DeviceId, - deviceContext->Generation, device, WDF_NO_HANDLE, 0, status, 0, 0); + VIIPER_UDE_TRACE_PLUG_IN_RETURNED, deviceId, + generation, device, WDF_NO_HANDLE, 0, status, 0, 0); if (!NT_SUCCESS(status)) { - ViiperReleaseDeviceSlot(controllerContext, device, slot); + ViiperReleaseDeviceSlot(controllerContext, device, slot, portReservation); + ViiperRetireActiveDevice(controllerContext, deviceContext); WdfObjectDelete(device); goto ExitAdmission; } - deviceContext->Plugged = TRUE; - InterlockedExchange(&deviceContext->ActiveCounted, 1); - InterlockedIncrement(&controllerContext->ActiveDevices); WdfRequestSetInformation(Request, 0); status = STATUS_SUCCESS; @@ -765,10 +826,11 @@ ViiperBeginRemoveDevice( InterlockedExchange(&deviceContext->Purging, TRUE); WdfSpinLockRelease(ControllerContext->BrokerLock); ViiperRemoveInputDeviceLocked(ControllerContext, current); + // Revoke logical ownership immediately, but keep the physical port + // reserved until this exact object's cleanup callback. Reusing a port + // while its prior child is still disappearing can strand that child + // and prevent the successor from enumerating. ControllerContext->Devices[index] = WDF_NO_HANDLE; - // Devices[] is the logical ownership table. Retire the slot and its - // active count while the UDE handle is still valid; KMDF may defer the - // object's cleanup long after PlugOutAndDelete consumes this handle. ViiperRetireActiveDevice(ControllerContext, deviceContext); *Device = current; status = STATUS_SUCCESS; @@ -821,6 +883,7 @@ ViiperDestroyVirtualDevice( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_REMOVE_CLAIMED, input->DeviceId, input->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + ViiperFlushD0ExitWorkItem(device); ViiperAbortDeviceManagementOperations(controller, device, STATUS_DEVICE_REMOVED); VIIPER_TRACE_LIFECYCLE( controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_PLUG_OUT_BEGIN, @@ -886,6 +949,7 @@ ViiperDestroyOwnedDevices( } deviceContext = ViiperGetDeviceContext(device); plugged = deviceContext->Plugged; + ViiperFlushD0ExitWorkItem(device); ViiperAbortDeviceManagementOperations(Controller, device, STATUS_FILE_CLOSED); // Completing a held UdeCx management request can make framework // cleanup runnable once the slot pins are released. Do not access the @@ -911,7 +975,7 @@ ViiperBeginControllerShutdown( ULONG deviceCount = 0; ULONG index; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); VIIPER_TRACE_LIFECYCLE( Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, @@ -941,9 +1005,12 @@ ViiperBeginControllerShutdown( for (index = 0; index < deviceCount; ++index) { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(devices[index]); - if (deviceContext->Plugged) { - ULONGLONG deviceId = deviceContext->DeviceId; - ULONG generation = deviceContext->Generation; + BOOLEAN plugged = deviceContext->Plugged; + ULONGLONG deviceId = deviceContext->DeviceId; + ULONG generation = deviceContext->Generation; + + ViiperFlushD0ExitWorkItem(devices[index]); + if (plugged) { NTSTATUS status; // A successful call starts UdeCx-owned asynchronous deletion. If @@ -965,7 +1032,8 @@ ViiperBeginControllerShutdown( VIIPER_TRACE_LIFECYCLE( Controller, VIIPER_UDE_TRACE_SOURCE_CONTROLLER, VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END, 0, 0, WDF_NO_HANDLE, - WDF_NO_HANDLE, 0, STATUS_SUCCESS, 0, 0); + WDF_NO_HANDLE, 0, STATUS_SUCCESS, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0), 0); } VOID @@ -978,7 +1046,7 @@ ViiperEvtVirtualDeviceCleanup( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; WDFFILEOBJECT ownerFile = WDF_NO_HANDLE; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); if (deviceContext->Controller == WDF_NO_HANDLE) { return; } @@ -1003,7 +1071,9 @@ ViiperEvtVirtualDeviceCleanup( } WdfSpinLockRelease(controllerContext->BrokerLock); - ViiperReleaseDeviceSlot(controllerContext, device, deviceContext->Slot); + ViiperReleaseDeviceSlot( + controllerContext, device, deviceContext->Slot, + deviceContext->PortReservation); // Normal removal retired the logical count before PlugOutAndDelete. This // is only the fallback for an unexpected framework-owned deletion. ViiperRetireActiveDevice(controllerContext, deviceContext); @@ -1014,7 +1084,8 @@ ViiperEvtVirtualDeviceCleanup( deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_DEVICE_CLEANUP_END, deviceContext->DeviceId, deviceContext->Generation, device, WDF_NO_HANDLE, 0, STATUS_SUCCESS, - deviceContext->PendingOperations, 0); + deviceContext->PendingOperations, + InterlockedCompareExchange(&controllerContext->ReservedPorts, 0, 0)); } static @@ -1030,6 +1101,7 @@ ViiperClearEndpointInputReportLocked( InterlockedExchange(&EndpointContext->InputTransitionCount, 0); } +_IRQL_requires_(PASSIVE_LEVEL) static VOID ViiperInvalidateEndpointInputReport( @@ -1038,6 +1110,7 @@ ViiperInvalidateEndpointInputReport( { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); if (endpointContext->InputLock != WDF_NO_HANDLE) { WdfWaitLockAcquire(endpointContext->InputLock, NULL); } @@ -1071,6 +1144,7 @@ ViiperInvalidateInputIfLifecycleClosed( WdfSpinLockRelease(controllerContext->BrokerLock); } +_IRQL_requires_(PASSIVE_LEVEL) static VOID ViiperInvalidateDeviceInputReports( @@ -1082,6 +1156,7 @@ ViiperInvalidateDeviceInputReports( ViiperGetControllerContext(deviceContext->Controller); ULONG index; + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // Device power/reset admission is already closed before this helper is // called, so no new report can become valid. Keep endpoint lookup and the // final atomic invalidation inside one shared index acquisition; a WDF @@ -1104,15 +1179,24 @@ ViiperEvtUsbDeviceD0Entry( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + NTSTATUS status = STATUS_SUCCESS; + // This callback is the exact UdeCx power boundary. Open direct input // admission before publishing the ordered advisory event to user mode. WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { - WdfSpinLockRelease(controllerContext->BrokerLock); - return STATUS_DEVICE_REMOVED; + status = STATUS_DEVICE_REMOVED; + } else if (InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->D0ExitPending, 0, 0) != 0) { + status = STATUS_DEVICE_BUSY; + } else { + InterlockedExchange(&deviceContext->InD0, TRUE); } - InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, TRUE); WdfSpinLockRelease(controllerContext->BrokerLock); + if (!NT_SUCCESS(status)) { + return status; + } (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Entry); return STATUS_SUCCESS; } @@ -1126,16 +1210,59 @@ ViiperEvtUsbDeviceD0Exit( { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + NTSTATUS status; + UNREFERENCED_PARAMETER(WakeSetting); // Close direct input admission synchronously. Waiting for the user-mode // notification would leave a scheduler window in which a fresh report // could complete a Windows poll after the child had left D0. WdfSpinLockAcquire(controllerContext->BrokerLock); - InterlockedExchange(&ViiperGetDeviceContext(Device)->InD0, FALSE); + InterlockedExchange(&deviceContext->InD0, FALSE); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + // Teardown already owns cache destruction and will consume the handle. + // No asynchronous power completion is owed when this callback returns + // success synchronously. + status = STATUS_SUCCESS; + } else if (InterlockedCompareExchange( + &deviceContext->D0ExitPending, TRUE, FALSE) != FALSE) { + NT_ASSERT(FALSE); + status = STATUS_DEVICE_BUSY; + } else { + // Enqueue before releasing the same gate which removal uses to set + // Purging. Teardown therefore either observes and flushes this work or + // wins first and prevents a late enqueue against a consumed handle. + WdfWorkItemEnqueue(deviceContext->D0ExitWorkItem); + status = STATUS_PENDING; + } WdfSpinLockRelease(controllerContext->BrokerLock); - ViiperInvalidateDeviceInputReports(Device); - (VOID)ViiperQueueDeviceLifecycleEvent(Device, ViiperUdeOperationDeviceD0Exit); - return STATUS_SUCCESS; + return status; +} + +VOID +ViiperEvtUsbDeviceD0ExitWorkItem( + _In_ WDFWORKITEM WorkItem + ) +{ + UDECXUSBDEVICE device = + (UDECXUSBDEVICE)WdfWorkItemGetParentObject(WorkItem); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + ViiperInvalidateDeviceInputReports(device); + (VOID)ViiperQueueDeviceLifecycleEvent( + device, ViiperUdeOperationDeviceD0Exit); + WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(InterlockedCompareExchange( + &deviceContext->D0ExitPending, 0, 0) != 0); + InterlockedExchange(&deviceContext->D0ExitPending, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + // UdeCx may synchronously advance lifecycle or cleanup once this returns. + // Do not access the device or any of its contexts after completion. + UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS); } NTSTATUS @@ -1154,10 +1281,10 @@ ViiperEvtUsbDeviceSetFunctionSuspendAndWake( // VIIPER's production controller set is low/full/high-speed, so UdeCx // never invokes this SuperSpeed-only callback for a supported child. A // virtual child has no physical function to power down; acknowledge the - // host's bookkeeping transition exactly as usbip-win2's UdeCx reference - // does, without mutating endpoint/media state behind UdeCx's queue - // lifecycle. If VIIPER adds a SuperSpeed controller with real remote-wake - // behavior, that device must add an explicit per-interface state contract + // host's bookkeeping transition without mutating endpoint/media state + // behind UdeCx's queue lifecycle. If VIIPER adds a SuperSpeed controller + // with real remote-wake behavior, that device must add an explicit + // per-interface state contract // rather than repurposing endpoint purge/start implicitly. return STATUS_SUCCESS; } @@ -1227,13 +1354,17 @@ ViiperEvtEndpointCleanup( ViiperAcquireDeviceLockExclusive(controllerContext); // Microsoft permits no ordinary object access after EvtCleanup is called, // even when a WDF reference postpones destruction. UdeCx therefore owns - // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission and - // asynchronously purges the associated WDF queue. Its completion callback - // acknowledges UdeCx only after all queued and driver-owned requests end. + // the lifetime ordering: EvtEndpointPurge closes BrokerLock admission while + // UdeCx owns its associated queue state. The passive work item acknowledges + // UdeCx only after all framework-delivered and VIIPER-owned requests end. // Endpoint creation failure has no published users. Cleanup must never be // used as a late wait for an operation which can still access this context. NT_ASSERT(InterlockedCompareExchange( &endpointContext->ActiveOperations, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) == 0); ViiperInvalidateEndpointInputReport(endpoint); if (deviceContext->DefaultEndpoint == endpoint) { deviceContext->DefaultEndpoint = WDF_NO_HANDLE; @@ -1307,7 +1438,17 @@ ViiperEvtEndpointAdd( endpointContext->Descriptor = descriptor; InitializeListHead(&endpointContext->AdmissionQueue); KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); + WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = endpoint; + status = WdfWorkItemCreate( + &workItemConfig, &attributes, &endpointContext->PurgeWorkItem); + if (!NT_SUCCESS(status)) { + return status; + } WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointResetWorkItem); + workItemConfig.AutomaticSerialization = WdfFalse; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); attributes.ParentObject = endpoint; status = WdfWorkItemCreate( @@ -1369,9 +1510,9 @@ ViiperEvtEndpointAdd( if (endpointContext->FastInput) { // A direct report can arrive just before Windows posts its interrupt // poll. Preserve that latest state and service the poll when the - // manual endpoint queue changes from empty to non-empty. This mirrors - // ViGEmBus's pending-read/cache contract without routing HID input - // through the ordered control/media broker. + // manual endpoint queue changes from empty to non-empty. Keep this + // pending-read/cache path separate from the ordered control/media + // broker. status = WdfIoQueueReadyNotify( endpointContext->Queue, ViiperEvtFastInputQueueReady, endpoint); if (!NT_SUCCESS(status)) { @@ -1421,7 +1562,9 @@ VOID ViiperCompleteRetrievedInputUrb( _In_ UDECXUSBENDPOINT Endpoint, _In_ WDFREQUEST Request, - _In_ NTSTATUS Status + _In_ NTSTATUS Status, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence ) { VIIPER_UDE_DEVICE_CONTEXT *deviceContext = @@ -1438,7 +1581,9 @@ ViiperCompleteRetrievedInputUrb( 0, Status, NT_SUCCESS(Status) ? USBD_STATUS_SUCCESS : USBD_STATUS_INTERNAL_HC_ERROR, - !NT_SUCCESS(Status)); + !NT_SUCCESS(Status), + NT_SUCCESS(Status) ? DirectInputBytes : 0, + NT_SUCCESS(Status) ? DirectInputSequence : 0); if (!queued) { NT_ASSERT(FALSE); } @@ -1448,27 +1593,33 @@ static NTSTATUS ViiperPrepareCachedInputUrb( _In_ UDECXUSBENDPOINT Endpoint, - _In_ WDFREQUEST Request + _In_ WDFREQUEST Request, + _Out_ ULONG *BytesPrepared, + _Out_ ULONGLONG *SequencePrepared ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); - VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); - VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = - ViiperGetControllerContext(deviceContext->Controller); PURB urb = ViiperGetUrb(Request); PUCHAR report = endpointContext->InputReport; ULONG reportLength = endpointContext->InputReportLength; ULONG transitionHead = 0; + ULONGLONG reportSequence; BOOLEAN transition = FALSE; ULONG transferLength; NTSTATUS status; + *BytesPrepared = 0; + *SequencePrepared = 0; + reportSequence = (ULONGLONG)InterlockedCompareExchange64( + &endpointContext->LastInputSequence, 0, 0); + if (InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0) { transitionHead = (ULONG)InterlockedCompareExchange( &endpointContext->InputTransitionHead, 0, 0); report = endpointContext->InputTransitionReports + ((SIZE_T)transitionHead * endpointContext->InputTransitionStride); reportLength = endpointContext->InputTransitionLengths[transitionHead]; + reportSequence = endpointContext->InputTransitionSequences[transitionHead]; transition = TRUE; } @@ -1498,8 +1649,8 @@ ViiperPrepareCachedInputUrb( } else { InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); } - InterlockedAdd64(&controllerContext->BytesFromDevice, reportLength); - InterlockedIncrement64(&controllerContext->InputReportsCompleted); + *BytesPrepared = reportLength; + *SequencePrepared = reportSequence; return STATUS_SUCCESS; } @@ -1517,11 +1668,13 @@ ViiperEvtFastInputQueueReady( ViiperGetControllerContext(deviceContext->Controller); WDFREQUEST request = WDF_NO_HANDLE; NTSTATUS completionStatus = STATUS_SUCCESS; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; BOOLEAN admitted = FALSE; BOOLEAN deliveryReady = FALSE; BOOLEAN completionAdmitted = FALSE; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // KMDF explicitly permits a passive ReadyNotify callback to retrieve the // request that made a manual queue non-empty. Copy the already-cached // latest state here, then transfer terminal ownership to the driver's @@ -1600,12 +1753,15 @@ ViiperEvtFastInputQueueReady( break; } ViiperInvalidateInputIfLifecycleClosed(endpoint); - completionStatus = ViiperPrepareCachedInputUrb(endpoint, request); + completionStatus = ViiperPrepareCachedInputUrb( + endpoint, request, &directInputBytes, &directInputSequence); InterlockedExchange( &endpointContext->CachedDeliveryPending, InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || InterlockedCompareExchange(&endpointContext->InputSnapshotPending, 0, 0) != 0); - ViiperCompleteRetrievedInputUrb(endpoint, request, completionStatus); + ViiperCompleteRetrievedInputUrb( + endpoint, request, completionStatus, + directInputBytes, directInputSequence); } WdfWaitLockRelease(endpointContext->InputLock); // Release the callback-lifetime hold only after the final endpoint access. @@ -1632,6 +1788,8 @@ ViiperSubmitInputReport( VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = NULL; WDFREQUEST urbRequest = WDF_NO_HANDLE; NTSTATUS status; + ULONG directInputBytes = 0; + ULONGLONG directInputSequence = 0; BOOLEAN admitted = FALSE; BOOLEAN lifecycleDrop = FALSE; @@ -1803,6 +1961,7 @@ ViiperSubmitInputReport( payload, input->PayloadLength); endpointContext->InputTransitionLengths[tail] = (USHORT)input->PayloadLength; + endpointContext->InputTransitionSequences[tail] = input->Sequence; InterlockedIncrement(&endpointContext->InputTransitionCount); InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); } else { @@ -1830,7 +1989,8 @@ ViiperSubmitInputReport( // reset/purge/D0 boundary. Revalidate under the same admission lock so // either this path or the lifecycle callback performs the final clear. ViiperInvalidateInputIfLifecycleClosed(endpoint); - status = ViiperPrepareCachedInputUrb(endpoint, urbRequest); + status = ViiperPrepareCachedInputUrb( + endpoint, urbRequest, &directInputBytes, &directInputSequence); InterlockedExchange( &endpointContext->CachedDeliveryPending, InterlockedCompareExchange(&endpointContext->InputTransitionCount, 0, 0) > 0 || @@ -1839,7 +1999,8 @@ ViiperSubmitInputReport( // This call is the active-operation handoff. It performs every remaining // endpoint lookup before enqueuing the DPC; the caller performs no endpoint // access after a concurrently running DPC can release rundown. - ViiperCompleteRetrievedInputUrb(endpoint, urbRequest, status); + ViiperCompleteRetrievedInputUrb( + endpoint, urbRequest, status, directInputBytes, directInputSequence); // The producer publication was accepted before servicing this host poll. // A malformed/short URB fails through its own DPC but does not make user // mode retry an already-accepted sequence or discard the retained edge. @@ -1859,7 +2020,7 @@ ViiperWaitForEndpointQuiescence( ViiperGetControllerContext(deviceContext->Controller); LARGE_INTEGER retryInterval; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // One millisecond is used only on the cold purge/reset path. The ordinary // case returns after one event wait and one read-only queue-state sample. retryInterval.QuadPart = -10 * 1000; @@ -1897,6 +2058,73 @@ ViiperWaitForEndpointQuiescence( } } +_IRQL_requires_(PASSIVE_LEVEL) +static +VOID +ViiperWaitForEndpointPurgeQuiescence( + _In_ UDECXUSBENDPOINT Endpoint, + _Out_ WDF_IO_QUEUE_STATE *FinalQueueState, + _Out_ ULONG *FinalQueuedRequests, + _Out_ ULONG *FinalDriverRequests + ) +{ + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); + LARGE_INTEGER retryInterval; + + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + retryInterval.QuadPart = -10 * 1000; + for (;;) { + WDF_IO_QUEUE_STATE queueState; + ULONG queuedRequests; + ULONG driverRequests; + BOOLEAN quiescent; + + (VOID)KeWaitForSingleObject( + &endpointContext->OperationsDrained, + Executive, + KernelMode, + FALSE, + NULL); + + // UdeCx exclusively owns the associated queue's START/PURGE state. + // Its PURGE callback can leave UdeCx-owned host polls queued while + // stopping delivery to this driver. Reject READY and require the + // framework's DriverNoRequests proof, then combine it with VIIPER's + // rundown in one BrokerLock sample. This also closes a callback + // delivered just before it could enter ActiveOperations without + // waiting for queued requests which the class extension itself owns. + WdfSpinLockAcquire(controllerContext->BrokerLock); + queueState = WdfIoQueueGetState( + endpointContext->Queue, &queuedRequests, &driverRequests); + quiescent = InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) > 0 && + InterlockedCompareExchange( + &endpointContext->Purging, 0, 0) != 0 && + !WDF_IO_QUEUE_READY(queueState) && + (queueState & WdfIoQueueDriverNoRequests) != 0 && + driverRequests == 0 && + InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0; + if (quiescent) { + *FinalQueueState = queueState; + *FinalQueuedRequests = queuedRequests; + *FinalDriverRequests = driverRequests; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + if (quiescent) { + return; + } + + // This runs only during an endpoint lifecycle transition. A short + // passive wait lets any callback already dispatched by KMDF reach its + // terminal DPC without consuming CPU or touching the input hot path. + (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); + } +} + VOID ViiperDrainControllerEndpointOperations( _In_ WDFDEVICE Controller @@ -1906,7 +2134,7 @@ ViiperDrainControllerEndpointOperations( ViiperGetControllerContext(Controller); ULONG deviceIndex; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // Hold the shared device index while observing every endpoint so cleanup // cannot invalidate a handle between lookup and the final driver-owned // operation proof. ShuttingDown is already set, so no broker or direct @@ -1953,7 +2181,7 @@ ViiperQuiesceResetByIdentity( BOOLEAN found = FALSE; ULONG deviceIndex; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // The asynchronous UdeCx reset request keeps the child alive. Retain the // shared index as an additional cleanup fence while joining any terminal // callback admitted after the reset event was first published. @@ -2093,7 +2321,6 @@ ViiperEvtEndpointReset( } InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); - ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); endpointContext->ResetRequest = Request; // A forwarded broker operation or direct input copy may have won @@ -2118,7 +2345,7 @@ ViiperEvtEndpointResetWorkItem( NTSTATUS status; BOOLEAN resetCurrent; - PAGED_CODE(); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); resetCurrent = ViiperQuiesceResetByIdentity( deviceContext->Controller, deviceContext->DeviceId, @@ -2162,70 +2389,101 @@ ViiperEvtEndpointResetWorkItem( } VOID -ViiperEvtEndpointQueuePurged( - _In_ WDFQUEUE Queue, - _In_ WDFCONTEXT Context +ViiperEvtEndpointPurgeWorkItem( + _In_ WDFWORKITEM WorkItem ) { - UDECXUSBENDPOINT endpoint = (UDECXUSBENDPOINT)Context; + UDECXUSBENDPOINT endpoint = + (UDECXUSBENDPOINT)WdfWorkItemGetParentObject(WorkItem); VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); WDFDEVICE controller = deviceContext->Controller; UDECXUSBDEVICE device = endpointContext->Device; ULONGLONG deviceId = deviceContext->DeviceId; ULONG generation = deviceContext->Generation; UCHAR endpointAddress = endpointContext->Descriptor.bEndpointAddress; - PAGED_CODE(); - UNREFERENCED_PARAMETER(Queue); - VIIPER_TRACE_LIFECYCLE( - deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED, deviceContext->DeviceId, - deviceContext->Generation, endpointContext->Device, endpoint, - endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, - endpointContext->ActiveOperations, - (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); - // WDF invokes this only after queued cancellation and every request it - // delivered to the endpoint driver has completed. Join direct-input work - // admitted from the controller queue before PURGE closed its BrokerLock - // gate, then clear cached state before acknowledging UdeCx. No queue-state - // polling is needed: this callback is the framework's purge-complete fence. - VIIPER_TRACE_LIFECYCLE( - deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN, deviceContext->DeviceId, - deviceContext->Generation, endpointContext->Device, endpoint, - endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, - endpointContext->ActiveOperations, - (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); - (VOID)KeWaitForSingleObject( - &endpointContext->OperationsDrained, - Executive, - KernelMode, - FALSE, - NULL); - VIIPER_TRACE_LIFECYCLE( - deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END, deviceContext->DeviceId, - deviceContext->Generation, endpointContext->Device, endpoint, - endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, - endpointContext->ActiveOperations, - (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); - NT_ASSERT(InterlockedCompareExchange( - &endpointContext->ActiveOperations, 0, 0) == 0); - ViiperInvalidateEndpointInputReport(endpoint); - VIIPER_TRACE_LIFECYCLE( - deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN, deviceContext->DeviceId, - deviceContext->Generation, endpointContext->Device, endpoint, - endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, - endpointContext->ActiveOperations, - (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); - UdecxUsbEndpointPurgeComplete(endpoint); - VIIPER_TRACE_LIFECYCLE( - controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END, deviceId, generation, - device, endpoint, endpointAddress, STATUS_SUCCESS, 0, 0); + NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + for (;;) { + WDF_IO_QUEUE_STATE queueState; + ULONG queuedRequests; + ULONG driverRequests; + LONG remaining; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) <= 0) { + InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + return; + } + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) != 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + ViiperWaitForEndpointPurgeQuiescence( + endpoint, &queueState, &queuedRequests, &driverRequests); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, (ULONG)queueState); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END, deviceContext->DeviceId, + deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, (ULONG)queueState); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0); + ViiperInvalidateEndpointInputReport(endpoint); + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN, + deviceContext->DeviceId, deviceContext->Generation, + endpointContext->Device, endpoint, + endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, + endpointContext->ActiveOperations, + (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + + // Retire exactly one callback before completing it. A synchronous + // final START observes zero and may reopen admission; an earlier START + // sees another outstanding PURGE and remains closed. Keep worker + // ownership through the completion so a reentrant PURGE is drained by + // this same invocation instead of being lost to work-item coalescing. + WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, 0, 0) != 0); + remaining = InterlockedDecrement(&endpointContext->PurgeOutstanding); + NT_ASSERT(remaining >= 0); + WdfSpinLockRelease(controllerContext->BrokerLock); + + UdecxUsbEndpointPurgeComplete(endpoint); + VIIPER_TRACE_LIFECYCLE( + controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_END, deviceId, generation, + device, endpoint, endpointAddress, STATUS_SUCCESS, remaining, 0); + + WdfSpinLockAcquire(controllerContext->BrokerLock); + if (InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0) { + InterlockedExchange(&endpointContext->PurgeWorkerActive, FALSE); + WdfSpinLockRelease(controllerContext->BrokerLock); + return; + } + WdfSpinLockRelease(controllerContext->BrokerLock); + } } VOID @@ -2237,6 +2495,8 @@ ViiperEvtEndpointPurge( VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(endpointContext->Device); VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); + BOOLEAN enqueueWorkItem; + LONG outstanding; VIIPER_TRACE_LIFECYCLE( deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, @@ -2251,9 +2511,13 @@ ViiperEvtEndpointPurge( WdfSpinLockAcquire(controllerContext->BrokerLock); InterlockedExchange(&endpointContext->Purging, TRUE); InterlockedExchange(&endpointContext->StartAnnounced, FALSE); + outstanding = InterlockedIncrement(&endpointContext->PurgeOutstanding); + enqueueWorkItem = InterlockedCompareExchange( + &endpointContext->PurgeWorkerActive, TRUE, FALSE) == FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); + NT_ASSERT(outstanding > 0); + (VOID)outstanding; InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); - ViiperInvalidateEndpointInputReport(Endpoint); ViiperPurgeEndpointOperations(Endpoint, STATUS_DEVICE_NOT_READY); VIIPER_TRACE_LIFECYCLE( deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, @@ -2263,10 +2527,11 @@ ViiperEvtEndpointPurge( endpointContext->ActiveOperations, (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); (VOID)ViiperQueueEndpointLifecycleEvent(Endpoint, ViiperUdeOperationEndpointPurge); - // UdeCx requires its client to stop dispatch, cancel queued requests, and - // acknowledge only after every driver-owned request has completed. The - // asynchronous queue callback is that framework-owned completion fence. - WdfIoQueuePurge(endpointContext->Queue, ViiperEvtEndpointQueuePurged, Endpoint); + // UdeCx owns the associated queue's state. Only the operations forwarded + // into VIIPER-owned paths are ours to cancel and join. The passive work + // item observes the class-extension state without changing it, performs a + // final cache clear, and acknowledges each outstanding callback only after + // all framework-delivered and VIIPER-owned work drains. VIIPER_TRACE_LIFECYCLE( deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED, deviceContext->DeviceId, @@ -2274,13 +2539,17 @@ ViiperEvtEndpointPurge( endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, endpointContext->ActiveOperations, (ULONG)WdfIoQueueGetState(endpointContext->Queue, NULL, NULL)); + // This must remain the final endpoint access. Once the worker completes a + // PURGE, UdeCx can synchronously advance lifecycle or delete the endpoint. + if (enqueueWorkItem) { + WdfWorkItemEnqueue(endpointContext->PurgeWorkItem); + } } static VOID ViiperActivateEndpoint( - _In_ UDECXUSBENDPOINT Endpoint, - _In_ BOOLEAN StartQueue + _In_ UDECXUSBENDPOINT Endpoint ) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); @@ -2295,12 +2564,17 @@ ViiperActivateEndpoint( // endpoints. UdeCx does not issue a separate START callback for every // newly selected endpoint on all supported Windows builds, so publish that // selection before completing the configuration request. A later explicit - // START still owns the KMDF queue transition, but its user-mode activation - // is deduplicated by StartAnnounced. PURGE closes both gates together. + // START still opens VIIPER's forwarded-operation admission gate, while its + // user-mode activation is deduplicated by StartAnnounced. UdeCx alone owns + // the associated KMDF queue transition. PURGE closes both VIIPER gates. InterlockedExchange64(&endpointContext->NextIsoStartFrame, 0); WdfSpinLockAcquire(controllerContext->BrokerLock); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0) { + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + InterlockedCompareExchange(&deviceContext->InD0, 0, 0) != 0 && + InterlockedCompareExchange(&deviceContext->D0ExitPending, 0, 0) == 0 && + InterlockedCompareExchange( + &endpointContext->PurgeOutstanding, 0, 0) == 0) { InterlockedExchange(&endpointContext->Purging, FALSE); active = TRUE; announce = InterlockedCompareExchange( @@ -2310,9 +2584,6 @@ ViiperActivateEndpoint( if (!active) { return; } - if (StartQueue) { - WdfIoQueueStart(endpointContext->Queue); - } if (announce) { status = ViiperQueueEndpointLifecycleEvent( Endpoint, ViiperUdeOperationEndpointStart); @@ -2331,7 +2602,7 @@ ViiperEvtEndpointStart( _In_ UDECXUSBENDPOINT Endpoint ) { - ViiperActivateEndpoint(Endpoint, TRUE); + ViiperActivateEndpoint(Endpoint); } VOID @@ -2357,7 +2628,7 @@ ViiperEvtEndpointsConfigure( endpointIndex < ConfigureParams->EndpointsToConfigureCount; ++endpointIndex) { ViiperActivateEndpoint( - ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE); + ConfigureParams->EndpointsToConfigure[endpointIndex]); } WdfRequestComplete(Request, STATUS_SUCCESS); return; @@ -2368,12 +2639,12 @@ ViiperEvtEndpointsConfigure( // leaves every non-default endpoint in UdeCx's preceding PURGE state. // Publish the selected endpoints before completing this asynchronous // configuration boundary. PURGE remains authoritative for release and - // a later explicit START performs only the KMDF queue transition. + // a later explicit START reopens only VIIPER-owned forwarding paths. for (endpointIndex = 0; endpointIndex < ConfigureParams->EndpointsToConfigureCount; ++endpointIndex) { ViiperActivateEndpoint( - ConfigureParams->EndpointsToConfigure[endpointIndex], FALSE); + ConfigureParams->EndpointsToConfigure[endpointIndex]); } WdfRequestComplete(Request, STATUS_SUCCESS); return; diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 114a4215..40d2535f 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -51,6 +51,9 @@ ViiperHandleNegotiate( if (!NT_SUCCESS(status)) { return status; } + if (outputLength < sizeof(*output)) { + return STATUS_BUFFER_TOO_SMALL; + } if (inputLength != sizeof(*input) || input->Header.Magic != VIIPER_UDE_MAGIC || input->Header.Flags != 0 || diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 4826d5eb..0e60eb95 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -108,6 +108,12 @@ typedef struct VIIPER_UDE_REQUEST_CONTEXT { ULONG IsoPacketCount; ULONG IsoStartFrame; BOOLEAN DirectionIn; + // Nonzero only for a cached direct-input delivery. The completion DPC + // snapshots these before UdeCx can recycle the request context, so stats + // and a crash dump describe an OS-visible completion rather than a copy + // which had not yet crossed the virtual host-controller boundary. + ULONG DirectInputBytes; + ULONGLONG DirectInputSequence; // Protected by the controller BrokerLock. The DPC removes and snapshots // these fields before UdeCx may recycle this request context. LIST_ENTRY CompletionEntry; @@ -163,6 +169,7 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG ActiveFileCleanups; volatile LONG CleanupRetries; volatile LONG ActiveDevices; + volatile LONG ReservedPorts; volatile LONG PendingOperations; volatile LONG PendingCompletions; volatile LONG WaitingDequeueCount; @@ -188,6 +195,12 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { // to the physical UDE port table below. ULONG InputDeviceCount; UDECXUSBDEVICE InputDevices[VIIPER_UDE_MAX_DEVICES]; + // A logical device leaves Devices[] as soon as removal wins admission, but + // its physical port cannot be reused until the matching framework cleanup + // callback runs. The epoch prevents a delayed cleanup from releasing a + // later reservation after an early create/plug-in failure. + ULONGLONG PortReservationEpochs[VIIPER_UDE_MAX_DEVICES]; + BOOLEAN PortReserved[VIIPER_UDE_MAX_DEVICES]; UDECXUSBDEVICE Devices[VIIPER_UDE_MAX_DEVICES]; } VIIPER_UDE_CONTROLLER_CONTEXT; @@ -251,13 +264,16 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_FILE_CONTEXT, ViiperGetFileContext typedef struct VIIPER_UDE_DEVICE_CONTEXT { WDFDEVICE Controller; + WDFWORKITEM D0ExitWorkItem; WDFFILEOBJECT OwnerFile; ULONGLONG DeviceId; ULONG Generation; ULONG Slot; + ULONGLONG PortReservation; UDECX_USB_DEVICE_SPEED Speed; BOOLEAN Plugged; volatile LONG InD0; + volatile LONG D0ExitPending; volatile LONG Resetting; volatile LONG64 ResetEpoch; volatile LONG Purging; @@ -278,11 +294,14 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; WDFQUEUE Queue; WDFWAITLOCK InputLock; + WDFWORKITEM PurgeWorkItem; WDFWORKITEM ResetWorkItem; WDFREQUEST ResetRequest; KEVENT OperationsDrained; USB_ENDPOINT_DESCRIPTOR Descriptor; volatile LONG Purging; + volatile LONG PurgeOutstanding; + volatile LONG PurgeWorkerActive; volatile LONG StartAnnounced; volatile LONG Resetting; volatile LONG64 ResetDeviceEpoch; @@ -301,6 +320,7 @@ typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { volatile LONG InputTransitionHead; volatile LONG InputTransitionCount; USHORT InputTransitionLengths[VIIPER_UDE_MAX_INPUT_TRANSITIONS]; + ULONGLONG InputTransitionSequences[VIIPER_UDE_MAX_INPUT_TRANSITIONS]; // BrokerLock protects this FIFO and every slot AdmissionEntry. It keeps // same-endpoint publication ordered without scanning the controller-wide // 4096-slot table on every USB transfer. @@ -326,6 +346,7 @@ EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL ViiperEvtIoDeviceControl; EVT_UDECX_WDF_DEVICE_QUERY_USB_CAPABILITY ViiperEvtQueryUsbCapability; EVT_UDECX_USB_DEVICE_D0_ENTRY ViiperEvtUsbDeviceD0Entry; EVT_UDECX_USB_DEVICE_D0_EXIT ViiperEvtUsbDeviceD0Exit; +EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem; EVT_UDECX_USB_DEVICE_SET_FUNCTION_SUSPEND_AND_WAKE ViiperEvtUsbDeviceSetFunctionSuspendAndWake; EVT_UDECX_USB_DEVICE_DEFAULT_ENDPOINT_ADD ViiperEvtDefaultEndpointAdd; EVT_UDECX_USB_DEVICE_ENDPOINT_ADD ViiperEvtEndpointAdd; @@ -337,7 +358,7 @@ EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL ViiperEvtEndpointIoInternalControl; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtUrbCanceledOnQueue; EVT_WDF_IO_QUEUE_STATE ViiperEvtFastInputQueueReady; EVT_WDF_IO_QUEUE_IO_CANCELED_ON_QUEUE ViiperEvtDequeueCanceledOnQueue; -EVT_WDF_IO_QUEUE_STATE ViiperEvtEndpointQueuePurged; +EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem; EVT_WDF_WORKITEM ViiperEvtEndpointResetWorkItem; EVT_WDF_DPC ViiperEvtCompletionDpc; EVT_WDF_OBJECT_CONTEXT_CLEANUP ViiperEvtVirtualDeviceCleanup; @@ -376,7 +397,9 @@ BOOLEAN ViiperQueueUrbCompletion( _In_ ULONGLONG Token, _In_ NTSTATUS Status, _In_ USBD_STATUS UsbdStatus, - _In_ BOOLEAN CompleteWithNtStatus); + _In_ BOOLEAN CompleteWithNtStatus, + _In_ ULONG DirectInputBytes, + _In_ ULONGLONG DirectInputSequence); _IRQL_requires_(PASSIVE_LEVEL) VOID ViiperDrainUrbCompletions(_In_ WDFDEVICE Controller); NTSTATUS ViiperSubmitInputReport(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request); diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 14f6854b..ca595a28 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.33 + 0.1.0.34 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -45,6 +45,8 @@ 1 27 Spectre + true + true @@ -113,4 +115,7 @@ + + + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index a2e333b8..4799300e 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(11) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.33" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.34" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index 6abf063b..c0613f5a 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.33 +DriverVer=08/14/2026,0.1.0.34 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 b/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 new file mode 100644 index 00000000..de8ced28 --- /dev/null +++ b/native/udecx/tools/New-ViiperUdeDebugBundle.ps1 @@ -0,0 +1,219 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$SourceRevision, + [Parameter(Mandatory = $true)][string]$DriverImagePath, + [Parameter(Mandatory = $true)][string]$DriverPdbPath, + [Parameter(Mandatory = $true)][string]$DriverMapPath, + [Parameter(Mandatory = $true)][string]$BrokerPath, + [Parameter(Mandatory = $true)][string]$BrokerBuildInfoPath, + [Parameter(Mandatory = $true)][string]$BrokerBuildManifestPath, + [Parameter(Mandatory = $true)][string]$HelperPath, + [Parameter(Mandatory = $true)][string]$HelperPdbPath, + [Parameter(Mandatory = $true)][string]$MediaProbePath, + [Parameter(Mandatory = $true)][string]$MediaProbePdbPath, + [Parameter(Mandatory = $true)][string]$InputProbePath, + [Parameter(Mandatory = $true)][string]$InputProbePdbPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ExactFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedName + ) + + $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop + if ($item.PSIsContainer -or $item.Name -cne $ExpectedName -or $item.Length -le 0 -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Expected a nonempty, case-exact, non-reparse '$ExpectedName' at '$Path'." + } + return $item +} + +function Copy-DebugArtifact { + param( + [Parameter(Mandatory = $true)]$Source, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$Role, + [Parameter(Mandatory = $true)][string]$Root + ) + + $destination = Join-Path $Root $RelativePath.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) + [IO.File]::Copy($Source.FullName, $destination, $false) + $item = Get-Item -LiteralPath $destination -Force + return [ordered]@{ + path = $RelativePath + role = $Role + length = $item.Length + sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +$root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path +$rootItem = Get-Item -LiteralPath $root -Force +if (-not $rootItem.PSIsContainer) { + throw "Repository root is not a directory: '$RepositoryRoot'." +} +$git = Get-Command git.exe -CommandType Application -ErrorAction Stop | + Select-Object -First 1 +$source = $SourceRevision.ToLowerInvariant() +$head = (& $git.Source -C $root rev-parse HEAD 2>&1 | Out-String).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0 -or $head -cne $source) { + throw "Debug source revision '$source' does not match repository HEAD '$head'." +} +$trackedStatus = @(& $git.Source -C $root status --porcelain=v1 --untracked-files=no 2>&1) +if ($LASTEXITCODE -ne 0 -or $trackedStatus.Count -ne 0) { + throw "Refusing a debug bundle from a modified tracked source tree.`n$($trackedStatus -join [Environment]::NewLine)" +} + +$inputs = [ordered]@{ + 'driver-image' = Resolve-ExactFile $DriverImagePath 'ViiperUde.sys' + 'driver-pdb' = Resolve-ExactFile $DriverPdbPath 'ViiperUde.pdb' + 'driver-map' = Resolve-ExactFile $DriverMapPath 'ViiperUde.map' + 'broker-image' = Resolve-ExactFile $BrokerPath 'viiper.exe' + 'broker-build-info' = Resolve-ExactFile $BrokerBuildInfoPath 'viiper.exe.buildinfo.txt' + 'broker-build-manifest' = Resolve-ExactFile $BrokerBuildManifestPath 'viiper.exe.build.json' + 'helper-image' = Resolve-ExactFile $HelperPath 'ViiperUdeCtl.exe' + 'helper-pdb' = Resolve-ExactFile $HelperPdbPath 'ViiperUdeCtl.pdb' + 'media-probe-image' = Resolve-ExactFile $MediaProbePath 'ViiperUdeMediaProbe.exe' + 'media-probe-pdb' = Resolve-ExactFile $MediaProbePdbPath 'ViiperUdeMediaProbe.pdb' + 'input-probe-image' = Resolve-ExactFile $InputProbePath 'ViiperUdeInputProbe.exe' + 'input-probe-pdb' = Resolve-ExactFile $InputProbePdbPath 'ViiperUdeInputProbe.pdb' +} + +try { + $brokerBuildManifest = Get-Content -LiteralPath ` + $inputs['broker-build-manifest'].FullName -Raw -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop +} +catch { + throw "Broker build manifest is not valid JSON. $($_.Exception.Message)" +} +$brokerHash = (Get-FileHash -LiteralPath $inputs['broker-image'].FullName ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$buildInfoHash = (Get-FileHash -LiteralPath $inputs['broker-build-info'].FullName ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$buildInfoText = Get-Content -LiteralPath $inputs['broker-build-info'].FullName -Raw +$declaredDwarfSections = @($brokerBuildManifest.embeddedDwarfSections | + ForEach-Object { [string]$_ }) +if ([int]$brokerBuildManifest.schema -ne 1 -or + [string]$brokerBuildManifest.sourceRevision -cne $source -or + [string]$brokerBuildManifest.commit -cne $source -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.version) -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.buildDate) -or + [string]::IsNullOrWhiteSpace([string]$brokerBuildManifest.goVersion) -or + -not [bool]$brokerBuildManifest.trimpath -or + -not [bool]$brokerBuildManifest.embeddedDwarf -or + @('debug_info', 'debug_line', 'debug_abbrev' | + Where-Object { $declaredDwarfSections -cnotcontains $_ }).Count -ne 0 -or + [string]$brokerBuildManifest.binary.name -cne 'viiper.exe' -or + [long]$brokerBuildManifest.binary.length -ne $inputs['broker-image'].Length -or + [string]$brokerBuildManifest.binary.sha256 -cne $brokerHash -or + [string]$brokerBuildManifest.buildInfoSha256 -cne $buildInfoHash -or + $buildInfoText -notmatch ('(?m)^\s*build\s+vcs\.revision=' + + [regex]::Escape($source) + '\s*$')) { + throw 'Broker image, embedded-DWARF policy, build metadata, and source revision are not an exact set.' +} + +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { + throw "Refusing to overwrite debug bundle '$output'." +} +[void][IO.Directory]::CreateDirectory($output) + +$files = [Collections.Generic.List[object]]::new() +try { + $layout = @( + @('driver-image', 'binaries/ViiperUde.sys', 'driver-image'), + @('broker-image', 'binaries/viiper.exe', 'broker-image-with-embedded-go-dwarf'), + @('helper-image', 'binaries/ViiperUdeCtl.exe', 'helper-image'), + @('media-probe-image', 'binaries/ViiperUdeMediaProbe.exe', 'media-probe-image'), + @('input-probe-image', 'binaries/ViiperUdeInputProbe.exe', 'input-probe-image'), + @('driver-pdb', 'symbols/ViiperUde.pdb', 'driver-private-pdb'), + @('driver-map', 'symbols/ViiperUde.map', 'driver-link-map'), + @('helper-pdb', 'symbols/ViiperUdeCtl.pdb', 'helper-private-pdb'), + @('media-probe-pdb', 'symbols/ViiperUdeMediaProbe.pdb', 'media-probe-private-pdb'), + @('input-probe-pdb', 'symbols/ViiperUdeInputProbe.pdb', 'input-probe-private-pdb'), + @('broker-build-info', 'symbols/viiper.exe.buildinfo.txt', 'broker-go-build-info'), + @('broker-build-manifest', 'symbols/viiper.exe.build.json', 'broker-build-manifest') + ) + foreach ($entry in $layout) { + [void]$files.Add((Copy-DebugArtifact -Source $inputs[$entry[0]] ` + -RelativePath $entry[1] -Role $entry[2] -Root $output)) + } + + $archiveName = "VIIPER-source-$source.zip" + $archiveRelative = "source/$archiveName" + $archivePath = Join-Path $output $archiveRelative.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($archivePath)) + & $git.Source -C $root archive --format=zip "--prefix=VIIPER-$source/" ` + "--output=$archivePath" $source + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + throw "git archive failed for exact source revision '$source'." + } + $archive = Get-Item -LiteralPath $archivePath -Force + if ($archive.Length -le 0) { + throw 'The exact source archive is empty.' + } + [void]$files.Add([ordered]@{ + path = $archiveRelative + role = 'exact-git-source-archive' + length = $archive.Length + sha256 = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + }) + + $manifest = [ordered]@{ + schema = 1 + sourceRevision = $source + sourceArchive = $archiveRelative + sourceArchiveFormat = 'git-archive-zip' + symbolPolicy = 'private-source-line-type-sidecars; embedded Go DWARF' + pairs = @( + [ordered]@{ image = 'binaries/ViiperUde.sys'; symbols = 'symbols/ViiperUde.pdb'; map = 'symbols/ViiperUde.map' }, + [ordered]@{ image = 'binaries/ViiperUdeCtl.exe'; symbols = 'symbols/ViiperUdeCtl.pdb' }, + [ordered]@{ image = 'binaries/ViiperUdeMediaProbe.exe'; symbols = 'symbols/ViiperUdeMediaProbe.pdb' }, + [ordered]@{ image = 'binaries/ViiperUdeInputProbe.exe'; symbols = 'symbols/ViiperUdeInputProbe.pdb' }, + [ordered]@{ image = 'binaries/viiper.exe'; symbols = 'embedded-go-dwarf'; buildInfo = 'symbols/viiper.exe.buildinfo.txt'; buildManifest = 'symbols/viiper.exe.build.json' } + ) + files = @($files) + } + $manifestPath = Join-Path $output 'ViiperUdeDebug.manifest.json' + [IO.File]::WriteAllText($manifestPath, + ($manifest | ConvertTo-Json -Depth 7 -Compress), + [Text.UTF8Encoding]::new($false)) + + $roundTrip = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ([int]$roundTrip.schema -ne 1 -or + [string]$roundTrip.sourceRevision -cne $source -or + @($roundTrip.files).Count -ne $files.Count) { + throw 'The emitted debug bundle manifest did not round-trip exactly.' + } + foreach ($entry in @($roundTrip.files)) { + $path = Join-Path $output ([string]$entry.path).Replace( + '/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if ($item.PSIsContainer -or $item.Length -ne [long]$entry.length -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne + [string]$entry.sha256) { + throw "Debug bundle verification failed for '$($entry.path)'." + } + } +} +catch { + if (Test-Path -LiteralPath $output) { + Remove-Item -LiteralPath $output -Recurse -Force + } + throw +} + +Write-Host "Created exact source-bound debug bundle at '$output'." diff --git a/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 index bb2fc8e9..6939a1a3 100644 --- a/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 +++ b/native/udecx/tools/Test-ViiperUdeDebugArtifacts.ps1 @@ -9,6 +9,18 @@ param( [Parameter(Mandatory = $true)] [string]$MapPath, + [string]$HelperPath, + + [string]$HelperPdbPath, + + [string]$MediaProbePath, + + [string]$MediaProbePdbPath, + + [string]$InputProbePath, + + [string]$InputProbePdbPath, + [string]$SymChkPath ) @@ -47,45 +59,105 @@ function Resolve-SymChk { throw 'symchk.exe is required to prove that the SYS and full private line PDB match.' } +function Test-RelocatablePdbReference { + param( + [Parameter(Mandatory = $true)]$Image, + [Parameter(Mandatory = $true)]$Pdb + ) + + $embeddedImageText = [Text.Encoding]::ASCII.GetString( + [IO.File]::ReadAllBytes($Image.FullName)) + $pdbNamePattern = '(?:^|\x00)' + [regex]::Escape($Pdb.Name) + '(?:\x00|$)' + if ($embeddedImageText -notmatch $pdbNamePattern -or + $embeddedImageText -match ('(?i)[A-Z]:\\[^\x00]{0,512}' + + [regex]::Escape($Pdb.Name))) { + throw "'$($Image.Name)' must embed only the relocatable '$($Pdb.Name)' basename." + } +} + +function Test-MatchingPrivateSymbols { + param( + [Parameter(Mandatory = $true)]$Image, + [Parameter(Mandatory = $true)]$Pdb, + [Parameter(Mandatory = $true)][string]$SymChk + ) + + $savedErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell 5.1 wraps native stderr as non-terminating + # ErrorRecord objects. Preserve the text and judge symchk by its exit + # code and the private line/type report. + $ErrorActionPreference = 'Continue' + $symbolOutput = (& $SymChk /v $Image.FullName /s $Pdb.DirectoryName 2>&1 | + ForEach-Object { $_.ToString() } | Out-String) + $symbolExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $savedErrorActionPreference + } + if ($symbolExitCode -ne 0 -or + $symbolOutput -notmatch '(?im)private symbols & lines' -or + $symbolOutput -notmatch '(?im)PDB Matched:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Line numbers:\s+TRUE' -or + $symbolOutput -notmatch '(?im)Type Info:\s+TRUE') { + throw "'$($Image.Name)' and '$($Pdb.Name)' are not a matching private source/line/type set.`n$symbolOutput" + } +} + $sys = Resolve-ExactArtifact -Path $SysPath -ExpectedName 'ViiperUde.sys' $pdb = Resolve-ExactArtifact -Path $PdbPath -ExpectedName 'ViiperUde.pdb' $map = Resolve-ExactArtifact -Path $MapPath -ExpectedName 'ViiperUde.map' $symchk = Resolve-SymChk -ExplicitPath $SymChkPath -$embeddedImageText = [Text.Encoding]::ASCII.GetString( - [IO.File]::ReadAllBytes($sys.FullName)) -if ($embeddedImageText -notmatch '(?:^|\x00)ViiperUde\.pdb(?:\x00|$)' -or - $embeddedImageText -match '(?i)[A-Z]:\\[^\x00]{0,512}ViiperUde\.pdb') { - throw 'The driver image must embed only the relocatable ViiperUde.pdb basename.' -} - -$savedErrorActionPreference = $ErrorActionPreference -try { - # Windows PowerShell 5.1 wraps native stderr as non-terminating ErrorRecord - # objects even when the native tool succeeds. Preserve that diagnostic text - # and judge the tool only by its exit code and matching-symbol report. - $ErrorActionPreference = 'Continue' - $symbolOutput = (& $symchk /v $sys.FullName /s $pdb.DirectoryName 2>&1 | - ForEach-Object { $_.ToString() } | Out-String) - $symbolExitCode = $LASTEXITCODE -} -finally { - $ErrorActionPreference = $savedErrorActionPreference -} -if ($symbolExitCode -ne 0 -or - $symbolOutput -notmatch '(?im)private symbols & lines' -or - $symbolOutput -notmatch '(?im)PDB Matched:\s+TRUE' -or - $symbolOutput -notmatch '(?im)Line numbers:\s+TRUE' -or - $symbolOutput -notmatch '(?im)Type Info:\s+TRUE') { - throw "The driver debug artifacts are not a matching private source/line/type set.`n$symbolOutput" -} +Test-RelocatablePdbReference -Image $sys -Pdb $pdb +Test-MatchingPrivateSymbols -Image $sys -Pdb $pdb -SymChk $symchk $mapText = Get-Content -LiteralPath $map.FullName -Raw -foreach ($symbol in @('ViiperTraceLifecycle', 'ViiperEvtEndpointQueuePurged', - 'ViiperEvtEndpointPurge', 'ViiperBeginControllerShutdown')) { +foreach ($symbol in @('ViiperTraceLifecycle', 'ViiperEvtEndpointPurgeWorkItem', + 'ViiperEvtEndpointPurge', 'ViiperBeginControllerShutdown', + 'ViiperEvtEndpointIoInternalControl', 'ViiperSubmitInputReport', + 'ViiperEvtFastInputQueueReady', 'ViiperPrepareCachedInputUrb', + 'ViiperQueueUrb', 'ViiperDispatchAvailable', 'ViiperSerializeOperation', + 'ViiperReserveIsoStartFrame', 'ViiperQueueUrbCompletion', + 'ViiperEvtCompletionDpc')) { if ($mapText -notmatch ('\b' + [regex]::Escape($symbol) + '\b')) { - throw "The driver link map does not contain required lifecycle symbol '$symbol'." + throw "The driver link map does not contain required lifecycle/hot-path symbol '$symbol'." + } +} + +$userModeArtifacts = @( + [pscustomobject]@{ + ImagePath = $HelperPath + PdbPath = $HelperPdbPath + ImageName = 'ViiperUdeCtl.exe' + PdbName = 'ViiperUdeCtl.pdb' + }, + [pscustomobject]@{ + ImagePath = $MediaProbePath + PdbPath = $MediaProbePdbPath + ImageName = 'ViiperUdeMediaProbe.exe' + PdbName = 'ViiperUdeMediaProbe.pdb' + }, + [pscustomobject]@{ + ImagePath = $InputProbePath + PdbPath = $InputProbePdbPath + ImageName = 'ViiperUdeInputProbe.exe' + PdbName = 'ViiperUdeInputProbe.pdb' + } +) +foreach ($artifact in $userModeArtifacts) { + $hasImage = -not [string]::IsNullOrWhiteSpace($artifact.ImagePath) + $hasPdb = -not [string]::IsNullOrWhiteSpace($artifact.PdbPath) + if ($hasImage -ne $hasPdb) { + throw "Both '$($artifact.ImageName)' and '$($artifact.PdbName)' must be supplied together." + } + if (-not $hasImage) { + continue } + $image = Resolve-ExactArtifact -Path $artifact.ImagePath -ExpectedName $artifact.ImageName + $userPdb = Resolve-ExactArtifact -Path $artifact.PdbPath -ExpectedName $artifact.PdbName + Test-RelocatablePdbReference -Image $image -Pdb $userPdb + Test-MatchingPrivateSymbols -Image $image -Pdb $userPdb -SymChk $symchk } -Write-Host "VIIPER UDE debug artifacts match and contain private symbols, line tables, type information, and lifecycle map symbols." +Write-Host 'VIIPER UDE debug artifacts match and contain private symbols, line tables, type information, and required lifecycle/hot-path symbols.' diff --git a/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 b/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 new file mode 100644 index 00000000..111b5b4e --- /dev/null +++ b/native/udecx/tools/Test-ViiperUdeStaticAnalysis.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$AnalysisDirectory, + + [ValidateRange(1, 1024)] + [int]$ExpectedSourceCount = 6 +) + +$ErrorActionPreference = 'Stop' +$analysisRoot = (Resolve-Path -LiteralPath $AnalysisDirectory).Path +$results = @(Get-ChildItem -LiteralPath $analysisRoot -File -Filter '*.nativecodeanalysis.xml') +if ($results.Count -ne $ExpectedSourceCount) { + throw "Expected $ExpectedSourceCount native code-analysis result files in '$analysisRoot'; found $($results.Count)." +} + +$defects = [Collections.Generic.List[object]]::new() +foreach ($result in $results) { + $settings = [Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $reader = [Xml.XmlReader]::Create($result.FullName, $settings) + try { + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + $document.Load($reader) + } finally { + $reader.Dispose() + } + + foreach ($defect in @($document.SelectNodes('/DEFECTS/*'))) { + $defects.Add([pscustomobject]@{ + Source = $result.Name + Detail = $defect.OuterXml + }) + } +} + +if ($defects.Count -ne 0) { + $details = ($defects | ForEach-Object { "$($_.Source): $($_.Detail)" }) -join [Environment]::NewLine + throw "Native driver static analysis reported $($defects.Count) defect(s):$([Environment]::NewLine)$details" +} + +Write-Host "VIIPER UDE native static analysis passed for $($results.Count) translation units." diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index d200f124..e55facc0 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -75,6 +75,21 @@ if ($null -eq $linkDefinition -or $linkDefinition.AdditionalOptions -notmatch '/PDBALTPATH:%_PDB%') { throw 'Every native build must emit a full matching PDB and link map with a relocatable image PDB path.' } +$releaseConfiguration = $project.SelectSingleNode( + "//msb:PropertyGroup[contains(@Condition, 'Release|x64')]", $namespace) +$staticAnalysisTarget = $project.SelectSingleNode( + "//msb:Target[@Name='VerifyViiperUdeStaticAnalysis']", $namespace) +if ($null -eq $releaseConfiguration -or + $releaseConfiguration.RunCodeAnalysis -cne 'true' -or + $releaseConfiguration.EnableMicrosoftCodeAnalysis -cne 'true' -or + $null -eq $staticAnalysisTarget -or + $staticAnalysisTarget.AfterTargets -cne 'ClCompile' -or + $staticAnalysisTarget.Condition -notmatch 'RunCodeAnalysis' -or + $null -eq $staticAnalysisTarget.Exec -or + $staticAnalysisTarget.Exec.Command -notmatch 'Test-ViiperUdeStaticAnalysis\.ps1' -or + $staticAnalysisTarget.Exec.Command -notmatch '-ExpectedSourceCount 6') { + throw 'Every Release driver build must run WDK static analysis and fail closed if any translation unit reports a defect.' +} $traceCompileItems = @($project.SelectNodes( "//msb:ClCompile[@Include='Trace.c']", $namespace)) if ($traceCompileItems.Count -ne 1) { @@ -167,6 +182,14 @@ foreach ($requiredHeaderContract in @( 'KEVENT CompletionOperationsDrained;', 'KEVENT FileCleanupsDrained;', 'WDFDPC CompletionDpc;', + 'WDFWORKITEM D0ExitWorkItem;', + 'volatile LONG D0ExitPending;', + 'EVT_WDF_WORKITEM ViiperEvtUsbDeviceD0ExitWorkItem;', + 'volatile LONG ReservedPorts;', + 'WDFWORKITEM PurgeWorkItem;', + 'volatile LONG PurgeOutstanding;', + 'volatile LONG PurgeWorkerActive;', + 'EVT_WDF_WORKITEM ViiperEvtEndpointPurgeWorkItem;', 'LIST_ENTRY CompletionQueue;', 'volatile LONG PendingCompletions;', 'volatile LONG ShuttingDown;')) { @@ -200,6 +223,86 @@ if ($deviceSource -notmatch 'WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE\(&attributes,\s*UDECXUSBENDPOINT\);[\s\S]{0,300}?attributes\.ExecutionLevel\s*=\s*WdfExecutionLevelPassive\s*;[\s\S]{0,300}?WdfIoQueueCreate') { throw 'Every endpoint queue must explicitly run at PASSIVE_LEVEL for buffer preparation and broker admission.' } +$d0ExitMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtUsbDeviceD0Exit\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0ExitWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtUsbDeviceD0ExitWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0EntryMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtUsbDeviceD0Entry\s*\([^)]*\)\s*\{(?.*?)^\}') +$d0ExitForbiddenDispatchWork = + 'ViiperInvalidate(?:Endpoint|Device)InputReports?|ViiperAcquireDeviceLock|WdfWaitLockAcquire|KeWaitForSingleObject|KeDelayExecutionThread' +if (-not $d0ExitMatch.Success -or + $d0ExitMatch.Groups['body'].Value -notmatch + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->InD0\s*,\s*FALSE\s*\)[\s\S]*controllerContext->ShuttingDown[\s\S]*deviceContext->Purging[\s\S]*status\s*=\s*STATUS_SUCCESS[\s\S]*D0ExitPending\s*,\s*TRUE\s*,\s*FALSE[\s\S]*status\s*=\s*STATUS_DEVICE_BUSY[\s\S]*WdfWorkItemEnqueue\s*\(\s*deviceContext->D0ExitWorkItem\s*\)[\s\S]*status\s*=\s*STATUS_PENDING[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*return\s+status' -or + $d0ExitMatch.Groups['body'].Value -match $d0ExitForbiddenDispatchWork -or + -not $d0ExitWorkItemMatch.Success -or + $d0ExitWorkItemMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL[\s\S]*ViiperInvalidateDeviceInputReports\s*\(\s*device\s*\)[\s\S]*ViiperUdeOperationDeviceD0Exit[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->D0ExitPending\s*,\s*FALSE\s*\)[\s\S]*UdecxUsbDeviceLinkPowerExitComplete\s*\(\s*device\s*,\s*STATUS_SUCCESS\s*\)' -or + -not $d0EntryMatch.Success -or + $d0EntryMatch.Groups['body'].Value -notmatch + 'WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*deviceContext->Purging[\s\S]*deviceContext->D0ExitPending[\s\S]*STATUS_DEVICE_BUSY[\s\S]*InterlockedExchange\s*\(\s*&deviceContext->InD0\s*,\s*TRUE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)' -or + $d0EntryMatch.Groups['body'].Value -match $d0ExitForbiddenDispatchWork) { + throw 'D0 entry/exit must gate admission at DISPATCH_LEVEL and defer cache invalidation to one passive asynchronous completion.' +} +$d0ExitCompletion = 'UdecxUsbDeviceLinkPowerExitComplete(device, STATUS_SUCCESS);' +$d0ExitCompletionOffset = $d0ExitWorkItemMatch.Groups['body'].Value.IndexOf( + $d0ExitCompletion, + [StringComparison]::Ordinal) +if ($d0ExitCompletionOffset -lt 0 -or + $d0ExitWorkItemMatch.Groups['body'].Value.Substring( + $d0ExitCompletionOffset + $d0ExitCompletion.Length) -match + 'deviceContext|controllerContext|ViiperGet|Wdf|VIIPER_TRACE') { + throw 'The D0-exit work item must not access device state after completing the UdeCx transition.' +} +if ($deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtUsbDeviceD0ExitWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;[\s\S]*?attributes\.ParentObject\s*=\s*device\s*;[\s\S]*?WdfWorkItemCreate\s*\([\s\S]*?&deviceContext->D0ExitWorkItem\s*\)[\s\S]*?ViiperClaimDeviceSlot[\s\S]*?UdecxUsbDevicePlugIn') { + throw 'Every virtual device must create its passive D0-exit work item before UdeCx exposure.' +} +$d0ExitFlushMatch = [regex]::Match( + $deviceSource, + '(?ms)^ViiperFlushD0ExitWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') +$destroyDeviceMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperDestroyVirtualDevice\s*\([^)]*\)\s*\{(?.*?)^\}') +$destroyOwnedMatch = [regex]::Match( + $deviceSource, + '(?ms)^BOOLEAN\s+ViiperDestroyOwnedDevices\s*\([^)]*\)\s*\{(?.*?)^\}') +$controllerShutdownMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperBeginControllerShutdown\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $d0ExitFlushMatch.Success -or + $d0ExitFlushMatch.Groups['body'].Value -notmatch + 'KeGetCurrentIrql\s*\(\s*\)\s*==\s*PASSIVE_LEVEL[\s\S]*WdfWorkItemFlush\s*\(\s*deviceContext->D0ExitWorkItem\s*\)[\s\S]*D0ExitPending\s*,\s*0\s*,\s*0\s*\)\s*==\s*0' -or + $d0ExitFlushMatch.Groups['body'].Value -match + 'if\s*\([^)]*D0ExitPending' -or + -not $destroyDeviceMatch.Success -or + $destroyDeviceMatch.Groups['body'].Value -notmatch + 'ViiperBeginRemoveDevice[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*device\s*\)[\s\S]*ViiperAbortDeviceManagementOperations[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*device\s*\)' -or + -not $destroyOwnedMatch.Success -or + $destroyOwnedMatch.Groups['body'].Value -notmatch + 'plugged\s*=\s*deviceContext->Plugged[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*device\s*\)[\s\S]*ViiperAbortDeviceManagementOperations[\s\S]*if\s*\(\s*plugged\s*\)[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*device\s*\)' -or + -not $controllerShutdownMatch.Success -or + $controllerShutdownMatch.Groups['body'].Value -notmatch + 'plugged\s*=\s*deviceContext->Plugged[\s\S]*ViiperFlushD0ExitWorkItem\s*\(\s*devices\[index\]\s*\)[\s\S]*if\s*\(\s*plugged\s*\)[\s\S]*UdecxUsbDevicePlugOutAndDelete\s*\(\s*devices\[index\]\s*\)') { + throw 'Every device-consuming teardown path must unconditionally join D0-exit work after closing admission and before consuming the UdeCx handle.' +} +$claimDeviceSlotMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+NTSTATUS\s+ViiperClaimDeviceSlot\s*\([^)]*\)\s*\{(?.*?)^\}') +$releaseDeviceSlotMatch = [regex]::Match( + $deviceSource, + '(?ms)^static\s+VOID\s+ViiperReleaseDeviceSlot\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $claimDeviceSlotMatch.Success -or + $claimDeviceSlotMatch.Groups['body'].Value -notmatch + 'ControllerContext->PortReserved\[freeSlot\]\s*=\s*TRUE\s*;[\s\S]*InterlockedIncrement\s*\(\s*&ControllerContext->ReservedPorts\s*\)[\s\S]*ControllerContext->Devices\[freeSlot\]\s*=\s*Device\s*;' -or + -not $releaseDeviceSlotMatch.Success -or + $releaseDeviceSlotMatch.Groups['body'].Value -notmatch + 'PortReservationEpochs\[Slot\]\s*==\s*PortReservation[\s\S]*ControllerContext->PortReserved\[Slot\]\s*=\s*FALSE\s*;[\s\S]*InterlockedDecrement\s*\(\s*&ControllerContext->ReservedPorts\s*\)[\s\S]*NT_ASSERT\s*\(\s*remaining\s*>=\s*0\s*\)') { + throw 'Physical-port accounting must increment after reservation publication and decrement only after an exact token-matched release.' +} if (($controllerSource + $deviceSource + $brokerSource) -match 'WdfWaitLock(?:Acquire|Release)\s*\([^;\r\n]*DeviceLock') { throw 'DeviceLock must remain embedded; a sibling WDF lock is unsafe during UdeCx child cleanup.' @@ -338,9 +441,44 @@ if (-not $endpointQuiescenceMatch.Success -or 'WDF_IO_QUEUE_IDLE|WdfIoQueueAcceptRequests|WdfIoQueueDispatchRequests') { throw 'Reset and terminal pre-consumption quiescence must join only driver-owned requests and BrokerLock-owned rundown.' } -$purgeQueueCallbackMatch = [regex]::Match( +$endpointAddMatch = [regex]::Match( + $deviceSource, + '(?ms)^NTSTATUS\s+ViiperEvtEndpointAdd\s*\([^)]*\)\s*\{(?.*?)^\}') +if (-not $endpointAddMatch.Success -or + $endpointAddMatch.Groups['body'].Value -notmatch + 'KeInitializeEvent\s*\(\s*&endpointContext->OperationsDrained[\s\S]*WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointPurgeWorkItem\s*\)[\s\S]*attributes\.ParentObject\s*=\s*endpoint\s*;[\s\S]*WdfWorkItemCreate\s*\([\s\S]*&endpointContext->PurgeWorkItem\s*\)') { + throw 'Every endpoint must own a passive PURGE work item before its queue can be published.' +} +$purgeQuiescenceMatch = [regex]::Match( $deviceSource, - '(?ms)^VOID\s+ViiperEvtEndpointQueuePurged\s*\([^)]*\)\s*\{(?.*?)^\}') + '(?ms)^ViiperWaitForEndpointPurgeQuiescence\s*\([^)]*\)\s*\{(?.*?)^\}') +$purgeSampleMatch = if ($purgeQuiescenceMatch.Success) { + [regex]::Match( + $purgeQuiescenceMatch.Groups['body'].Value, + '(?ms)WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)\s*;(?.*?)WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)\s*;') +} else { + [Text.RegularExpressions.Match]::Empty +} +if (-not $purgeQuiescenceMatch.Success -or + $purgeQuiescenceMatch.Groups['body'].Value -notmatch + 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained' -or + $purgeQuiescenceMatch.Groups['body'].Value -notmatch 'KeDelayExecutionThread' -or + -not $purgeSampleMatch.Success -or + $purgeSampleMatch.Groups['sample'].Value -notmatch + 'WdfIoQueueGetState\s*\(\s*endpointContext->Queue' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->PurgeOutstanding' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->Purging' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch '!\s*WDF_IO_QUEUE_READY' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'WdfIoQueueDriverNoRequests' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'driverRequests\s*==\s*0' -or + $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->ActiveOperations' -or + $purgeQuiescenceMatch.Groups['body'].Value -match + 'WDF_IO_QUEUE_(?:IDLE|PURGED)|queuedRequests\s*==\s*0') { + throw 'Endpoint PURGE must prove stopped driver-owned quiescence without waiting on UdeCx-owned queued requests.' +} +$purgeWorkItemMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointPurgeWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') $endpointPurgeMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperEvtEndpointPurge\s*\([^)]*\)\s*\{(?.*?)^\}') @@ -350,30 +488,47 @@ $endpointStartMatch = [regex]::Match( $endpointActivateMatch = [regex]::Match( $deviceSource, '(?ms)^static\s+VOID\s+ViiperActivateEndpoint\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $purgeQueueCallbackMatch.Success -or - $purgeQueueCallbackMatch.Groups['body'].Value -notmatch - 'KeWaitForSingleObject\s*\(\s*&endpointContext->OperationsDrained[\s\S]*endpointContext->ActiveOperations[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)' -or +if (-not $purgeWorkItemMatch.Success -or + $purgeWorkItemMatch.Groups['body'].Value -notmatch + 'WdfWorkItemGetParentObject\s*\(\s*WorkItem\s*\)[\s\S]*for\s*\(\s*;\s*;\s*\)[\s\S]*PurgeOutstanding[\s\S]*PurgeWorkerActive[\s\S]*ViiperWaitForEndpointPurgeQuiescence\s*\(\s*endpoint\s*,[\s\S]*&queueState\s*,[\s\S]*&queuedRequests\s*,[\s\S]*&driverRequests\s*\)[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*InterlockedDecrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)[\s\S]*PurgeOutstanding[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*FALSE\s*\)' -or + $purgeWorkItemMatch.Groups['body'].Value -notmatch + 'InterlockedDecrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*UdecxUsbEndpointPurgeComplete\s*\(\s*endpoint\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*FALSE\s*\)' -or -not $endpointPurgeMatch.Success -or $endpointPurgeMatch.Groups['body'].Value -notmatch - 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->StartAnnounced\s*,\s*FALSE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*WdfIoQueuePurge\s*\(\s*endpointContext->Queue\s*,\s*ViiperEvtEndpointQueuePurged\s*,\s*Endpoint\s*\)' -or + 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*TRUE\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->StartAnnounced\s*,\s*FALSE\s*\)[\s\S]*InterlockedIncrement\s*\(\s*&endpointContext->PurgeOutstanding\s*\)[\s\S]*InterlockedCompareExchange\s*\(\s*&endpointContext->PurgeWorkerActive\s*,\s*TRUE\s*,\s*FALSE\s*\)[\s\S]*ViiperPurgeEndpointOperations[\s\S]*if\s*\(\s*enqueueWorkItem\s*\)[\s\S]*WdfWorkItemEnqueue\s*\(\s*endpointContext->PurgeWorkItem\s*\)' -or + $endpointPurgeMatch.Groups['body'].Value -match + 'ViiperInvalidateEndpointInputReport' -or + $deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointPurgeWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;' -or + $deviceSource -notmatch + 'WDF_WORKITEM_CONFIG_INIT\s*\(\s*&workItemConfig\s*,\s*ViiperEvtEndpointResetWorkItem\s*\)\s*;\s*workItemConfig\.AutomaticSerialization\s*=\s*WdfFalse\s*;' -or -not $endpointStartMatch.Success -or $endpointStartMatch.Groups['body'].Value -notmatch - 'ViiperActivateEndpoint\s*\(\s*Endpoint\s*,\s*TRUE\s*\)' -or + 'ViiperActivateEndpoint\s*\(\s*Endpoint\s*\)' -or -not $endpointActivateMatch.Success -or $endpointActivateMatch.Groups['body'].Value -notmatch - 'InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*StartAnnounced[\s\S]*if\s*\(\s*StartQueue\s*\)[\s\S]*WdfIoQueueStart\s*\(\s*endpointContext->Queue\s*\)[\s\S]*ViiperQueueEndpointLifecycleEvent') { - throw 'Endpoint PURGE/START must use the UdeCx-required asynchronous WDF queue lifecycle and keep admission closed until START.' + 'deviceContext->InD0[\s\S]*deviceContext->D0ExitPending[\s\S]*endpointContext->PurgeOutstanding[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Purging\s*,\s*FALSE\s*\)[\s\S]*StartAnnounced[\s\S]*ViiperQueueEndpointLifecycleEvent' -or + $endpointActivateMatch.Groups['body'].Value -match 'PurgeWorkerActive') { + throw 'Endpoint PURGE/START must count every callback, preserve worker ownership across synchronous completion, and reopen only after the final decrement.' } +$endpointResetMatch = [regex]::Match( + $deviceSource, + '(?ms)^VOID\s+ViiperEvtEndpointReset\s*\([^)]*\)\s*\{(?.*?)^\}') $resetWorkItemMatch = [regex]::Match( $deviceSource, '(?ms)^VOID\s+ViiperEvtEndpointResetWorkItem\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $resetWorkItemMatch.Success -or +if (-not $endpointResetMatch.Success -or + $endpointResetMatch.Groups['body'].Value -match + 'ViiperInvalidate(?:Endpoint|Device)InputReports?|ViiperAcquireDeviceLock|WdfWaitLockAcquire|KeWaitForSingleObject|KeDelayExecutionThread' -or + -not $resetWorkItemMatch.Success -or $resetWorkItemMatch.Groups['body'].Value -notmatch - 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetCurrent\s*\)[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Resetting\s*,\s*FALSE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperQueueAcknowledgedEndpointLifecycleEvent') { - throw 'Endpoint reset publication must prove a live exact identity after DriverNoRequests/rundown and fail closed on removal.' + 'ViiperQuiesceResetByIdentity[\s\S]*if\s*\(\s*!resetCurrent\s*\)[\s\S]*WdfSpinLockAcquire\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*InterlockedExchange\s*\(\s*&endpointContext->Resetting\s*,\s*FALSE\s*\)[\s\S]*WdfSpinLockRelease\s*\(\s*controllerContext->BrokerLock\s*\)[\s\S]*WdfRequestComplete\s*\(\s*request\s*,\s*STATUS_DEVICE_NOT_READY\s*\)[\s\S]*ViiperInvalidateEndpointInputReport\s*\(\s*endpoint\s*\)[\s\S]*ViiperQueueAcknowledgedEndpointLifecycleEvent') { + throw 'Endpoint RESET must defer passive input invalidation, prove a live exact identity after rundown, and fail closed on removal.' } foreach ($forbiddenAssociatedQueueMutation in @( + 'WdfIoQueuePurge', 'WdfIoQueuePurgeSynchronously', + 'WdfIoQueueStart', 'WdfIoQueueStop', 'WdfIoQueueStopSynchronously', 'WdfIoQueueDrain', @@ -403,7 +558,7 @@ $endpointsConfigureMatch = [regex]::Match( '(?ms)^VOID\s+ViiperEvtEndpointsConfigure\s*\([^)]*\)\s*\{(?.*?)^\}') if (-not $endpointsConfigureMatch.Success -or $endpointsConfigureMatch.Groups['body'].Value -notmatch - 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*EndpointsToConfigureCount[\s\S]*ViiperActivateEndpoint\s*\([\s\S]*FALSE\s*\)[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or + 'case\s+UdecxEndpointsConfigureTypeDeviceConfigurationChange\s*:[\s\S]*EndpointsToConfigureCount[\s\S]*ViiperActivateEndpoint\s*\(\s*ConfigureParams->EndpointsToConfigure\[endpointIndex\]\s*\)\s*;[\s\S]*WdfRequestComplete\s*\(\s*Request\s*,\s*STATUS_SUCCESS\s*\)[\s\S]*return\s*;[\s\S]*case\s+UdecxEndpointsConfigureTypeInterfaceSettingChange\s*:' -or $endpointsConfigureMatch.Groups['body'].Value -match 'ViiperBeginAcknowledgedDeviceReset|ViiperUdeOperationDeviceReset') { throw 'Device configuration selection must announce selected dynamic endpoints before completing directly.' @@ -455,6 +610,10 @@ $controllerCleanupMatch = [regex]::Match( if (-not $controllerCleanupMatch.Success) { throw 'Could not locate ViiperEvtControllerCleanup for teardown validation.' } +if ($controllerCleanupMatch.Groups['body'].Value -notmatch + 'context->ActiveDevices[\s\S]*context->ReservedPorts[\s\S]*context->InputDeviceCount\s*==\s*0[\s\S]*for\s*\(\s*index\s*=\s*0\s*;[\s\S]*VIIPER_UDE_MAX_DEVICES[\s\S]*!context->PortReserved\[index\]') { + throw 'Controller cleanup must prove both logical-device and physical-port accounting reached zero.' +} $forbiddenCleanupCalls = @( 'WdfTimerStop', 'WdfIoQueuePurgeSynchronously', diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index bef25f87..40a8b0c6 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -5344,7 +5344,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "037546fe63e5507cadf58c7b151f096fa52a533ce4cda4397040bf0b748e347d") { + "7d769fa2edc36556a5d7f5c63d855625ada9bbc6236ea8cf73892b4b41499293") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } From 3ca7e07a13e99ec716240e491ab0d426457769d2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 20:01:25 -0500 Subject: [PATCH 232/240] Fix native broker symbol validation --- .github/workflows/native-ude.yml | 4 ++-- internal/transport/udecx/live_validation_contract_test.go | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-ude.yml b/.github/workflows/native-ude.yml index 9480b632..c8e82943 100644 --- a/.github/workflows/native-ude.yml +++ b/.github/workflows/native-ude.yml @@ -328,9 +328,9 @@ jobs: ) $nmMatches = @(& go tool nm $output 2>&1 | Select-String -Pattern $nmPatterns) $nmExitCode = $LASTEXITCODE - $nmText = $nmMatches -join [Environment]::NewLine + $nmLines = @($nmMatches | ForEach-Object { $_.Line }) if ($nmExitCode -ne 0 -or - @($nmPatterns | Where-Object { $nmText -notmatch $_ }).Count -ne 0) { + @($nmPatterns | Where-Object { @($nmLines -match $_).Count -eq 0 }).Count -ne 0) { throw 'Source-bound native broker is missing required Go/DWARF hot-path symbols.' } $brokerAscii = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($output)) diff --git a/internal/transport/udecx/live_validation_contract_test.go b/internal/transport/udecx/live_validation_contract_test.go index e359fdb0..d72f52ac 100644 --- a/internal/transport/udecx/live_validation_contract_test.go +++ b/internal/transport/udecx/live_validation_contract_test.go @@ -174,9 +174,14 @@ func TestNativeWorkflowPublishesSourceBoundLiveProbes(t *testing.T) { "'ViiperUdeInputProbe.exe' = (Get-FileHash", "ViiperUdeLiveProbes.manifest.json", "ViiperUdeLiveProbes-windows-amd64-${{ github.sha }}", + "$nmLines = @($nmMatches | ForEach-Object { $_.Line })", + "@($nmPatterns | Where-Object { @($nmLines -match $_).Count -eq 0 }).Count", } { if !strings.Contains(contract, required) { t.Fatalf("native workflow source-bound probes omitted %q", required) } } + if strings.Contains(contract, "$nmText = $nmMatches -join") { + t.Fatal("native workflow joins distinct symbol lines before applying end-anchored checks") + } } From abefabb6be2fa82427afeb1695895323c9f41ffe Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 14 Aug 2026 20:57:03 -0500 Subject: [PATCH 233/240] Fix native purge teardown and diagnostics --- internal/server/usb/native.go | 4 +- .../usb/native_live_teardown_gate_test.go | 471 ++++++++++++++++++ .../native_live_teardown_gate_windows_test.go | 118 +++++ internal/server/usb/server.go | 3 +- internal/transport/udecx/client_windows.go | 2 +- internal/transport/udecx/descriptors.go | 4 +- .../udecx/driver_dispatch_contract_test.go | 30 +- ...river_endpoint_quiescence_contract_test.go | 35 +- .../udecx/driver_lifecycle_contract_test.go | 7 + internal/transport/udecx/host.go | 2 +- internal/transport/udecx/protocol.go | 14 +- .../transport/udecx/protocol_contract_test.go | 2 + internal/transport/udecx/protocol_test.go | 14 +- native/udecx/driver/Broker.c | 19 +- native/udecx/driver/Device.c | 26 +- native/udecx/driver/Ioctl.c | 2 + native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 16 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 2 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 4 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 11 +- .../tools/Test-ViiperUdeReleaseBundle.ps1 | 4 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 4 +- .../Test-ViiperUdeTargetCompatibility.ps1 | 5 +- native/udecx/tools/ViiperUdeCtl.cpp | 36 +- 26 files changed, 765 insertions(+), 76 deletions(-) create mode 100644 internal/server/usb/native_live_teardown_gate_test.go create mode 100644 internal/server/usb/native_live_teardown_gate_windows_test.go diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index 314ff321..ae88e9bd 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -218,8 +218,8 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o p.invalidateInterruptInput(dev, 0) p.clearDeviceLanes(identity) case udecx.OperationSetInterface: - // UdeCx is documented by usbip-win2 0.9.7.8 to return incorrect - // interface/alternate values for some composite devices. Treat this as + // Some UdeCx stacks return incorrect interface/alternate values for + // composite devices. Treat this callback data as // a hint only. Interfaces with endpoint-bearing alternate settings are // driven by the exact endpoint descriptors carried by start/purge and // transfer operations instead. diff --git a/internal/server/usb/native_live_teardown_gate_test.go b/internal/server/usb/native_live_teardown_gate_test.go new file mode 100644 index 00000000..341240ad --- /dev/null +++ b/internal/server/usb/native_live_teardown_gate_test.go @@ -0,0 +1,471 @@ +package usb_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/Alia5/VIIPER/internal/transport/udecx" +) + +type nativeLiveTeardownDeviceKey struct { + deviceID uint64 + generation uint32 + deviceObject uint64 +} + +type nativeLiveTeardownEndpointKey struct { + nativeLiveTeardownDeviceKey + endpointObject uint64 + endpointAddress uint8 +} + +type nativeLivePurgeProgress struct { + key nativeLiveTeardownEndpointKey + beginSequence uint64 + quiescentSequence uint64 + drainEndSequence uint64 + completeEndSequence uint64 +} + +type nativeLiveEndpointHistory struct { + beginSequences []uint64 + cycles []*nativeLivePurgeProgress + building *nativeLivePurgeProgress + prefixFragments int +} + +type nativeLiveTeardownAudit struct { + complete bool + purgeCount int + diagnostic string +} + +func nativeLiveTraceEventName(event uint16) string { + switch event { + case udecx.TraceEndpointPurgeBegin: + return "endpoint-purge-begin" + case udecx.TraceEndpointDriverQuiescent: + return "endpoint-driver-quiescent" + case udecx.TraceEndpointDrainEnd: + return "endpoint-drain-end" + case udecx.TraceEndpointPurgeCompleteEnd: + return "endpoint-purge-complete-end" + case udecx.TraceEndpointCleanupEnd: + return "endpoint-cleanup-end" + case udecx.TraceDeviceCleanupEnd: + return "device-cleanup-end" + default: + return fmt.Sprintf("event-%d", event) + } +} + +func nativeLiveTeardownDevice(record udecx.LifecycleTraceRecord) nativeLiveTeardownDeviceKey { + return nativeLiveTeardownDeviceKey{ + deviceID: record.DeviceID, + generation: record.Generation, + deviceObject: record.DeviceObject, + } +} + +func nativeLiveTeardownEndpoint(record udecx.LifecycleTraceRecord) nativeLiveTeardownEndpointKey { + return nativeLiveTeardownEndpointKey{ + nativeLiveTeardownDeviceKey: nativeLiveTeardownDevice(record), + endpointObject: record.EndpointObject, + endpointAddress: record.EndpointAddress, + } +} + +func nativeLiveEndpointDiagnostic( + prefix string, + progress *nativeLivePurgeProgress, + last udecx.LifecycleTraceRecord, +) string { + return fmt.Sprintf( + "%s: device=%#x generation=%d device-object=%#x endpoint=%#02x endpoint-object=%#x purge-sequence=%d last-sequence=%d last-event=%s line=%d queue-state=%#x active=%d", + prefix, + progress.key.deviceID, + progress.key.generation, + progress.key.deviceObject, + progress.key.endpointAddress, + progress.key.endpointObject, + progress.beginSequence, + last.PublishedSequence, + nativeLiveTraceEventName(last.Event), + last.Line, + last.QueueState, + last.ActiveOperations, + ) +} + +func auditNativeLiveTeardown( + trace udecx.LifecycleTrace, + stats udecx.Stats, +) (nativeLiveTeardownAudit, error) { + if trace.LatestSequence == 0 { + return nativeLiveTeardownAudit{diagnostic: "no lifecycle records are published yet"}, nil + } + wantRecords := trace.LatestSequence + if wantRecords > udecx.LifecycleTraceCapacity { + wantRecords = udecx.LifecycleTraceCapacity + } + if uint64(len(trace.Records)) != wantRecords { + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "lifecycle suffix snapshot is incomplete: latest-sequence=%d records=%d want=%d", + trace.LatestSequence, len(trace.Records), wantRecords)}, nil + } + firstSequence := trace.LatestSequence - uint64(len(trace.Records)) + 1 + for index, record := range trace.Records { + expected := firstSequence + uint64(index) + if record.PublishedSequence != expected { + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "lifecycle snapshot has a sequence gap: index=%d sequence=%d want=%d latest=%d", + index, record.PublishedSequence, expected, trace.LatestSequence)}, nil + } + } + truncation := "" + if firstSequence > 1 { + truncation = fmt.Sprintf( + "retained lifecycle suffix starts at sequence %d (latest=%d capacity=%d); %d prefix records are unavailable", + firstSequence, trace.LatestSequence, udecx.LifecycleTraceCapacity, firstSequence-1) + } + withTruncation := func(diagnostic string) string { + if truncation == "" { + return diagnostic + } + return truncation + "; " + diagnostic + } + + histories := make(map[nativeLiveTeardownEndpointKey]*nativeLiveEndpointHistory) + lastEndpoint := make(map[nativeLiveTeardownEndpointKey]udecx.LifecycleTraceRecord) + lastDevice := make(map[nativeLiveTeardownDeviceKey]udecx.LifecycleTraceRecord) + endpointCleanup := make(map[nativeLiveTeardownEndpointKey]uint64) + deviceCleanup := make(map[nativeLiveTeardownDeviceKey]uint64) + historyFor := func(key nativeLiveTeardownEndpointKey) *nativeLiveEndpointHistory { + history := histories[key] + if history == nil { + history = &nativeLiveEndpointHistory{} + histories[key] = history + } + return history + } + + for _, record := range trace.Records { + deviceKey := nativeLiveTeardownDevice(record) + lastDevice[deviceKey] = record + if record.EndpointObject != 0 { + lastEndpoint[nativeLiveTeardownEndpoint(record)] = record + } + + switch record.Event { + case udecx.TraceEndpointPurgeBegin: + key := nativeLiveTeardownEndpoint(record) + historyFor(key).beginSequences = append( + historyFor(key).beginSequences, record.PublishedSequence) + case udecx.TraceEndpointDriverQuiescent: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building != nil { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "driver-quiescent sequence %d overlaps an unfinished retained cycle for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + if record.Status != 0 || record.ActiveOperations != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "driver-quiescent sequence %d is not terminal: status=%#x active=%d", + record.PublishedSequence, uint32(record.Status), record.ActiveOperations) + } + history.building = &nativeLivePurgeProgress{ + key: key, + quiescentSequence: record.PublishedSequence, + } + case udecx.TraceEndpointDrainEnd: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building == nil { + if firstSequence > 1 && len(history.cycles) == 0 { + history.prefixFragments++ + continue + } + return nativeLiveTeardownAudit{}, fmt.Errorf( + "drain-end sequence %d has no quiescent purge for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + history.building.drainEndSequence = record.PublishedSequence + case udecx.TraceEndpointPurgeCompleteEnd: + key := nativeLiveTeardownEndpoint(record) + history := historyFor(key) + if history.building == nil || history.building.drainEndSequence == 0 { + if firstSequence > 1 && len(history.cycles) == 0 { + history.prefixFragments++ + history.building = nil + continue + } + return nativeLiveTeardownAudit{}, fmt.Errorf( + "purge-complete sequence %d has no drained purge for device=%#x generation=%d endpoint=%#02x object=%#x", + record.PublishedSequence, key.deviceID, key.generation, + key.endpointAddress, key.endpointObject) + } + history.building.completeEndSequence = record.PublishedSequence + history.cycles = append(history.cycles, history.building) + history.building = nil + case udecx.TraceEndpointCleanupEnd: + endpointCleanup[nativeLiveTeardownEndpoint(record)] = record.PublishedSequence + case udecx.TraceDeviceCleanupEnd: + deviceCleanup[deviceKey] = record.PublishedSequence + } + } + + progresses := make([]*nativeLivePurgeProgress, 0) + for key, history := range histories { + if history.building != nil { + last := lastEndpoint[key] + phase := "drain-end" + if history.building.drainEndSequence != 0 { + phase = "purge-complete-end" + } + return nativeLiveTeardownAudit{diagnostic: withTruncation(fmt.Sprintf( + "retained endpoint cycle has not reached %s: device=%#x generation=%d endpoint=%#02x object=%#x quiescent-sequence=%d last-sequence=%d last-event=%s line=%d", + phase, key.deviceID, key.generation, key.endpointAddress, key.endpointObject, + history.building.quiescentSequence, last.PublishedSequence, + nativeLiveTraceEventName(last.Event), last.Line))}, nil + } + beginCount := len(history.beginSequences) + cycleCount := len(history.cycles) + pairCount := beginCount + if cycleCount < pairCount { + pairCount = cycleCount + } + if beginCount > cycleCount { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "%d retained purge begin(s) for device=%#x generation=%d endpoint=%#02x object=%#x have only %d complete cycles; at least one has not reached driver-quiescent; begin-sequences=%v", + beginCount, key.deviceID, key.generation, key.endpointAddress, + key.endpointObject, cycleCount, history.beginSequences))}, nil + } + for index := 0; index < pairCount; index++ { + beginSequence := history.beginSequences[beginCount-pairCount+index] + progress := history.cycles[cycleCount-pairCount+index] + if beginSequence >= progress.quiescentSequence { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "retained purge begin sequence %d has no later complete cycle for device=%#x generation=%d endpoint=%#02x object=%#x", + beginSequence, key.deviceID, key.generation, key.endpointAddress, + key.endpointObject))}, nil + } + progress.beginSequence = beginSequence + progresses = append(progresses, progress) + } + } + + if len(progresses) == 0 && firstSequence == 1 { + last := trace.Records[len(trace.Records)-1] + return nativeLiveTeardownAudit{diagnostic: fmt.Sprintf( + "no endpoint purge was observed; latest sequence=%d event=%s line=%d", + last.PublishedSequence, nativeLiveTraceEventName(last.Event), last.Line)}, nil + } + + for _, progress := range progresses { + last := lastEndpoint[progress.key] + if sequence := endpointCleanup[progress.key]; sequence <= progress.completeEndSequence { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation( + nativeLiveEndpointDiagnostic("purged endpoint has not reached endpoint-cleanup-end", progress, last))}, nil + } + if sequence := deviceCleanup[progress.key.nativeLiveTeardownDeviceKey]; sequence <= progress.completeEndSequence { + last = lastDevice[progress.key.nativeLiveTeardownDeviceKey] + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation( + nativeLiveEndpointDiagnostic("purged device has not reached device-cleanup-end", progress, last))}, nil + } + } + + if stats.ActiveDevices != 0 || stats.PendingOperations != 0 || stats.ReservedPorts != 0 { + return nativeLiveTeardownAudit{purgeCount: len(progresses), diagnostic: withTruncation(fmt.Sprintf( + "kernel teardown counters are not clean: ActiveDevices=%d PendingOperations=%d ReservedPorts=%d", + stats.ActiveDevices, stats.PendingOperations, stats.ReservedPorts))}, nil + } + + summary := truncation + if summary != "" { + prefixFragments := 0 + for _, history := range histories { + prefixFragments += history.prefixFragments + } + summary += fmt.Sprintf( + "; audited %d retained purge begin(s), ignored %d prefix-only phase fragment(s); zeroed kernel counters prove whole-run cleanup", + len(progresses), prefixFragments) + } + return nativeLiveTeardownAudit{complete: true, purgeCount: len(progresses), diagnostic: summary}, nil +} + +func nativeLiveTrace(events ...uint16) udecx.LifecycleTrace { + const ( + deviceID = uint64(0x5649495000000001) + deviceObject = uint64(0xffff800000001000) + endpointObject = uint64(0xffff800000002000) + ) + records := make([]udecx.LifecycleTraceRecord, 0, len(events)) + for index, event := range events { + record := udecx.LifecycleTraceRecord{ + PublishedSequence: uint64(index + 1), + DeviceID: deviceID, + DeviceObject: deviceObject, + Generation: 1, + Event: event, + Line: uint32(100 + index), + } + if event >= udecx.TraceEndpointPurgeBegin && event <= udecx.TraceEndpointCleanupEnd { + record.EndpointObject = endpointObject + record.EndpointAddress = 0x81 + } + if event == udecx.TraceEndpointPurgeBegin || event == udecx.TraceEndpointDriverQuiescent { + record.QueueState = 0x0f + } + records = append(records, record) + } + return udecx.LifecycleTrace{LatestSequence: uint64(len(records)), Records: records} +} + +func nativeLiveRolledTrace(events ...uint16) udecx.LifecycleTrace { + latestSequence := uint64(udecx.LifecycleTraceCapacity + 10) + firstSequence := latestSequence - udecx.LifecycleTraceCapacity + 1 + prefixRecords := udecx.LifecycleTraceCapacity - len(events) + records := make([]udecx.LifecycleTraceRecord, 0, udecx.LifecycleTraceCapacity) + for index := 0; index < prefixRecords; index++ { + records = append(records, udecx.LifecycleTraceRecord{ + PublishedSequence: firstSequence + uint64(index), + Event: udecx.TraceControllerShutdownBegin, + }) + } + for _, record := range nativeLiveTrace(events...).Records { + record.PublishedSequence = firstSequence + uint64(len(records)) + records = append(records, record) + } + return udecx.LifecycleTrace{LatestSequence: latestSequence, Records: records} +} + +func TestNativeLiveTeardownAuditAcceptsReadyQueuePurge(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !audit.complete || audit.purgeCount != 1 { + t.Fatalf("ready 0x0f purge did not pass teardown audit: %+v", audit) + } +} + +func TestNativeLiveTeardownAuditTracksRepeatedPurgesFIFO(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !audit.complete || audit.purgeCount != 2 { + t.Fatalf("repeated purges were not correlated one-for-one: %+v", audit) + } +} + +func TestNativeLiveTeardownAuditReportsEveryIncompletePhase(t *testing.T) { + tests := []struct { + name string + events []uint16 + diagnostic string + }{ + {name: "quiescent", events: []uint16{udecx.TraceEndpointPurgeBegin}, diagnostic: "driver-quiescent"}, + {name: "drain", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + }, diagnostic: "drain-end"}, + {name: "complete", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + }, diagnostic: "purge-complete-end"}, + {name: "endpoint cleanup", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, udecx.TraceEndpointPurgeCompleteEnd, + }, diagnostic: "endpoint-cleanup-end"}, + {name: "device cleanup", events: []uint16{ + udecx.TraceEndpointPurgeBegin, udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + }, diagnostic: "device-cleanup-end"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + audit, err := auditNativeLiveTeardown(nativeLiveTrace(test.events...), udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if audit.complete || !strings.Contains(audit.diagnostic, test.diagnostic) { + t.Fatalf("incomplete phase diagnostic=%q want %q", audit.diagnostic, test.diagnostic) + } + }) + } +} + +func TestNativeLiveTeardownAuditToleratesRolloverAndFailsReservations(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + rolled := nativeLiveRolledTrace( + // This completion belongs to a purge whose begin and drain were in the + // overwritten prefix. The complete retained cycle after it remains + // independently auditable. + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointDriverQuiescent, + udecx.TraceEndpointDrainEnd, + udecx.TraceEndpointPurgeCompleteEnd, + udecx.TraceEndpointCleanupEnd, + udecx.TraceDeviceCleanupEnd, + ) + rolledAudit, err := auditNativeLiveTeardown(rolled, udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if !rolledAudit.complete || rolledAudit.purgeCount != 1 || + !strings.Contains(rolledAudit.diagnostic, "retained lifecycle suffix") { + t.Fatalf("complete retained suffix did not survive rollover: %+v", rolledAudit) + } + + stalledAudit, err := auditNativeLiveTeardown( + nativeLiveRolledTrace(udecx.TraceEndpointPurgeBegin), udecx.Stats{}) + if err != nil { + t.Fatal(err) + } + if stalledAudit.complete || + !strings.Contains(stalledAudit.diagnostic, "driver-quiescent") || + !strings.Contains(stalledAudit.diagnostic, "retained lifecycle suffix") { + t.Fatalf("retained stalled purge was hidden by rollover: %+v", stalledAudit) + } + + audit, err := auditNativeLiveTeardown(trace, udecx.Stats{ReservedPorts: 1}) + if err != nil { + t.Fatal(err) + } + if audit.complete || !strings.Contains(audit.diagnostic, "ReservedPorts=1") { + t.Fatalf("reserved port leak did not block teardown: %+v", audit) + } +} diff --git a/internal/server/usb/native_live_teardown_gate_windows_test.go b/internal/server/usb/native_live_teardown_gate_windows_test.go new file mode 100644 index 00000000..17b313cb --- /dev/null +++ b/internal/server/usb/native_live_teardown_gate_windows_test.go @@ -0,0 +1,118 @@ +//go:build windows + +package usb_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/transport/udecx" +) + +const nativeLiveTeardownGateTimeout = 15 * time.Second + +func openNativeLiveTeardownClient(ctx context.Context) (*udecx.Client, error) { + var lastTemporary error + for { + client, err := udecx.Open(ctx) + if err == nil { + return client, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("acquire clean controller after live teardown: %w (last transient error: %v)", + ctxErr, lastTemporary) + } + var temporary interface{ Temporary() bool } + if !errors.As(err, &temporary) || !temporary.Temporary() { + return nil, err + } + lastTemporary = err + timer := time.NewTimer(50 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("acquire clean controller after live teardown: %w (last transient error: %v)", + ctx.Err(), lastTemporary) + case <-timer.C: + } + } +} + +func waitForNativeLiveTeardown(ctx context.Context, client *udecx.Client) error { + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + lastDiagnostic := "no lifecycle snapshot queried" + var lastStats udecx.Stats + timedOut := func() error { + return fmt.Errorf("teardown did not complete within %s: %s; stats=%+v", + nativeLiveTeardownGateTimeout, lastDiagnostic, lastStats) + } + for { + if ctx.Err() != nil { + return timedOut() + } + trace, err := client.QueryLifecycleTrace(ctx) + if err != nil { + if ctx.Err() != nil { + return timedOut() + } + return fmt.Errorf("query lifecycle trace: %w", err) + } + stats, err := client.QueryStats(ctx) + if err != nil { + if ctx.Err() != nil { + return timedOut() + } + return fmt.Errorf("query teardown stats: %w", err) + } + lastStats = stats + audit, err := auditNativeLiveTeardown(trace, stats) + if err != nil { + return err + } + if audit.complete { + if audit.diagnostic != "" { + fmt.Fprintf(os.Stderr, "native live teardown gate notice: %s\n", audit.diagnostic) + } + return nil + } + lastDiagnostic = audit.diagnostic + + select { + case <-ctx.Done(): + return timedOut() + case <-ticker.C: + } + } +} + +func runNativeLiveTeardownGate(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + client, err := openNativeLiveTeardownClient(ctx) + if err != nil { + return err + } + auditErr := waitForNativeLiveTeardown(ctx, client) + closeErr := client.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close teardown-audit controller: %w", closeErr) + } + return errors.Join(auditErr, closeErr) +} + +func TestMain(m *testing.M) { + code := m.Run() + if os.Getenv(liveNativeTestEnvironment) == "1" && + os.Getenv(liveNativeCrashChild) != "1" { + if err := runNativeLiveTeardownGate(nativeLiveTeardownGateTimeout); err != nil { + fmt.Fprintf(os.Stderr, "native live teardown gate failed: %v\n", err) + code = 1 + } + } + os.Exit(code) +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index a84ab72b..eab6e446 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -1402,7 +1402,8 @@ func (s *Server) buildIsoInResponse( // USB/IP removes the padding between ISO packets on the wire. The client // restores each packet at its descriptor offset after receiving the compact // payload. Sending the original offset gaps here makes actual_length differ - // from the sum of packet actual lengths, so usbip-win2 discards capture data. + // from the sum of packet actual lengths, so the receiving stack rejects the + // capture data. respData := make([]byte, 0, maximumLen) nextServiceSlot := serviceStart for i, packet := range submitted { diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 76bef049..6c846129 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -771,7 +771,7 @@ func (c *Client) ioctl(ctx context.Context, code uint32, input, output []byte) ( if err == nil && c.skipCompletionPortOnSuccess { // FILE_SKIP_COMPLETION_PORT_ON_SUCCESS guarantees that no completion // packet exists for this exact immediate-success operation. Returning - // inline mirrors ViGEmBus's report submission path and avoids waking the + // inline preserves the direct report-submission path and avoids waking the // completion pump merely to hand the same result back to this goroutine. return immediate, nil } diff --git a/internal/transport/udecx/descriptors.go b/internal/transport/udecx/descriptors.go index 2c487420..b52995df 100644 --- a/internal/transport/udecx/descriptors.go +++ b/internal/transport/udecx/descriptors.go @@ -11,8 +11,8 @@ const defaultDevicePendingOperations = 512 // EndpointDescriptorForNativeUdeCx translates the scheduling fields which // USBHUB3 interprets using high-speed rules even when UdeCx is told that the -// emulated device is full speed. usbip-win2 applies the same translation in -// its UDE transport: without it, Windows rejects full-speed audio ISO +// emulated device is full speed. Without this presentation translation, +// Windows rejects full-speed audio ISO // bInterval=1 before an URB ever reaches the client driver. // // This is a UdeCx presentation adapter only. The controller's logical USB diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index f19007e0..e9bf6164 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -412,9 +412,11 @@ func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { requireContractOrder(t, submit, "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 &&", "return STATUS_DEVICE_BUSY;", - "InterlockedExchange64(&endpointContext->LastInputSequence", "RtlCopyMemory(endpointContext->InputReport", "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) {", + "endpointContext->InputTransitionSequences[tail] = input->Sequence;", + "InterlockedExchange64(&endpointContext->LastInputSequence", + "InterlockedExchange(&endpointContext->InputReportValid, TRUE);", "InterlockedIncrement(&endpointContext->InputTransitionCount);", "WdfIoQueueRetrieveNextRequest(endpointContext->Queue") ready := normalizedContract(nativeCFunction(t, device, "ViiperEvtFastInputQueueReady")) @@ -434,6 +436,23 @@ func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { "InterlockedDecrement(&endpointContext->InputTransitionCount)") } +func TestNativeCompletionValidatesImmutableIdentityBeforeClaim(t *testing.T) { + broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") + complete := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteOperation")) + requireContractOrder(t, complete, + "controllerContext->PendingSlots[slot].Token == completion->Token", + "controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight", + "controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId", + "controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation", + "controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting;", + "WdfObjectReference(urbRequest);", + "identityMismatch = TRUE;", + "WdfSpinLockRelease(controllerContext->BrokerLock);", + "if (identityMismatch)", + "return STATUS_INVALID_PARAMETER;", + "WdfRequestUnmarkCancelable(urbRequest);") +} + func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") @@ -661,9 +680,10 @@ func TestNativeFastInputUsesSharedIndexedLifetimeAdmission(t *testing.T) { "WdfWaitLockRelease(endpointContext->InputLock);", "ViiperCompleteRetrievedInputUrb( endpoint, urbRequest, status, directInputBytes, directInputSequence);") requireContractOrder(t, submit, - "endpointContext->LastInputSequence", "RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength);", "endpointContext->InputReportLength = input->PayloadLength;", + "endpointContext->InputTransitionSequences[tail] = input->Sequence;", + "InterlockedExchange64(&endpointContext->LastInputSequence", "InterlockedExchange(&endpointContext->InputReportValid, TRUE);", "InterlockedIncrement64(&controllerContext->InputReportsSubmitted);", "WdfIoQueueRetrieveNextRequest(endpointContext->Queue, &urbRequest);", @@ -806,11 +826,13 @@ func TestNativeEndpointRundownPrecedesCleanupAndDPCMayRunImmediately(t *testing. "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", "endpointContext->PurgeOutstanding", "endpointContext->Purging", - "!WDF_IO_QUEUE_READY(queueState)", "WdfIoQueueDriverNoRequests", "driverRequests == 0", "endpointContext->ActiveOperations") - for _, forbidden := range []string{"WDF_IO_QUEUE_IDLE", "queuedRequests == 0"} { + for _, forbidden := range []string{ + "WDF_IO_QUEUE_READY", "WdfIoQueueNoRequests", "WDF_IO_QUEUE_IDLE", + "WDF_IO_QUEUE_PURGED", "queuedRequests == 0", + } { if strings.Contains(purgeQuiescence, forbidden) { t.Fatalf("endpoint PURGE incorrectly waits on UdeCx-owned queue state %q", forbidden) } diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index af9eb3ec..f8982c2a 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -54,7 +54,6 @@ func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { "WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests);", "endpointContext->PurgeOutstanding", "endpointContext->Purging", - "!WDF_IO_QUEUE_READY(queueState)", "WdfIoQueueDriverNoRequests", "driverRequests == 0", "endpointContext->ActiveOperations", @@ -63,6 +62,8 @@ func TestNativeEndpointQuiescenceUsesReadOnlyUdeCxQueueState(t *testing.T) { "return;", "KeDelayExecutionThread(") for _, forbidden := range []string{ + "WDF_IO_QUEUE_READY", + "WdfIoQueueNoRequests", "WDF_IO_QUEUE_IDLE", "WDF_IO_QUEUE_PURGED", "queuedRequests == 0", @@ -234,8 +235,8 @@ func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing beginPurge := func(state *purgeState) { state.purging = true - // The class extension closes dispatch before invoking the callback. - state.queueReady = false + // The callback closes upstream delivery. The associated WDF queue may + // retain READY bookkeeping until PurgeComplete acknowledges it. state.callbacks++ state.outstanding++ if !state.workerActive { @@ -256,7 +257,7 @@ func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing quiescent := func(state *purgeState) bool { // queuedHostPolls is intentionally excluded: those requests remain owned // by UdeCx while delivery is stopped. - return state.outstanding > 0 && state.purging && !state.queueReady && + return state.outstanding > 0 && state.purging && state.driverNoRequest && state.driverRequests == 0 && state.activeOperations == 0 } @@ -276,7 +277,7 @@ func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing return true } - state := purgeState{driverNoRequest: true, queuedHostPolls: 7} + state := purgeState{queueReady: true, driverNoRequest: true, queuedHostPolls: 7} beginPurge(&state) beginPurge(&state) if state.outstanding != 2 || state.enqueues != 1 || !state.workerActive { @@ -326,10 +327,11 @@ func TestNativeEndpointPurgeWorkerCountsRepeatedAndReentrantCallbacks(t *testing state purgeState want bool }{ - {name: "stopped with queued host polls", state: purgeState{ + {name: "ready with queued class-owned host polls", state: purgeState{ + purging: true, queueReady: true, driverNoRequest: true, + outstanding: 1, queuedHostPolls: 99}, want: true}, + {name: "non-ready queue is also observational", state: purgeState{ purging: true, driverNoRequest: true, outstanding: 1, queuedHostPolls: 99}, want: true}, - {name: "ready queue", state: purgeState{ - purging: true, queueReady: true, driverNoRequest: true, outstanding: 1}}, {name: "framework callback delivered", state: purgeState{ purging: true, driverNoRequest: false, outstanding: 1}}, {name: "driver request held", state: purgeState{ @@ -752,6 +754,7 @@ func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *te func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { type endpoint struct { open bool + purgeCallback bool queueAccepting bool queueDispatching bool queued int @@ -764,7 +767,8 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { deliverByWDF := func(state *endpoint) bool { // The asynchronous reset request is the class-extension fence: UdeCx // cannot deliver a successor transfer until the client completes it. - if state.resetOutstanding || !state.queueDispatching || state.queued == 0 { + if state.purgeCallback || state.resetOutstanding || + !state.queueDispatching || state.queued == 0 { return false } state.queued-- @@ -792,14 +796,13 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { } udeCxBeginPurge := func(state *endpoint) { state.open = false - // UdeCx owns this transition. A stopped+idle queue may still accept - // and retain host requests (0x0d), while dispatch remains closed until - // START. VIIPER must not consume or wait on those queued requests. - state.queueDispatching = false + state.purgeCallback = true + // UdeCx owns this transition. The visible queue may retain READY state + // (0x0f) until PurgeComplete, but the callback is the upstream boundary: + // no successor transfer may be delivered through this purge instance. } queuePurgeComplete := func(state *endpoint) bool { - queueReady := state.queueAccepting && state.queueDispatching - return !queueReady && state.driverNoRequests && + return state.purgeCallback && state.driverNoRequests && state.driverOwned == 0 && state.active == 0 && !state.open } closeForShutdown := func(state *endpoint) { @@ -842,7 +845,7 @@ func TestNativeDeliveredBeforeRundownInterleavings(t *testing.T) { } runTerminalDPC(&purge) if !queuePurgeComplete(&purge) || purge.terminalDPCs != 1 || - !purge.queueAccepting || purge.queueDispatching || purge.queued != 1 { + !purge.queueAccepting || !purge.queueDispatching || purge.queued != 1 { t.Fatalf("purge consumed queued host polls or failed driver-rundown proof: %+v", purge) } diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index e2c1fc06..51a93dfa 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -129,6 +129,7 @@ func TestKernelOwnerCleanupJoinsFiniteMutationRundown(t *testing.T) { func TestKernelDelayedCleanupReservesPhysicalPortAndCannotRevokeSuccessor(t *testing.T) { device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") for _, required := range []string{ "ULONGLONG PortReservationEpochs[VIIPER_UDE_MAX_DEVICES];", "BOOLEAN PortReserved[VIIPER_UDE_MAX_DEVICES];", @@ -167,6 +168,12 @@ func TestKernelDelayedCleanupReservesPhysicalPortAndCannotRevokeSuccessor(t *tes "context->InputDeviceCount == 0", "for (index = 0; index < VIIPER_UDE_MAX_DEVICES; ++index)", "NT_ASSERT(!context->PortReserved[index]);") + queryStats := normalizedContract(nativeCFunction(t, ioctl, "ViiperHandleQueryStats")) + requireContractOrder(t, queryStats, + "RtlZeroMemory(output, sizeof(*output));", + "output->ReservedPorts =", + "InterlockedCompareExchange(&context->ReservedPorts, 0, 0);", + "WdfRequestSetInformation(Request, sizeof(*output));") remove := normalizedContract(nativeCFunction(t, device, "ViiperBeginRemoveDevice")) requireContractOrder(t, remove, diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 46ee7451..36e2c39a 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -497,7 +497,7 @@ func lifecycleTraceEventName(event uint16) string { "plug-in-begin", "plug-in-returned", "remove-claimed", "management-abort-begin", "management-abort-end", "plug-out-begin", "plug-out-returned", "endpoint-purge-begin", "endpoint-operations-purged", - "endpoint-queue-purge-requested", "endpoint-queue-purged", + "endpoint-queue-purge-requested", "endpoint-driver-quiescent", "endpoint-drain-begin", "endpoint-drain-end", "endpoint-purge-complete-begin", "endpoint-purge-complete-end", "endpoint-cleanup-begin", "endpoint-cleanup-end", "device-cleanup-begin", diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index d8aed4d1..dd480135 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -16,12 +16,12 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 11 + ABIMinor uint16 = 12 // DriverPackageVersion is the native driver package version built and // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.34" + DriverPackageVersion = "0.1.0.35" BuildIdentitySize = sha256.Size HeaderSize = 16 @@ -34,7 +34,7 @@ const ( OperationSize = 104 CompletionSize = 72 InputReportSize = 48 - StatsSize = 144 + StatsSize = 152 LifecycleTraceRecordSize = 80 LifecycleTraceSize = 41008 LifecycleTraceCapacity = 512 @@ -107,7 +107,7 @@ const ( TraceEndpointPurgeBegin TraceEndpointOperationsPurged TraceEndpointQueuePurgeRequested - TraceEndpointQueuePurged + TraceEndpointDriverQuiescent TraceEndpointDrainBegin TraceEndpointDrainEnd TraceEndpointPurgeCompleteBegin @@ -600,6 +600,7 @@ type Stats struct { CleanupRetries uint32 InputReportsSubmitted uint64 InputReportsCompleted uint64 + ReservedPorts uint32 } func ParseStats(src []byte) (Stats, error) { @@ -610,6 +611,10 @@ func ParseStats(src []byte) (Stats, error) { if h.Size != StatsSize { return Stats{}, ErrInvalidSize } + reservedPorts := binary.LittleEndian.Uint32(src[144:148]) + if reservedPorts > MaxDevices || binary.LittleEndian.Uint32(src[148:152]) != 0 { + return Stats{}, ErrInvalidRange + } return Stats{ OperationsDequeued: binary.LittleEndian.Uint64(src[16:24]), OperationsCompleted: binary.LittleEndian.Uint64(src[24:32]), @@ -629,6 +634,7 @@ func ParseStats(src []byte) (Stats, error) { CleanupRetries: binary.LittleEndian.Uint32(src[124:128]), InputReportsSubmitted: binary.LittleEndian.Uint64(src[128:136]), InputReportsCompleted: binary.LittleEndian.Uint64(src[136:144]), + ReservedPorts: reservedPorts, }, nil } diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index 427cbda6..e9b4f650 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -156,6 +156,8 @@ type contractStats struct { CleanupRetries uint32 InputReportsSubmitted uint64 InputReportsCompleted uint64 + ReservedPorts uint32 + Reserved uint32 } type contractLifecycleTraceRecord struct { diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 02e4519e..ec180246 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "7d769fa2edc36556a5d7f5c63d855625ada9bbc6236ea8cf73892b4b41499293" + const wantHex = "a0185735dc6d1397e40744fcb0055ded753f30fe4b991d027065707eacecec18" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -514,15 +514,25 @@ func TestIdentityAndStatsLayout(t *testing.T) { binary.LittleEndian.PutUint32(raw[120:124], 7) binary.LittleEndian.PutUint64(raw[128:136], 37) binary.LittleEndian.PutUint64(raw[136:144], 41) + binary.LittleEndian.PutUint32(raw[144:148], 9) stats, err := ParseStats(raw) if err != nil { t.Fatal(err) } if stats.OperationsDequeued != 11 || stats.BytesFromDevice != 29 || stats.NotificationEvents != 31 || stats.ActiveDevices != 3 || stats.PendingOperations != 5 || stats.WaitingDequeues != 7 || - stats.InputReportsSubmitted != 37 || stats.InputReportsCompleted != 41 { + stats.InputReportsSubmitted != 37 || stats.InputReportsCompleted != 41 || stats.ReservedPorts != 9 { t.Fatalf("unexpected stats: %+v", stats) } + binary.LittleEndian.PutUint32(raw[148:152], 1) + if _, err := ParseStats(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("nonzero reserved stats word error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[148:152], 0) + binary.LittleEndian.PutUint32(raw[144:148], MaxDevices+1) + if _, err := ParseStats(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("out-of-range reserved-port count error=%v want ErrInvalidRange", err) + } } func TestParseLifecycleTracePreservesDebugState(t *testing.T) { diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index a1edd766..bce4d397 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -2411,6 +2411,7 @@ ViiperCompleteOperation( ULONG isoErrorCount = 0; NTSTATUS status; BOOLEAN expectedLateAbort = FALSE; + BOOLEAN identityMismatch = FALSE; BOOLEAN queued; status = ViiperValidateBrokerOwner(controller, CompletionRequest); @@ -2486,14 +2487,26 @@ ViiperCompleteOperation( WdfSpinLockAcquire(controllerContext->BrokerLock); if (controllerContext->PendingSlots[slot].Token == completion->Token && controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight) { - urbRequest = controllerContext->PendingSlots[slot].Request; - controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; - WdfObjectReference(urbRequest); + if (controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId && + controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation) { + urbRequest = controllerContext->PendingSlots[slot].Request; + controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; + WdfObjectReference(urbRequest); + } else { + // A correct token with conflicting immutable identity must not + // claim the request. The owner will fault on this reply and file + // cleanup remains the authoritative retirement path. + identityMismatch = TRUE; + } } else { expectedLateAbort = ViiperExpectedLateAbortLocked( &controllerContext->PendingSlots[slot], completion->Token); } WdfSpinLockRelease(controllerContext->BrokerLock); + if (identityMismatch) { + InterlockedIncrement64(&controllerContext->InvalidMessages); + return STATUS_INVALID_PARAMETER; + } if (urbRequest == WDF_NO_HANDLE) { InterlockedIncrement64(&controllerContext->LateCompletions); return expectedLateAbort ? STATUS_SUCCESS : STATUS_NOT_FOUND; diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index ddeac0f6..125dcf4f 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1945,10 +1945,8 @@ ViiperSubmitInputReport( // queued controller state is also appended to the bounded transition FIFO; // deadline-generated idle samples therefore cannot crowd out press/release // edges while Windows has no interrupt poll parked. - InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); RtlCopyMemory(endpointContext->InputReport, payload, input->PayloadLength); endpointContext->InputReportLength = input->PayloadLength; - InterlockedExchange(&endpointContext->InputReportValid, TRUE); if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) { ULONG count = (ULONG)InterlockedCompareExchange( &endpointContext->InputTransitionCount, 0, 0); @@ -1962,6 +1960,15 @@ ViiperSubmitInputReport( input->PayloadLength); endpointContext->InputTransitionLengths[tail] = (USHORT)input->PayloadLength; endpointContext->InputTransitionSequences[tail] = input->Sequence; + } + // The interlocked sequence publication is the release boundary for both + // the latest snapshot and optional transition payload. Consumers take the + // same endpoint lock, while the explicit payload-before-sequence order + // keeps the cache contract correct if that synchronization is later + // narrowed for latency. + InterlockedExchange64(&endpointContext->LastInputSequence, (LONG64)input->Sequence); + InterlockedExchange(&endpointContext->InputReportValid, TRUE); + if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0) { InterlockedIncrement(&endpointContext->InputTransitionCount); InterlockedExchange(&endpointContext->InputSnapshotPending, FALSE); } else { @@ -2090,12 +2097,12 @@ ViiperWaitForEndpointPurgeQuiescence( NULL); // UdeCx exclusively owns the associated queue's START/PURGE state. - // Its PURGE callback can leave UdeCx-owned host polls queued while - // stopping delivery to this driver. Reject READY and require the - // framework's DriverNoRequests proof, then combine it with VIIPER's - // rundown in one BrokerLock sample. This also closes a callback - // delivered just before it could enter ActiveOperations without - // waiting for queued requests which the class extension itself owns. + // The PURGE callback is the upstream stop/cancel boundary even when + // the associated queue retains its READY bookkeeping until the client + // acknowledges the transition. DriverNoRequests joins callbacks + // already delivered across that boundary; VIIPER's rundown joins + // their forwarded and terminal-DPC ownership. Sample both under the + // BrokerLock without waiting for UdeCx-owned queued host polls. WdfSpinLockAcquire(controllerContext->BrokerLock); queueState = WdfIoQueueGetState( endpointContext->Queue, &queuedRequests, &driverRequests); @@ -2103,7 +2110,6 @@ ViiperWaitForEndpointPurgeQuiescence( &endpointContext->PurgeOutstanding, 0, 0) > 0 && InterlockedCompareExchange( &endpointContext->Purging, 0, 0) != 0 && - !WDF_IO_QUEUE_READY(queueState) && (queueState & WdfIoQueueDriverNoRequests) != 0 && driverRequests == 0 && InterlockedCompareExchange( @@ -2435,7 +2441,7 @@ ViiperEvtEndpointPurgeWorkItem( endpoint, &queueState, &queuedRequests, &driverRequests); VIIPER_TRACE_LIFECYCLE( deviceContext->Controller, VIIPER_UDE_TRACE_SOURCE_DEVICE, - VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED, deviceContext->DeviceId, + VIIPER_UDE_TRACE_ENDPOINT_DRIVER_QUIESCENT, deviceContext->DeviceId, deviceContext->Generation, endpointContext->Device, endpoint, endpointContext->Descriptor.bEndpointAddress, STATUS_SUCCESS, endpointContext->ActiveOperations, (ULONG)queueState); diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index 40d2535f..a98f165b 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -166,6 +166,8 @@ ViiperHandleQueryStats( (ULONGLONG)ViiperReadCounter(&context->InputReportsSubmitted); output->InputReportsCompleted = (ULONGLONG)ViiperReadCounter(&context->InputReportsCompleted); + output->ReservedPorts = + (ULONG)InterlockedCompareExchange(&context->ReservedPorts, 0, 0); WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index ca595a28..1bde6ea1 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/14/2026 - 0.1.0.34 + 0.1.0.35 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -113,7 +113,7 @@ - + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 4799300e..e1e51b81 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,8 +35,8 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(11) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.34" +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(12) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.35" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ @@ -93,7 +93,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_TRACE_ENDPOINT_PURGE_BEGIN 11 #define VIIPER_UDE_TRACE_ENDPOINT_OPERATIONS_PURGED 12 #define VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGE_REQUESTED 13 -#define VIIPER_UDE_TRACE_ENDPOINT_QUEUE_PURGED 14 +#define VIIPER_UDE_TRACE_ENDPOINT_DRIVER_QUIESCENT 14 #define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_BEGIN 15 #define VIIPER_UDE_TRACE_ENDPOINT_DRAIN_END 16 #define VIIPER_UDE_TRACE_ENDPOINT_PURGE_COMPLETE_BEGIN 17 @@ -282,6 +282,8 @@ typedef struct VIIPER_UDE_STATS { VIIPER_UDE_UINT32 CleanupRetries; VIIPER_UDE_UINT64 InputReportsSubmitted; VIIPER_UDE_UINT64 InputReportsCompleted; + VIIPER_UDE_UINT32 ReservedPorts; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_STATS; typedef struct VIIPER_UDE_LIFECYCLE_TRACE_RECORD { @@ -329,7 +331,7 @@ static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI dr static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); -static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L @@ -343,7 +345,7 @@ _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI d _Static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_STATS) == 144, "VIIPER_UDE_STATS ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); _Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); _Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); #endif @@ -365,7 +367,7 @@ typedef char VIIPER_UDE_ABI_ISO_PACKET_SIZE[(sizeof(VIIPER_UDE_ISO_PACKET) == 16 typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 104) ? 1 : -1]; typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 48) ? 1 : -1]; -typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 144) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 152) ? 1 : -1]; typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_RECORD_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80) ? 1 : -1]; typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008) ? 1 : -1]; @@ -486,6 +488,8 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, WaitingDequeues, 120); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, CleanupRetries, 124); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsSubmitted, 128); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, InputReportsCompleted, 136); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, ReservedPorts, 144); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, Reserved, 148); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, PublishedSequence, 0); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE_RECORD, TimestampQpc, 8); diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index c0613f5a..b8b87560 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.34 +DriverVer=08/14/2026,0.1.0.35 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index 110159fb..40e5c108 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -81,7 +81,7 @@ if ($versionNodes.Count -ne 1) { } $driverPackageVersion = $versionNodes[0].InnerText.Trim() $driverABIMajor = 1 -$driverABIMinor = 11 +$driverABIMinor = 12 $driverCapabilities = [uint32]29 $driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $SourceRevision ` diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 25dfe27d..4cc14f5e 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -106,7 +106,7 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $source = $SourceRevision.ToLowerInvariant() $buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $source -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 11 -Capabilities 29 + -ABIMajor 1 -ABIMinor 12 -Capabilities 29 $manifest = [ordered]@{ schema = 2 @@ -117,7 +117,7 @@ $manifest = [ordered]@{ sourceRevision = $source driverPackageVersion = $driverVersion driverABIMajor = 1 - driverABIMinor = 11 + driverABIMinor = 12 driverCapabilities = '0x0000001d' driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 38305b67..c4eaaa8d 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -39,12 +39,17 @@ $requiredContracts = [ordered]@{ 'CandidateDisposition::InstallRequired &&\s*!prior\.devices\.empty\(\) && prior\.devices\[0\]\.started &&' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' 'exact negotiated capability identity' = 'response\.Capabilities != negotiatedCapabilities' - 'known previous ABI negotiation' = 'kPreviousAbiMinor = 10' + 'known previous ABI negotiation' = 'kPreviousAbiMinor = 11' 'known previous ABI capabilities' = 'kPreviousAbiCapabilities' 'previous ABI pristine-upgrade boundary' = 'IsPreviousAbiRetryEligible\(' - 'previous ABI invalid-parameter retry only' = - 'error\.code == ERROR_INVALID_PARAMETER[\s\S]{0,180}abi-negotiate-result' + 'previous ABI version-mismatch retry errors' = + 'error\.code == ERROR_REVISION_MISMATCH[\s\S]{0,120}error\.code == ERROR_INVALID_PARAMETER[\s\S]{0,180}abi-negotiate-result' 'previous ABI stats header validation' = 'stats\.Header\.Minor != negotiatedMinor' + 'previous ABI stats wire size' = 'kPreviousAbiStatsSize = 144' + 'reserved-port wire-range validation' = 'stats\.ReservedPorts > VIIPER_UDE_MAX_DEVICES' + 'stats reserved-word validation' = 'stats\.Reserved != 0' + 'reserved-port pristine-runtime gate' = + 'negotiatedMinor == VIIPER_UDE_ABI_MINOR && stats\.ReservedPorts != 0' 'source-bound manifest identity' = 'driverBuildIdentity' 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' 'install rollback' = 'RollbackInstall\(' diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index fd1c6bf3..9ee51661 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -156,11 +156,11 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 11 -Capabilities 29 + -ABIMajor 1 -ABIMinor 12 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or [string]$manifest.driverPackageVersion -cne $driverVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 11 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 12 -or [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or -not [bool]$manifest.releaseEligible -or diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 10ddfbfa..12128faf 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -739,11 +739,11 @@ $driverPackageVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverPackageVersion ` - -ABIMajor 1 -ABIMinor 11 -Capabilities 29 + -ABIMajor 1 -ABIMinor 12 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 11 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 12 -or [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index e55facc0..345299d9 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -468,13 +468,12 @@ if (-not $purgeQuiescenceMatch.Success -or 'WdfIoQueueGetState\s*\(\s*endpointContext->Queue' -or $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->PurgeOutstanding' -or $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->Purging' -or - $purgeSampleMatch.Groups['sample'].Value -notmatch '!\s*WDF_IO_QUEUE_READY' -or $purgeSampleMatch.Groups['sample'].Value -notmatch 'WdfIoQueueDriverNoRequests' -or $purgeSampleMatch.Groups['sample'].Value -notmatch 'driverRequests\s*==\s*0' -or $purgeSampleMatch.Groups['sample'].Value -notmatch 'endpointContext->ActiveOperations' -or $purgeQuiescenceMatch.Groups['body'].Value -match - 'WDF_IO_QUEUE_(?:IDLE|PURGED)|queuedRequests\s*==\s*0') { - throw 'Endpoint PURGE must prove stopped driver-owned quiescence without waiting on UdeCx-owned queued requests.' + 'WDF_IO_QUEUE_(?:READY|IDLE|PURGED)|WdfIoQueueNoRequests|queuedRequests\s*==\s*0') { + throw 'Endpoint PURGE must prove driver-owned quiescence without waiting on class-owned queue readiness or queued requests.' } $purgeWorkItemMatch = [regex]::Match( $deviceSource, diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 40a8b0c6..b343c490 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -88,10 +88,11 @@ constexpr wchar_t kServiceName[] = L"ViiperUde"; constexpr wchar_t kProviderName[] = L"VIIPER Project"; constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; -constexpr VIIPER_UDE_UINT16 kPreviousAbiMinor = 10; +constexpr VIIPER_UDE_UINT16 kPreviousAbiMinor = 11; constexpr VIIPER_UDE_UINT32 kPreviousAbiCapabilities = VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS; + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE; +constexpr DWORD kPreviousAbiStatsSize = 144; constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; @@ -2532,7 +2533,8 @@ bool IsPreviousAbiRetryEligible( const std::string* expectedBuildIdentity, const Error& error) { return requirePristineRuntime && expectedBuildIdentity == nullptr && - error.code == ERROR_INVALID_PARAMETER && + (error.code == ERROR_REVISION_MISMATCH || + error.code == ERROR_INVALID_PARAMETER) && (error.phase == L"abi-negotiate" || error.phase == L"abi-negotiate-result"); } @@ -2640,6 +2642,8 @@ bool VerifyAbiHealth( } if (requirePristineRuntime) { VIIPER_UDE_STATS stats{}; + const DWORD expectedStatsSize = negotiatedMinor == VIIPER_UDE_ABI_MINOR + ? static_cast(sizeof(stats)) : kPreviousAbiStatsSize; DWORD statsReturned = 0; WinHandle statsEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (!statsEvent) { @@ -2711,10 +2715,12 @@ bool VerifyAbiHealth( return SetLastErrorDetail(error, L"upgrade-pristine-stats-result"); } } - if (statsReturned != sizeof(stats) || stats.Header.Magic != VIIPER_UDE_MAGIC || + if (statsReturned != expectedStatsSize || stats.Header.Magic != VIIPER_UDE_MAGIC || stats.Header.Major != VIIPER_UDE_ABI_MAJOR || stats.Header.Minor != negotiatedMinor || - stats.Header.Size != sizeof(stats) || stats.Header.Flags != 0) { + stats.Header.Size != expectedStatsSize || stats.Header.Flags != 0 || + (negotiatedMinor == VIIPER_UDE_ABI_MINOR && + (stats.ReservedPorts > VIIPER_UDE_MAX_DEVICES || stats.Reserved != 0))) { return SetError(error, L"upgrade-pristine-stats", ERROR_REVISION_MISMATCH, L"loaded driver returned an invalid pristine-runtime statistics record"); } @@ -2726,7 +2732,8 @@ bool VerifyAbiHealth( stats.NotificationEvents != 0 || stats.NotificationEventOverflows != 0 || stats.ActiveDevices != 0 || stats.PendingOperations != 0 || stats.WaitingDequeues != 0 || stats.CleanupRetries != 0 || - stats.InputReportsSubmitted != 0 || stats.InputReportsCompleted != 0) { + stats.InputReportsSubmitted != 0 || stats.InputReportsCompleted != 0 || + (negotiatedMinor == VIIPER_UDE_ABI_MINOR && stats.ReservedPorts != 0)) { return SetError(error, L"upgrade-runtime-reboot-boundary", ERROR_SUCCESS_REBOOT_REQUIRED, L"the loaded native bus has serviced virtual-device work since boot; restart Windows and rerun the identical package command before creating another virtual device"); @@ -5344,14 +5351,14 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "7d769fa2edc36556a5d7f5c63d855625ada9bbc6236ea8cf73892b4b41499293") { + "a0185735dc6d1397e40744fcb0055ded753f30fe4b991d027065707eacecec18") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } return outcome; } Error previousAbiError; - previousAbiError.code = ERROR_INVALID_PARAMETER; + previousAbiError.code = ERROR_REVISION_MISMATCH; previousAbiError.phase = L"abi-negotiate-result"; if (!IsPreviousAbiRetryEligible(true, nullptr, previousAbiError) || IsPreviousAbiRetryEligible(false, nullptr, previousAbiError) || @@ -5360,6 +5367,19 @@ Outcome SelfTest() { L"previous-ABI retry escaped the pristine upgrade-only boundary"); return outcome; } + previousAbiError.code = ERROR_INVALID_PARAMETER; + if (!IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { + SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, + L"legacy previous-ABI mismatch was not accepted at the pristine boundary"); + return outcome; + } + previousAbiError.code = ERROR_ACCESS_DENIED; + if (IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { + SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, + L"previous-ABI retry accepted an unrelated negotiation failure"); + return outcome; + } + previousAbiError.code = ERROR_REVISION_MISMATCH; previousAbiError.phase = L"abi-negotiate-timeout"; if (IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, From aca9d694da0d6f90ea1858d66f48f8b1e78ab218 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 02:19:33 -0500 Subject: [PATCH 234/240] Harden native driver upgrade transaction --- internal/cmd/native_package_contract_test.go | 243 ++- internal/transport/udecx/protocol.go | 4 +- internal/transport/udecx/protocol_test.go | 2 +- native/udecx/README.md | 2 +- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 2 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 131 +- native/udecx/tools/ViiperUdeCtl.cpp | 1397 +++++++++++++---- 9 files changed, 1410 insertions(+), 377 deletions(-) diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index b6075d0b..fa5814fd 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -177,8 +177,9 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "--expected-broker-sha-256", "ParseBrokerCommitProof", "driverRollbackAuthorized", "CreatePipe(", "PROC_THREAD_ATTRIBUTE_HANDLE_LIST", "kMaximumBrokerProofBytes", - "RollbackInstall(prior", "broker-reboot-boundary", + "RollbackInstall(", "broker-reboot-boundary", "--transaction-deadline-unix-ms", "kBrokerRollbackCeilingMs", + "kDriverRollbackCeilingMs", "CreatePrivateNamespaceW", "WAIT_ABANDONED", "ReleaseMutex", "FILE_FLAG_OVERLAPPED", "CancelIoEx", "kCancelledIoDrainMs", "RegisterRootDeviceExact", "rollback-identity-verification", @@ -189,8 +190,31 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "LoadLibraryExW", "LOAD_LIBRARY_SEARCH_SYSTEM32", "GetProcAddress", "ValidateExactPackageDirectory", "Sha256Handle(manifest.get()", "RequestBrokerQuiescence", "SignalBrokerHandoff", - "requirePristineRuntime", "IOCTL_VIIPER_UDE_QUERY_STATS", + "SetupCopyOEMInfW(", "SP_COPY_NOOVERWRITE", "ERROR_FILE_EXISTS", + "SetupUninstallOEMInfW(", "RemoveStagedCandidateExact(", + "VerifyPackageInventory(", "packageStagedHere", "bindingMutationStarted", + "stage-package-inventory-verification", "stage-root-binding-verification", + "stage-concurrent-publication", "post-quiescence-package-inventory-verification", + "post-quiescence-root-verification", "final-pre-bind-root-topology-verification", + "final-pre-bind-package-inventory-verification", + "final-pre-bind-root-verification", "post-bind-package-inventory-verification", + "PreparePreinstalledDriverOnDevice(", "CommitPreparedDriverBinding(", + "requirePristineRuntime", "RequiresDriverMutation(", + "RequiresPristineRuntimeProof(", "RuntimeStatsArePristine(", + "AbiCompatibilityProfile", "{12, 29, 152, true}", + "{11, 29, 144, false}", "{10, 13, 144, false}", + "AbiCompatibilityProfilesAreValid()", "IsAbiRetryEligible(", + "AbiHealthPurpose::PristineUpgrade", "AbiHealthPurpose::PristineRecheck", + "AbiHealthPurpose::RollbackHealth", "AbiNegotiationResponseMatchesProfile(", + "StatsRecordMatchesProfile(", "offsetof(VIIPER_UDE_STATS, ReservedPorts) == 144", + "rollback-runtime-start-verification", "rollback-runtime-abi-profile", + "rollback-stopped-state-verification", "RollbackLifecycleStateMatches(", + "self-test-rollback-lifecycle", + "transaction-deadline-before-broker-quiescence", + "MarkTransactionMutationStarted();", "recoveredReceipt", + "IOCTL_VIIPER_UDE_QUERY_STATS", "upgrade-runtime-reboot-boundary", + "self-test-pristine-runtime-decision", "self-test-pristine-runtime-stats", "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", "--broker-quiesce-abort-handle", "--broker-handoff-handle", } @@ -206,35 +230,204 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("driver helper retained obsolete nested broker option %q", obsolete) } } - if strings.Contains(helperSource, "UpdateDriverForPlugAndPlayDevicesW(") { - t.Error("driver helper must bind only an exact selected preinstalled package with DiInstallDevice") - } if !strings.Contains(helperSource, "InstallPreinstalledDriverOnDevice(") || !strings.Contains(helperSource, "DiInstallDevice(") { t.Error("driver helper lost exact preinstalled-driver selection and DiInstallDevice binding") } - upgradeRemove := strings.Index(helperSource, `L"upgrade-deadline-before-device-removal"`) - upgradeQuiesce := strings.Index(helperSource, "RequestBrokerQuiescence(options") - upgradeStartedGate := strings.Index(helperSource, - "!prior.devices.empty() && prior.devices[0].started &&") - upgradePristine := strings.Index(helperSource, - "options.transactionDeadlineUnixMs, nullptr, &outcome.error, true") - upgradeAbsent := strings.Index(helperSource, "CaptureSnapshot(&afterRemoval") - upgradeStage := strings.Index(helperSource, "DiInstallDriverW(nullptr, candidate.infPath.c_str()") - upgradeIdentity := strings.Index(helperSource, "ExactRootRegistrationMode::Upgrade") - upgradeBind := -1 - if upgradeIdentity >= 0 { - if relative := strings.Index(helperSource[upgradeIdentity:], - "InstallPreinstalledDriverOnDevice("); relative >= 0 { - upgradeBind = upgradeIdentity + relative + installStart := strings.Index(helperSource, "Outcome Install(const InstallOptions& options)") + installEnd := strings.Index(helperSource, "struct PackageBackup {") + if installStart < 0 || installEnd <= installStart { + t.Fatal("driver helper forward install transaction is missing or malformed") + } + forwardInstall := helperSource[installStart:installEnd] + for _, forbidden := range []string{ + "DiInstallDriverW(", "UpdateDriverForPlugAndPlayDevicesW(", + `L"upgrade-deadline-before-device-removal"`, + "ExactRootRegistrationMode", "RegisterRootDeviceExact(", + } { + if strings.Contains(forwardInstall, forbidden) { + t.Errorf("forward install retained remove/recreate or device-auto-binding operation %q", forbidden) + } + } + quiescenceStart := strings.Index(helperSource, + "bool RequestBrokerQuiescence(const InstallOptions& options") + quiescenceEnd := strings.Index(helperSource, + "bool SignalBrokerHandoff(") + if quiescenceStart < 0 || quiescenceEnd <= quiescenceStart { + t.Fatal("broker quiescence implementation is missing or malformed") + } + quiescenceSource := helperSource[quiescenceStart:quiescenceEnd] + quiescenceDeadline := strings.Index(quiescenceSource, + `L"transaction-deadline-before-broker-quiescence"`) + quiescenceSignal := strings.Index(quiescenceSource, + "SetEvent(options.brokerQuiesceRequest)") + if quiescenceDeadline < 0 || quiescenceSignal <= quiescenceDeadline { + t.Error("broker quiescence can signal a healthy broker after the package deadline") + } + helperStageStart := strings.Index(helperSource, "bool StageCandidatePackage(") + helperStageEnd := strings.Index(helperSource, "bool RemoveDevice(") + if helperStageStart < 0 || helperStageEnd <= helperStageStart { + t.Fatal("add-only package staging implementation is missing or malformed") + } + stageSource := helperSource[helperStageStart:helperStageEnd] + stageMutationMark := strings.Index(stageSource, "MarkTransactionMutationStarted();") + setupCopy := strings.Index(stageSource, "SetupCopyOEMInfW(") + stageOwnership := strings.Index(stageSource, "*stagedHere = true;") + receiptValidation := strings.Index(stageSource, "const size_t destinationLength") + receiptRecovery := strings.Index(stageSource, "recoveredReceipt") + if stageMutationMark < 0 || setupCopy <= stageMutationMark || + stageOwnership <= setupCopy || receiptValidation <= stageOwnership || + receiptRecovery <= receiptValidation { + t.Error("SetupCopy fault interleavings no longer preserve mutation classification, success-only ownership, and exact receipt recovery") + } + commitStart := strings.Index(helperSource, "bool CommitPreparedDriverBinding(") + commitEnd := strings.Index(helperSource, "bool InstallPreinstalledDriverOnDevice(") + if commitStart < 0 || commitEnd <= commitStart { + t.Fatal("prepared selected-device binding commit is missing or malformed") + } + commitSource := helperSource[commitStart:commitEnd] + commitDeadline := strings.Index(commitSource, + `L"transaction-deadline-before-selected-device-binding"`) + commitMutation := strings.Index(commitSource, "MarkTransactionMutationStarted();") + commitSelection := strings.Index(commitSource, "SetupDiSetSelectedDriverW(") + commitInstall := strings.Index(commitSource, "DiInstallDevice(") + if commitDeadline < 0 || commitMutation <= commitDeadline || + commitSelection <= commitMutation || commitInstall <= commitSelection || + strings.Contains(commitSource[commitSelection:commitInstall], + "CheckTransactionDeadline(") { + t.Error("prepared binding commit no longer performs one deadline check followed immediately by selected-driver and DiInstallDevice mutation") + } + abiHealthStart := strings.Index(helperSource, "bool VerifyAbiHealth(") + abiHealthEnd := strings.Index(helperSource, "bool VerifyInstalledBinding(") + if abiHealthStart < 0 || abiHealthEnd <= abiHealthStart { + t.Fatal("ABI health implementation is missing or malformed") + } + abiHealthSource := helperSource[abiHealthStart:abiHealthEnd] + for _, fragment := range []string{ + "profiles = kAbiCompatibilityProfiles.data();", + "profileCount = kAbiCompatibilityProfiles.size();", + "IssueAbiNegotiation(device.get(), deadlineUnixMs, profiles[index].minor", + "IsAbiRetryEligible(purpose, expectedBuildIdentity, *error)", + "AbiNegotiationResponseMatchesProfile(", + "StatsRecordMatchesProfile(", + } { + if !strings.Contains(abiHealthSource, fragment) { + t.Errorf("bounded ABI negotiation lost %q", fragment) + } + } + mutationDecision := strings.Index(forwardInstall, + "const bool driverMutation =") + stageCall := strings.Index(forwardInstall, "if (!StageCandidatePackage(") + stageInventory := strings.Index(forwardInstall, + `L"stage-package-inventory-verification"`) + stageRootProof := strings.Index(forwardInstall, `L"stage-root-binding-verification"`) + quiesce := strings.Index(forwardInstall, "RequestBrokerQuiescence(options") + postQuiesceInventory := strings.Index(forwardInstall, + `L"post-quiescence-package-inventory-verification"`) + postQuiesceRootProof := strings.Index(forwardInstall, + `L"post-quiescence-root-verification"`) + pristineDecision := strings.Index(forwardInstall, + "const bool requiresPristineRuntimeProof =") + pristineProof := -1 + if pristineDecision >= 0 { + if relative := strings.Index(forwardInstall[pristineDecision:], + "AbiHealthPurpose::PristineUpgrade"); relative >= 0 { + pristineProof = pristineDecision + relative } } - if upgradeQuiesce < 0 || upgradeStartedGate <= upgradeQuiesce || - upgradePristine <= upgradeStartedGate || - upgradeRemove <= upgradePristine || upgradeAbsent <= upgradeRemove || - upgradeStage <= upgradeAbsent || upgradeIdentity <= upgradeStage || - upgradeBind <= upgradeIdentity { - t.Error("driver upgrade no longer treats a stopped exact root as quiesced while requiring pristine runtime proof for a running root before removal, absence proof, staging, exact-identity recreation, and binding") + prepareBinding := strings.Index(forwardInstall, + "PreparePreinstalledDriverOnDevice(") + finalTopology := -1 + finalInventory := -1 + finalRootProof := -1 + finalPristineProof := -1 + commitBinding := -1 + if prepareBinding >= 0 { + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-root-topology-verification"`); relative >= 0 { + finalTopology = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-package-inventory-verification"`); relative >= 0 { + finalInventory = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + `L"final-pre-bind-root-verification"`); relative >= 0 { + finalRootProof = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + "AbiHealthPurpose::PristineRecheck"); relative >= 0 { + finalPristineProof = prepareBinding + relative + } + if relative := strings.Index(forwardInstall[prepareBinding:], + "CommitPreparedDriverBinding("); relative >= 0 { + commitBinding = prepareBinding + relative + } + } + postBindInventory := strings.Index(forwardInstall, + `L"post-bind-package-inventory-verification"`) + if mutationDecision < 0 || stageCall <= mutationDecision || + stageInventory <= stageCall || stageRootProof <= stageInventory || + quiesce <= stageRootProof || postQuiesceInventory <= quiesce || + postQuiesceRootProof <= postQuiesceInventory || + pristineDecision <= postQuiesceRootProof || pristineProof <= pristineDecision || + prepareBinding <= pristineProof || finalTopology <= prepareBinding || + finalInventory <= finalTopology || + finalRootProof <= finalInventory || finalPristineProof <= finalRootProof || + commitBinding <= finalPristineProof || postBindInventory <= commitBinding { + t.Error("driver replacement no longer orders add-only stage, exact inventory/root proof, broker quiescence, pristine admission, read-only driver preparation, final inventory/root/pristine proof, immediate in-place binding, and post-bind inventory proof") + } + if finalPristineProof >= 0 && commitBinding > finalPristineProof { + finalProofToCommit := forwardInstall[finalPristineProof:commitBinding] + for _, forbidden := range []string{ + "CaptureSnapshot(", "CaptureAndVerify", "FindExactDevices(", + "PreparePreinstalledDriverOnDevice(", "VerifyPackageInventory(", + "SetupDiBuildDriverInfoList(", + } { + if strings.Contains(finalProofToCommit, forbidden) { + t.Errorf("fallible operation %q reopened the final pristine-proof to bind window", forbidden) + } + } + } + rollbackStart := strings.Index(helperSource, "bool RollbackInstall(") + rollbackEnd := strings.Index(helperSource, "bool LockPackageFiles(") + if rollbackStart < 0 || rollbackEnd <= rollbackStart { + t.Fatal("driver helper install rollback is missing or malformed") + } + installRollback := helperSource[rollbackStart:rollbackEnd] + restoreBinding := strings.Index(installRollback, "RestorePriorBinding(") + removeStaged := strings.Index(installRollback, "RemoveStagedCandidateExact(") + verifyInventory := strings.Index(installRollback, "VerifyPackageInventory(") + if restoreBinding < 0 || removeStaged <= restoreBinding || + verifyInventory <= removeStaged { + t.Error("install rollback no longer restores a mutated binding before exact staged-here cleanup and prior-inventory proof") + } + rollbackStarted := strings.Index(installRollback, + "if (prior.devices[0].started)") + rollbackStartedProof := strings.Index(installRollback, + `L"rollback-runtime-start-verification"`) + rollbackProfileProof := strings.Index(installRollback, + `L"rollback-runtime-abi-profile"`) + rollbackHealth := strings.Index(installRollback, + "AbiHealthPurpose::RollbackHealth") + rollbackStoppedComparator := strings.LastIndex(installRollback, + "RollbackLifecycleStateMatches(") + rollbackStoppedProof := strings.Index(installRollback, + `L"rollback-stopped-state-verification"`) + if rollbackStarted <= verifyInventory || rollbackStartedProof <= rollbackStarted || + rollbackProfileProof <= rollbackStartedProof || rollbackHealth <= rollbackProfileProof || + rollbackStoppedComparator <= rollbackHealth || + rollbackStoppedProof <= rollbackStoppedComparator { + t.Error("rollback no longer proves started/problem-zero plus ABI health for a formerly-running root and exact stopped/problem state for a captured stopped root") + } + for _, decision := range []string{ + "CandidateDisposition::Exact, true, true, true", + "CandidateDisposition::InstallRequired, false, false, false", + "CandidateDisposition::Exact, false, true, false", + } { + if !strings.Contains(helperSource, decision) { + t.Errorf("driver helper lost pristine-runtime decision case %q", decision) + } } if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index dd480135..96349272 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -21,7 +21,7 @@ const ( // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.35" + DriverPackageVersion = "0.1.0.36" BuildIdentitySize = sha256.Size HeaderSize = 16 @@ -527,7 +527,7 @@ type Completion struct { Payload []byte } -// InputReport is the ViGEm-style fast path for interrupt-IN endpoints. The +// InputReport is the low-overhead fast path for interrupt-IN endpoints. The // host parks the Windows polling request in the kernel and user mode submits // only a fresh, already encoded report. Audio, control, output, and lifecycle // traffic deliberately remain on the ordered operation broker. diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index ec180246..6096c571 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "a0185735dc6d1397e40744fcb0055ded753f30fe4b991d027065707eacecec18" + const wantHex = "6796b0cf22a80984b283662a50a3b364c46218e37766a2e1880b38851b65d9ad" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { diff --git a/native/udecx/README.md b/native/udecx/README.md index ed2d55a4..a0bbf8e8 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -83,7 +83,7 @@ never accepted by a Release recipe or production workflow. - ABI, lifecycle, descriptor, cancellation, and fault tests live beside the Go broker packages and in the native-driver CI gates. -The interrupt-IN path follows ViGEmBus's useful pending-read principle without +The interrupt-IN path follows a proven pending-read principle without copying its target-specific implementation. Each endpoint owns a preallocated, sequence-checked latest-state cache. A report arriving before a Windows poll is retained and completed after KMDF's manual-queue ready notification crosses a diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 1bde6ea1..19658d10 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -13,8 +13,8 @@ ViiperUde 17.0 x64 - 08/14/2026 - 0.1.0.35 + 08/15/2026 + 0.1.0.36 $(VIIPER_NATIVE_SOURCE_REVISION) diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index e1e51b81..c8a0f92e 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -36,7 +36,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) #define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(12) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.35" +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.36" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index b8b87560..b282af09 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/14/2026,0.1.0.35 +DriverVer=08/15/2026,0.1.0.36 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index c4eaaa8d..8c6dc15e 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -30,29 +30,68 @@ $requiredContracts = [ordered]@{ 'driver-store source capture' = 'SetupGetInfDriverStoreLocationW\(' 'installed INF ownership' = 'DEVPKEY_Device_DriverInfPath' 'installed version ownership' = 'DEVPKEY_Device_DriverVersion' - 'documented package install' = 'DiInstallDriverW\(' + 'documented add-only package staging' = 'SetupCopyOEMInfW\(' + 'non-overwriting package staging' = 'SP_COPY_NOOVERWRITE' + 'documented idempotent package staging result' = 'copyError != ERROR_FILE_EXISTS' 'documented package removal' = 'DiUninstallDriverW\(' 'ABI health negotiation' = 'IOCTL_VIIPER_UDE_NEGOTIATE' 'pristine upgrade statistics' = 'IOCTL_VIIPER_UDE_QUERY_STATS' 'pristine upgrade reboot boundary' = 'upgrade-runtime-reboot-boundary' - 'stopped owned upgrade skips unavailable live ABI proof' = - 'CandidateDisposition::InstallRequired &&\s*!prior\.devices\.empty\(\) && prior\.devices\[0\]\.started &&' + 'all running-root driver mutations require pristine proof' = + 'const bool requiresPristineRuntimeProof =[\s\S]{0,180}RequiresPristineRuntimeProof\(' + 'already-staged exact binding is classified as a driver mutation' = + 'RequiresDriverMutation\(disposition, exactBindingHealthy\)' + 'stopped and absent roots skip unavailable live ABI proof' = + 'self-test-pristine-runtime-decision' + 'every nonzero runtime counter is rejected' = + 'self-test-pristine-runtime-stats' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' - 'exact negotiated capability identity' = 'response\.Capabilities != negotiatedCapabilities' - 'known previous ABI negotiation' = 'kPreviousAbiMinor = 11' - 'known previous ABI capabilities' = 'kPreviousAbiCapabilities' - 'previous ABI pristine-upgrade boundary' = 'IsPreviousAbiRetryEligible\(' + 'exact negotiated capability identity' = 'response\.Capabilities == profile\.capabilities' + 'explicit ABI 1.12 profile' = '\{12, 29, 152, true\}' + 'explicit ABI 1.11 profile' = '\{11, 29, 144, false\}' + 'explicit ABI 1.10 profile' = '\{10, 13, 144, false\}' + 'strict ABI profile order' = 'AbiCompatibilityProfilesAreValid\(\)' + 'legacy statistics boundary assertion' = 'offsetof\(VIIPER_UDE_STATS, ReservedPorts\) == 144' + 'previous ABI pristine-upgrade boundary' = 'IsAbiRetryEligible\(' 'previous ABI version-mismatch retry errors' = 'error\.code == ERROR_REVISION_MISMATCH[\s\S]{0,120}error\.code == ERROR_INVALID_PARAMETER[\s\S]{0,180}abi-negotiate-result' - 'previous ABI stats header validation' = 'stats\.Header\.Minor != negotiatedMinor' - 'previous ABI stats wire size' = 'kPreviousAbiStatsSize = 144' - 'reserved-port wire-range validation' = 'stats\.ReservedPorts > VIIPER_UDE_MAX_DEVICES' - 'stats reserved-word validation' = 'stats\.Reserved != 0' + 'exact ABI negotiation response validation' = 'AbiNegotiationResponseMatchesProfile\(' + 'exact ABI statistics response validation' = 'StatsRecordMatchesProfile\(' + 'previous ABI stats header validation' = 'stats\.Header\.Minor == profile\.minor' + 'previous ABI stats wire size' = 'stats\.Header\.Size == profile\.statsSize' + 'reserved-port wire-range validation' = 'stats\.ReservedPorts <= VIIPER_UDE_MAX_DEVICES' + 'stats reserved-word validation' = 'stats\.Reserved == 0' 'reserved-port pristine-runtime gate' = - 'negotiatedMinor == VIIPER_UDE_ABI_MINOR && stats\.ReservedPorts != 0' + '!profile\.hasReservedPortFields \|\| stats\.ReservedPorts == 0' 'source-bound manifest identity' = 'driverBuildIdentity' 'same-ABI stale-kernel rejection' = 'expectedBuildIdentity' 'install rollback' = 'RollbackInstall\(' + 'exact staged-here rollback removal' = 'SetupUninstallOEMInfW\(' + 'non-forced staged-here rollback removal' = + 'SetupUninstallOEMInfW\([\s\S]{0,120}stagedCandidate\.publishedName\.c_str\(\), 0, nullptr' + 'exact rollback package inventory proof' = 'VerifyPackageInventory\(' + 'formerly-running rollback start proof' = 'rollback-runtime-start-verification' + 'formerly-running rollback ABI proof' = 'AbiHealthPurpose::RollbackHealth' + 'captured stopped rollback state proof' = 'rollback-stopped-state-verification' + 'exact rollback lifecycle comparator' = 'RollbackLifecycleStateMatches\(' + 'stage mutation marked before SetupCopy' = + 'MarkTransactionMutationStarted\(\);[\s\S]{0,180}SetupCopyOEMInfW\(' + 'successful stage retains cleanup ownership' = '\*stagedHere = true' + 'malformed stage receipt recovery' = + 'FindPublishedCandidate\([\s\S]{0,160}recoveredReceipt' + 'post-stage exact inventory proof' = 'stage-package-inventory-verification' + 'post-quiescence exact inventory proof' = 'post-quiescence-package-inventory-verification' + 'final pre-bind exact inventory proof' = 'final-pre-bind-package-inventory-verification' + 'post-bind exact inventory proof' = 'post-bind-package-inventory-verification' + 'post-stage full root invariance proof' = 'stage-root-binding-verification' + 'post-quiescence full root invariance proof' = 'post-quiescence-root-verification' + 'prepared-driver final root invariance proof' = 'final-pre-bind-root-verification' + 'fresh global pre-bind topology proof' = 'final-pre-bind-root-topology-verification' + 'read-only compatible-driver preparation' = 'PreparePreinstalledDriverOnDevice\(' + 'immediate selected-device binding commit' = 'CommitPreparedDriverBinding\(' + 'exact final pristine ABI recheck' = 'AbiHealthPurpose::PristineRecheck' + 'broker deadline before quiescence signal' = + 'transaction-deadline-before-broker-quiescence[\s\S]{0,180}SetEvent\(options\.brokerQuiesceRequest\)' 'broker health transaction' = 'RunBrokerInstall\(' 'canonical broker proof parser' = 'ParseBrokerCommitProof\(' 'bounded broker proof channel' = 'kMaximumBrokerProofBytes' @@ -136,9 +175,10 @@ $requiredContracts = [ordered]@{ 'nested rollback budget composition' = '3ULL \* 60ULL \* 1000ULL' 'forward root mutation deadline' = 'transaction-deadline-before-root-registration' 'forward root property deadline' = 'transaction-deadline-before-root-properties' - 'device binding mutation deadline' = 'transaction-deadline-before-device-binding' - 'driver package mutation deadline' = 'transaction-deadline-before-driver-install' - 'selected driver mutation deadline' = 'transaction-deadline-before-driver-selection' + 'device binding mutation deadline' = 'transaction-deadline-before-selected-device-binding' + 'driver package mutation deadline' = 'transaction-deadline-before-driver-stage' + 'finite install rollback deadline' = 'install-rollback-deadline-staged-package' + 'selected driver mutation deadline' = 'transaction-deadline-before-selected-device-binding' 'owned generated root namespace' = 'kRootDeviceName\[\] = L"VIIPERUDE"' 'legacy generated root rollback namespace' = 'kLegacyRootDeviceName\[\] = L"USB"' 'exact generated root identity validation' = 'IsOwnedGeneratedRootInstanceId\(' @@ -151,9 +191,7 @@ $requiredContracts = [ordered]@{ 'rollback root registration deadline' = 'rollback-deadline-before-root-registration' 'exact rollback devnode identity' = 'RegisterRootDeviceExact\(' 'rollback identity verification' = 'rollback-identity-verification' - 'upgrade devnode removal boundary' = 'upgrade-deadline-before-device-removal' - 'upgrade devnode absence verification' = 'upgrade-device-removal-verification' - 'exact upgrade devnode identity' = 'ExactRootRegistrationMode::Upgrade' + 'in-place existing-root binding' = 'SameEnumeratedRootState\(' 'structured reboot exit' = 'ERROR_SUCCESS_REBOOT_REQUIRED' 'guarded downgrade' = '--allow-controlled-downgrade' } @@ -165,32 +203,40 @@ foreach ($entry in $requiredContracts.GetEnumerator()) { } $orderedMutationContracts = [ordered]@{ - 'driver package deadline immediately precedes mutation' = - 'CheckTransactionDeadline\(options,[\s\S]{0,180}transaction-deadline-before-driver-install[\s\S]{0,800}DiInstallDriverW\(' + 'driver package staging deadline immediately precedes add-only mutation' = + 'transaction-deadline-before-driver-stage[\s\S]{0,1800}MarkTransactionMutationStarted\(\);[\s\S]{0,180}SetupCopyOEMInfW\(' 'root property deadline immediately precedes mutation' = 'transaction-deadline-before-root-properties[\s\S]{0,240}mutationStarted[\s\S]{0,180}SetupDiSetDeviceRegistryPropertyW\(' 'root registration deadline immediately precedes mutation' = 'transaction-deadline-before-root-registration[\s\S]{0,240}SetupDiCallClassInstaller\(DIF_REGISTERDEVICE' - 'device binding deadline immediately precedes mutation' = - 'transaction-deadline-before-device-binding[\s\S]{0,500}DiInstallDevice\(' 'selected driver deadline immediately precedes mutation' = - 'transaction-deadline-before-driver-selection[\s\S]{0,300}mutationStarted[\s\S]{0,180}SetupDiSetSelectedDriverW\(' + 'transaction-deadline-before-selected-device-binding[\s\S]{0,300}mutationStarted[\s\S]{0,220}SetupDiSetSelectedDriverW\([\s\S]{0,500}DiInstallDevice\(' + 'broker deadline immediately precedes quiescence signal' = + 'transaction-deadline-before-broker-quiescence[\s\S]{0,180}SetEvent\(options\.brokerQuiesceRequest\)' 'remove deadline immediately precedes device mutation' = 'CheckTransactionDeadline\(transactionDeadlineUnixMs, deadlinePhase, error\)[\s\S]{0,300}mutationStarted[\s\S]{0,180}DiUninstallDevice\(' 'first-time root creation uses the owned device name' = 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}kRootDeviceName[\s\S]{0,120}DICD_GENERATE_ID' 'registered devnode cleanup state survives post-registration validation' = - 'bool registeredAndVerified = false;[\s\S]{0,1200}createdHere = registrationSucceeded;[\s\S]{0,160}if \(registeredAndVerified\)' - 'upgrade removes and proves the captured devnode absent before staging' = - 'upgrade-deadline-before-device-removal[\s\S]{0,800}CaptureSnapshot\(&afterRemoval[\s\S]{0,1800}DiInstallDriverW\(' - 'upgrade restores exact identity before exact package binding' = - 'DiInstallDriverW\([\s\S]{0,2200}prior\.devices\[0\]\.instanceId[\s\S]{0,300}ExactRootRegistrationMode::Upgrade[\s\S]{0,700}InstallPreinstalledDriverOnDevice\(' - 'broker quiescence precedes all classified driver mutation' = - 'if \(driverMutation && !options\.brokerExecutable\.empty\(\)[\s\S]{0,180}RequestBrokerQuiescence\([\s\S]{0,1200}CandidateDisposition::InstallRequired' - 'broker quiescence and pristine proof precede upgrade root removal' = - 'RequestBrokerQuiescence\([\s\S]{0,5000}&outcome\.error, true[\s\S]{0,5000}upgrade-deadline-before-device-removal' + 'const bool registeredAndVerified = inventoryVerified && RegisterRootDevice\([\s\S]{0,500}createdHere = registrationSucceeded;[\s\S]{0,160}if \(registeredAndVerified\)' + 'add-only stage inventory and exact root proof precede broker quiescence' = + '!StageCandidatePackage\([\s\S]{0,2600}stage-package-inventory-verification[\s\S]{0,800}stage-root-binding-verification[\s\S]{0,1800}RequestBrokerQuiescence\(' + 'broker quiescence inventory and fresh root proof precede pristine admission' = + 'RequestBrokerQuiescence\([\s\S]{0,1200}post-quiescence-package-inventory-verification[\s\S]{0,1000}post-quiescence-root-verification[\s\S]{0,1800}AbiHealthPurpose::PristineUpgrade' + 'driver preparation and final proofs precede immediate in-place binding' = + 'PreparePreinstalledDriverOnDevice\([\s\S]{0,800}final-pre-bind-root-topology-verification[\s\S]{0,800}final-pre-bind-package-inventory-verification[\s\S]{0,900}final-pre-bind-root-verification[\s\S]{0,1000}AbiHealthPurpose::PristineRecheck[\s\S]{0,900}CommitPreparedDriverBinding\(' + 'new root registration is confined to an absent captured root' = + 'if \(prior\.devices\.empty\(\)\) \{[\s\S]{0,300}RegisterRootDevice\(' + 'post-stage failure reaches exact common rollback' = + '!StageCandidatePackage\([\s\S]{0,14000}if \(outcome\.error\.code != ERROR_SUCCESS && driverMutationStarted\)[\s\S]{0,1800}packageStagedHere \? &publishedCandidate : nullptr[\s\S]{0,300}RollbackInstall\(' + 'binding restore precedes exact staged cleanup and inventory proof' = + 'if \(bindingMutationStarted\)[\s\S]{0,300}RestorePriorBinding\([\s\S]{0,700}RemoveStagedCandidateExact\([\s\S]{0,500}VerifyPackageInventory\(' + 'formerly-running rollback requires exact start and ABI health' = + 'if \(prior\.devices\[0\]\.started\)[\s\S]{0,500}rollback-runtime-start-verification[\s\S]{0,800}AbiHealthPurpose::RollbackHealth' + 'captured-stopped rollback requires exact stopped problem state' = + 'AbiHealthPurpose::RollbackHealth[\s\S]{0,400}RollbackLifecycleStateMatches\([\s\S]{0,300}rollback-stopped-state-verification' 'broker handoff follows exact binding verification and precedes nested commit' = - 'VerifyInstalledBinding\([\s\S]{0,3000}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' + 'VerifyInstalledBinding\([\s\S]{0,5000}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' 'recovery journal is published and preservation armed before mutation' = 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' 'failed remove rollback preserves published evidence before return' = @@ -211,6 +257,20 @@ foreach ($entry in $orderedMutationContracts.GetEnumerator()) { } } +$forwardInstallStart = $source.IndexOf('Outcome Install(const InstallOptions& options)') +$forwardInstallEnd = $source.IndexOf('struct PackageBackup {', $forwardInstallStart) +if ($forwardInstallStart -lt 0 -or $forwardInstallEnd -le $forwardInstallStart) { + throw 'ViiperUdeCtl forward install transaction is missing or malformed.' +} +$forwardInstallSource = $source.Substring( + $forwardInstallStart, $forwardInstallEnd - $forwardInstallStart) +if ($forwardInstallSource -match '\b(?:DiInstallDriverW|UpdateDriverForPlugAndPlayDevicesW)\s*\(') { + throw 'Forward install must use add-only staging plus exact selected-device binding, never a device-auto-binding package API.' +} +if ($forwardInstallSource -match 'upgrade-deadline-before-device-removal|ExactRootRegistrationMode|RegisterRootDeviceExact\(') { + throw 'Forward install must never remove and recreate an existing root before exact in-place binding.' +} + if ($source -match 'SUOI_FORCEDELETE') { throw 'ViiperUdeCtl must never force-delete a published INF.' } @@ -266,9 +326,8 @@ if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -n } $forceInfUses = [regex]::Matches($source, '\bDIIRFLAG_FORCE_INF\b').Count -if ($forceInfUses -ne 1 -or - $source -notmatch 'const DWORD installFlags = downgrade \? DIIRFLAG_FORCE_INF : 0;') { - throw 'Forced package selection must exist only behind the validated downgrade decision.' +if ($forceInfUses -ne 0) { + throw 'Add-only staging and exact selected-device binding must not force global INF selection.' } $forceBindUses = [regex]::Matches($source, '\bINSTALLFLAG_FORCE\b').Count diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b343c490..b44ee490 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -88,11 +89,47 @@ constexpr wchar_t kServiceName[] = L"ViiperUde"; constexpr wchar_t kProviderName[] = L"VIIPER Project"; constexpr wchar_t kCatalogName[] = L"ViiperUde.cat"; constexpr wchar_t kDriverFileName[] = L"ViiperUde.sys"; -constexpr VIIPER_UDE_UINT16 kPreviousAbiMinor = 11; -constexpr VIIPER_UDE_UINT32 kPreviousAbiCapabilities = - VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE; -constexpr DWORD kPreviousAbiStatsSize = 144; + +struct AbiCompatibilityProfile { + VIIPER_UDE_UINT16 minor; + VIIPER_UDE_UINT32 capabilities; + DWORD statsSize; + bool hasReservedPortFields; +}; + +constexpr std::array kAbiCompatibilityProfiles{{ + {12, 29, 152, true}, + {11, 29, 144, false}, + {10, 13, 144, false}, +}}; + +constexpr bool AbiCompatibilityProfilesAreValid() noexcept { + return kAbiCompatibilityProfiles[0].minor == VIIPER_UDE_ABI_MINOR && + kAbiCompatibilityProfiles[0].capabilities == VIIPER_UDE_ADVERTISED_CAPABILITIES && + kAbiCompatibilityProfiles[0].statsSize == sizeof(VIIPER_UDE_STATS) && + kAbiCompatibilityProfiles[0].hasReservedPortFields && + kAbiCompatibilityProfiles[1].minor == 11 && + kAbiCompatibilityProfiles[1].capabilities == 29 && + kAbiCompatibilityProfiles[1].statsSize == 144 && + !kAbiCompatibilityProfiles[1].hasReservedPortFields && + kAbiCompatibilityProfiles[2].minor == 10 && + kAbiCompatibilityProfiles[2].capabilities == 13 && + kAbiCompatibilityProfiles[2].statsSize == 144 && + !kAbiCompatibilityProfiles[2].hasReservedPortFields && + kAbiCompatibilityProfiles[0].minor == kAbiCompatibilityProfiles[1].minor + 1 && + kAbiCompatibilityProfiles[1].minor == kAbiCompatibilityProfiles[2].minor + 1; +} + +static_assert(VIIPER_UDE_ABI_MAJOR == 1, "ABI compatibility table major drift"); +static_assert(VIIPER_UDE_ABI_MINOR == 12, "ABI compatibility table current minor drift"); +static_assert(VIIPER_UDE_ADVERTISED_CAPABILITIES == 29, + "ABI compatibility table current capabilities drift"); +static_assert(sizeof(VIIPER_UDE_STATS) == 152, + "ABI compatibility table current statistics size drift"); +static_assert(offsetof(VIIPER_UDE_STATS, ReservedPorts) == 144, + "ABI 1.10/1.11 statistics boundary drift"); +static_assert(AbiCompatibilityProfilesAreValid(), + "ABI compatibility profiles must be exact and strictly descending"); constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; @@ -1988,6 +2025,22 @@ enum class CandidateDisposition { Exact, }; +bool RequiresDriverMutation( + CandidateDisposition disposition, + bool exactBindingHealthy) noexcept { + return disposition == CandidateDisposition::InstallRequired || + (disposition == CandidateDisposition::Exact && !exactBindingHealthy); +} + +bool RequiresPristineRuntimeProof( + CandidateDisposition disposition, + bool exactBindingHealthy, + bool rootPresent, + bool rootStarted) noexcept { + return RequiresDriverMutation(disposition, exactBindingHealthy) && + rootPresent && rootStarted; +} + bool ClassifyCandidatePackage( const PackageInfo& candidate, const std::vector& installedPackages, @@ -2095,6 +2148,224 @@ bool CaptureSnapshot(Snapshot* snapshot, Error* error) { return true; } +bool SameRootBinding(const DeviceState& left, const DeviceState& right) noexcept { + return _wcsicmp(left.instanceId.c_str(), right.instanceId.c_str()) == 0 && + left.present == right.present && + _wcsicmp(left.service.c_str(), right.service.c_str()) == 0 && + _wcsicmp(left.publishedInf.c_str(), right.publishedInf.c_str()) == 0 && + left.version == right.version && SamePackageBytes(left.package, right.package); +} + +bool SameEnumeratedRootState( + const DeviceState& left, + const DeviceState& right) noexcept { + return _wcsicmp(left.instanceId.c_str(), right.instanceId.c_str()) == 0 && + left.present == right.present && left.started == right.started && + left.problem == right.problem && + _wcsicmp(left.service.c_str(), right.service.c_str()) == 0 && + _wcsicmp(left.publishedInf.c_str(), right.publishedInf.c_str()) == 0 && + left.version == right.version; +} + +bool SameCapturedRootState( + const Snapshot& left, + const Snapshot& right) noexcept { + return left.devices.size() == right.devices.size() && + (left.devices.empty() || + (SameRootBinding(left.devices[0], right.devices[0]) && + left.devices[0].started == right.devices[0].started && + left.devices[0].problem == right.devices[0].problem)); +} + +bool RollbackLifecycleStateMatches( + const DeviceState& captured, + const DeviceState& restored) noexcept { + if (captured.started) { + return restored.started && restored.problem == 0; + } + return !restored.started && restored.problem == captured.problem; +} + +bool CaptureAndVerifyRootUnchanged( + const Snapshot& expected, + const wchar_t* phase, + Snapshot* observed, + Error* error) { + Snapshot current; + if (!CaptureSnapshot(¤t, error)) { + return false; + } + if (!SameCapturedRootState(expected, current)) { + return SetError(error, phase, + ERROR_REVISION_MISMATCH, + L"the captured root identity or lifecycle state changed before exact binding"); + } + if (observed != nullptr) { + *observed = std::move(current); + } + return true; +} + +bool CaptureAndVerifyPreparedRootUnchanged( + const DeviceState& expected, + HDEVINFO set, + DEVINST expectedDevInst, + const wchar_t* phase, + Error* error) { + std::vector> matches; + if (!FindExactDevices(set, &matches, error)) { + return false; + } + if (matches.size() != 1 || matches[0].first.DevInst != expectedDevInst) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the selected compatible-driver list no longer belongs to the captured root devnode"); + } + + DeviceState& observed = matches[0].second; + std::filesystem::path infDirectory; + if (!GetSystemInfDirectory(&infDirectory, error)) { + return false; + } + bool owned = false; + PackageInfo package; + if (!IsOwnedGeneratedRootInstanceId(observed.instanceId) || + _wcsicmp(observed.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(observed.publishedInf) || + !LoadOwnedPackage(infDirectory / observed.publishedInf, + true, false, &package, &owned, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, phase, ERROR_ACCESS_DENIED, + L"the selected root no longer has an exact owned package identity"); + } + return false; + } + package.publishedName = observed.publishedInf; + observed.package = std::move(package); + if (!owned || !(observed.version == observed.package.version) || + !SameRootBinding(expected, observed) || + expected.started != observed.started || expected.problem != observed.problem) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the selected root identity, lifecycle state, or package bytes changed before binding"); + } + return true; +} + +bool StageCandidatePackage( + const PackageInfo& candidate, + bool production, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* stagedHere, + PackageInfo* published, + Error* error) { + *stagedHere = false; + *published = PackageInfo{}; + const std::wstring sourcePath = candidate.infPath.native(); + if (sourcePath.empty() || sourcePath.size() >= MAX_PATH) { + return SetError(error, L"stage-driver-package-path", ERROR_FILENAME_EXCED_RANGE, + L"SetupCopyOEMInf requires a canonical source INF path shorter than MAX_PATH"); + } + if (!CheckTransactionDeadline(transactionDeadlineUnixMs, + L"transaction-deadline-before-driver-stage", error)) { + return false; + } + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + std::error_code systemInfError; + const std::filesystem::path canonicalSystemInf = + std::filesystem::canonical(systemInf, systemInfError); + if (systemInfError) { + return SetError(error, L"stage-system-inf-directory", + static_cast(systemInfError.value()), + L"the canonical system INF directory could not be captured before staging"); + } + + std::array destination{}; + DWORD required = 0; + // SetupCopyOEMInf can publish bytes before returning or before subsequent + // receipt validation. Mark the protected transaction as potentially + // mutated before the API boundary; stagedHere remains success-only so + // rollback never claims ownership of a preexisting or uncertain package. + MarkTransactionMutationStarted(); + const BOOL copied = SetupCopyOEMInfW( + sourcePath.c_str(), nullptr, SPOST_PATH, SP_COPY_NOOVERWRITE, + destination.data(), static_cast(destination.size()), + &required, nullptr); + const DWORD copyError = copied ? ERROR_SUCCESS : GetLastError(); + if (copied) { + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + *stagedHere = true; + } else if (copyError != ERROR_FILE_EXISTS) { + if (mutationStarted != nullptr) { + *mutationStarted = true; + } + } + if (!copied && copyError != ERROR_FILE_EXISTS) { + // An unexpected API failure is not proof of package ownership. Leave + // stagedHere false; common rollback will prove the prior inventory and + // fail closed if SetupAPI nevertheless changed it. + return SetError(error, L"stage-driver-package", copyError, + L"add-only candidate import into the Driver Store failed"); + } + + const size_t destinationLength = + wcsnlen_s(destination.data(), destination.size()); + bool receiptValid = destinationLength != 0 && + destinationLength < destination.size() && + required == destinationLength + 1; + std::filesystem::path destinationPath; + if (receiptValid) { + destinationPath = destination.data(); + std::error_code parentError; + const std::filesystem::path canonicalParent = + std::filesystem::canonical(destinationPath.parent_path(), parentError); + receiptValid = !parentError && + IsSafePublishedInfName(destinationPath.filename().wstring()) && + _wcsicmp(canonicalParent.c_str(), canonicalSystemInf.c_str()) == 0; + } + if (receiptValid) { + // Preserve the API's exact, safe published-name receipt immediately. + // Full bytes/catalog/signer verification below may still fail, but + // rollback can then identify and verify only this package. + *published = candidate; + published->infPath = destinationPath; + published->publishedName = destinationPath.filename().wstring(); + } else { + PackageInfo recoveredReceipt; + Error ignoredReceiptError; + if (FindPublishedCandidate( + candidate, &recoveredReceipt, &ignoredReceiptError)) { + *published = std::move(recoveredReceipt); + } + return SetError(error, L"stage-published-inf", ERROR_INVALID_DATA, + L"SetupCopyOEMInf returned a malformed published INF identity"); + } + std::filesystem::path resolvedPublishedPath; + PackageInfo verifiedPublished; + if (!GetPublishedInfPath(candidate.infPath, &resolvedPublishedPath, error) || + _wcsicmp(resolvedPublishedPath.c_str(), destinationPath.c_str()) != 0 || + !FindPublishedCandidate(candidate, &verifiedPublished, error) || + _wcsicmp(verifiedPublished.infPath.c_str(), resolvedPublishedPath.c_str()) != 0 || + _wcsicmp(verifiedPublished.publishedName.c_str(), + resolvedPublishedPath.filename().c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"stage-published-inf", ERROR_REVISION_MISMATCH, + L"add-only staging did not resolve to the unique exact candidate package"); + } + return false; + } + if (production && + !VerifyMicrosoftHardwareInfSigner(verifiedPublished.infPath, error)) { + return false; + } + *published = std::move(verifiedPublished); + return true; +} + bool RemoveDevice( HDEVINFO set, SP_DEVINFO_DATA& data, @@ -2239,20 +2510,51 @@ bool DriverInfoUsesPublishedPackage( _wcsicmp(publishedPath.filename().c_str(), expectedPublishedName.c_str()) == 0; } -bool InstallPreinstalledDriverOnDevice( +struct PreparedDriverBinding { + HDEVINFO set = INVALID_HANDLE_VALUE; + SP_DEVINFO_DATA* device = nullptr; + SP_DRVINFO_DATA_W selected{}; + bool active = false; + + PreparedDriverBinding() = default; + PreparedDriverBinding(const PreparedDriverBinding&) = delete; + PreparedDriverBinding& operator=(const PreparedDriverBinding&) = delete; + + ~PreparedDriverBinding() { + Reset(); + } + + bool Reset() noexcept { + if (!active) { + return true; + } + const BOOL destroyed = SetupDiDestroyDriverInfoList( + set, device, SPDIT_COMPATDRIVER); + active = false; + set = INVALID_HANDLE_VALUE; + device = nullptr; + selected = SP_DRVINFO_DATA_W{}; + return destroyed != FALSE; + } +}; + +bool PreparePreinstalledDriverOnDevice( HDEVINFO set, SP_DEVINFO_DATA* device, const PackageInfo& publishedPackage, - uint64_t transactionDeadlineUnixMs, - bool* mutationStarted, - bool* rebootRequired, + PreparedDriverBinding* prepared, Error* error) { + if (prepared == nullptr || prepared->active || + set == INVALID_HANDLE_VALUE || device == nullptr) { + return SetError(error, L"repair-prepare-driver-binding", + ERROR_INVALID_PARAMETER); + } if (!SetupDiBuildDriverInfoList(set, device, SPDIT_COMPATDRIVER)) { return SetLastErrorDetail(error, L"repair-build-compatible-driver-list"); } - const auto destroyList = [&]() { - return SetupDiDestroyDriverInfoList(set, device, SPDIT_COMPATDRIVER) != FALSE; - }; + prepared->set = set; + prepared->device = device; + prepared->active = true; SP_DRVINFO_DATA_W selected{}; size_t exactMatches = 0; @@ -2262,7 +2564,7 @@ bool InstallPreinstalledDriverOnDevice( if (!SetupDiEnumDriverInfoW(set, device, SPDIT_COMPATDRIVER, index, &driver)) { if (GetLastError() != ERROR_NO_MORE_ITEMS) { const DWORD code = GetLastError(); - destroyList(); + prepared->Reset(); return SetError(error, L"repair-enumerate-compatible-driver", code); } break; @@ -2274,7 +2576,7 @@ bool InstallPreinstalledDriverOnDevice( set, device, &driver, &probe, sizeof(probe), &required) && GetLastError() != ERROR_INSUFFICIENT_BUFFER) { const DWORD code = GetLastError(); - destroyList(); + prepared->Reset(); return SetError(error, L"repair-compatible-driver-detail", code); } const DWORD detailBytes = std::max( @@ -2285,7 +2587,7 @@ bool InstallPreinstalledDriverOnDevice( if (!SetupDiGetDriverInfoDetailW( set, device, &driver, detail, detailBytes, nullptr)) { const DWORD code = GetLastError(); - destroyList(); + prepared->Reset(); return SetError(error, L"repair-compatible-driver-detail", code); } if (DriverInfoUsesPublishedPackage( @@ -2295,45 +2597,72 @@ bool InstallPreinstalledDriverOnDevice( } } if (exactMatches != 1) { - destroyList(); + prepared->Reset(); return SetError(error, L"repair-exact-driver-selection", exactMatches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, L"compatible driver list must contain exactly one node for the exact preinstalled package"); } + prepared->selected = selected; + return true; +} + +bool CommitPreparedDriverBinding( + PreparedDriverBinding* prepared, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + if (prepared == nullptr || !prepared->active || + prepared->set == INVALID_HANDLE_VALUE || prepared->device == nullptr || + prepared->selected.cbSize != sizeof(SP_DRVINFO_DATA_W)) { + return SetError(error, L"repair-commit-driver-binding", + ERROR_INVALID_PARAMETER); + } if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, - L"transaction-deadline-before-driver-selection", error)) { - destroyList(); + L"transaction-deadline-before-selected-device-binding", error)) { return false; } MarkTransactionMutationStarted(); if (mutationStarted != nullptr) { *mutationStarted = true; } - if (!SetupDiSetSelectedDriverW(set, device, &selected)) { + if (!SetupDiSetSelectedDriverW( + prepared->set, prepared->device, &prepared->selected)) { const DWORD code = GetLastError(); - destroyList(); + prepared->Reset(); return SetError(error, L"repair-select-exact-driver", code); } - if (transactionDeadlineUnixMs != 0 && - !CheckTransactionDeadline(transactionDeadlineUnixMs, - L"transaction-deadline-before-device-binding", error)) { - destroyList(); - return false; - } BOOL reboot = FALSE; - if (!DiInstallDevice(nullptr, set, device, &selected, 0, &reboot)) { + if (!DiInstallDevice(nullptr, prepared->set, prepared->device, + &prepared->selected, 0, &reboot)) { const DWORD code = GetLastError(); - destroyList(); + prepared->Reset(); return SetError(error, L"repair-install-preinstalled-driver", code); } - if (!destroyList()) { + if (!prepared->Reset()) { return SetLastErrorDetail(error, L"repair-destroy-compatible-driver-list"); } *rebootRequired = *rebootRequired || reboot != FALSE; return true; } +bool InstallPreinstalledDriverOnDevice( + HDEVINFO set, + SP_DEVINFO_DATA* device, + const PackageInfo& publishedPackage, + uint64_t transactionDeadlineUnixMs, + bool* mutationStarted, + bool* rebootRequired, + Error* error) { + PreparedDriverBinding prepared; + return PreparePreinstalledDriverOnDevice( + set, device, publishedPackage, &prepared, error) && + CommitPreparedDriverBinding( + &prepared, transactionDeadlineUnixMs, mutationStarted, + rebootRequired, error); +} + bool IsGeneratedRootInstanceIdForDeviceName( const std::wstring& instanceId, const wchar_t* deviceName) { @@ -2355,16 +2684,10 @@ bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId) { IsGeneratedRootInstanceIdForDeviceName(instanceId, kLegacyRootDeviceName); } -enum class ExactRootRegistrationMode { - Upgrade, - Rollback, -}; - bool RegisterRootDeviceExact( const GUID& classGuid, const std::wstring& instanceId, uint64_t transactionDeadlineUnixMs, - ExactRootRegistrationMode mode, bool* mutationStarted, bool* registrationSucceeded, DeviceInfoSet* set, @@ -2373,34 +2696,29 @@ bool RegisterRootDeviceExact( if (registrationSucceeded != nullptr) { *registrationSucceeded = false; } - const bool rollback = mode == ExactRootRegistrationMode::Rollback; if (!IsOwnedGeneratedRootInstanceId(instanceId)) { - return SetError(error, rollback ? L"rollback-instance-id" : L"upgrade-instance-id", + return SetError(error, L"rollback-instance-id", ERROR_INVALID_DATA, L"captured root devnode identity is outside the VIIPER or legacy generated root namespace"); } *set = DeviceInfoSet(SetupDiCreateDeviceInfoList(&classGuid, nullptr)); if (!*set) { - return SetLastErrorDetail(error, rollback - ? L"rollback-create-device-info-list" : L"upgrade-create-device-info-list"); + return SetLastErrorDetail(error, L"rollback-create-device-info-list"); } *data = SP_DEVINFO_DATA{}; data->cbSize = sizeof(*data); // With DICD_GENERATE_ID absent, SetupAPI treats DeviceName as the complete - // device instance ID. Upgrade and rollback must never substitute a fresh - // ROOT instance. + // device instance ID. Rollback must never substitute a fresh ROOT instance. if (!SetupDiCreateDeviceInfoW(set->get(), instanceId.c_str(), &classGuid, nullptr, nullptr, 0, data)) { - return SetLastErrorDetail(error, rollback - ? L"rollback-create-exact-root-devnode" : L"upgrade-create-exact-root-devnode"); + return SetLastErrorDetail(error, L"rollback-create-exact-root-devnode"); } const size_t idCharacters = std::size(kHardwareId) + 1; std::vector identifiers(idCharacters, L'\0'); std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, - rollback ? L"rollback-deadline-before-root-properties" - : L"upgrade-deadline-before-root-properties", error)) { + L"rollback-deadline-before-root-properties", error)) { return false; } MarkTransactionMutationStarted(); @@ -2410,13 +2728,11 @@ bool RegisterRootDeviceExact( if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { - return SetLastErrorDetail(error, rollback - ? L"rollback-set-root-hardware-id" : L"upgrade-set-root-hardware-id"); + return SetLastErrorDetail(error, L"rollback-set-root-hardware-id"); } if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, - rollback ? L"rollback-deadline-before-root-registration" - : L"upgrade-deadline-before-root-registration", error)) { + L"rollback-deadline-before-root-registration", error)) { return false; } MarkTransactionMutationStarted(); @@ -2424,8 +2740,7 @@ bool RegisterRootDeviceExact( *mutationStarted = true; } if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { - return SetLastErrorDetail(error, rollback - ? L"rollback-register-exact-root-devnode" : L"upgrade-register-exact-root-devnode"); + return SetLastErrorDetail(error, L"rollback-register-exact-root-devnode"); } if (registrationSucceeded != nullptr) { *registrationSucceeded = true; @@ -2528,22 +2843,89 @@ bool IssueAbiNegotiation( return true; } -bool IsPreviousAbiRetryEligible( - bool requirePristineRuntime, +enum class AbiHealthPurpose { + ExactCandidate, + PristineUpgrade, + PristineRecheck, + RollbackHealth, +}; + +bool IsAbiRetryEligible( + AbiHealthPurpose purpose, const std::string* expectedBuildIdentity, const Error& error) { - return requirePristineRuntime && expectedBuildIdentity == nullptr && + return purpose == AbiHealthPurpose::PristineUpgrade && + expectedBuildIdentity == nullptr && (error.code == ERROR_REVISION_MISMATCH || error.code == ERROR_INVALID_PARAMETER) && (error.phase == L"abi-negotiate" || error.phase == L"abi-negotiate-result"); } +bool RuntimeStatsArePristine( + const VIIPER_UDE_STATS& stats, + const AbiCompatibilityProfile& profile) noexcept { + return stats.OperationsDequeued == 0 && stats.OperationsCompleted == 0 && + stats.OperationsCancelled == 0 && stats.OperationsPurged == 0 && + stats.LateCompletions == 0 && stats.InvalidMessages == 0 && + stats.QueueExhaustions == 0 && stats.IsoPackets == 0 && + stats.BytesToDevice == 0 && stats.BytesFromDevice == 0 && + stats.NotificationEvents == 0 && stats.NotificationEventOverflows == 0 && + stats.ActiveDevices == 0 && stats.PendingOperations == 0 && + stats.WaitingDequeues == 0 && stats.CleanupRetries == 0 && + stats.InputReportsSubmitted == 0 && stats.InputReportsCompleted == 0 && + (!profile.hasReservedPortFields || stats.ReservedPorts == 0); +} + +bool AbiNegotiationResponseMatchesProfile( + const VIIPER_UDE_NEGOTIATE_RESPONSE& response, + DWORD returned, + VIIPER_UDE_UINT64 clientNonce, + const AbiCompatibilityProfile& profile) noexcept { + return returned == sizeof(response) && + response.Header.Magic == VIIPER_UDE_MAGIC && + response.Header.Major == VIIPER_UDE_ABI_MAJOR && + response.Header.Minor == profile.minor && + response.Header.Size == sizeof(response) && response.Header.Flags == 0 && + response.ClientNonce == clientNonce && response.DriverNonce != 0 && + response.Capabilities == profile.capabilities && + response.MaxDevices == VIIPER_UDE_MAX_DEVICES && + response.MaxDescriptorBytes == VIIPER_UDE_MAX_DESCRIPTOR_BYTES && + response.MaxTransferBytes == VIIPER_UDE_MAX_TRANSFER_BYTES && + response.MaxIsoPackets == VIIPER_UDE_MAX_ISO_PACKETS && + response.MaxPendingOperations == VIIPER_UDE_MAX_PENDING_OPERATIONS; +} + +bool StatsRecordMatchesProfile( + const VIIPER_UDE_STATS& stats, + DWORD returned, + const AbiCompatibilityProfile& profile) noexcept { + return returned == profile.statsSize && + stats.Header.Magic == VIIPER_UDE_MAGIC && + stats.Header.Major == VIIPER_UDE_ABI_MAJOR && + stats.Header.Minor == profile.minor && + stats.Header.Size == profile.statsSize && stats.Header.Flags == 0 && + (!profile.hasReservedPortFields || + (stats.ReservedPorts <= VIIPER_UDE_MAX_DEVICES && stats.Reserved == 0)); +} + bool VerifyAbiHealth( uint64_t deadlineUnixMs, const std::string* expectedBuildIdentity, Error* error, - bool requirePristineRuntime = false) { + AbiHealthPurpose purpose = AbiHealthPurpose::ExactCandidate, + const AbiCompatibilityProfile* requiredProfile = nullptr, + AbiCompatibilityProfile* negotiatedProfile = nullptr) { + const bool requiresKnownProfile = + purpose == AbiHealthPurpose::PristineRecheck || + purpose == AbiHealthPurpose::RollbackHealth; + if ((requiresKnownProfile && requiredProfile == nullptr) || + (!requiresKnownProfile && requiredProfile != nullptr) || + (purpose != AbiHealthPurpose::ExactCandidate && + expectedBuildIdentity != nullptr)) { + return SetError(error, L"abi-health-purpose", ERROR_INVALID_PARAMETER, + L"ABI health purpose and compatibility profile are inconsistent"); + } DeviceInfoSet set(SetupDiGetClassDevsW( &kViiperInterfaceGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)); if (!set) { @@ -2598,25 +2980,54 @@ bool VerifyAbiHealth( return SetLastErrorDetail(error, L"abi-interface-open", L"native broker interface is unavailable or still owned by another process"); } - VIIPER_UDE_UINT16 negotiatedMinor = VIIPER_UDE_ABI_MINOR; - VIIPER_UDE_UINT32 negotiatedCapabilities = VIIPER_UDE_ADVERTISED_CAPABILITIES; + const AbiCompatibilityProfile* profiles = nullptr; + size_t profileCount = 0; + bool requirePristineRuntime = false; + switch (purpose) { + case AbiHealthPurpose::ExactCandidate: + profiles = &kAbiCompatibilityProfiles[0]; + profileCount = 1; + break; + case AbiHealthPurpose::PristineUpgrade: + profiles = kAbiCompatibilityProfiles.data(); + profileCount = kAbiCompatibilityProfiles.size(); + requirePristineRuntime = true; + break; + case AbiHealthPurpose::PristineRecheck: + profiles = requiredProfile; + profileCount = 1; + requirePristineRuntime = true; + break; + case AbiHealthPurpose::RollbackHealth: + profiles = requiredProfile; + profileCount = 1; + break; + } + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; VIIPER_UDE_UINT64 clientNonce = 0; DWORD returned = 0; - if (!IssueAbiNegotiation(device.get(), deadlineUnixMs, negotiatedMinor, - negotiatedCapabilities, &response, &clientNonce, &returned, error)) { - if (!IsPreviousAbiRetryEligible( - requirePristineRuntime, expectedBuildIdentity, *error)) { - return false; + const AbiCompatibilityProfile* selectedProfile = nullptr; + for (size_t index = 0; index < profileCount; ++index) { + response = VIIPER_UDE_NEGOTIATE_RESPONSE{}; + clientNonce = 0; + returned = 0; + if (IssueAbiNegotiation(device.get(), deadlineUnixMs, profiles[index].minor, + profiles[index].capabilities, &response, &clientNonce, &returned, error)) { + selectedProfile = &profiles[index]; + break; } - *error = Error{}; - negotiatedMinor = kPreviousAbiMinor; - negotiatedCapabilities = kPreviousAbiCapabilities; - if (!IssueAbiNegotiation(device.get(), deadlineUnixMs, negotiatedMinor, - negotiatedCapabilities, &response, &clientNonce, &returned, error)) { + if (index + 1 == profileCount || + !IsAbiRetryEligible(purpose, expectedBuildIdentity, *error)) { return false; } + *error = Error{}; + } + if (selectedProfile == nullptr) { + return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, + L"no compatible native driver ABI profile was negotiated"); } + std::string loadedBuildIdentity; loadedBuildIdentity.reserve(VIIPER_UDE_BUILD_IDENTITY_BYTES * 2); static constexpr char digits[] = "0123456789abcdef"; @@ -2624,17 +3035,8 @@ bool VerifyAbiHealth( loadedBuildIdentity.push_back(digits[byte >> 4U]); loadedBuildIdentity.push_back(digits[byte & 0x0fU]); } - if (returned != sizeof(response) || response.Header.Magic != VIIPER_UDE_MAGIC || - response.Header.Major != VIIPER_UDE_ABI_MAJOR || - response.Header.Minor != negotiatedMinor || - response.Header.Size != sizeof(response) || response.Header.Flags != 0 || - response.ClientNonce != clientNonce || response.DriverNonce == 0 || - response.Capabilities != negotiatedCapabilities || - response.MaxDevices != VIIPER_UDE_MAX_DEVICES || - response.MaxDescriptorBytes != VIIPER_UDE_MAX_DESCRIPTOR_BYTES || - response.MaxTransferBytes != VIIPER_UDE_MAX_TRANSFER_BYTES || - response.MaxIsoPackets != VIIPER_UDE_MAX_ISO_PACKETS || - response.MaxPendingOperations != VIIPER_UDE_MAX_PENDING_OPERATIONS || + if (!AbiNegotiationResponseMatchesProfile( + response, returned, clientNonce, *selectedProfile) || (expectedBuildIdentity != nullptr && loadedBuildIdentity != *expectedBuildIdentity)) { return SetError(error, L"abi-negotiate", ERROR_REVISION_MISMATCH, @@ -2642,8 +3044,6 @@ bool VerifyAbiHealth( } if (requirePristineRuntime) { VIIPER_UDE_STATS stats{}; - const DWORD expectedStatsSize = negotiatedMinor == VIIPER_UDE_ABI_MINOR - ? static_cast(sizeof(stats)) : kPreviousAbiStatsSize; DWORD statsReturned = 0; WinHandle statsEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (!statsEvent) { @@ -2715,30 +3115,19 @@ bool VerifyAbiHealth( return SetLastErrorDetail(error, L"upgrade-pristine-stats-result"); } } - if (statsReturned != expectedStatsSize || stats.Header.Magic != VIIPER_UDE_MAGIC || - stats.Header.Major != VIIPER_UDE_ABI_MAJOR || - stats.Header.Minor != negotiatedMinor || - stats.Header.Size != expectedStatsSize || stats.Header.Flags != 0 || - (negotiatedMinor == VIIPER_UDE_ABI_MINOR && - (stats.ReservedPorts > VIIPER_UDE_MAX_DEVICES || stats.Reserved != 0))) { + if (!StatsRecordMatchesProfile(stats, statsReturned, *selectedProfile)) { return SetError(error, L"upgrade-pristine-stats", ERROR_REVISION_MISMATCH, L"loaded driver returned an invalid pristine-runtime statistics record"); } - if (stats.OperationsDequeued != 0 || stats.OperationsCompleted != 0 || - stats.OperationsCancelled != 0 || stats.OperationsPurged != 0 || - stats.LateCompletions != 0 || stats.InvalidMessages != 0 || - stats.QueueExhaustions != 0 || stats.IsoPackets != 0 || - stats.BytesToDevice != 0 || stats.BytesFromDevice != 0 || - stats.NotificationEvents != 0 || stats.NotificationEventOverflows != 0 || - stats.ActiveDevices != 0 || stats.PendingOperations != 0 || - stats.WaitingDequeues != 0 || stats.CleanupRetries != 0 || - stats.InputReportsSubmitted != 0 || stats.InputReportsCompleted != 0 || - (negotiatedMinor == VIIPER_UDE_ABI_MINOR && stats.ReservedPorts != 0)) { + if (!RuntimeStatsArePristine(stats, *selectedProfile)) { return SetError(error, L"upgrade-runtime-reboot-boundary", ERROR_SUCCESS_REBOOT_REQUIRED, L"the loaded native bus has serviced virtual-device work since boot; restart Windows and rerun the identical package command before creating another virtual device"); } } + if (negotiatedProfile != nullptr) { + *negotiatedProfile = *selectedProfile; + } return true; } @@ -2787,33 +3176,101 @@ bool UninstallPackage(const PackageInfo& package, bool* rebootRequired, Error* e return true; } -std::set PublishedNames(const std::vector& packages) { - std::set names; - for (const PackageInfo& package : packages) { - std::wstring lower = package.publishedName; - std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { - return static_cast(towlower(character)); - }); - names.insert(std::move(lower)); +bool SamePackageInventory( + const std::vector& left, + const std::vector& right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (size_t index = 0; index < left.size(); ++index) { + if (_wcsicmp(left[index].publishedName.c_str(), + right[index].publishedName.c_str()) != 0 || + !(left[index].version == right[index].version) || + !SamePackageBytes(left[index], right[index])) { + return false; + } } - return names; + return true; } -std::vector NewPackageIndices( - const std::vector& prior, - const std::vector& current) { - const std::set priorNames = PublishedNames(prior); - std::vector indices; - for (size_t index = 0; index < current.size(); ++index) { - std::wstring lower = current[index].publishedName; - std::transform(lower.begin(), lower.end(), lower.begin(), [](wchar_t character) { - return static_cast(towlower(character)); +bool ContainsExactPackage( + const std::vector& packages, + const PackageInfo& candidate) noexcept { + return std::any_of(packages.begin(), packages.end(), + [&](const PackageInfo& package) { + return package.version == candidate.version && + SamePackageBytes(package, candidate); }); - if (!priorNames.contains(lower)) { - indices.push_back(index); +} + +bool VerifyPackageInventory( + const std::vector& expected, + const wchar_t* phase, + Error* error) { + std::vector observed; + if (!EnumerateOwnedPackages(&observed, error)) { + return false; + } + if (!SamePackageInventory(expected, observed)) { + return SetError(error, phase, ERROR_REVISION_MISMATCH, + L"the exact captured Driver Store inventory changed during the protected transaction"); + } + return true; +} + +bool RemoveStagedCandidateExact( + const PackageInfo& stagedCandidate, + uint64_t rollbackDeadlineUnixMs, + Error* error) { + if (!IsSafePublishedInfName(stagedCandidate.publishedName)) { + return SetError(error, L"rollback-staged-package-identity", ERROR_INVALID_NAME, + L"the staged-here candidate lacks a safe exact published INF identity"); + } + std::vector current; + if (!EnumerateOwnedPackages(¤t, error)) { + return false; + } + size_t matches = 0; + for (const PackageInfo& package : current) { + if (_wcsicmp(package.publishedName.c_str(), + stagedCandidate.publishedName.c_str()) == 0) { + if (!(package.version == stagedCandidate.version) || + !SamePackageBytes(package, stagedCandidate)) { + return SetError(error, L"rollback-staged-package-identity", + ERROR_REVISION_MISMATCH, + L"the staged-here published INF no longer matches the exact candidate"); + } + ++matches; + } + } + if (matches != 1) { + return SetError(error, L"rollback-staged-package-identity", + matches == 0 ? ERROR_NOT_FOUND : ERROR_DUPLICATE_SERVICE_NAME, + L"rollback requires exactly one matching staged-here published INF"); + } + Snapshot topology; + if (!CaptureSnapshot(&topology, error)) { + return false; + } + for (const DeviceState& device : topology.devices) { + if (_wcsicmp(device.publishedInf.c_str(), + stagedCandidate.publishedName.c_str()) == 0) { + return SetError(error, L"rollback-staged-package-in-use", + ERROR_DEVICE_IN_USE, + L"rollback refuses to remove a staged-here package still bound to a root device"); } } - return indices; + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-staged-package", error)) { + return false; + } + MarkTransactionMutationStarted(); + if (!SetupUninstallOEMInfW( + stagedCandidate.publishedName.c_str(), 0, nullptr)) { + return SetLastErrorDetail(error, L"rollback-staged-package-remove", + L"the exact unbound staged-here candidate could not be removed"); + } + return true; } bool RestorePriorBinding( @@ -2885,8 +3342,7 @@ bool RestorePriorBinding( return SetLastErrorDetail(error, L"rollback-inf-class"); } if (!RegisterRootDeviceExact(classGuid, expected.instanceId, - transactionDeadlineUnixMs, ExactRootRegistrationMode::Rollback, - nullptr, nullptr, + transactionDeadlineUnixMs, nullptr, nullptr, &target, &targetData, error)) { return false; } @@ -2924,24 +3380,73 @@ bool RestorePriorBinding( return true; } -bool RollbackInstall(const Snapshot& prior, bool* rebootRequired, Error* error) { - if (!RestorePriorBinding(prior, 0, rebootRequired, error)) { +bool RollbackInstall( + const Snapshot& prior, + const PackageInfo* stagedHereCandidate, + bool bindingMutationStarted, + const AbiCompatibilityProfile* priorAbiProfile, + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + Error* error) { + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-root", error)) { return false; } - std::vector current; - if (!EnumerateOwnedPackages(¤t, error)) { + if (bindingMutationStarted) { + if (!RestorePriorBinding( + prior, rollbackDeadlineUnixMs, rebootRequired, error)) { + return false; + } + } else if (!CaptureAndVerifyRootUnchanged( + prior, L"rollback-stage-root-invariance", nullptr, error)) { + return false; + } + if (stagedHereCandidate != nullptr && + !RemoveStagedCandidateExact( + *stagedHereCandidate, rollbackDeadlineUnixMs, error)) { return false; } - for (size_t index : NewPackageIndices(prior.packages, current)) { - if (!UninstallPackage(current[index], rebootRequired, error)) { + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-inventory", error)) { + return false; + } + if (!VerifyPackageInventory( + prior.packages, L"rollback-package-inventory", error)) { + return false; + } + if (bindingMutationStarted && !prior.devices.empty() && !*rebootRequired) { + Snapshot restored; + if (!CaptureSnapshot(&restored, error) || restored.devices.size() != 1 || + !SameRootBinding(prior.devices[0], restored.devices[0])) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"rollback-runtime-binding-verification", + ERROR_REVISION_MISMATCH, + L"rollback did not preserve the exact captured root and package identity"); + } return false; } - } - if (!prior.devices.empty() && !*rebootRequired) { - // Driver rollback can prove exact instance/package binding, but the - // prior transient started/problem state is not a restorable identity. - return VerifyInstalledBinding( - prior.devices[0].package, prior.devices[0].publishedInf, true, error); + if (prior.devices[0].started) { + if (!RollbackLifecycleStateMatches( + prior.devices[0], restored.devices[0])) { + return SetError(error, L"rollback-runtime-start-verification", + ERROR_DEVICE_NOT_AVAILABLE, + L"rollback did not restore the formerly-running root to started/problem-zero state"); + } + if (priorAbiProfile == nullptr) { + return SetError(error, L"rollback-runtime-abi-profile", + ERROR_REVISION_MISMATCH, + L"rollback lacks the exact known-compatible ABI profile captured before binding"); + } + return VerifyAbiHealth( + rollbackDeadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, priorAbiProfile, nullptr); + } + if (!RollbackLifecycleStateMatches( + prior.devices[0], restored.devices[0])) { + return SetError(error, L"rollback-stopped-state-verification", + ERROR_DEVICE_NOT_AVAILABLE, + L"rollback changed the captured stopped/problem lifecycle state"); + } } return true; } @@ -3068,6 +3573,10 @@ bool RequestBrokerQuiescence(const InstallOptions& options, Error* error) { return SetError(error, L"broker-quiescence-handles", ERROR_INVALID_HANDLE, L"driver mutation requires the inherited broker quiescence handshake"); } + if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker-quiescence", error)) { + return false; + } if (!SetEvent(options.brokerQuiesceRequest)) { return SetLastErrorDetail(error, L"broker-quiescence-request"); } @@ -3721,6 +4230,7 @@ Outcome Install(const InstallOptions& options) { return outcome; } PackageInfo publishedCandidate; + std::vector expectedTransactionInventory = prior.packages; bool exactBindingHealthy = false; if (disposition == CandidateDisposition::Exact) { if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error) || @@ -3740,14 +4250,14 @@ Outcome Install(const InstallOptions& options) { // Same-version bytes are immutable. An exact package with a missing, // stopped, or stale binding may repair only the ROOT topology from the // already-published exact INF. It selects that preinstalled package for the - // specific devnode and calls DiInstallDevice; it never calls - // DiInstallDriverW or UpdateDriverForPlugAndPlayDevicesW and therefore - // cannot replace same-version DriverStore content. - const bool topologyRepair = - disposition == CandidateDisposition::Exact && !exactBindingHealthy; + // specific devnode and calls DiInstallDevice, so it cannot replace + // same-version Driver Store content or auto-bind any other device. const bool driverMutation = - disposition == CandidateDisposition::InstallRequired || topologyRepair; + RequiresDriverMutation(disposition, exactBindingHealthy); bool driverMutationStarted = false; + bool packageStagedHere = false; + bool bindingMutationStarted = false; + std::optional priorAbiProfile; DeviceInfoSet created; SP_DEVINFO_DATA createdData{}; createdData.cbSize = sizeof(createdData); @@ -3755,9 +4265,7 @@ Outcome Install(const InstallOptions& options) { bool registrationSucceeded = false; GUID candidateClassGuid{}; wchar_t candidateClassName[MAX_CLASS_NAME_LEN]{}; - const bool needsRootRegistration = - disposition == CandidateDisposition::InstallRequired || - (topologyRepair && prior.devices.empty()); + const bool needsRootRegistration = driverMutation && prior.devices.empty(); if (needsRootRegistration && !SetupDiGetINFClassW(candidate.infPath.c_str(), &candidateClassGuid, candidateClassName, MAX_CLASS_NAME_LEN, nullptr)) { @@ -3766,157 +4274,178 @@ Outcome Install(const InstallOptions& options) { return outcome; } + // Import a new candidate with the add-only Driver Store API while the + // captured root remains fully intact. Prove the unique published bytes, + // catalog/signer, and unchanged root binding before asking the broker to + // quiesce. Every failure after a successful import falls through to the + // snapshot rollback path, which removes the new package and preserves the + // prior binding. + if (disposition == CandidateDisposition::InstallRequired) { + if (!StageCandidatePackage( + candidate, options.production, options.transactionDeadlineUnixMs, + &driverMutationStarted, &packageStagedHere, + &publishedCandidate, &outcome.error)) { + // Exact staging proof recorded the failure. + } else { + if (!packageStagedHere && + !ContainsExactPackage(prior.packages, candidate)) { + SetError(&outcome.error, L"stage-concurrent-publication", ERROR_RETRY, + L"an exact candidate appeared after the Driver Store snapshot; rerun the identical transaction"); + } + if (outcome.error.code == ERROR_SUCCESS && packageStagedHere) { + expectedTransactionInventory.push_back(publishedCandidate); + std::sort(expectedTransactionInventory.begin(), + expectedTransactionInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + if (outcome.error.code == ERROR_SUCCESS && + !VerifyPackageInventory(expectedTransactionInventory, + L"stage-package-inventory-verification", &outcome.error)) { + // Any concurrent package publication fails closed. + } else if (outcome.error.code == ERROR_SUCCESS && + !CaptureAndVerifyRootUnchanged( + prior, L"stage-root-binding-verification", + nullptr, &outcome.error)) { + // The candidate remained staged so common rollback can remove it. + } + } + outcome.changed = outcome.changed || driverMutationStarted; + } + // The service mutex remains owned by the outer package transaction. Ask it - // to stop only a trusted running broker after classification proves a - // driver mutation is necessary, then keep that mutex held across exact - // root replacement and binding verification. This prevents the broker from - // retaining a UdeCx handle that turns synchronous removal into a reboot. - if (driverMutation && !options.brokerExecutable.empty() && + // to stop only a trusted running broker after exact candidate publication + // and root-invariance proof, then keep that mutex held across exact + // selected-device binding and verification. This prevents the broker from + // retaining a UdeCx handle across the package switch. + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !options.brokerExecutable.empty() && !RequestBrokerQuiescence(options, &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + // A newly staged package is a completed mutation and must take the + // common rollback path even when broker quiescence fails. } - // UdeCx child deletion is asynchronous. Older installed images could - // release their logical device slot before framework teardown settled, - // leaving DiUninstallDevice blocked indefinitely even though the broker - // reported an empty bus. Once the trusted broker is stopped, require a - // zero-lifetime-work runtime before replacing a running root. A PnP-stopped - // exact owned root has no live UdeCx stack or ABI endpoint; its captured - // devnode/package identity is already the quiescence proof. Do not start an - // old driver solely to upgrade it. Removal, absence, and rollback checks - // below still guard the stopped-root transaction. For a running root, a - // restart resets these counters and guarantees that no pre-upgrade child - // object can survive into root removal. - if (disposition == CandidateDisposition::InstallRequired && - !prior.devices.empty() && prior.devices[0].started && - outcome.error.code == ERROR_SUCCESS && - !VerifyAbiHealth( - options.transactionDeadlineUnixMs, nullptr, &outcome.error, true)) { - if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { - outcome.rebootRequired = true; - } + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !VerifyPackageInventory(expectedTransactionInventory, + L"post-quiescence-package-inventory-verification", &outcome.error)) { + // Quiescence never authorizes concurrent Driver Store changes. } - if (outcome.error.code == ERROR_SUCCESS && - disposition == CandidateDisposition::InstallRequired) { - // Updating a running root bus in place makes DiInstallDriverW report a - // reboot even though this helper immediately restores the old package. - // Remove only the exact captured VIIPER-owned devnode first, prove its - // topology is gone, then stage and bind the candidate to the same root - // identity. Snapshot rollback recreates the old exact identity/package - // if any later step fails. - if (!prior.devices.empty()) { - DeviceInfoSet replaced = OpenRootDevices(); - std::vector> replacements; - if (!replaced) { - SetLastErrorDetail(&outcome.error, L"upgrade-open-root-devices"); - } else if (!FindExactDevices(replaced.get(), &replacements, &outcome.error)) { - // Exact enumeration recorded the failure. - } else if (replacements.size() != 1 || - _wcsicmp(replacements[0].second.instanceId.c_str(), - prior.devices[0].instanceId.c_str()) != 0 || - _wcsicmp(replacements[0].second.publishedInf.c_str(), - prior.devices[0].publishedInf.c_str()) != 0 || - !(replacements[0].second.version == prior.devices[0].version)) { - SetError(&outcome.error, L"upgrade-root-identity", ERROR_REVISION_MISMATCH, - L"captured root devnode identity or package binding changed before replacement"); - } else { - bool removalReboot = false; - if (RemoveDevice(replaced.get(), replacements[0].first, - options.transactionDeadlineUnixMs, - L"upgrade-deadline-before-device-removal", - &driverMutationStarted, &removalReboot, &outcome.error)) { - outcome.rebootRequired = removalReboot; - if (removalReboot) { - SetError(&outcome.error, L"upgrade-device-removal-reboot-boundary", - ERROR_SUCCESS_REBOOT_REQUIRED, - L"the exact prior root bus could not be removed synchronously; the captured binding will be restored before restart"); - } else { - Snapshot afterRemoval; - if (!CaptureSnapshot(&afterRemoval, &outcome.error)) { - // Exact inventory recorded the failure. - } else if (!afterRemoval.devices.empty()) { - SetError(&outcome.error, L"upgrade-device-removal-verification", - ERROR_DEVICE_IN_USE, - L"the exact prior root bus remained present after synchronous removal"); - } - } - } - outcome.changed = outcome.changed || driverMutationStarted; - } - } - - if (outcome.error.code == ERROR_SUCCESS && - CheckTransactionDeadline(options, - L"transaction-deadline-before-driver-install", &outcome.error)) { - BOOL installReboot = FALSE; - const DWORD installFlags = downgrade ? DIIRFLAG_FORCE_INF : 0; - MarkTransactionMutationStarted(); - driverMutationStarted = true; - outcome.changed = true; - if (!DiInstallDriverW(nullptr, candidate.infPath.c_str(), installFlags, &installReboot)) { - SetLastErrorDetail(&outcome.error, L"install-driver-package"); - } else { - outcome.rebootRequired = outcome.rebootRequired || installReboot != FALSE; - if (installReboot) { - SetError(&outcome.error, L"driver-package-reboot-boundary", - ERROR_SUCCESS_REBOOT_REQUIRED, - L"Windows could not stage the candidate driver package without a restart; the captured binding will be restored first"); - } else if (!FindPublishedCandidate(candidate, &publishedCandidate, &outcome.error)) { - // Exact Driver Store inventory recorded the failure. - } else if (options.production && !VerifyMicrosoftHardwareInfSigner( - publishedCandidate.infPath, &outcome.error)) { - // The staged package must retain its exact production HLK/WHCP policy. - } - } - } + Snapshot preBinding; + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !CaptureAndVerifyRootUnchanged( + prior, L"post-quiescence-root-verification", + &preBinding, &outcome.error)) { + // Full identity and lifecycle state must still match immediately before + // runtime admission and exact device binding. } - if (outcome.error.code == ERROR_SUCCESS && driverMutation && - (prior.devices.empty() || disposition == CandidateDisposition::InstallRequired)) { - bool registeredAndVerified = false; - if (prior.devices.empty()) { - registeredAndVerified = RegisterRootDevice( - candidateClassGuid, options.transactionDeadlineUnixMs, - &driverMutationStarted, ®istrationSucceeded, - &created, &createdData, &outcome.error); + // UdeCx child deletion is asynchronous. Older installed images could + // release their logical device slot before framework teardown settled. + // Once the trusted broker is stopped, require a zero-lifetime-work runtime + // before any in-place package binding mutation of a running root. + // A PnP-stopped exact owned root has no live UdeCx stack or ABI endpoint; + // its captured devnode/package identity is already the quiescence proof. + // Do not start an old driver solely to replace it. Exact binding and + // rollback checks below still guard the stopped-root transaction. For a + // running root, a restart resets these counters and guarantees that no + // pre-replacement child object can survive into rebinding. + const bool currentRootPresent = preBinding.devices.size() == 1 && + preBinding.devices[0].present; + const bool requiresPristineRuntimeProof = + outcome.error.code == ERROR_SUCCESS && + RequiresPristineRuntimeProof( + disposition, exactBindingHealthy, currentRootPresent, + currentRootPresent && preBinding.devices[0].started); + if (outcome.error.code == ERROR_SUCCESS && requiresPristineRuntimeProof) { + AbiCompatibilityProfile negotiatedProfile{}; + if (!VerifyAbiHealth( + options.transactionDeadlineUnixMs, nullptr, &outcome.error, + AbiHealthPurpose::PristineUpgrade, nullptr, + &negotiatedProfile)) { + if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { + outcome.rebootRequired = true; + } } else { - registeredAndVerified = RegisterRootDeviceExact( - candidateClassGuid, prior.devices[0].instanceId, - options.transactionDeadlineUnixMs, ExactRootRegistrationMode::Upgrade, - &driverMutationStarted, ®istrationSucceeded, - &created, &createdData, &outcome.error); - } - createdHere = registrationSucceeded; - if (registeredAndVerified) { - InstallPreinstalledDriverOnDevice( - created.get(), &createdData, publishedCandidate, - options.transactionDeadlineUnixMs, &driverMutationStarted, - &outcome.rebootRequired, &outcome.error); + priorAbiProfile = negotiatedProfile; } - outcome.changed = outcome.changed || driverMutationStarted; } - if (outcome.error.code == ERROR_SUCCESS && topologyRepair && !prior.devices.empty()) { - DeviceInfoSet repairSet = OpenRootDevices(); - std::vector> repairDevices; - if (!repairSet) { - SetLastErrorDetail(&outcome.error, L"repair-open-root-devices"); - } else if (!FindExactDevices(repairSet.get(), &repairDevices, &outcome.error)) { - // Exact enumeration recorded the failure. - } else if (repairDevices.size() != 1 || - _wcsicmp(repairDevices[0].second.instanceId.c_str(), - prior.devices[0].instanceId.c_str()) != 0) { - SetError(&outcome.error, L"repair-root-identity", ERROR_REVISION_MISMATCH, - L"root devnode identity changed before exact topology repair"); + if (outcome.error.code == ERROR_SUCCESS && driverMutation) { + if (prior.devices.empty()) { + const bool inventoryVerified = VerifyPackageInventory( + expectedTransactionInventory, + L"final-pre-bind-package-inventory-verification", &outcome.error); + const bool registeredAndVerified = inventoryVerified && RegisterRootDevice( + candidateClassGuid, options.transactionDeadlineUnixMs, + &bindingMutationStarted, ®istrationSucceeded, + &created, &createdData, &outcome.error); + createdHere = registrationSucceeded; + if (registeredAndVerified) { + InstallPreinstalledDriverOnDevice( + created.get(), &createdData, publishedCandidate, + options.transactionDeadlineUnixMs, &bindingMutationStarted, + &outcome.rebootRequired, &outcome.error); + } } else { - InstallPreinstalledDriverOnDevice( - repairSet.get(), &repairDevices[0].first, publishedCandidate, - options.transactionDeadlineUnixMs, &driverMutationStarted, - &outcome.rebootRequired, &outcome.error); - outcome.changed = outcome.changed || driverMutationStarted; + DeviceInfoSet bindingSet = OpenRootDevices(); + std::vector> bindingDevices; + PreparedDriverBinding prepared; + if (!bindingSet) { + SetLastErrorDetail(&outcome.error, L"binding-open-root-devices"); + } else if (!FindExactDevices( + bindingSet.get(), &bindingDevices, &outcome.error)) { + // Exact enumeration recorded the failure. + } else if (bindingDevices.size() != 1 || + !SameEnumeratedRootState( + bindingDevices[0].second, prior.devices[0])) { + SetError(&outcome.error, L"binding-root-invariance", + ERROR_REVISION_MISMATCH, + L"the captured root identity or lifecycle state changed before compatible-driver preparation"); + } else if (!PreparePreinstalledDriverOnDevice( + bindingSet.get(), &bindingDevices[0].first, + publishedCandidate, &prepared, &outcome.error)) { + // Exact compatible-driver selection recorded the failure. + } else if (!CaptureAndVerifyRootUnchanged( + prior, + L"final-pre-bind-root-topology-verification", + nullptr, &outcome.error)) { + // A fresh global set catches roots absent from the prepared set. + } else if (!VerifyPackageInventory(expectedTransactionInventory, + L"final-pre-bind-package-inventory-verification", + &outcome.error)) { + // No concurrent package can enter the selected-driver window. + } else if (!CaptureAndVerifyPreparedRootUnchanged( + prior.devices[0], bindingSet.get(), + bindingDevices[0].first.DevInst, + L"final-pre-bind-root-verification", &outcome.error)) { + // The final same-devnode proof includes exact package bytes. + } else if (requiresPristineRuntimeProof && + (!priorAbiProfile.has_value() || + !VerifyAbiHealth( + options.transactionDeadlineUnixMs, nullptr, + &outcome.error, AbiHealthPurpose::PristineRecheck, + priorAbiProfile.has_value() + ? &priorAbiProfile.value() : nullptr, + nullptr))) { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"final-pre-bind-abi-profile", + ERROR_REVISION_MISMATCH, + L"the exact pre-quiescence ABI profile is unavailable for final pristine proof"); + } else if (outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED) { + outcome.rebootRequired = true; + } + } else { + CommitPreparedDriverBinding( + &prepared, options.transactionDeadlineUnixMs, + &bindingMutationStarted, &outcome.rebootRequired, + &outcome.error); + } } + driverMutationStarted = driverMutationStarted || bindingMutationStarted; + outcome.changed = outcome.changed || bindingMutationStarted; } if (outcome.error.code == ERROR_SUCCESS) { @@ -3931,13 +4460,22 @@ Outcome Install(const InstallOptions& options) { outcome.rebootRequired, &outcome.error))) { // Verification recorded the exact failure. } + if (outcome.error.code == ERROR_SUCCESS && driverMutation && + !VerifyPackageInventory(expectedTransactionInventory, + L"post-bind-package-inventory-verification", &outcome.error)) { + // A concurrent package mutation invalidates the transaction outcome. + } if (outcome.error.code != ERROR_SUCCESS && driverMutationStarted) { const Error installError = outcome.error; Error rollbackError; bool rollbackReboot = outcome.rebootRequired; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; if (createdHere) { Error cleanupError; - if (!RemoveDevice(created.get(), createdData, 0, nullptr, nullptr, + if (!RemoveDevice(created.get(), createdData, rollbackDeadline, + L"install-rollback-deadline-created-device", + nullptr, &rollbackReboot, &cleanupError)) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; @@ -3946,7 +4484,12 @@ Outcome Install(const InstallOptions& options) { return outcome; } } - if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + const PackageInfo* stagedHereCandidate = + packageStagedHere ? &publishedCandidate : nullptr; + if (RollbackInstall( + prior, stagedHereCandidate, bindingMutationStarted, + priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError)) { outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = installError; @@ -4000,9 +4543,13 @@ Outcome Install(const InstallOptions& options) { } Error rollbackError; bool rollbackReboot = outcome.rebootRequired; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; if (createdHere) { Error cleanupError; - if (!RemoveDevice(created.get(), createdData, 0, nullptr, nullptr, + if (!RemoveDevice(created.get(), createdData, rollbackDeadline, + L"install-rollback-deadline-created-device", + nullptr, &rollbackReboot, &cleanupError)) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; @@ -4011,7 +4558,12 @@ Outcome Install(const InstallOptions& options) { return outcome; } } - if (RollbackInstall(prior, &rollbackReboot, &rollbackError)) { + const PackageInfo* stagedHereCandidate = + packageStagedHere ? &publishedCandidate : nullptr; + if (RollbackInstall( + prior, stagedHereCandidate, bindingMutationStarted, + priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError)) { outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(brokerError); @@ -5282,6 +5834,23 @@ Outcome SelfTest() { L"an exact same-version candidate was not classified as repair-only"); return outcome; } + if (!RequiresDriverMutation(CandidateDisposition::InstallRequired, false) || + !RequiresDriverMutation(CandidateDisposition::Exact, false) || + RequiresDriverMutation(CandidateDisposition::Exact, true) || + !RequiresPristineRuntimeProof( + CandidateDisposition::InstallRequired, false, true, true) || + !RequiresPristineRuntimeProof( + CandidateDisposition::Exact, false, true, true) || + RequiresPristineRuntimeProof( + CandidateDisposition::Exact, true, true, true) || + RequiresPristineRuntimeProof( + CandidateDisposition::InstallRequired, false, false, false) || + RequiresPristineRuntimeProof( + CandidateDisposition::Exact, false, true, false)) { + SetError(&outcome.error, L"self-test-pristine-runtime-decision", ERROR_INVALID_DATA, + L"pristine-runtime admission does not cover exactly the running-root mutation boundary"); + return outcome; + } PackageInfo conflict = candidate; conflict.infSha256 = "different-inf"; classificationError = {}; @@ -5351,39 +5920,172 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "a0185735dc6d1397e40744fcb0055ded753f30fe4b991d027065707eacecec18") { + "6796b0cf22a80984b283662a50a3b364c46218e37766a2e1880b38851b65d9ad") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } return outcome; } - Error previousAbiError; - previousAbiError.code = ERROR_REVISION_MISMATCH; - previousAbiError.phase = L"abi-negotiate-result"; - if (!IsPreviousAbiRetryEligible(true, nullptr, previousAbiError) || - IsPreviousAbiRetryEligible(false, nullptr, previousAbiError) || - IsPreviousAbiRetryEligible(true, &buildIdentity, previousAbiError)) { - SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, - L"previous-ABI retry escaped the pristine upgrade-only boundary"); - return outcome; + const std::array abiPurposes{ + AbiHealthPurpose::ExactCandidate, + AbiHealthPurpose::PristineUpgrade, + AbiHealthPurpose::PristineRecheck, + AbiHealthPurpose::RollbackHealth, + }; + const std::array retryCodes{ + ERROR_REVISION_MISMATCH, ERROR_INVALID_PARAMETER, + }; + const std::array retryPhases{ + L"abi-negotiate", L"abi-negotiate-result", + }; + for (const AbiHealthPurpose purpose : abiPurposes) { + for (const DWORD code : retryCodes) { + for (const std::wstring& phase : retryPhases) { + Error retryError; + retryError.code = code; + retryError.phase = phase; + const bool expected = purpose == AbiHealthPurpose::PristineUpgrade; + if (IsAbiRetryEligible(purpose, nullptr, retryError) != expected || + IsAbiRetryEligible(purpose, &buildIdentity, retryError)) { + SetError(&outcome.error, L"self-test-abi-retry", ERROR_INVALID_DATA, + L"ABI retry escaped the strict pristine-upgrade mismatch boundary"); + return outcome; + } + } + } } - previousAbiError.code = ERROR_INVALID_PARAMETER; - if (!IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { - SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, - L"legacy previous-ABI mismatch was not accepted at the pristine boundary"); + Error unrelatedRetryError; + unrelatedRetryError.code = ERROR_ACCESS_DENIED; + unrelatedRetryError.phase = L"abi-negotiate"; + Error wrongPhaseRetryError; + wrongPhaseRetryError.code = ERROR_REVISION_MISMATCH; + wrongPhaseRetryError.phase = L"abi-negotiate-timeout"; + if (IsAbiRetryEligible( + AbiHealthPurpose::PristineUpgrade, nullptr, unrelatedRetryError) || + IsAbiRetryEligible( + AbiHealthPurpose::PristineUpgrade, nullptr, wrongPhaseRetryError)) { + SetError(&outcome.error, L"self-test-abi-retry", ERROR_INVALID_DATA, + L"ABI retry accepted a non-version negotiation failure"); return outcome; } - previousAbiError.code = ERROR_ACCESS_DENIED; - if (IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { - SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, - L"previous-ABI retry accepted an unrelated negotiation failure"); + + const auto makeNegotiationResponse = [](const AbiCompatibilityProfile& profile) { + VIIPER_UDE_NEGOTIATE_RESPONSE response{}; + response.Header.Magic = VIIPER_UDE_MAGIC; + response.Header.Major = VIIPER_UDE_ABI_MAJOR; + response.Header.Minor = profile.minor; + response.Header.Size = sizeof(response); + response.ClientNonce = 0x123456789abcdef0ULL; + response.DriverNonce = 1; + response.Capabilities = profile.capabilities; + response.MaxDevices = VIIPER_UDE_MAX_DEVICES; + response.MaxDescriptorBytes = VIIPER_UDE_MAX_DESCRIPTOR_BYTES; + response.MaxTransferBytes = VIIPER_UDE_MAX_TRANSFER_BYTES; + response.MaxIsoPackets = VIIPER_UDE_MAX_ISO_PACKETS; + response.MaxPendingOperations = VIIPER_UDE_MAX_PENDING_OPERATIONS; + return response; + }; + const auto negotiationValidationIsExhaustive = + [&](const AbiCompatibilityProfile& profile) { + const VIIPER_UDE_NEGOTIATE_RESPONSE response = + makeNegotiationResponse(profile); + const auto rejects = [&](auto mutate) { + VIIPER_UDE_NEGOTIATE_RESPONSE changed = response; + mutate(changed); + return !AbiNegotiationResponseMatchesProfile( + changed, sizeof(changed), response.ClientNonce, profile); + }; + return AbiNegotiationResponseMatchesProfile( + response, sizeof(response), response.ClientNonce, profile) && + !AbiNegotiationResponseMatchesProfile( + response, sizeof(response) - 1, response.ClientNonce, profile) && + rejects([](auto& value) { value.Header.Magic ^= 1; }) && + rejects([](auto& value) { ++value.Header.Major; }) && + rejects([](auto& value) { ++value.Header.Minor; }) && + rejects([](auto& value) { ++value.Header.Size; }) && + rejects([](auto& value) { value.Header.Flags = 1; }) && + rejects([](auto& value) { ++value.ClientNonce; }) && + rejects([](auto& value) { value.DriverNonce = 0; }) && + rejects([](auto& value) { ++value.Capabilities; }) && + rejects([](auto& value) { ++value.MaxDevices; }) && + rejects([](auto& value) { ++value.MaxDescriptorBytes; }) && + rejects([](auto& value) { ++value.MaxTransferBytes; }) && + rejects([](auto& value) { ++value.MaxIsoPackets; }) && + rejects([](auto& value) { ++value.MaxPendingOperations; }); + }; + const auto statsValidationIsExhaustive = + [](const AbiCompatibilityProfile& profile) { + VIIPER_UDE_STATS stats{}; + stats.Header.Magic = VIIPER_UDE_MAGIC; + stats.Header.Major = VIIPER_UDE_ABI_MAJOR; + stats.Header.Minor = profile.minor; + stats.Header.Size = profile.statsSize; + const auto rejects = [&](auto mutate) { + VIIPER_UDE_STATS changed = stats; + mutate(changed); + return !StatsRecordMatchesProfile( + changed, profile.statsSize, profile); + }; + const bool commonFieldsExact = StatsRecordMatchesProfile( + stats, profile.statsSize, profile) && + !StatsRecordMatchesProfile(stats, profile.statsSize - 1, profile) && + rejects([](auto& value) { value.Header.Magic ^= 1; }) && + rejects([](auto& value) { ++value.Header.Major; }) && + rejects([](auto& value) { ++value.Header.Minor; }) && + rejects([](auto& value) { ++value.Header.Size; }) && + rejects([](auto& value) { value.Header.Flags = 1; }); + stats.ReservedPorts = VIIPER_UDE_MAX_DEVICES + 1; + stats.Reserved = 1; + const bool reservedRangeExact = profile.hasReservedPortFields + ? !StatsRecordMatchesProfile(stats, profile.statsSize, profile) + : StatsRecordMatchesProfile(stats, profile.statsSize, profile); + return commonFieldsExact && reservedRangeExact; + }; + for (const AbiCompatibilityProfile& profile : kAbiCompatibilityProfiles) { + if (!negotiationValidationIsExhaustive(profile) || + !statsValidationIsExhaustive(profile)) { + SetError(&outcome.error, L"self-test-abi-profile-validation", + ERROR_INVALID_DATA, + L"an ABI profile response or statistics field escaped exact validation"); + return outcome; + } + } + + VIIPER_UDE_STATS pristineStats{}; + const auto rejectsNonzeroRuntimeCounter = [](auto member) { + VIIPER_UDE_STATS stats{}; + stats.*member = 1; + return !RuntimeStatsArePristine(stats, kAbiCompatibilityProfiles[0]); + }; + if (!RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[0]) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsDequeued) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsCompleted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsCancelled) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::OperationsPurged) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::LateCompletions) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InvalidMessages) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::QueueExhaustions) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::IsoPackets) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::BytesToDevice) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::BytesFromDevice) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::NotificationEvents) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::NotificationEventOverflows) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::ActiveDevices) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::PendingOperations) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::WaitingDequeues) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::CleanupRetries) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InputReportsSubmitted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::InputReportsCompleted) || + !rejectsNonzeroRuntimeCounter(&VIIPER_UDE_STATS::ReservedPorts)) { + SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, + L"a nonzero runtime counter escaped the pre-mutation reboot boundary"); return outcome; } - previousAbiError.code = ERROR_REVISION_MISMATCH; - previousAbiError.phase = L"abi-negotiate-timeout"; - if (IsPreviousAbiRetryEligible(true, nullptr, previousAbiError)) { - SetError(&outcome.error, L"self-test-previous-abi-retry", ERROR_INVALID_DATA, - L"previous-ABI retry accepted a non-version negotiation failure"); + pristineStats.ReservedPorts = 1; + if (!RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[1]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2])) { + SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, + L"a legacy ABI inspected a counter outside its returned statistics record"); return outcome; } JsonValue value; @@ -5410,10 +6112,89 @@ Outcome SelfTest() { preservedPackage.publishedName = L"oem7.inf"; PackageInfo newPackage; newPackage.publishedName = L"oem9.inf"; - const std::vector cleanup = NewPackageIndices( - {priorPackage}, {preservedPackage, newPackage}); - if (cleanup != std::vector{1}) { - SetError(&outcome.error, L"self-test-rollback-cleanup", ERROR_INVALID_DATA); + newPackage.sysSha256 = "new-sys"; + PackageInfo changedPackage = preservedPackage; + changedPackage.sysSha256 = "changed"; + if (!SamePackageInventory({priorPackage}, {preservedPackage}) || + SamePackageInventory({priorPackage}, {preservedPackage, newPackage}) || + SamePackageInventory({priorPackage}, {changedPackage}) || + !ContainsExactPackage({priorPackage}, preservedPackage) || + ContainsExactPackage({priorPackage}, newPackage)) { + SetError(&outcome.error, L"self-test-rollback-inventory", ERROR_INVALID_DATA, + L"rollback package inventory comparison is not exact and name-bound"); + return outcome; + } + DeviceState capturedRoot; + capturedRoot.instanceId = L"ROOT\\VIIPERUDE\\0000"; + capturedRoot.present = true; + capturedRoot.started = true; + capturedRoot.service = kServiceName; + capturedRoot.publishedInf = L"oem7.inf"; + capturedRoot.version = one; + capturedRoot.package = priorPackage; + capturedRoot.package.infSha256 = "prior-inf"; + capturedRoot.package.sysSha256 = "prior-sys"; + capturedRoot.package.catSha256 = "prior-cat"; + Snapshot capturedRootSnapshot; + capturedRootSnapshot.devices.push_back(capturedRoot); + Snapshot observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.packages.push_back(newPackage); + if (!SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"add-only package publication changed the captured root comparison"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + DeviceState concurrentRoot = capturedRoot; + concurrentRoot.instanceId = L"ROOT\\VIIPERUDE\\0001"; + observedRootSnapshot.devices.push_back(std::move(concurrentRoot)); + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a concurrently registered second root escaped global topology verification"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.devices[0].started = false; + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a root lifecycle change escaped post-stage verification"); + return outcome; + } + observedRootSnapshot = capturedRootSnapshot; + observedRootSnapshot.devices[0].publishedInf = L"oem9.inf"; + if (SameCapturedRootState(capturedRootSnapshot, observedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"a root package rebind escaped post-stage verification"); + return outcome; + } + if (!SameCapturedRootState(Snapshot{}, Snapshot{}) || + SameCapturedRootState(Snapshot{}, capturedRootSnapshot)) { + SetError(&outcome.error, L"self-test-stage-root-invariance", ERROR_INVALID_DATA, + L"absent-root post-stage verification is not exact"); + return outcome; + } + DeviceState stoppedRoot = capturedRoot; + stoppedRoot.started = false; + stoppedRoot.problem = CM_PROB_DISABLED; + DeviceState restoredStoppedRoot = stoppedRoot; + if (!RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"an exact stopped/problem rollback state was rejected"); + return outcome; + } + restoredStoppedRoot.started = true; + restoredStoppedRoot.problem = 0; + if (RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"rollback accepted a captured stopped root that was unexpectedly started"); + return outcome; + } + restoredStoppedRoot = stoppedRoot; + ++restoredStoppedRoot.problem; + if (RollbackLifecycleStateMatches(stoppedRoot, restoredStoppedRoot) || + !RollbackLifecycleStateMatches(capturedRoot, capturedRoot)) { + SetError(&outcome.error, L"self-test-rollback-lifecycle", ERROR_INVALID_DATA, + L"rollback lifecycle comparison is not exact for stopped or running roots"); return outcome; } const std::filesystem::path recoveryRoot = From a9b99f3e5bb8efde005829aec83d0f0392cb6234 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 04:56:12 -0500 Subject: [PATCH 235/240] Make driver install switch crash recoverable --- internal/cmd/native_package_contract_test.go | 364 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 452 +- native/udecx/tools/ViiperUdeCtl.cpp | 7387 ++++++++++++++++- 3 files changed, 7984 insertions(+), 219 deletions(-) diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index fa5814fd..9c6a4ca0 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -223,6 +223,362 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("driver helper lost %q", fragment) } } + sourceRegion := func(name, start, end string, lastStart bool) string { + t.Helper() + startIndex := strings.Index(helperSource, start) + if lastStart { + startIndex = strings.LastIndex(helperSource, start) + } + endIndex := -1 + if startIndex >= 0 { + if relative := strings.Index(helperSource[startIndex+len(start):], end); relative >= 0 { + endIndex = startIndex + len(start) + relative + } + } + if startIndex < 0 || endIndex <= startIndex { + t.Fatalf("driver helper %s source region is missing or malformed", name) + } + return helperSource[startIndex:endIndex] + } + assertOrdered := func(name, source string, fragments ...string) { + t.Helper() + cursor := -1 + for _, fragment := range fragments { + relative := strings.Index(source[cursor+1:], fragment) + if relative < 0 { + t.Fatalf("driver helper violates %s ordering at %q", name, fragment) + } + cursor += relative + 1 + } + } + + journalRequired := []string{ + "SHGetKnownFolderPath(", "FOLDERID_ProgramData", + `kInstallRecoveryProductDirectory[] = L"VIIPER"`, + `kInstallRecoveryComponentDirectory[] = L"UdeCx"`, + `kInstallRecoveryTransactionsDirectory[] = L"Transactions"`, + `kInstallRecoveryActiveDirectory[] = L"active-v2"`, + `kInstallRecoveryJournalPrefix[] = L"journal-"`, + "OpenStableDirectory(", "CreateOrOpenInstallRecoveryDirectory(", + "FILE_FLAG_OPEN_REPARSE_POINT", "FILE_ATTRIBUTE_REPARSE_POINT", + "VerifyProtectedFileSystemSecurity(", "WriteInstallJournalRecord(", + `\"previousSha256\"`, `\"payloadSha256\"`, + "FILE_FLAG_WRITE_THROUGH", "FlushFileBuffers(file.get())", + "MOVEFILE_WRITE_THROUGH", "install-journal-readback", + "Outcome Recover(", `L"recover"`, "ReconcileInstallJournal(", + "SynchronousMutationWatchdog", "InvokeAuthoritativeSynchronousMutation(", + "deadlineOverrun", "ManualReconciliationRequired", + "ForwardRebootPending", "RestoreRebootPending", + `\"direction\"`, `\"rollbackAuthorized\"`, + "ValidateInstallJournalTransition(", "impl_->poisoned = true", + "ValidateAndDiscardInstallJournalTemporaryFile(", + "OpenExistingInstallRecoveryDirectory(", "MapGenericMask(", + "StageReceiptCaptured ownership record", "exactPreRollbackInventory", + "RootSnapshotIsAuthorizedForInstallRollback(", + "InstallJournalNeedsRestoreRebootPending(", + "pendingRebootBootIdentifier", "freshRebootRequired", + "rootRegistrationInstanceId", "RootRegistrationIntentCaptured", + "ObservePriorEmptyInstallRecoveryRoot(", + "ReadInstallRecoveryHardwareIds(", + "DecodeCanonicalInstallRecoveryString(", + "RemoveAuthorizedPriorEmptyRootAfterAdmission(", + "PartialRootRemovalEntered", "PartialRootRemovalReturned", + "PartialRootRemovalRebootPending", "partialRootRemovalBinding", + "partialRootRemovalBootIdentifier", + "InstallRecoveryChainHasActive(", + "BuildInstallRecoveryProductDirectorySecurity(", + "VerifyProtectedProductDirectorySecurity(", + } + for _, fragment := range journalRequired { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper install journal lost %q", fragment) + } + } + journalPhases := []string{ + "Prepared", + "SetupCopyEntered", "SetupCopyReturned", "StageReceiptCaptured", + "QuiesceSignalEntered", "QuiesceSignalReturned", + "RootRegistrationIntentCaptured", + "RootRegistrationEntered", "RootRegistrationReturned", + "DiInstallEntered", "DiInstallReturned", "PriorAbiProfileCaptured", + "DriverValidated", + "BrokerHandoffEntered", "BrokerHandoffReturned", + "BrokerChildEntered", "BrokerChildSettled", + "RollbackBindingEntered", "PartialRootRemovalEntered", + "PartialRootRemovalReturned", "PartialRootRemovalRebootPending", + "RollbackBindingReturned", + "SetupUninstallEntered", "SetupUninstallReturned", + "ForwardValidated", "ExactPriorRestored", + "ForwardRebootPending", "RestoreRebootPending", + "ManualReconciliationRequired", + } + for _, phase := range journalPhases { + qualified := "InstallJournalPhase::" + phase + if strings.Count(helperSource, qualified) < 2 { + t.Errorf("driver helper install journal does not both define and use phase %q", phase) + } + } + + recoveryPathSource := sourceRegion("fixed recovery path", + "bool ResolveInstallRecoveryPaths(", "bool GetBootIdentifier(", false) + assertOrdered("fixed ProgramData path", recoveryPathSource, + "SHGetKnownFolderPath(", "FOLDERID_ProgramData", + "*product = *programData / kInstallRecoveryProductDirectory;", + "*component = *product / kInstallRecoveryComponentDirectory;", + "*transactions = *component / kInstallRecoveryTransactionsDirectory;", + "*active = *transactions / kInstallRecoveryActiveDirectory;") + for _, fragment := range []string{ + "FILE_FLAG_OPEN_REPARSE_POINT", "FILE_ATTRIBUTE_REPARSE_POINT", + "VerifyProtectedFileSystemSecurity(", + "CreateOrOpenInstallRecoveryDirectory(", + "active, false, true, &activeHandle", + } { + if !strings.Contains(recoveryPathSource, fragment) { + t.Errorf("driver helper fixed recovery path lost %q", fragment) + } + } + + journalWriterSource := sourceRegion("append-only journal writer", + "bool WriteInstallJournalRecord(", "bool GenerateInstallTransactionId(", false) + assertOrdered("durable journal publication", journalWriterSource, + "BuildInstallJournalPayload(", "Sha256Data(payload, &digest", + `\"payloadSha256\"`, "CREATE_NEW", "FILE_FLAG_WRITE_THROUGH", + "FlushFileBuffers(file.get())", "MoveFileExW(", "MOVEFILE_WRITE_THROUGH", + "OPEN_EXISTING", "ReadFile(file.get(), observed.data()", + "observed != record", "trailingRead != 0", + "state->previousDigest = digest", "++state->sequence") + if strings.Contains(journalWriterSource, "MOVEFILE_REPLACE_EXISTING") { + t.Error("driver helper append-only journal can replace a published record") + } + journalLoadSource := sourceRegion("journal chain loader", + "bool LoadInstallJournal(", "bool RetireLoadedInstallJournal(", false) + assertOrdered("journal hash-chain validation", journalLoadSource, + "std::string priorDigest(kZeroSha256)", + "ParseInstallJournalEnvelope(", + "parsed.sequence != expectedSequence", + "parsed.previousDigest.c_str(), priorDigest.c_str()", + "priorDigest = digest") + + journalPrepareSource := sourceRegion("install journal preparation", + "bool InstallJournal::Prepare(", "bool InstallJournal::Record(", false) + assertOrdered("protected evidence before Prepared", journalPrepareSource, + "BackupPackagesIntoDirectory(", "CopyCandidateIntoInstallJournal(", + "impl_->state.prior = prior;", "impl_->state.candidate = candidate;", + "impl_->state.phase = InstallJournalPhase::Prepared;", + "WriteInstallJournalRecord(") + + journalRecordSource := sourceRegion("atomic journal record", + "bool InstallJournal::RecordNext(", "bool InstallJournal::RecordCutpoint(", false) + assertOrdered("atomic journal state publication", journalRecordSource, + "ValidateInstallJournalTransition(&impl_->state, next", + "WriteInstallJournalRecord(", "impl_->state = std::move(next);", + "PublishInstallRecoveryEvidence(") + if !strings.Contains(journalRecordSource, "impl_->poisoned = true") { + t.Error("driver helper can continue after an indeterminate journal append") + } + + watchdogSource := sourceRegion("authoritative mutation watchdog", + "class SynchronousMutationWatchdog final", "class DeviceInfoSet final", false) + for _, fragment := range []string{ + "completion_.get(), waitMilliseconds", + "timedOut_.store(true", "WaitForSingleObject(completion_.get(), INFINITE)", + "thread_.join()", "gLastSynchronousMutationTimedOut = watchdog.Complete()", + } { + if !strings.Contains(watchdogSource, fragment) { + t.Errorf("driver helper authoritative watchdog lost %q", fragment) + } + } + for _, forbidden := range []string{ + "CancelIoEx(", "CancelSynchronousIo(", "TerminateThread(", + "TerminateProcess(", ".detach()", + } { + if strings.Contains(watchdogSource, forbidden) { + t.Errorf("driver helper authoritative watchdog contains forbidden cancellation %q", forbidden) + } + } + + installEntrySource := sourceRegion("install entry", + "Outcome Install(const InstallOptions& options)", "struct PackageBackup {", false) + assertOrdered("install pre-mutation reconciliation", installEntrySource, + "mutex.Acquire(", "ReconcileInstallJournal(", "ValidateCandidateInputs(", + "CaptureSnapshot(", "installJournal.Prepare(") + if strings.Count(installEntrySource, + "RemoveAuthorizedPriorEmptyRootAfterAdmission(") != 2 || + strings.Count(installEntrySource, + "VerifyPriorTopologyBeforePackageRollback(") != 2 || + strings.Count(installEntrySource, + "!prior.devices.empty() && bindingMutationStarted") != 2 { + t.Error("driver helper must apply receipt-bound root cleanup and strict post-removal proof in both in-process rollback branches without generic prior-empty deletion") + } + removeEntrySource := sourceRegion("remove entry", + "Outcome Remove(const RemoveOptions& options)", "Outcome Recover(", false) + assertOrdered("remove pre-mutation reconciliation", removeEntrySource, + "mutex.Acquire(", "ReconcileInstallJournal(", "CaptureSnapshot(", + "BackupPackages(") + + reconcileSource := sourceRegion("startup journal reconciliation", + "bool ReconcileInstallJournal(", "bool RollbackRemove(", true) + for _, fragment := range []string{ + "ForwardRebootPending && sameBoot", "RestoreRebootPending && sameBoot", + "return rebootPending(", + "loaded.state.phase == InstallJournalPhase::BrokerChildEntered", + "loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned", + "loaded.state.rollbackAuthorized", "loaded.state.hasBrokerProof", + "loaded.state.brokerProofSuccess", + "no mutation was attempted", "InstallJournalPhase::ManualReconciliationRequired", + "appendPartialRootRemovalEntered", + "install-journal-partial-root-removal-inventory", + "install-journal-pre-package-rollback-inventory", + "ClassifyPartialRootRemovalJournalRecovery(", + } { + if !strings.Contains(reconcileSource, fragment) { + t.Errorf("driver helper startup reconciliation lost %q", fragment) + } + } + + type phaseWrappedAPI struct { + name, start, end, entered, wrapper, api, returned string + } + phaseWrappedAPIs := []phaseWrappedAPI{ + {"SetupCopyOEMInfW", "bool StageCandidatePackage(", "bool RemoveDevice(", + "SetupCopyEntered", "InvokeAuthoritativeSynchronousMutation(", + "SetupCopyOEMInfW(", "SetupCopyReturned"}, + {"DiInstallDevice", "bool CommitPreparedDriverBinding(", + "bool InstallPreinstalledDriverOnDevice(", "DiInstallEntered", + "InvokeAuthoritativeSynchronousMutation(", "DiInstallDevice(", "DiInstallReturned"}, + {"SetupUninstallOEMInfW", "bool RemoveStagedCandidateExact(", + "bool RestorePriorBinding(", "SetupUninstallEntered", + "InvokeAuthoritativeSynchronousMutation(", "SetupUninstallOEMInfW(", + "SetupUninstallReturned"}, + {"broker quiescence signal", "bool RequestBrokerQuiescence(", + "bool SignalBrokerHandoff(", "QuiesceSignalEntered", "", + "SetEvent(options.brokerQuiesceRequest)", "QuiesceSignalReturned"}, + {"broker handoff signal", "bool SignalBrokerHandoff(", + "bool ValidateTransactionDeadlineBudget(", "BrokerHandoffEntered", "", + "SetEvent(options.brokerHandoff)", "BrokerHandoffReturned"}, + {"broker child", "bool RunBrokerInstall(", "Outcome Install(", + "BrokerChildEntered", "", "CreateProcessW(", "BrokerChildSettled"}, + } + for _, contract := range phaseWrappedAPIs { + region := sourceRegion("phase-wrapped "+contract.name, + contract.start, contract.end, false) + fragments := []string{contract.entered} + if contract.wrapper != "" { + fragments = append(fragments, contract.wrapper) + } + fragments = append(fragments, contract.api, contract.returned) + assertOrdered("phase-wrapped "+contract.name, region, fragments...) + } + for _, registration := range []struct { + name, start, end string + }{ + {"forward root registration", "bool RegisterRootDevice(", + "bool DriverInfoUsesPublishedPackage("}, + {"restore root registration", "bool RegisterRootDeviceExact(", + "bool IssueAbiNegotiation("}, + } { + region := sourceRegion(registration.name, registration.start, registration.end, false) + entered := strings.Index(region, "RootRegistrationEntered") + mutation := strings.Index(region, "SetupDiCallClassInstaller(") + returned := strings.LastIndex(region, "RootRegistrationReturned") + if entered < 0 || mutation <= entered || returned <= mutation || + !strings.Contains(region[entered:mutation], "InvokeAuthoritativeSynchronousMutation(") { + t.Errorf("driver helper %s is not enclosed by authoritative entered/returned phases", registration.name) + } + } + forwardRegistrationSource := sourceRegion("forward root registration receipt", + "bool RegisterRootDevice(", "bool DriverInfoUsesPublishedPackage(", false) + assertOrdered("generated root receipt before every registration mutation", + forwardRegistrationSource, + "SetupDiCreateDeviceInfoW(", "DICD_GENERATE_ID", + "SetupDiGetDeviceInstanceIdW(", + "RecordActiveInstallJournalRootRegistrationIntent(", + "InstallJournalPhase::RootRegistrationEntered", + "SetupDiSetDeviceRegistryPropertyW(", + "SetupDiCallClassInstaller(", "DIF_REGISTERDEVICE", + "InstallJournalPhase::RootRegistrationReturned") + + partialRootSource := sourceRegion("in-process receipt-bound partial root removal", + "bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission(", + "bool CurrentRootIsAuthorizedForInstallRollback(", false) + assertOrdered("partial root removal write-ahead and authoritative return", + partialRootSource, + "InstallJournalPhase::PartialRootRemovalEntered", + "VerifyPackageInventory(", "observe(false, &confirmed", "RemoveDevice(", + "RecordAuthoritativeReturn(", + "InstallJournalPhase::PartialRootRemovalReturned", "observe(true, &after") + for _, fragment := range []string{ + "RemoveUnboundExactRoot", "RemoveCandidateBoundExactRoot", + "PendingExactRootRemoval", "freshRemovalReboot", + "rootRemovalRebootPending", + } { + if !strings.Contains(partialRootSource, fragment) { + t.Errorf("driver helper partial root removal lost %q", fragment) + } + } + + rawRootSource := sourceRegion("broad raw root topology observer", + "bool ObservePriorEmptyInstallRecoveryRoot(", + "bool VerifyInstallJournalRawPriorTopology(", false) + for _, fragment := range []string{ + "ReadInstallRecoveryHardwareIds(", + "IsInGeneratedRootDeviceNamespace(", "hardwareIds.containsExpected", + "related.size() == 1U", "loaded.state.hasRootRegistrationIntent", + "loaded.state.rootRegistrationInstanceId.c_str()", + "ReadCanonicalInstallRecoveryService(", + "ReadCanonicalInstallRecoveryDevicePropertyString(", + "CM_PROB_WILL_BE_REMOVED", "ClassifyPartialInstallRootRecovery(", + } { + if !strings.Contains(rawRootSource, fragment) { + t.Errorf("driver helper broad raw root observer lost %q", fragment) + } + } + + openChainSource := sourceRegion("install recovery directory chain", + " bool OpenChain(", "};", false) + assertOrdered("product-only recovery discovery", openChainSource, + "bool productExists = false;", "bool componentExists = false;", + "bool transactionsExist = false;", "bool activeExists = false;", + "if (!productExists) return true;", + "if (!componentExists) return true;", + "if (!transactionsExist) return true;", + "*exists = InstallRecoveryChainHasActive(") + for _, fragment := range []string{ + "BuildInstallRecoveryProductDirectorySecurity(", "*exactTargetUserSid", + "CreateOrOpenInstallRecoveryDirectoryWithSecurity(", + "VerifyProtectedProductDirectorySecurity(", "exactTargetUserSid", + } { + if !strings.Contains(openChainSource, fragment) { + t.Errorf("driver helper install recovery chain lost %q", fragment) + } + } + + forwardRetireSource := sourceRegion("forward journal retirement", + "bool InstallJournal::RetireAfterForwardValidation(", + "bool InstallJournal::RetireAfterPriorValidation(", false) + forwardPendingEnd := strings.Index(forwardRetireSource, "std::string expectedBuildIdentity") + if forwardPendingEnd < 0 { + t.Fatal("driver helper forward reboot-pending branch is missing") + } + forwardPending := forwardRetireSource[:forwardPendingEnd] + if !strings.Contains(forwardPending, "InstallJournalPhase::ForwardRebootPending") || + strings.Contains(forwardPending, "remove_all(") || + strings.Contains(forwardPending, "ClearActiveRecoveryEvidence(") { + t.Error("driver helper forward reboot-pending path does not retain journal evidence") + } + priorRetireSource := sourceRegion("prior journal retirement", + "bool InstallJournal::RetireAfterPriorValidation(", + "bool RequireJournalObject(", false) + priorPendingEnd := strings.Index(priorRetireSource, "const auto validatePrior") + if priorPendingEnd < 0 { + t.Fatal("driver helper restore reboot-pending branch is missing") + } + priorPending := priorRetireSource[:priorPendingEnd] + if !strings.Contains(priorPending, "InstallJournalPhase::RestoreRebootPending") || + strings.Contains(priorPending, "remove_all(") || + strings.Contains(priorPending, "ClearActiveRecoveryEvidence(") { + t.Error("driver helper restore reboot-pending path does not retain journal evidence") + } for _, obsolete := range []string{ "--expected-token-sha256", "--expected-broker-sha256", } { @@ -240,6 +596,12 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Fatal("driver helper forward install transaction is missing or malformed") } forwardInstall := helperSource[installStart:installEnd] + if strings.Contains(forwardInstall, "InstallJournalPhase::DiInstallReturned") { + t.Error("forward install synthesizes DiInstallReturned outside the actual API wrapper") + } + if !strings.Contains(forwardInstall, "InstallJournalPhase::StageReceiptCaptured") { + t.Error("forward install lost the distinct exact stage-receipt phase") + } for _, forbidden := range []string{ "DiInstallDriverW(", "UpdateDriverForPlugAndPlayDevicesW(", `L"upgrade-deadline-before-device-removal"`, @@ -317,7 +679,7 @@ func TestNativePackageProductionSourceContract(t *testing.T) { } mutationDecision := strings.Index(forwardInstall, "const bool driverMutation =") - stageCall := strings.Index(forwardInstall, "if (!StageCandidatePackage(") + stageCall := strings.Index(forwardInstall, "StageCandidatePackage(") stageInventory := strings.Index(forwardInstall, `L"stage-package-inventory-verification"`) stageRootProof := strings.Index(forwardInstall, `L"stage-root-binding-verification"`) diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index 8c6dc15e..a1e205cd 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -12,6 +12,50 @@ if ([string]::IsNullOrWhiteSpace($SourcePath)) { } $source = Get-Content -LiteralPath $SourcePath -Raw + +function Get-SourceContractRegion { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Start, + [Parameter(Mandatory = $true)][string]$End, + [Parameter(Mandatory = $true)][string]$Name, + [switch]$LastStart + ) + + $startIndex = if ($LastStart) { + $Text.LastIndexOf($Start, [StringComparison]::Ordinal) + } else { + $Text.IndexOf($Start, [StringComparison]::Ordinal) + } + $endIndex = if ($startIndex -ge 0) { + $Text.IndexOf($End, $startIndex + $Start.Length, [StringComparison]::Ordinal) + } else { + -1 + } + if ($startIndex -lt 0 -or $endIndex -le $startIndex) { + throw "ViiperUdeCtl $Name source region is missing or malformed." + } + return $Text.Substring($startIndex, $endIndex - $startIndex) +} + +function Assert-OrderedSourceFragments { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string[]]$Fragments, + [Parameter(Mandatory = $true)][string]$Name + ) + + $cursor = -1 + foreach ($fragment in $Fragments) { + $next = $Text.IndexOf( + $fragment, $cursor + 1, [StringComparison]::Ordinal) + if ($next -lt 0) { + throw "ViiperUdeCtl violates its $Name ordering contract at '$fragment'." + } + $cursor = $next + } +} + $requiredContracts = [ordered]@{ 'source-manifest preflight' = 'ValidateManifest\(' 'installer manifest hash binding' = '--manifest-sha256' @@ -75,7 +119,7 @@ $requiredContracts = [ordered]@{ 'captured stopped rollback state proof' = 'rollback-stopped-state-verification' 'exact rollback lifecycle comparator' = 'RollbackLifecycleStateMatches\(' 'stage mutation marked before SetupCopy' = - 'MarkTransactionMutationStarted\(\);[\s\S]{0,180}SetupCopyOEMInfW\(' + 'MarkTransactionMutationStarted\(\);[\s\S]{0,500}SetupCopyOEMInfW\(' 'successful stage retains cleanup ownership' = '\*stagedHere = true' 'malformed stage receipt recovery' = 'FindPublishedCandidate\([\s\S]{0,160}recoveredReceipt' @@ -91,7 +135,7 @@ $requiredContracts = [ordered]@{ 'immediate selected-device binding commit' = 'CommitPreparedDriverBinding\(' 'exact final pristine ABI recheck' = 'AbiHealthPurpose::PristineRecheck' 'broker deadline before quiescence signal' = - 'transaction-deadline-before-broker-quiescence[\s\S]{0,180}SetEvent\(options\.brokerQuiesceRequest\)' + 'transaction-deadline-before-broker-quiescence[\s\S]{0,700}SetEvent\(options\.brokerQuiesceRequest\)' 'broker health transaction' = 'RunBrokerInstall\(' 'canonical broker proof parser' = 'ParseBrokerCommitProof\(' 'bounded broker proof channel' = 'kMaximumBrokerProofBytes' @@ -202,25 +246,403 @@ foreach ($entry in $requiredContracts.GetEnumerator()) { } } +$installJournalRequired = [ordered]@{ + 'known-folder ProgramData resolution' = 'SHGetKnownFolderPath\(' + 'exact ProgramData known-folder identity' = 'FOLDERID_ProgramData' + 'fixed VIIPER recovery segment' = 'kInstallRecoveryProductDirectory\[\] = L"VIIPER"' + 'fixed UdeCx recovery segment' = 'kInstallRecoveryComponentDirectory\[\] = L"UdeCx"' + 'fixed transaction recovery segment' = 'kInstallRecoveryTransactionsDirectory\[\] = L"Transactions"' + 'single active recovery identity' = 'kInstallRecoveryActiveDirectory\[\] = L"active-v2"' + 'append-only journal prefix' = 'kInstallRecoveryJournalPrefix\[\] = L"journal-"' + 'protected recovery directory open' = 'OpenStableDirectory\(' + 'reparse-safe recovery directory handles' = 'FILE_FLAG_OPEN_REPARSE_POINT' + 'reparse rejection for recovery directories' = 'FILE_ATTRIBUTE_REPARSE_POINT' + 'exact recovery ACL verification' = 'VerifyProtectedFileSystemSecurity\(' + 'install journal writer' = 'WriteInstallJournalRecord\(' + 'journal previous-record hash' = '\\"previousSha256\\"' + 'journal envelope hash' = '\\"payloadSha256\\"' + 'journal hash-chain comparison' = 'parsed\.previousDigest[\s\S]{0,120}priorDigest' + 'write-through journal file' = 'FILE_FLAG_WRITE_THROUGH' + 'flushed journal bytes' = 'install-journal-flush' + 'write-through journal publication' = 'MOVEFILE_WRITE_THROUGH' + 'published journal readback' = 'install-journal-readback' + 'explicit recovery command' = 'Outcome Recover\(' + 'automatic pre-mutation recovery' = 'ReconcileInstallJournal\(' + 'recovery CLI route' = '_wcsicmp\(argv\[1\], L"recover"\)' + 'broker-unsettled manual retention' = + 'loaded\.state\.brokerEntered[\s\S]{0,120}!rollbackWasAuthorized[\s\S]{0,500}no mutation was attempted' + 'durable rollback direction' = '\"direction\"[\s\S]{0,200}\"rollbackAuthorized\"' + 'legal journal transitions' = 'ValidateInstallJournalTransition\(' + 'poisoned append latch' = 'impl_->poisoned = true' + 'canonical next-temp cleanup' = 'ValidateAndDiscardInstallJournalTemporaryFile\(' + 'verified existing component walk' = 'OpenExistingInstallRecoveryDirectory\(' + 'generic ACL normalization' = 'MapGenericMask\(' + 'durable-only stage ownership' = 'StageReceiptCaptured ownership record' + 'exact pre-rollback inventory' = 'exactPreRollbackInventory' + 'exact root rollback authority' = 'RootSnapshotIsAuthorizedForInstallRollback\(' + 'same-boot rollback reboot cutpoint' = 'InstallJournalNeedsRestoreRebootPending\(' + 'durable pending reboot boot epoch' = 'pendingRebootBootIdentifier' + 'fresh reboot epoch authority' = 'freshRebootRequired' + 'durable generated root receipt' = 'rootRegistrationInstanceId' + 'pre-registration root receipt phase' = 'RootRegistrationIntentCaptured' + 'broad prior-empty root observer' = 'ObservePriorEmptyInstallRecoveryRoot\(' + 'canonical raw hardware ID reader' = 'ReadInstallRecoveryHardwareIds\(' + 'canonical raw string reader' = 'DecodeCanonicalInstallRecoveryString\(' + 'receipt-bound partial root cleanup' = 'RemoveAuthorizedPriorEmptyRootAfterAdmission\(' + 'partial root removal entered receipt' = 'PartialRootRemovalEntered' + 'partial root removal returned receipt' = 'PartialRootRemovalReturned' + 'partial root removal reboot boundary' = 'PartialRootRemovalRebootPending' + 'partial root removal exact shape' = 'partialRootRemovalBinding' + 'partial root removal attempt epoch' = 'partialRootRemovalBootIdentifier' + 'product-only chain discovery model' = 'InstallRecoveryChainHasActive\(' + 'target-user product ACL builder' = 'BuildInstallRecoveryProductDirectorySecurity\(' + 'exact target-user product ACL verifier' = 'VerifyProtectedProductDirectorySecurity\(' + 'forward reboot-pending phase' = 'ForwardRebootPending' + 'restore reboot-pending phase' = 'RestoreRebootPending' + 'manual reconciliation phase' = 'ManualReconciliationRequired' + 'authoritative mutation watchdog' = 'SynchronousMutationWatchdog' + 'authoritative mutation wrapper' = 'InvokeAuthoritativeSynchronousMutation\(' + 'deadline overrun retained in journal' = 'deadlineOverrun' +} + +foreach ($entry in $installJournalRequired.GetEnumerator()) { + if ($source -notmatch $entry.Value) { + throw "ViiperUdeCtl is missing its $($entry.Key) install-journal contract." + } +} + +$installJournalPhases = @( + 'Prepared', + 'SetupCopyEntered', + 'SetupCopyReturned', + 'StageReceiptCaptured', + 'QuiesceSignalEntered', + 'QuiesceSignalReturned', + 'RootRegistrationIntentCaptured', + 'RootRegistrationEntered', + 'RootRegistrationReturned', + 'DiInstallEntered', + 'DiInstallReturned', + 'PriorAbiProfileCaptured', + 'DriverValidated', + 'BrokerHandoffEntered', + 'BrokerHandoffReturned', + 'BrokerChildEntered', + 'BrokerChildSettled', + 'RollbackBindingEntered', + 'PartialRootRemovalEntered', + 'PartialRootRemovalReturned', + 'PartialRootRemovalRebootPending', + 'RollbackBindingReturned', + 'SetupUninstallEntered', + 'SetupUninstallReturned', + 'ForwardValidated', + 'ExactPriorRestored', + 'ForwardRebootPending', + 'RestoreRebootPending', + 'ManualReconciliationRequired' +) +foreach ($phase in $installJournalPhases) { + $qualified = 'InstallJournalPhase::' + $phase + if ([regex]::Matches($source, [regex]::Escape($qualified)).Count -lt 2) { + throw "ViiperUdeCtl install journal does not both define and use phase '$phase'." + } +} + +$recoveryPathSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ResolveInstallRecoveryPaths(' -End 'bool GetBootIdentifier(' ` + -Name 'fixed recovery path' +Assert-OrderedSourceFragments -Text $recoveryPathSource -Name 'fixed ProgramData path' ` + -Fragments @( + 'SHGetKnownFolderPath(', + 'FOLDERID_ProgramData', + '*product = *programData / kInstallRecoveryProductDirectory;', + '*component = *product / kInstallRecoveryComponentDirectory;', + '*transactions = *component / kInstallRecoveryTransactionsDirectory;', + '*active = *transactions / kInstallRecoveryActiveDirectory;' + ) +foreach ($fragment in @( + 'FILE_FLAG_OPEN_REPARSE_POINT', + 'FILE_ATTRIBUTE_REPARSE_POINT', + 'VerifyProtectedFileSystemSecurity(', + 'CreateOrOpenInstallRecoveryDirectory(', + 'active, false, true, &activeHandle' +)) { + if (-not $recoveryPathSource.Contains($fragment)) { + throw "ViiperUdeCtl fixed recovery path lost '$fragment'." + } +} + +$journalWriterSource = Get-SourceContractRegion -Text $source ` + -Start 'bool WriteInstallJournalRecord(' -End 'bool GenerateInstallTransactionId(' ` + -Name 'append-only journal writer' +Assert-OrderedSourceFragments -Text $journalWriterSource -Name 'durable journal publication' ` + -Fragments @( + 'BuildInstallJournalPayload(', + 'Sha256Data(payload, &digest', + '\"payloadSha256\"', + 'CREATE_NEW', + 'FILE_FLAG_WRITE_THROUGH', + 'FlushFileBuffers(file.get())', + 'MoveFileExW(', + 'MOVEFILE_WRITE_THROUGH', + 'OPEN_EXISTING', + 'ReadFile(file.get(), observed.data()', + 'observed != record', + 'trailingRead != 0', + 'state->previousDigest = digest', + '++state->sequence' + ) +if ($journalWriterSource.Contains('MOVEFILE_REPLACE_EXISTING')) { + throw 'ViiperUdeCtl append-only journal must never replace a published record.' +} + +$journalPrepareSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::Prepare(' -End 'bool InstallJournal::Record(' ` + -Name 'install journal preparation' +Assert-OrderedSourceFragments -Text $journalPrepareSource -Name 'pre-Prepared protected evidence' ` + -Fragments @( + 'BackupPackagesIntoDirectory(', + 'CopyCandidateIntoInstallJournal(', + 'impl_->state.prior = prior;', + 'impl_->state.candidate = candidate;', + 'impl_->state.phase = InstallJournalPhase::Prepared;', + 'WriteInstallJournalRecord(' + ) + +$journalRecordSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::RecordNext(' -End 'bool InstallJournal::RecordCutpoint(' ` + -Name 'atomic install journal record' +Assert-OrderedSourceFragments -Text $journalRecordSource -Name 'atomic journal state publication' ` + -Fragments @( + 'ValidateInstallJournalTransition(&impl_->state, next', + 'WriteInstallJournalRecord(', + 'impl_->state = std::move(next);', + 'PublishInstallRecoveryEvidence(' + ) +if (-not $journalRecordSource.Contains('impl_->poisoned = true')) { + throw 'ViiperUdeCtl must poison the in-process journal after an indeterminate append or publication.' +} + +$watchdogSource = Get-SourceContractRegion -Text $source ` + -Start 'class SynchronousMutationWatchdog final' -End 'class DeviceInfoSet final' ` + -Name 'authoritative synchronous mutation watchdog' +foreach ($fragment in @( + 'completion_.get(), waitMilliseconds', + 'timedOut_.store(true', + 'WaitForSingleObject(completion_.get(), INFINITE)', + 'thread_.join()', + 'gLastSynchronousMutationTimedOut = watchdog.Complete()' +)) { + if (-not $watchdogSource.Contains($fragment)) { + throw "ViiperUdeCtl authoritative watchdog lost '$fragment'." + } +} +foreach ($forbidden in @( + 'CancelIoEx(', 'CancelSynchronousIo(', 'TerminateThread(', + 'TerminateProcess(', '.detach()' +)) { + if ($watchdogSource.Contains($forbidden)) { + throw "ViiperUdeCtl authoritative watchdog contains forbidden cancellation '$forbidden'." + } +} + +$installEntrySource = Get-SourceContractRegion -Text $source ` + -Start 'Outcome Install(const InstallOptions& options)' -End 'struct PackageBackup {' ` + -Name 'install entry' +Assert-OrderedSourceFragments -Text $installEntrySource -Name 'install pre-mutation reconciliation' ` + -Fragments @( + 'mutex.Acquire(', + 'ReconcileInstallJournal(', + 'ValidateCandidateInputs(', + 'CaptureSnapshot(', + 'installJournal.Prepare(' + ) +if ([regex]::Matches($installEntrySource, + 'RemoveAuthorizedPriorEmptyRootAfterAdmission\(').Count -ne 2 -or + [regex]::Matches($installEntrySource, + 'VerifyPriorTopologyBeforePackageRollback\(').Count -ne 2 -or + [regex]::Matches($installEntrySource, + '!prior\.devices\.empty\(\) && bindingMutationStarted').Count -ne 2) { + throw 'ViiperUdeCtl must apply receipt-bound root cleanup and strict post-removal proof in both in-process rollback branches without generic prior-empty deletion.' +} + +$removeEntrySource = Get-SourceContractRegion -Text $source ` + -Start 'Outcome Remove(const RemoveOptions& options)' -End 'Outcome Recover(' ` + -Name 'remove entry' +Assert-OrderedSourceFragments -Text $removeEntrySource -Name 'remove pre-mutation reconciliation' ` + -Fragments @( + 'mutex.Acquire(', + 'ReconcileInstallJournal(', + 'CaptureSnapshot(', + 'BackupPackages(' + ) + +$reconcileSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ReconcileInstallJournal(' -End 'bool RollbackRemove(' ` + -Name 'startup journal reconciliation' -LastStart +foreach ($fragment in @( + 'ForwardRebootPending && sameBoot', + 'RestoreRebootPending && sameBoot', + 'return rebootPending(', + 'loaded.state.phase == InstallJournalPhase::BrokerChildEntered', + 'loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned', + 'loaded.state.rollbackAuthorized', + 'loaded.state.hasBrokerProof', + 'loaded.state.brokerProofSuccess', + 'no mutation was attempted', + 'InstallJournalPhase::ManualReconciliationRequired' + 'appendPartialRootRemovalEntered' + 'install-journal-partial-root-removal-inventory' + 'install-journal-pre-package-rollback-inventory' + 'ClassifyPartialRootRemovalJournalRecovery(' +)) { + if (-not $reconcileSource.Contains($fragment)) { + throw "ViiperUdeCtl startup reconciliation lost '$fragment'." + } +} + +$apiPhaseContracts = @( + @('bool StageCandidatePackage(', 'bool RemoveDevice(', 'SetupCopyEntered', 'SetupCopyOEMInfW(', 'SetupCopyReturned'), + @('bool CommitPreparedDriverBinding(', 'bool InstallPreinstalledDriverOnDevice(', 'DiInstallEntered', 'DiInstallDevice(', 'DiInstallReturned'), + @('bool RemoveStagedCandidateExact(', 'bool RestorePriorBinding(', 'SetupUninstallEntered', 'SetupUninstallOEMInfW(', 'SetupUninstallReturned'), + @('bool RequestBrokerQuiescence(', 'bool SignalBrokerHandoff(', 'QuiesceSignalEntered', 'SetEvent(options.brokerQuiesceRequest)', 'QuiesceSignalReturned'), + @('bool SignalBrokerHandoff(', 'bool ValidateTransactionDeadlineBudget(', 'BrokerHandoffEntered', 'SetEvent(options.brokerHandoff)', 'BrokerHandoffReturned'), + @('bool RunBrokerInstall(', 'Outcome Install(', 'BrokerChildEntered', 'CreateProcessW(', 'BrokerChildSettled') +) +foreach ($contract in $apiPhaseContracts) { + $region = Get-SourceContractRegion -Text $source -Start $contract[0] ` + -End $contract[1] -Name ("phase-wrapped API " + $contract[3]) + Assert-OrderedSourceFragments -Text $region -Name ("phase-wrapped API " + $contract[3]) ` + -Fragments @($contract[2], $contract[3], $contract[4]) +} + +foreach ($registrationStart in @('bool RegisterRootDevice(', 'bool RegisterRootDeviceExact(')) { + $registrationEnd = if ($registrationStart -eq 'bool RegisterRootDevice(') { + 'bool DriverInfoUsesPublishedPackage(' + } else { + 'bool IssueAbiNegotiation(' + } + $region = Get-SourceContractRegion -Text $source -Start $registrationStart ` + -End $registrationEnd -Name 'phase-wrapped root registration' + $entered = $region.IndexOf('RootRegistrationEntered', [StringComparison]::Ordinal) + $mutation = $region.IndexOf('SetupDiCallClassInstaller(', [StringComparison]::Ordinal) + $returned = $region.LastIndexOf('RootRegistrationReturned', [StringComparison]::Ordinal) + if ($entered -lt 0 -or $mutation -le $entered -or $returned -le $mutation) { + throw 'ViiperUdeCtl root registration is not enclosed by durable entered/returned phases.' + } +} + +$forwardRegistrationSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RegisterRootDevice(' -End 'bool DriverInfoUsesPublishedPackage(' ` + -Name 'forward root registration receipt' +Assert-OrderedSourceFragments -Text $forwardRegistrationSource ` + -Name 'generated root receipt before every registration mutation' ` + -Fragments @( + 'SetupDiCreateDeviceInfoW(', + 'DICD_GENERATE_ID', + 'SetupDiGetDeviceInstanceIdW(', + 'RecordActiveInstallJournalRootRegistrationIntent(', + 'InstallJournalPhase::RootRegistrationEntered', + 'SetupDiSetDeviceRegistryPropertyW(', + 'SetupDiCallClassInstaller(', + 'DIF_REGISTERDEVICE', + 'InstallJournalPhase::RootRegistrationReturned' + ) + +$partialRootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission(' ` + -End 'bool CurrentRootIsAuthorizedForInstallRollback(' ` + -Name 'in-process receipt-bound partial root removal' +Assert-OrderedSourceFragments -Text $partialRootSource ` + -Name 'partial root removal write-ahead and authoritative return' ` + -Fragments @( + 'InstallJournalPhase::PartialRootRemovalEntered', + 'VerifyPackageInventory(', + 'observe(false, &confirmed', + 'RemoveDevice(', + 'RecordAuthoritativeReturn(', + 'InstallJournalPhase::PartialRootRemovalReturned', + 'observe(true, &after' + ) +foreach ($fragment in @( + 'RemoveUnboundExactRoot', + 'RemoveCandidateBoundExactRoot', + 'PendingExactRootRemoval', + 'freshRemovalReboot', + 'rootRemovalRebootPending' +)) { + if (-not $partialRootSource.Contains($fragment)) { + throw "ViiperUdeCtl partial root removal lost '$fragment'." + } +} + +$rawRootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ObservePriorEmptyInstallRecoveryRoot(' ` + -End 'bool VerifyInstallJournalRawPriorTopology(' ` + -Name 'broad raw root topology observer' +foreach ($fragment in @( + 'ReadInstallRecoveryHardwareIds(', + 'IsInGeneratedRootDeviceNamespace(', + 'hardwareIds.containsExpected', + 'related.size() == 1U', + 'loaded.state.hasRootRegistrationIntent', + 'loaded.state.rootRegistrationInstanceId.c_str()', + 'ReadCanonicalInstallRecoveryService(', + 'ReadCanonicalInstallRecoveryDevicePropertyString(', + 'CM_PROB_WILL_BE_REMOVED', + 'ClassifyPartialInstallRootRecovery(' +)) { + if (-not $rawRootSource.Contains($fragment)) { + throw "ViiperUdeCtl broad raw root observer lost '$fragment'." + } +} + +$openChainSource = Get-SourceContractRegion -Text $source ` + -Start ' bool OpenChain(' -End '};' -Name 'install recovery directory chain' +Assert-OrderedSourceFragments -Text $openChainSource ` + -Name 'product-only recovery discovery' ` + -Fragments @( + 'bool productExists = false;', + 'bool componentExists = false;', + 'bool transactionsExist = false;', + 'bool activeExists = false;', + 'if (!productExists) return true;', + 'if (!componentExists) return true;', + 'if (!transactionsExist) return true;', + '*exists = InstallRecoveryChainHasActive(' + ) +foreach ($fragment in @( + 'BuildInstallRecoveryProductDirectorySecurity(', + '*exactTargetUserSid', + 'CreateOrOpenInstallRecoveryDirectoryWithSecurity(', + 'VerifyProtectedProductDirectorySecurity(', + 'exactTargetUserSid' +)) { + if (-not $openChainSource.Contains($fragment)) { + throw "ViiperUdeCtl install recovery chain lost '$fragment'." + } +} + $orderedMutationContracts = [ordered]@{ 'driver package staging deadline immediately precedes add-only mutation' = - 'transaction-deadline-before-driver-stage[\s\S]{0,1800}MarkTransactionMutationStarted\(\);[\s\S]{0,180}SetupCopyOEMInfW\(' + 'transaction-deadline-before-driver-stage[\s\S]{0,1800}MarkTransactionMutationStarted\(\);[\s\S]{0,500}SetupCopyOEMInfW\(' 'root property deadline immediately precedes mutation' = - 'transaction-deadline-before-root-properties[\s\S]{0,240}mutationStarted[\s\S]{0,180}SetupDiSetDeviceRegistryPropertyW\(' + 'transaction-deadline-before-root-properties[\s\S]{0,900}mutationStarted[\s\S]{0,300}SetupDiSetDeviceRegistryPropertyW\(' 'root registration deadline immediately precedes mutation' = - 'transaction-deadline-before-root-registration[\s\S]{0,240}SetupDiCallClassInstaller\(DIF_REGISTERDEVICE' + 'transaction-deadline-before-root-registration[\s\S]{0,900}SetupDiCallClassInstaller\([\s\S]{0,120}DIF_REGISTERDEVICE' 'selected driver deadline immediately precedes mutation' = - 'transaction-deadline-before-selected-device-binding[\s\S]{0,300}mutationStarted[\s\S]{0,220}SetupDiSetSelectedDriverW\([\s\S]{0,500}DiInstallDevice\(' + 'transaction-deadline-before-selected-device-binding[\s\S]{0,700}mutationStarted[\s\S]{0,400}SetupDiSetSelectedDriverW\([\s\S]{0,1800}DiInstallDevice\(' 'broker deadline immediately precedes quiescence signal' = - 'transaction-deadline-before-broker-quiescence[\s\S]{0,180}SetEvent\(options\.brokerQuiesceRequest\)' + 'transaction-deadline-before-broker-quiescence[\s\S]{0,700}SetEvent\(options\.brokerQuiesceRequest\)' 'remove deadline immediately precedes device mutation' = 'CheckTransactionDeadline\(transactionDeadlineUnixMs, deadlinePhase, error\)[\s\S]{0,300}mutationStarted[\s\S]{0,180}DiUninstallDevice\(' 'first-time root creation uses the owned device name' = 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}kRootDeviceName[\s\S]{0,120}DICD_GENERATE_ID' - 'registered devnode cleanup state survives post-registration validation' = - 'const bool registeredAndVerified = inventoryVerified && RegisterRootDevice\([\s\S]{0,500}createdHere = registrationSucceeded;[\s\S]{0,160}if \(registeredAndVerified\)' + 'failed registration cannot suppress receipt-authorized cleanup' = + 'bool registrationSucceeded = false[\s\S]{0,30000}RemoveAuthorizedPriorEmptyRootAfterAdmission\(' 'add-only stage inventory and exact root proof precede broker quiescence' = - '!StageCandidatePackage\([\s\S]{0,2600}stage-package-inventory-verification[\s\S]{0,800}stage-root-binding-verification[\s\S]{0,1800}RequestBrokerQuiescence\(' + 'StageCandidatePackage\([\s\S]{0,5000}stage-package-inventory-verification[\s\S]{0,1600}stage-root-binding-verification[\s\S]{0,3000}RequestBrokerQuiescence\(' 'broker quiescence inventory and fresh root proof precede pristine admission' = 'RequestBrokerQuiescence\([\s\S]{0,1200}post-quiescence-package-inventory-verification[\s\S]{0,1000}post-quiescence-root-verification[\s\S]{0,1800}AbiHealthPurpose::PristineUpgrade' 'driver preparation and final proofs precede immediate in-place binding' = @@ -228,7 +650,7 @@ $orderedMutationContracts = [ordered]@{ 'new root registration is confined to an absent captured root' = 'if \(prior\.devices\.empty\(\)\) \{[\s\S]{0,300}RegisterRootDevice\(' 'post-stage failure reaches exact common rollback' = - '!StageCandidatePackage\([\s\S]{0,14000}if \(outcome\.error\.code != ERROR_SUCCESS && driverMutationStarted\)[\s\S]{0,1800}packageStagedHere \? &publishedCandidate : nullptr[\s\S]{0,300}RollbackInstall\(' + 'StageCandidatePackage\([\s\S]{0,22000}if \(outcome\.error\.code != ERROR_SUCCESS && driverMutationStarted\)[\s\S]{0,3000}packageStagedHere \? &publishedCandidate : nullptr[\s\S]{0,600}RollbackInstall\(' 'binding restore precedes exact staged cleanup and inventory proof' = 'if \(bindingMutationStarted\)[\s\S]{0,300}RestorePriorBinding\([\s\S]{0,700}RemoveStagedCandidateExact\([\s\S]{0,500}VerifyPackageInventory\(' 'formerly-running rollback requires exact start and ABI health' = @@ -236,7 +658,7 @@ $orderedMutationContracts = [ordered]@{ 'captured-stopped rollback requires exact stopped problem state' = 'AbiHealthPurpose::RollbackHealth[\s\S]{0,400}RollbackLifecycleStateMatches\([\s\S]{0,300}rollback-stopped-state-verification' 'broker handoff follows exact binding verification and precedes nested commit' = - 'VerifyInstalledBinding\([\s\S]{0,5000}SignalBrokerHandoff\([\s\S]{0,180}RunBrokerInstall\(' + 'VerifyInstalledBinding\([\s\S]{0,12000}SignalBrokerHandoff\([\s\S]{0,800}RunBrokerInstall\(' 'recovery journal is published and preservation armed before mutation' = 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' 'failed remove rollback preserves published evidence before return' = @@ -264,6 +686,12 @@ if ($forwardInstallStart -lt 0 -or $forwardInstallEnd -le $forwardInstallStart) } $forwardInstallSource = $source.Substring( $forwardInstallStart, $forwardInstallEnd - $forwardInstallStart) +if ($forwardInstallSource.Contains('InstallJournalPhase::DiInstallReturned')) { + throw 'Forward install must not synthesize a DiInstallReturned phase outside the actual API wrapper.' +} +if (-not $forwardInstallSource.Contains('InstallJournalPhase::StageReceiptCaptured')) { + throw 'Forward install must durably separate the exact stage receipt from SetupCopyOEMInfW return.' +} if ($forwardInstallSource -match '\b(?:DiInstallDriverW|UpdateDriverForPlugAndPlayDevicesW)\s*\(') { throw 'Forward install must use add-only staging plus exact selected-device binding, never a device-auto-binding package API.' } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index b44ee490..79012ae7 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -18,11 +18,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -31,6 +34,7 @@ #include #include +#include #include #include #include @@ -75,6 +79,8 @@ BOOL WINAPI DiUninstallDriverW(HWND, LPCWSTR, DWORD, PBOOL); #pragma comment(lib, "Advapi32.lib") #pragma comment(lib, "Crypt32.lib") #pragma comment(lib, "Wintrust.lib") +#pragma comment(lib, "Shell32.lib") +#pragma comment(lib, "Ole32.lib") namespace { @@ -130,6 +136,24 @@ static_assert(offsetof(VIIPER_UDE_STATS, ReservedPorts) == 144, "ABI 1.10/1.11 statistics boundary drift"); static_assert(AbiCompatibilityProfilesAreValid(), "ABI compatibility profiles must be exact and strictly descending"); + +constexpr bool SameAbiCompatibilityProfile( + const AbiCompatibilityProfile& left, + const AbiCompatibilityProfile& right) noexcept { + return left.minor == right.minor && + left.capabilities == right.capabilities && + left.statsSize == right.statsSize && + left.hasReservedPortFields == right.hasReservedPortFields; +} + +constexpr bool IsKnownAbiCompatibilityProfile( + const AbiCompatibilityProfile& profile) noexcept { + return std::any_of(kAbiCompatibilityProfiles.begin(), + kAbiCompatibilityProfiles.end(), + [&](const AbiCompatibilityProfile& known) { + return SameAbiCompatibilityProfile(profile, known); + }); +} constexpr wchar_t kModelSection[] = L"Standard.NTamd64.10.0...17763"; constexpr wchar_t kInstallSection[] = L"ViiperUde_Install"; constexpr wchar_t kTransactionNamespace[] = L"VIIPER_UDE_DRIVER_TRANSACTION_NAMESPACE_V1"; @@ -155,6 +179,21 @@ constexpr wchar_t kRecoveryRecordSecurity[] = constexpr wchar_t kRecoveryRecordName[] = L"recovery-v1.json"; constexpr wchar_t kRecoveryRecordTemporaryName[] = L"recovery-v1.json.tmp"; constexpr size_t kMaximumRecoveryRecordBytes = 256U * 1024U; +constexpr wchar_t kInstallRecoveryProductDirectory[] = L"VIIPER"; +constexpr wchar_t kInstallRecoveryComponentDirectory[] = L"UdeCx"; +constexpr wchar_t kInstallRecoveryTransactionsDirectory[] = L"Transactions"; +constexpr wchar_t kInstallRecoveryActiveDirectory[] = L"active-v2"; +constexpr wchar_t kInstallRecoverySettledPrefix[] = L"settled-v2-"; +constexpr wchar_t kInstallRecoveryJournalPrefix[] = L"journal-"; +constexpr wchar_t kInstallRecoveryJournalSuffix[] = L".json"; +constexpr wchar_t kInstallRecoveryTemporarySuffix[] = L".tmp"; +constexpr wchar_t kInstallRecoveryPriorDirectory[] = L"prior"; +constexpr wchar_t kInstallRecoveryCandidateDirectory[] = L"candidate"; +constexpr size_t kMaximumInstallRecoveryRecords = 96; +constexpr std::string_view kInstallRecoveryKind = + "VIIPER-UDE-install-switch-recovery"; +constexpr std::string_view kZeroSha256 = + "0000000000000000000000000000000000000000000000000000000000000000"; constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; @@ -168,6 +207,7 @@ bool gActiveRecoveryRecordWritten = false; std::array gActiveBackupRoot{}; bool gActiveBackupRootRetained = false; bool gTransactionMutationStarted = false; +bool gLastSynchronousMutationTimedOut = false; void MarkTransactionMutationStarted() noexcept { gTransactionMutationStarted = true; @@ -268,11 +308,22 @@ void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { stream << L" nestedExitCode=" << *outcome.error.nestedExitCode; } stream << L" message=" << std::quoted(outcome.error.message); - if (!outcome.error.recoveryRecord.empty()) { - stream << L" recoveryRecord=" << std::quoted(outcome.error.recoveryRecord) + const std::wstring recoveryRecord = + !outcome.error.recoveryRecord.empty() + ? outcome.error.recoveryRecord + : gActiveRecoveryRecord[0] != L'\0' + ? std::wstring(gActiveRecoveryRecord.data()) + : std::wstring{}; + const bool recoveryRecordWritten = + !outcome.error.recoveryRecord.empty() + ? outcome.error.recoveryRecordWritten + : gActiveRecoveryRecordWritten; + if (!recoveryRecord.empty()) { + stream << L" recoveryRecord=" << std::quoted(recoveryRecord) << L" recoveryRecordWritten=" - << (outcome.error.recoveryRecordWritten ? 1 : 0); - if (!outcome.error.recoveryRecordWritten) { + << (recoveryRecordWritten ? 1 : 0); + if (!recoveryRecordWritten && + !outcome.error.recoveryRecord.empty()) { stream << L" recoveryRecordPhase=" << std::quoted(outcome.error.recoveryRecordPhase) << L" recoveryRecordWin32Error=" @@ -281,10 +332,18 @@ void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { << std::quoted(outcome.error.recoveryRecordMessage); } } - if (!outcome.error.recoveryBackup.empty()) { - stream << L" recoveryBackup=" << std::quoted(outcome.error.recoveryBackup) + const std::wstring recoveryBackup = + !outcome.error.recoveryBackup.empty() + ? outcome.error.recoveryBackup + : gActiveBackupRoot[0] != L'\0' + ? std::wstring(gActiveBackupRoot.data()) + : std::wstring{}; + if (!recoveryBackup.empty()) { + stream << L" recoveryBackup=" << std::quoted(recoveryBackup) << L" recoveryBackupRetained=" - << (outcome.error.recoveryBackupRetained ? 1 : 0); + << ((!outcome.error.recoveryBackup.empty() + ? outcome.error.recoveryBackupRetained + : gActiveBackupRootRetained) ? 1 : 0); } } stream << L"\n"; @@ -324,6 +383,83 @@ class WinHandle final { HANDLE value_ = INVALID_HANDLE_VALUE; }; +class SynchronousMutationWatchdog final { +public: + SynchronousMutationWatchdog( + uint64_t deadlineUnixMs, + const wchar_t* apiName) + : apiName_(apiName == nullptr ? L"unknown" : apiName) { + completion_.reset(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!completion_) { + throw std::bad_alloc(); + } + thread_ = std::thread([this, deadlineUnixMs]() noexcept { + for (;;) { + const uint64_t now = CurrentUnixMilliseconds(); + const DWORD waitMilliseconds = deadlineUnixMs <= now + ? 0 + : static_cast(std::min( + deadlineUnixMs - now, + std::numeric_limits::max() - 1ULL)); + const DWORD wait = WaitForSingleObject( + completion_.get(), waitMilliseconds); + if (wait == WAIT_OBJECT_0) { + return; + } + if (wait == WAIT_TIMEOUT) { + timedOut_.store(true, std::memory_order_release); + std::wstring diagnostic = + L"VIIPER: authoritative synchronous mutation exceeded its deadline; " + L"the owner remains alive and is still waiting for "; + diagnostic += apiName_; + diagnostic += L" to return.\n"; + OutputDebugStringW(diagnostic.c_str()); + WaitForSingleObject(completion_.get(), INFINITE); + return; + } + timedOut_.store(true, std::memory_order_release); + return; + } + }); + } + + ~SynchronousMutationWatchdog() noexcept { + Complete(); + } + + SynchronousMutationWatchdog(const SynchronousMutationWatchdog&) = delete; + SynchronousMutationWatchdog& operator=(const SynchronousMutationWatchdog&) = delete; + + bool Complete() noexcept { + if (completion_) { + SetEvent(completion_.get()); + } + if (thread_.joinable()) { + thread_.join(); + } + return timedOut_.load(std::memory_order_acquire); + } + +private: + std::wstring apiName_; + WinHandle completion_; + std::thread thread_; + std::atomic timedOut_{false}; +}; + +template +auto InvokeAuthoritativeSynchronousMutation( + uint64_t deadlineUnixMs, + const wchar_t* apiName, + Callback&& callback) -> decltype(callback()) { + SynchronousMutationWatchdog watchdog(deadlineUnixMs, apiName); + auto result = callback(); + const DWORD callbackError = GetLastError(); + gLastSynchronousMutationTimedOut = watchdog.Complete(); + SetLastError(callbackError); + return result; +} + class DeviceInfoSet final { public: explicit DeviceInfoSet(HDEVINFO value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} @@ -703,11 +839,37 @@ class JsonParser final { } else if (codePoint <= 0x7ffU) { value->push_back(static_cast(0xc0U | (codePoint >> 6U))); value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); - } else { + } else if (codePoint <= 0xffffU) { value->push_back(static_cast(0xe0U | (codePoint >> 12U))); value->push_back(static_cast(0x80U | ((codePoint >> 6U) & 0x3fU))); value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } else { + value->push_back(static_cast(0xf0U | (codePoint >> 18U))); + value->push_back(static_cast(0x80U | ((codePoint >> 12U) & 0x3fU))); + value->push_back(static_cast(0x80U | ((codePoint >> 6U) & 0x3fU))); + value->push_back(static_cast(0x80U | (codePoint & 0x3fU))); + } + } + + bool ParseUnicodeEscape(uint32_t* codePoint, std::string* message) { + if (position_ + 4 > text_.size()) { + *message = "short JSON unicode escape"; + return false; + } + uint32_t parsed = 0; + for (unsigned index = 0; index < 4; ++index) { + const char digit = text_[position_++]; + parsed <<= 4U; + if (digit >= '0' && digit <= '9') parsed |= static_cast(digit - '0'); + else if (digit >= 'a' && digit <= 'f') parsed |= static_cast(digit - 'a' + 10); + else if (digit >= 'A' && digit <= 'F') parsed |= static_cast(digit - 'A' + 10); + else { + *message = "invalid JSON unicode escape"; + return false; + } } + *codePoint = parsed; + return true; } bool ParseString(std::string* value, std::string* message) { @@ -744,24 +906,27 @@ class JsonParser final { case 'r': value->push_back('\r'); break; case 't': value->push_back('\t'); break; case 'u': { - if (position_ + 4 > text_.size()) { - *message = "short JSON unicode escape"; + uint32_t codePoint = 0; + if (!ParseUnicodeEscape(&codePoint, message)) { return false; } - uint32_t codePoint = 0; - for (unsigned index = 0; index < 4; ++index) { - const char digit = text_[position_++]; - codePoint <<= 4U; - if (digit >= '0' && digit <= '9') codePoint |= static_cast(digit - '0'); - else if (digit >= 'a' && digit <= 'f') codePoint |= static_cast(digit - 'a' + 10); - else if (digit >= 'A' && digit <= 'F') codePoint |= static_cast(digit - 'A' + 10); - else { - *message = "invalid JSON unicode escape"; + if (codePoint >= 0xd800U && codePoint <= 0xdbffU) { + if (position_ + 2 > text_.size() || text_[position_] != '\\' || + text_[position_ + 1] != 'u') { + *message = "high surrogate JSON escape lacks a low surrogate"; return false; } - } - if (codePoint >= 0xd800U && codePoint <= 0xdfffU) { - *message = "surrogate JSON escapes are not permitted in install manifests"; + position_ += 2; + uint32_t low = 0; + if (!ParseUnicodeEscape(&low, message) || + low < 0xdc00U || low > 0xdfffU) { + *message = "invalid low surrogate JSON escape"; + return false; + } + codePoint = 0x10000U + + ((codePoint - 0xd800U) << 10U) + (low - 0xdc00U); + } else if (codePoint >= 0xdc00U && codePoint <= 0xdfffU) { + *message = "unpaired low surrogate JSON escape"; return false; } AppendUtf8(codePoint, value); @@ -2020,6 +2185,68 @@ struct Snapshot { std::vector packages; }; +enum class InstallJournalPhase { + Prepared, + SetupCopyEntered, + SetupCopyReturned, + StageReceiptCaptured, + QuiesceSignalEntered, + QuiesceSignalReturned, + RootRegistrationIntentCaptured, + RootRegistrationEntered, + RootRegistrationReturned, + DiInstallEntered, + DiInstallReturned, + PriorAbiProfileCaptured, + DriverValidated, + BrokerHandoffEntered, + BrokerHandoffReturned, + BrokerChildEntered, + BrokerChildSettled, + RollbackBindingEntered, + PartialRootRemovalEntered, + PartialRootRemovalReturned, + PartialRootRemovalRebootPending, + RollbackBindingReturned, + SetupUninstallEntered, + SetupUninstallReturned, + ForwardValidated, + ExactPriorRestored, + ForwardRebootPending, + RestoreRebootPending, + ManualReconciliationRequired, +}; + +enum class InstallJournalDirection { + Forward, + Rollback, +}; + +bool RecordActiveInstallJournalCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + +bool RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error); + +bool RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error); + +bool RecordActiveInstallJournalRootRegistrationIntent( + const std::wstring& instanceId, + Error* error); + enum class CandidateDisposition { InstallRequired, Exact, @@ -2288,12 +2515,24 @@ bool StageCandidatePackage( // receipt validation. Mark the protected transaction as potentially // mutated before the API boundary; stagedHere remains success-only so // rollback never claims ownership of a preexisting or uncertain package. + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupCopyEntered, true, ERROR_SUCCESS, + false, error)) { + return false; + } MarkTransactionMutationStarted(); - const BOOL copied = SetupCopyOEMInfW( - sourcePath.c_str(), nullptr, SPOST_PATH, SP_COPY_NOOVERWRITE, - destination.data(), static_cast(destination.size()), - &required, nullptr); + const BOOL copied = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"SetupCopyOEMInfW", [&]() { + return SetupCopyOEMInfW( + sourcePath.c_str(), nullptr, SPOST_PATH, SP_COPY_NOOVERWRITE, + destination.data(), static_cast(destination.size()), + &required, nullptr); + }); const DWORD copyError = copied ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupCopyReturned, copied != FALSE, + copyError, gLastSynchronousMutationTimedOut, &journalReturnError); if (copied) { if (mutationStarted != nullptr) { *mutationStarted = true; @@ -2308,6 +2547,10 @@ bool StageCandidatePackage( // An unexpected API failure is not proof of package ownership. Leave // stagedHere false; common rollback will prove the prior inventory and // fail closed if SetupAPI nevertheless changed it. + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } return SetError(error, L"stage-driver-package", copyError, L"add-only candidate import into the Driver Store failed"); } @@ -2363,6 +2606,14 @@ bool StageCandidatePackage( return false; } *published = std::move(verifiedPublished); + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"stage-driver-package-timeout", ERROR_TIMEOUT, + L"SetupCopyOEMInfW exceeded the transaction deadline; its authoritative return and exact receipt were retained for rollback"); + } return true; } @@ -2373,7 +2624,8 @@ bool RemoveDevice( const wchar_t* deadlinePhase, bool* mutationStarted, bool* rebootRequired, - Error* error) { + Error* error, + bool* freshRebootRequired = nullptr) { if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, deadlinePhase, error)) { return false; @@ -2386,6 +2638,9 @@ bool RemoveDevice( if (!DiUninstallDevice(nullptr, set, &data, 0, &reboot)) { return SetLastErrorDetail(error, L"remove-devnode"); } + if (freshRebootRequired != nullptr) { + *freshRebootRequired = reboot != FALSE; + } *rebootRequired = *rebootRequired || reboot != FALSE; return true; } @@ -2454,6 +2709,33 @@ bool RegisterRootDevice( DICD_GENERATE_ID, data)) { return SetLastErrorDetail(error, L"create-root-devnode"); } + DWORD instanceCharacters = 0; + SetupDiGetDeviceInstanceIdW( + set->get(), data, nullptr, 0, &instanceCharacters); + if (instanceCharacters == 0U || + GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail( + error, L"capture-generated-root-instance-id"); + } + std::vector generatedInstance(instanceCharacters); + if (!SetupDiGetDeviceInstanceIdW( + set->get(), data, generatedInstance.data(), + instanceCharacters, nullptr)) { + return SetLastErrorDetail( + error, L"capture-generated-root-instance-id"); + } + const std::wstring intendedInstanceId = generatedInstance.data(); + if (!IsEqualGUID(classGuid, GUID_DEVCLASS_USB) || + !IsGeneratedRootInstanceIdForDeviceName( + intendedInstanceId, kRootDeviceName)) { + return SetError(error, L"capture-generated-root-instance-id", + ERROR_INVALID_DATA, + L"SetupAPI generated a root identity or class outside the exact VIIPER transaction namespace"); + } + if (!RecordActiveInstallJournalRootRegistrationIntent( + intendedInstanceId, error)) { + return false; + } const size_t idCharacters = std::size(kHardwareId) + 1; std::vector identifiers(idCharacters, L'\0'); std::copy(std::begin(kHardwareId), std::end(kHardwareId), identifiers.begin()); @@ -2461,6 +2743,11 @@ bool RegisterRootDevice( L"transaction-deadline-before-root-properties", error)) { return false; } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } MarkTransactionMutationStarted(); if (mutationStarted != nullptr) { *mutationStarted = true; @@ -2469,7 +2756,15 @@ bool RegisterRootDevice( set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { - return SetLastErrorDetail(error, L"set-root-hardware-id"); + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"set-root-hardware-id", code); } if (!CheckTransactionDeadline(transactionDeadlineUnixMs, L"transaction-deadline-before-root-registration", error)) { @@ -2479,8 +2774,24 @@ bool RegisterRootDevice( if (mutationStarted != nullptr) { *mutationStarted = true; } - if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { - return SetLastErrorDetail(error, L"register-root-devnode"); + const BOOL registered = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)", + [&]() { + return SetupDiCallClassInstaller( + DIF_REGISTERDEVICE, set->get(), data); + }); + const DWORD registerError = registered ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + registered != FALSE, registerError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!registered) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"register-root-devnode", registerError); } if (registrationSucceeded != nullptr) { *registrationSucceeded = true; @@ -2490,9 +2801,18 @@ bool RegisterRootDevice( set->get(), data, instanceId, static_cast(std::size(instanceId)), nullptr)) { return SetLastErrorDetail(error, L"verify-generated-root-instance-id"); } - if (!IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName)) { + if (!IsGeneratedRootInstanceIdForDeviceName(instanceId, kRootDeviceName) || + _wcsicmp(instanceId, intendedInstanceId.c_str()) != 0) { return SetError(error, L"verify-generated-root-instance-id", ERROR_INVALID_DATA, - L"SetupAPI generated a root identity outside the VIIPER-owned namespace"); + L"registered root identity changed from the exact durable pre-registration receipt"); + } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"register-root-devnode-timeout", ERROR_TIMEOUT, + L"root registration exceeded the transaction deadline; its authoritative return was retained for rollback"); } return true; } @@ -2634,16 +2954,49 @@ bool CommitPreparedDriverBinding( return SetError(error, L"repair-select-exact-driver", code); } BOOL reboot = FALSE; - if (!DiInstallDevice(nullptr, prepared->set, prepared->device, - &prepared->selected, 0, &reboot)) { - const DWORD code = GetLastError(); + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::DiInstallEntered, true, ERROR_SUCCESS, + false, error)) { + prepared->Reset(); + return false; + } + const BOOL installed = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, L"DiInstallDevice", [&]() { + return DiInstallDevice(nullptr, prepared->set, prepared->device, + &prepared->selected, 0, &reboot); + }); + const DWORD installError = installed ? ERROR_SUCCESS : GetLastError(); + const bool combinedRebootRequired = + *rebootRequired || reboot != FALSE; + *rebootRequired = combinedRebootRequired; + Error journalReturnError; + const bool journalReturnRecorded = + RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase::DiInstallReturned, installed != FALSE, + installError, gLastSynchronousMutationTimedOut, + combinedRebootRequired, reboot != FALSE, + &journalReturnError); + if (!installed) { + const DWORD code = installError; prepared->Reset(); + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } return SetError(error, L"repair-install-preinstalled-driver", code); } if (!prepared->Reset()) { return SetLastErrorDetail(error, L"repair-destroy-compatible-driver-list"); } - *rebootRequired = *rebootRequired || reboot != FALSE; + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"repair-install-preinstalled-driver-timeout", + ERROR_TIMEOUT, + L"DiInstallDevice exceeded the transaction deadline; its authoritative return was retained for rollback"); + } return true; } @@ -2721,6 +3074,11 @@ bool RegisterRootDeviceExact( L"rollback-deadline-before-root-properties", error)) { return false; } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } MarkTransactionMutationStarted(); if (mutationStarted != nullptr) { *mutationStarted = true; @@ -2728,7 +3086,15 @@ bool RegisterRootDeviceExact( if (!SetupDiSetDeviceRegistryPropertyW(set->get(), data, SPDRP_HARDWAREID, reinterpret_cast(identifiers.data()), static_cast(identifiers.size() * sizeof(wchar_t)))) { - return SetLastErrorDetail(error, L"rollback-set-root-hardware-id"); + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"rollback-set-root-hardware-id", code); } if (transactionDeadlineUnixMs != 0 && !CheckTransactionDeadline(transactionDeadlineUnixMs, @@ -2739,12 +3105,38 @@ bool RegisterRootDeviceExact( if (mutationStarted != nullptr) { *mutationStarted = true; } - if (!SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set->get(), data)) { - return SetLastErrorDetail(error, L"rollback-register-exact-root-devnode"); + const BOOL registered = InvokeAuthoritativeSynchronousMutation( + transactionDeadlineUnixMs, + L"SetupDiCallClassInstaller(DIF_REGISTERDEVICE)", [&]() { + return SetupDiCallClassInstaller( + DIF_REGISTERDEVICE, set->get(), data); + }); + const DWORD registerError = registered ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::RootRegistrationReturned, + registered != FALSE, registerError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!registered) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"rollback-register-exact-root-devnode", + registerError); } if (registrationSucceeded != nullptr) { *registrationSucceeded = true; } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"rollback-register-exact-root-devnode-timeout", + ERROR_TIMEOUT, + L"exact root registration exceeded the rollback deadline; its authoritative return was retained"); + } return true; } @@ -3001,6 +3393,7 @@ bool VerifyAbiHealth( case AbiHealthPurpose::RollbackHealth: profiles = requiredProfile; profileCount = 1; + requirePristineRuntime = true; break; } @@ -3264,12 +3657,40 @@ bool RemoveStagedCandidateExact( L"install-rollback-deadline-staged-package", error)) { return false; } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupUninstallEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } MarkTransactionMutationStarted(); - if (!SetupUninstallOEMInfW( - stagedCandidate.publishedName.c_str(), 0, nullptr)) { - return SetLastErrorDetail(error, L"rollback-staged-package-remove", + const BOOL removed = InvokeAuthoritativeSynchronousMutation( + rollbackDeadlineUnixMs, L"SetupUninstallOEMInfW", [&]() { + return SetupUninstallOEMInfW( + stagedCandidate.publishedName.c_str(), 0, nullptr); + }); + const DWORD removeError = removed ? ERROR_SUCCESS : GetLastError(); + Error journalReturnError; + const bool journalReturnRecorded = RecordActiveInstallJournalCutpoint( + InstallJournalPhase::SetupUninstallReturned, + removed != FALSE, removeError, gLastSynchronousMutationTimedOut, + &journalReturnError); + if (!removed) { + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + return SetError(error, L"rollback-staged-package-remove", removeError, L"the exact unbound staged-here candidate could not be removed"); } + if (!journalReturnRecorded) { + *error = std::move(journalReturnError); + return false; + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"rollback-staged-package-remove-timeout", + ERROR_TIMEOUT, + L"SetupUninstallOEMInfW exceeded the rollback deadline; its authoritative return was retained"); + } return true; } @@ -3528,6 +3949,124 @@ struct InstallOptions { HANDLE brokerHandoff = nullptr; }; +struct BrokerCommitProof; +struct InstallJournalStateData; + +class InstallJournal final { +public: + InstallJournal(); + ~InstallJournal(); + InstallJournal(const InstallJournal&) = delete; + InstallJournal& operator=(const InstallJournal&) = delete; + + bool Prepare( + const Snapshot& prior, + const PackageInfo& candidate, + const std::filesystem::path& candidateDirectory, + const std::vector& expectedInventory, + const InstallOptions& options, + Error* error); + bool Record( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + bool RecordCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error); + bool RecordAuthoritativeReturn( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool freshRebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error); + bool RecordPriorAbiProfile( + const AbiCompatibilityProfile& profile, + const PackageInfo& publishedCandidate, + bool packageStagedHere, + Error* error); + bool RecordRootRegistrationIntent( + const std::wstring& instanceId, + Error* error); + bool RecordBrokerProof( + const BrokerCommitProof& proof, + Error* error); + bool RecordRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error); + bool RetireAfterForwardValidation( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool rebootRequired, + uint64_t deadlineUnixMs, + Error* error); + bool RetireAfterPriorValidation( + bool rebootRequired, + Error* error); + bool RemoveAuthorizedPriorEmptyRootAfterAdmission( + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + bool* rootRemovalRebootPending, + Error* error); + bool VerifyPriorTopologyBeforePackageRollback(Error* error) const; + void AttachEvidence(Error* error) const; + +private: + bool RecordNext( + InstallJournalStateData next, + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool freshRebootRequired, + Error* error); + struct Impl; + std::unique_ptr impl_; +}; + +InstallJournal* gActiveInstallJournal = nullptr; + +class ActiveInstallJournalScope final { +public: + explicit ActiveInstallJournalScope(InstallJournal* journal) noexcept + : prior_(gActiveInstallJournal) { + gActiveInstallJournal = journal; + } + ~ActiveInstallJournalScope() { + gActiveInstallJournal = prior_; + } + ActiveInstallJournalScope(const ActiveInstallJournalScope&) = delete; + ActiveInstallJournalScope& operator=(const ActiveInstallJournalScope&) = delete; + +private: + InstallJournal* prior_; +}; + +bool ReconcileInstallJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome); + uint64_t CurrentUnixMilliseconds() { FILETIME now{}; GetSystemTimeAsFileTime(&now); @@ -3577,8 +4116,26 @@ bool RequestBrokerQuiescence(const InstallOptions& options, Error* error) { options, L"transaction-deadline-before-broker-quiescence", error)) { return false; } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } if (!SetEvent(options.brokerQuiesceRequest)) { - return SetLastErrorDetail(error, L"broker-quiescence-request"); + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalReturned, + false, code, false, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"broker-quiescence-request", code); + } + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::QuiesceSignalReturned, + true, ERROR_SUCCESS, false, error)) { + return false; } const std::array responses{ options.brokerQuiesceReady, options.brokerQuiesceAbort, @@ -3617,10 +4174,27 @@ bool SignalBrokerHandoff(const InstallOptions& options, Error* error) { return SetError(error, L"broker-handoff-handle", ERROR_INVALID_HANDLE, L"authenticated broker commit requires the inherited service-lock handoff"); } + if (!CheckTransactionDeadline( + options, L"transaction-deadline-before-broker-handoff", error) || + !RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerHandoffEntered, + true, ERROR_SUCCESS, false, error)) { + return false; + } if (!SetEvent(options.brokerHandoff)) { - return SetLastErrorDetail(error, L"broker-handoff-signal"); + const DWORD code = GetLastError(); + Error journalError; + if (!RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase::BrokerHandoffReturned, + code, &journalError)) { + *error = std::move(journalError); + return false; + } + return SetError(error, L"broker-handoff-signal", code); } - return true; + return RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerHandoffReturned, + true, ERROR_SUCCESS, false, error); } bool ValidateTransactionDeadlineBudget(uint64_t deadlineUnixMs, Error* error) { @@ -3768,6 +4342,22 @@ struct BrokerCommitProof { std::wstring diagnostic; }; +bool BrokerProofFieldsAreCanonical( + bool success, + bool changed, + std::string_view rollback, + DWORD exitCode, + bool driverRollbackAuthorized) noexcept { + return (success && !driverRollbackAuthorized && + rollback == "not-needed" && exitCode == 0U) || + (!success && !changed && driverRollbackAuthorized && + rollback == "not-needed" && exitCode == 4U) || + (!success && changed && driverRollbackAuthorized && + rollback == "succeeded" && exitCode == 1U) || + (!success && changed && !driverRollbackAuthorized && + rollback == "failed" && exitCode == 3U); +} + bool IsUnsafeBrokerDiagnosticCharacter(wchar_t value) { const uint32_t codePoint = static_cast(value); // The outer structured result is consumed by installers and logs. Preserve @@ -4064,12 +4654,25 @@ bool RunBrokerInstall( return SetError(error, L"broker-handle-list-update", code); } PROCESS_INFORMATION process{}; + if (!RecordActiveInstallJournalCutpoint( + InstallJournalPhase::BrokerChildEntered, + true, ERROR_SUCCESS, false, error)) { + deleteAttributeList(); + return false; + } if (!CreateProcessW(options.brokerExecutable.c_str(), mutableCommand.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, nullptr, options.brokerExecutable.parent_path().c_str(), &startup.StartupInfo, &process)) { const DWORD code = GetLastError(); deleteAttributeList(); + Error journalError; + if (!RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase::BrokerChildSettled, + code, &journalError)) { + *error = std::move(journalError); + return false; + } return SetError(error, L"broker-start", code); } MarkTransactionMutationStarted(); @@ -4165,6 +4768,10 @@ bool RunBrokerInstall( } *driverRollbackAuthorized = proof.driverRollbackAuthorized; *brokerChanged = proof.changed; + if (gActiveInstallJournal != nullptr && + !gActiveInstallJournal->RecordBrokerProof(proof, error)) { + return false; + } if (!proof.success) { return SetBrokerCommitFailure(proof, error); } @@ -4183,6 +4790,11 @@ Outcome Install(const InstallOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + Outcome recoveryOutcome; + if (!ReconcileInstallJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { + return recoveryOutcome; + } std::filesystem::path packageDirectory; std::vector packageLocks; PackageInfo candidate; @@ -4247,6 +4859,23 @@ Outcome Install(const InstallOptions& options) { SamePackageBytes(prior.devices[0].package, candidate); } + InstallJournal installJournal; + if (!installJournal.Prepare( + prior, candidate, packageDirectory, + expectedTransactionInventory, options, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + ActiveInstallJournalScope activeJournal(&installJournal); + if (disposition == CandidateDisposition::Exact && + !installJournal.Record( + InstallJournalPhase::Prepared, &publishedCandidate, + false, false, false, true, ERROR_SUCCESS, false, + &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + // Same-version bytes are immutable. An exact package with a missing, // stopped, or stale binding may repair only the ROOT topology from the // already-published exact INF. It selects that preinstalled package for the @@ -4261,7 +4890,6 @@ Outcome Install(const InstallOptions& options) { DeviceInfoSet created; SP_DEVINFO_DATA createdData{}; createdData.cbSize = sizeof(createdData); - bool createdHere = false; bool registrationSucceeded = false; GUID candidateClassGuid{}; wchar_t candidateClassName[MAX_CLASS_NAME_LEN]{}; @@ -4281,10 +4909,28 @@ Outcome Install(const InstallOptions& options) { // snapshot rollback path, which removes the new package and preserves the // prior binding. if (disposition == CandidateDisposition::InstallRequired) { - if (!StageCandidatePackage( + const bool stageSucceeded = StageCandidatePackage( candidate, options.production, options.transactionDeadlineUnixMs, &driverMutationStarted, &packageStagedHere, - &publishedCandidate, &outcome.error)) { + &publishedCandidate, &outcome.error); + const Error stageError = outcome.error; + Error stageJournalError; + const PackageInfo* stageReceipt = + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr; + const bool stageCallReturned = + driverMutationStarted || stageReceipt != nullptr; + if (stageCallReturned && !installJournal.Record( + InstallJournalPhase::StageReceiptCaptured, stageReceipt, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, stageSucceeded, + stageSucceeded ? ERROR_SUCCESS : stageError.code, + gLastSynchronousMutationTimedOut, &stageJournalError)) { + outcome.error = std::move(stageJournalError); + } else if (!stageSucceeded) { + outcome.error = stageError; + } + if (!stageSucceeded) { // Exact staging proof recorded the failure. } else { if (!packageStagedHere && @@ -4369,7 +5015,13 @@ Outcome Install(const InstallOptions& options) { outcome.rebootRequired = true; } } else { - priorAbiProfile = negotiatedProfile; + if (!installJournal.RecordPriorAbiProfile( + negotiatedProfile, publishedCandidate, + packageStagedHere, &outcome.error)) { + // The exact compatibility profile must be durable before bind. + } else { + priorAbiProfile = negotiatedProfile; + } } } @@ -4382,7 +5034,6 @@ Outcome Install(const InstallOptions& options) { candidateClassGuid, options.transactionDeadlineUnixMs, &bindingMutationStarted, ®istrationSucceeded, &created, &createdData, &outcome.error); - createdHere = registrationSucceeded; if (registeredAndVerified) { InstallPreinstalledDriverOnDevice( created.get(), &createdData, publishedCandidate, @@ -4465,31 +5116,127 @@ Outcome Install(const InstallOptions& options) { L"post-bind-package-inventory-verification", &outcome.error)) { // A concurrent package mutation invalidates the transaction outcome. } + if (outcome.error.code == ERROR_SUCCESS && + !installJournal.Record( + InstallJournalPhase::DriverValidated, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, true, ERROR_SUCCESS, false, + &outcome.error)) { + // A durable validation boundary is required before broker handoff. + } + const auto verifyPostAdmissionRollbackInventory = + [&](const wchar_t* phase, Error* error) { + std::vector exactInventory = prior.packages; + if (packageStagedHere) { + if (!IsSafePublishedInfName( + publishedCandidate.publishedName) || + !SamePackageBytes(publishedCandidate, candidate) || + !(publishedCandidate.version == candidate.version)) { + return SetError(error, phase, ERROR_INVALID_DATA, + L"staged-here rollback lacks its exact published candidate receipt"); + } + if (!ContainsExactPackage( + exactInventory, publishedCandidate)) { + exactInventory.push_back(publishedCandidate); + } + } + std::sort(exactInventory.begin(), exactInventory.end(), + [](const PackageInfo& left, + const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + return VerifyPackageInventory(exactInventory, phase, error); + }; if (outcome.error.code != ERROR_SUCCESS && driverMutationStarted) { const Error installError = outcome.error; Error rollbackError; bool rollbackReboot = outcome.rebootRequired; const uint64_t rollbackDeadline = CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; - if (createdHere) { - Error cleanupError; - if (!RemoveDevice(created.get(), createdData, rollbackDeadline, - L"install-rollback-deadline-created-device", - nullptr, - &rollbackReboot, &cleanupError)) { - outcome.rollback = L"failed"; - outcome.rebootRequired = rollbackReboot; - outcome.error = std::move(cleanupError); - outcome.exitCode = ExitCode::RollbackFailed; - return outcome; - } + if (!installJournal.Record( + InstallJournalPhase::RollbackBindingEntered, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, true, ERROR_SUCCESS, false, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-rollback-post-admission-inventory", + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const bool rollbackRebootAtAdmission = rollbackReboot; + bool rootRemovalRebootPending = false; + if (!installJournal.RemoveAuthorizedPriorEmptyRootAfterAdmission( + rollbackDeadline, &rollbackReboot, + &rootRemovalRebootPending, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (rootRemovalRebootPending) { + outcome.rollback = L"not-needed"; + outcome.rebootRequired = true; + SetError(&outcome.error, + L"install-partial-root-removal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"receipt-bound root removal requires a restart before package rollback can continue"); + outcome.exitCode = ExitCode::RebootRequired; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-rollback-pre-package-inventory", + &rollbackError) || + !installJournal.VerifyPriorTopologyBeforePackageRollback( + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; } const PackageInfo* stagedHereCandidate = packageStagedHere ? &publishedCandidate : nullptr; + const bool restoreBindingThroughStrictSnapshot = + !prior.devices.empty() && bindingMutationStarted; if (RollbackInstall( - prior, stagedHereCandidate, bindingMutationStarted, + prior, stagedHereCandidate, + restoreBindingThroughStrictSnapshot, priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, rollbackDeadline, &rollbackReboot, &rollbackError)) { + Error journalError; + if (!installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + true, ERROR_SUCCESS, false, + &journalError) || + !installJournal.RetireAfterPriorValidation( + rollbackReboot, &journalError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = installError; @@ -4500,10 +5247,34 @@ Outcome Install(const InstallOptions& options) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(rollbackError); + Error journalError; + installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + false, outcome.error.code, false, + &journalError); + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, false, outcome.error.code, false, + &journalError); outcome.exitCode = ExitCode::RollbackFailed; return outcome; } if (outcome.error.code != ERROR_SUCCESS) { + Error journalError; + if (!installJournal.RetireAfterPriorValidation( + outcome.rebootRequired, &journalError)) { + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.exitCode = outcome.rebootRequired && outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED ? ExitCode::RebootRequired : ExitCode::PreflightRejected; @@ -4529,12 +5300,29 @@ Outcome Install(const InstallOptions& options) { outcome.changed = outcome.changed || brokerChanged; if (brokerError.code != ERROR_SUCCESS) { if (!driverRollbackAuthorized) { + Error journalError; + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + outcome.rebootRequired, false, brokerError.code, + false, &journalError); outcome.rollback = L"failed"; outcome.error = std::move(brokerError); + installJournal.AttachEvidence(&outcome.error); outcome.exitCode = ExitCode::RollbackFailed; return outcome; } if (!driverMutationStarted) { + Error journalError; + if (!installJournal.RetireAfterPriorValidation( + outcome.rebootRequired, &journalError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.rollback = brokerChanged ? L"succeeded" : L"not-needed"; outcome.error = std::move(brokerError); outcome.exitCode = outcome.error.code == ERROR_SUCCESS_REBOOT_REQUIRED @@ -4545,25 +5333,87 @@ Outcome Install(const InstallOptions& options) { bool rollbackReboot = outcome.rebootRequired; const uint64_t rollbackDeadline = CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; - if (createdHere) { - Error cleanupError; - if (!RemoveDevice(created.get(), createdData, rollbackDeadline, - L"install-rollback-deadline-created-device", - nullptr, - &rollbackReboot, &cleanupError)) { - outcome.rollback = L"failed"; - outcome.rebootRequired = rollbackReboot; - outcome.error = std::move(cleanupError); - outcome.exitCode = ExitCode::RollbackFailed; - return outcome; - } + if (!installJournal.Record( + InstallJournalPhase::RollbackBindingEntered, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, true, ERROR_SUCCESS, false, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-broker-rollback-post-admission-inventory", + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + const bool rollbackRebootAtAdmission = rollbackReboot; + bool rootRemovalRebootPending = false; + if (!installJournal.RemoveAuthorizedPriorEmptyRootAfterAdmission( + rollbackDeadline, &rollbackReboot, + &rootRemovalRebootPending, + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } + if (rootRemovalRebootPending) { + outcome.rollback = L"not-needed"; + outcome.rebootRequired = true; + SetError(&outcome.error, + L"install-partial-root-removal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"receipt-bound root removal requires a restart before package rollback can continue"); + outcome.exitCode = ExitCode::RebootRequired; + return outcome; + } + if (!verifyPostAdmissionRollbackInventory( + L"install-broker-rollback-pre-package-inventory", + &rollbackError) || + !installJournal.VerifyPriorTopologyBeforePackageRollback( + &rollbackError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(rollbackError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; } const PackageInfo* stagedHereCandidate = packageStagedHere ? &publishedCandidate : nullptr; + const bool restoreBindingThroughStrictSnapshot = + !prior.devices.empty() && bindingMutationStarted; if (RollbackInstall( - prior, stagedHereCandidate, bindingMutationStarted, + prior, stagedHereCandidate, + restoreBindingThroughStrictSnapshot, priorAbiProfile.has_value() ? &priorAbiProfile.value() : nullptr, rollbackDeadline, &rollbackReboot, &rollbackError)) { + Error journalError; + if (!installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + true, ERROR_SUCCESS, false, + &journalError) || + !installJournal.RetireAfterPriorValidation( + rollbackReboot, &journalError)) { + outcome.rollback = L"failed"; + outcome.rebootRequired = rollbackReboot; + outcome.error = std::move(journalError); + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.rollback = L"succeeded"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(brokerError); @@ -4574,11 +5424,36 @@ Outcome Install(const InstallOptions& options) { outcome.rollback = L"failed"; outcome.rebootRequired = rollbackReboot; outcome.error = std::move(rollbackError); + Error journalError; + installJournal.RecordAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + false, outcome.error.code, false, + &journalError); + installJournal.Record( + InstallJournalPhase::ManualReconciliationRequired, + IsSafePublishedInfName(publishedCandidate.publishedName) + ? &publishedCandidate : nullptr, + packageStagedHere, bindingMutationStarted, + rollbackReboot, false, outcome.error.code, false, + &journalError); outcome.exitCode = ExitCode::RollbackFailed; return outcome; } } + if (!installJournal.RetireAfterForwardValidation( + candidate, publishedCandidate.publishedName, + outcome.rebootRequired, options.transactionDeadlineUnixMs, + &outcome.error)) { + outcome.rollback = L"failed"; + outcome.exitCode = ExitCode::RollbackFailed; + return outcome; + } outcome.success = true; outcome.rollback = L"not-needed"; outcome.exitCode = outcome.rebootRequired ? ExitCode::RebootRequired : ExitCode::Success; @@ -4709,12 +5584,170 @@ bool VerifyProtectedFileSystemSecurity( return true; } -bool CreateProtectedBackupDirectory( - const std::filesystem::path& path, - Error* error) { - LocalSecurityDescriptor security; - if (!security.Initialize( - kRollbackDirectorySecurity, L"rollback-backup-directory-security", error)) { +constexpr ACCESS_MASK kProductReadExecuteMask = + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_EA | + FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE; + +ACCESS_MASK NormalizeProductDirectoryAccessMask( + ACCESS_MASK mask) noexcept { + GENERIC_MAPPING mapping{ + FILE_GENERIC_READ, + FILE_GENERIC_WRITE, + FILE_GENERIC_EXECUTE, + FILE_ALL_ACCESS, + }; + MapGenericMask(&mask, &mapping); + return mask; +} + +bool ProductDirectoryMaskIsReadExecuteOnly( + ACCESS_MASK mask) noexcept { + mask = NormalizeProductDirectoryAccessMask(mask); + return mask != 0 && (mask & ~kProductReadExecuteMask) == 0; +} + +bool VerifyProtectedProductDirectorySecurity( + HANDLE handle, + const std::wstring* exactTargetUserSid, + Error* error) { + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const DWORD securityError = GetSecurityInfo( + handle, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + if (securityError != ERROR_SUCCESS) { + return SetError(error, L"install-journal-product-security", + securityError); + } + const auto fail = [&](DWORD code, std::wstring message) { + LocalFree(descriptor); + return SetError(error, L"install-journal-product-security", + code, std::move(message)); + }; + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + BYTE systemBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD systemSize = sizeof(systemBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize) || + !CreateWellKnownSid(WinLocalSystemSid, nullptr, + systemBuffer, &systemSize)) { + return fail(GetLastError(), + L"could not construct product-directory principals"); + } + PSID targetUser = nullptr; + if (exactTargetUserSid != nullptr && + (!IsSafeTargetUserSid(*exactTargetUserSid) || + !ConvertStringSidToSidW( + exactTargetUserSid->c_str(), &targetUser))) { + return fail(GetLastError() == ERROR_SUCCESS + ? ERROR_INVALID_SID : GetLastError(), + L"could not construct the exact product-directory target user principal"); + } + const auto freeTargetUser = [&]() { + if (targetUser != nullptr) { + LocalFree(targetUser); + targetUser = nullptr; + } + }; + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION information{}; + if (owner == nullptr || + (exactTargetUserSid != nullptr + ? !EqualSid(owner, administratorsBuffer) + : (!EqualSid(owner, administratorsBuffer) && + !EqualSid(owner, systemBuffer))) || + dacl == nullptr || + !GetSecurityDescriptorControl(descriptor, &control, &revision) || + (control & SE_DACL_PROTECTED) == 0 || + !GetAclInformation(dacl, &information, sizeof(information), + AclSizeInformation) || + (exactTargetUserSid != nullptr + ? information.AceCount != 3U + : information.AceCount < 2U)) { + freeTargetUser(); + return fail(ERROR_INVALID_SECURITY_DESCR, + L"product directory must have a protected Administrators/LocalSystem-owned DACL"); + } + constexpr BYTE inheritedFlags = + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + bool administratorsSeen = false; + bool systemSeen = false; + bool targetUserSeen = false; + for (DWORD index = 0; index < information.AceCount; ++index) { + void* rawAce = nullptr; + if (!GetAce(dacl, index, &rawAce) || rawAce == nullptr) { + const DWORD code = GetLastError(); + freeTargetUser(); + return fail(code == ERROR_SUCCESS ? ERROR_INVALID_ACL : code, + L"product directory DACL could not be enumerated"); + } + const auto* ace = static_cast(rawAce); + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + (ace->Header.AceFlags & ~inheritedFlags) != 0) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory contains a deny, inherited, or otherwise unsupported access rule"); + } + PSID sid = const_cast(&ace->SidStart); + const ACCESS_MASK normalizedMask = + NormalizeProductDirectoryAccessMask(ace->Mask); + if (EqualSid(sid, administratorsBuffer) || + EqualSid(sid, systemBuffer)) { + bool& seen = EqualSid(sid, administratorsBuffer) + ? administratorsSeen : systemSeen; + if (seen || ace->Header.AceFlags != inheritedFlags || + normalizedMask != FILE_ALL_ACCESS) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory Administrators/LocalSystem rules are not exact full-control entries"); + } + seen = true; + continue; + } + if (exactTargetUserSid != nullptr && + EqualSid(sid, targetUser)) { + if (targetUserSeen || + ace->Header.AceFlags != inheritedFlags || + normalizedMask != kProductReadExecuteMask) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory target-user rule is not exact inherited read/execute access"); + } + targetUserSeen = true; + continue; + } + if (!ProductDirectoryMaskIsReadExecuteOnly(ace->Mask)) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory grants a non-system principal create, write, delete, ownership, or ACL authority"); + } + if (exactTargetUserSid != nullptr) { + freeTargetUser(); + return fail(ERROR_INVALID_ACL, + L"product directory grants read/execute access to a principal other than the requested target user"); + } + } + freeTargetUser(); + LocalFree(descriptor); + if (!administratorsSeen || !systemSeen || + (exactTargetUserSid != nullptr && !targetUserSeen)) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_ACL, + L"product directory is missing exact Administrators or LocalSystem full control"); + } + return true; +} + +bool CreateProtectedBackupDirectory( + const std::filesystem::path& path, + Error* error) { + LocalSecurityDescriptor security; + if (!security.Initialize( + kRollbackDirectorySecurity, L"rollback-backup-directory-security", error)) { return false; } if (!CreateDirectoryW(path.c_str(), security.attributes())) { @@ -5084,14 +6117,11 @@ class BackupDirectory final { bool preserve_ = false; }; -bool BackupPackages( +bool BackupPackagesIntoDirectory( const std::vector& packages, - BackupDirectory* root, + const std::filesystem::path& baseDirectory, std::vector* backups, Error* error) { - if (!root->Create(error)) { - return false; - } backups->clear(); for (size_t index = 0; index < packages.size(); ++index) { std::filesystem::path storeInf; @@ -5106,7 +6136,8 @@ bool BackupPackages( } return false; } - const std::filesystem::path destination = root->path() / std::to_wstring(index); + const std::filesystem::path destination = + baseDirectory / std::to_wstring(index); std::filesystem::path signerCatalog; if (!VerifyInfSignature(storeInf, &signerCatalog, error)) { return false; @@ -5151,6 +6182,15 @@ bool BackupPackages( return true; } +bool BackupPackages( + const std::vector& packages, + BackupDirectory* root, + std::vector* backups, + Error* error) { + return root->Create(error) && + BackupPackagesIntoDirectory(packages, root->path(), backups, error); +} + bool IsSha256Digest(std::string_view value) { return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char character) { @@ -5365,141 +6405,5047 @@ bool BuildRemoveRecoveryRecord( if (record->size() > kMaximumRecoveryRecordBytes) { return SetError(error, L"recovery-record-size", ERROR_FILE_TOO_LARGE); } - return true; + return true; +} + +bool WriteProtectedRecoveryRecord( + const std::filesystem::path& path, + std::string_view record, + Error* error) { + if (path.filename() != kRecoveryRecordName || + record.empty() || record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"recovery-record-create", ERROR_INVALID_PARAMETER); + } + const std::filesystem::path temporaryPath = + path.parent_path() / kRecoveryRecordTemporaryName; + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"recovery-record-security", error)) { + return false; + } + WinHandle file(CreateFileW(temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | + FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + const DWORD createError = GetLastError(); + if (!file) { + return SetError(error, L"recovery-record-create", createError); + } + const auto discardTemporary = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + const BOOL queriedAttributes = GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); + const DWORD attributeError = queriedAttributes ? ERROR_SUCCESS : GetLastError(); + if (!queriedAttributes || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + const DWORD code = queriedAttributes + ? ERROR_REPARSE_TAG_MISMATCH : attributeError; + SetError(error, L"recovery-record-create", code, + L"recovery record must be a regular non-reparse file"); + discardTemporary(); + return false; + } + if (!VerifyProtectedFileSystemSecurity( + file.get(), false, L"recovery-record-security", error)) { + discardTemporary(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + DWORD written = 0; + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD writeError = GetLastError(); + const DWORD code = writeError == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : writeError; + SetError(error, L"recovery-record-write", code); + discardTemporary(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"recovery-record-flush"); + discardTemporary(); + return false; + } + file.reset(); + if (!MoveFileExW( + temporaryPath.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"recovery-record-publish", code); + } + + file.reset(CreateFileW(path.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | + FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"recovery-record-reopen"); + } + attributes = {}; + const BOOL queriedPublished = GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); + const DWORD publishedQueryError = queriedPublished + ? ERROR_SUCCESS : GetLastError(); + if (!queriedPublished || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + const DWORD code = queriedPublished + ? ERROR_REPARSE_TAG_MISMATCH : publishedQueryError; + return SetError(error, L"recovery-record-reopen", code, + L"published recovery record must be a regular non-reparse file"); + } + if (!VerifyProtectedFileSystemSecurity( + file.get(), false, L"recovery-record-security", error)) { + return false; + } + offset = 0; + std::array verification{}; + while (offset < record.size()) { + const DWORD requested = static_cast(std::min( + verification.size(), record.size() - offset)); + DWORD read = 0; + if (!ReadFile(file.get(), verification.data(), requested, &read, nullptr)) { + return SetLastErrorDetail(error, L"recovery-record-verify"); + } + if (read != requested || + std::memcmp(verification.data(), record.data() + offset, read) != 0) { + return SetError(error, L"recovery-record-verify", ERROR_CRC, + L"published recovery record bytes do not match the flushed transaction journal"); + } + offset += read; + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr)) { + return SetLastErrorDetail(error, L"recovery-record-verify"); + } + if (trailingRead != 0) { + return SetError(error, L"recovery-record-verify", ERROR_FILE_INVALID, + L"published recovery record contains trailing bytes"); + } + if (!FlushFileBuffers(file.get())) { + return SetLastErrorDetail(error, L"recovery-record-published-flush"); + } + return true; +} + +const char* InstallJournalPhaseName(InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::Prepared: return "Prepared"; + case InstallJournalPhase::SetupCopyEntered: return "SetupCopyEntered"; + case InstallJournalPhase::SetupCopyReturned: return "SetupCopyReturned"; + case InstallJournalPhase::StageReceiptCaptured: + return "StageReceiptCaptured"; + case InstallJournalPhase::QuiesceSignalEntered: return "QuiesceSignalEntered"; + case InstallJournalPhase::QuiesceSignalReturned: return "QuiesceSignalReturned"; + case InstallJournalPhase::RootRegistrationIntentCaptured: + return "RootRegistrationIntentCaptured"; + case InstallJournalPhase::RootRegistrationEntered: return "RootRegistrationEntered"; + case InstallJournalPhase::RootRegistrationReturned: return "RootRegistrationReturned"; + case InstallJournalPhase::DiInstallEntered: return "DiInstallEntered"; + case InstallJournalPhase::DiInstallReturned: return "DiInstallReturned"; + case InstallJournalPhase::PriorAbiProfileCaptured: + return "PriorAbiProfileCaptured"; + case InstallJournalPhase::DriverValidated: return "DriverValidated"; + case InstallJournalPhase::BrokerHandoffEntered: return "BrokerHandoffEntered"; + case InstallJournalPhase::BrokerHandoffReturned: return "BrokerHandoffReturned"; + case InstallJournalPhase::BrokerChildEntered: return "BrokerChildEntered"; + case InstallJournalPhase::BrokerChildSettled: return "BrokerChildSettled"; + case InstallJournalPhase::RollbackBindingEntered: return "RollbackBindingEntered"; + case InstallJournalPhase::PartialRootRemovalEntered: + return "PartialRootRemovalEntered"; + case InstallJournalPhase::PartialRootRemovalReturned: + return "PartialRootRemovalReturned"; + case InstallJournalPhase::PartialRootRemovalRebootPending: + return "PartialRootRemovalRebootPending"; + case InstallJournalPhase::RollbackBindingReturned: return "RollbackBindingReturned"; + case InstallJournalPhase::SetupUninstallEntered: return "SetupUninstallEntered"; + case InstallJournalPhase::SetupUninstallReturned: return "SetupUninstallReturned"; + case InstallJournalPhase::ForwardValidated: return "ForwardValidated"; + case InstallJournalPhase::ExactPriorRestored: return "ExactPriorRestored"; + case InstallJournalPhase::ForwardRebootPending: return "ForwardRebootPending"; + case InstallJournalPhase::RestoreRebootPending: return "RestoreRebootPending"; + case InstallJournalPhase::ManualReconciliationRequired: + return "ManualReconciliationRequired"; + } + return "ManualReconciliationRequired"; +} + +std::optional ParseInstallJournalPhase( + std::string_view value) noexcept { + for (InstallJournalPhase phase : { + InstallJournalPhase::Prepared, + InstallJournalPhase::SetupCopyEntered, + InstallJournalPhase::SetupCopyReturned, + InstallJournalPhase::StageReceiptCaptured, + InstallJournalPhase::QuiesceSignalEntered, + InstallJournalPhase::QuiesceSignalReturned, + InstallJournalPhase::RootRegistrationIntentCaptured, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::PriorAbiProfileCaptured, + InstallJournalPhase::DriverValidated, + InstallJournalPhase::BrokerHandoffEntered, + InstallJournalPhase::BrokerHandoffReturned, + InstallJournalPhase::BrokerChildEntered, + InstallJournalPhase::BrokerChildSettled, + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::PartialRootRemovalEntered, + InstallJournalPhase::PartialRootRemovalReturned, + InstallJournalPhase::PartialRootRemovalRebootPending, + InstallJournalPhase::RollbackBindingReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::ForwardValidated, + InstallJournalPhase::ExactPriorRestored, + InstallJournalPhase::ForwardRebootPending, + InstallJournalPhase::RestoreRebootPending, + InstallJournalPhase::ManualReconciliationRequired}) { + if (value == InstallJournalPhaseName(phase)) { + return phase; + } + } + return std::nullopt; +} + +bool InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::PriorAbiProfileCaptured: + case InstallJournalPhase::DiInstallEntered: + case InstallJournalPhase::DiInstallReturned: + return true; + default: + return false; + } +} + +const char* InstallJournalDirectionName( + InstallJournalDirection direction) noexcept { + return direction == InstallJournalDirection::Rollback + ? "rollback" : "forward"; +} + +std::optional ParseInstallJournalDirection( + std::string_view value) noexcept { + if (value == "forward") return InstallJournalDirection::Forward; + if (value == "rollback") return InstallJournalDirection::Rollback; + return std::nullopt; +} + +bool Utf8ToWide(std::string_view value, std::wstring* wide, Error* error) { + if (value.size() > static_cast(std::numeric_limits::max())) { + return SetError(error, L"install-journal-utf8", ERROR_BUFFER_OVERFLOW); + } + if (value.empty()) { + wide->clear(); + return true; + } + const int bytes = static_cast(value.size()); + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), bytes, nullptr, 0); + if (required <= 0) { + return SetLastErrorDetail(error, L"install-journal-utf8"); + } + wide->assign(static_cast(required), L'\0'); + if (MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), bytes, + wide->data(), required) != required) { + return SetLastErrorDetail(error, L"install-journal-utf8"); + } + return true; +} + +void AppendJsonUtf8String(std::string* output, std::string_view value) { + static constexpr char digits[] = "0123456789abcdef"; + output->push_back('"'); + for (unsigned char character : value) { + if (character == '"' || character == '\\') { + output->push_back('\\'); + output->push_back(static_cast(character)); + } else if (character < 0x20U) { + output->append("\\u00"); + output->push_back(digits[(character >> 4U) & 0x0fU]); + output->push_back(digits[character & 0x0fU]); + } else { + output->push_back(static_cast(character)); + } + } + output->push_back('"'); +} + +bool ResolveInstallRecoveryPaths( + std::filesystem::path* programData, + std::filesystem::path* product, + std::filesystem::path* component, + std::filesystem::path* transactions, + std::filesystem::path* active, + Error* error) { + PWSTR raw = nullptr; + const HRESULT result = SHGetKnownFolderPath( + FOLDERID_ProgramData, KF_FLAG_DEFAULT, nullptr, &raw); + if (FAILED(result) || raw == nullptr) { + return SetError(error, L"install-journal-programdata", + HRESULT_CODE(result == S_OK ? E_FAIL : result)); + } + try { + *programData = std::filesystem::path(raw).lexically_normal(); + *product = *programData / kInstallRecoveryProductDirectory; + *component = *product / kInstallRecoveryComponentDirectory; + *transactions = *component / kInstallRecoveryTransactionsDirectory; + *active = *transactions / kInstallRecoveryActiveDirectory; + } catch (...) { + CoTaskMemFree(raw); + throw; + } + CoTaskMemFree(raw); + if (!programData->is_absolute() || + active->lexically_relative(*programData).empty()) { + return SetError(error, L"install-journal-programdata", ERROR_INVALID_NAME, + L"known ProgramData did not resolve an absolute journal parent"); + } + return true; +} + +bool OpenStableDirectory( + const std::filesystem::path& path, + bool exactProtectedSecurity, + WinHandle* handle, + Error* error) { + handle->reset(CreateFileW( + path.c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS, + nullptr)); + if (!*handle) { + return SetLastErrorDetail(error, L"install-journal-directory-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + handle->get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"install-journal-directory-open", + ERROR_REPARSE_TAG_MISMATCH, + L"install journal components must be regular non-reparse directories"); + } + return !exactProtectedSecurity || VerifyProtectedFileSystemSecurity( + handle->get(), true, L"install-journal-directory-security", error); +} + +bool CreateOrOpenInstallRecoveryDirectoryWithSecurity( + const std::filesystem::path& path, + bool allowExisting, + bool exactProtectedSecurity, + const wchar_t* securitySddl, + WinHandle* handle, + bool* created, + Error* error) { + LocalSecurityDescriptor security; + if (!security.Initialize( + securitySddl, + L"install-journal-directory-security", error)) { + return false; + } + *created = false; + if (CreateDirectoryW(path.c_str(), security.attributes())) { + *created = true; + } else { + const DWORD code = GetLastError(); + if (code != ERROR_ALREADY_EXISTS || !allowExisting) { + return SetError(error, L"install-journal-directory-create", + code == ERROR_ALREADY_EXISTS ? ERROR_INSTALL_SUSPEND : code, + code == ERROR_ALREADY_EXISTS + ? L"an unfinished native driver transaction already exists" + : std::wstring{}); + } + } + return OpenStableDirectory(path, exactProtectedSecurity, handle, error); +} + +bool CreateOrOpenInstallRecoveryDirectory( + const std::filesystem::path& path, + bool allowExisting, + bool exactProtectedSecurity, + WinHandle* handle, + bool* created, + Error* error) { + return CreateOrOpenInstallRecoveryDirectoryWithSecurity( + path, allowExisting, exactProtectedSecurity, + kRollbackDirectorySecurity, handle, created, error); +} + +bool BuildInstallRecoveryProductDirectorySecurity( + const std::wstring& targetUserSid, + std::wstring* sddl, + Error* error) { + if (!IsSafeTargetUserSid(targetUserSid)) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_SID, + L"fresh product-directory creation requires one canonical target-user SID"); + } + *sddl = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + L"(A;OICI;GRGX;;;" + targetUserSid + L")"; + return true; +} + +bool InstallRecoveryChainHasActive( + bool productExists, + bool componentExists, + bool transactionsExist, + bool activeExists) noexcept { + return productExists && componentExists && + transactionsExist && activeExists; +} + +bool OpenExistingInstallRecoveryDirectory( + const std::filesystem::path& path, + bool exactProtectedSecurity, + WinHandle* handle, + bool* exists, + Error* error) { + *exists = false; + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD code = GetLastError(); + if (code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND) { + return true; + } + return SetError(error, L"install-journal-discovery", code); + } + if (!OpenStableDirectory(path, exactProtectedSecurity, handle, error)) { + return false; + } + *exists = true; + return true; +} + +struct InstallRecoveryDirectory { + std::filesystem::path programData; + std::filesystem::path product; + std::filesystem::path component; + std::filesystem::path transactions; + std::filesystem::path active; + WinHandle programDataHandle; + WinHandle productHandle; + WinHandle componentHandle; + WinHandle transactionsHandle; + WinHandle activeHandle; + bool activeCreated = false; + + bool OpenChain( + bool createActive, + const std::wstring* exactTargetUserSid, + bool* exists, + Error* error) { + *exists = false; + if (!ResolveInstallRecoveryPaths( + &programData, &product, &component, &transactions, &active, + error) || + !OpenStableDirectory( + programData, false, &programDataHandle, error)) { + return false; + } + if (!createActive) { + bool productExists = false; + bool componentExists = false; + bool transactionsExist = false; + bool activeExists = false; + if (!OpenExistingInstallRecoveryDirectory( + product, false, &productHandle, + &productExists, error)) { + return false; + } + if (!productExists) return true; + if (!VerifyProtectedProductDirectorySecurity( + productHandle.get(), nullptr, error) || + !OpenExistingInstallRecoveryDirectory( + component, true, &componentHandle, + &componentExists, error)) { + return false; + } + if (!componentExists) return true; + if (!OpenExistingInstallRecoveryDirectory( + transactions, true, &transactionsHandle, + &transactionsExist, error)) { + return false; + } + if (!transactionsExist) return true; + if (!OpenExistingInstallRecoveryDirectory( + active, true, &activeHandle, + &activeExists, error)) { + return false; + } + *exists = InstallRecoveryChainHasActive( + productExists, componentExists, + transactionsExist, activeExists); + return true; + } + if (exactTargetUserSid == nullptr) { + return SetError(error, L"install-journal-product-security", + ERROR_INVALID_PARAMETER, + L"fresh install journal creation requires the exact target-user SID"); + } + std::wstring productSecurity; + bool created = false; + if (!BuildInstallRecoveryProductDirectorySecurity( + *exactTargetUserSid, &productSecurity, error) || + !CreateOrOpenInstallRecoveryDirectoryWithSecurity( + product, true, false, productSecurity.c_str(), + &productHandle, &created, error) || + !VerifyProtectedProductDirectorySecurity( + productHandle.get(), exactTargetUserSid, error) || + !CreateOrOpenInstallRecoveryDirectory( + component, true, true, &componentHandle, &created, error) || + !CreateOrOpenInstallRecoveryDirectory( + transactions, true, true, &transactionsHandle, &created, + error)) { + return false; + } + if (createActive) { + const bool opened = CreateOrOpenInstallRecoveryDirectory( + active, false, true, &activeHandle, &created, error); + activeCreated = created; + if (!opened) { + return false; + } + *exists = true; + return true; + } + if (!OpenStableDirectory(active, true, &activeHandle, error)) { + return false; + } + *exists = true; + return true; + } +}; + +bool RetireInstallRecoveryActiveDirectory( + InstallRecoveryDirectory* directory, + std::string_view transactionId, + Error* error) { + if (directory == nullptr || !IsSha256Digest(transactionId) || + directory->active.filename() != kInstallRecoveryActiveDirectory) { + return SetError(error, L"install-journal-retire-identity", + ERROR_INVALID_PARAMETER); + } + std::wstring transactionIdWide( + transactionId.begin(), transactionId.end()); + const std::filesystem::path tombstone = + directory->transactions / + (std::wstring(kInstallRecoverySettledPrefix) + transactionIdWide); + directory->activeHandle.reset(); + if (!MoveFileExW(directory->active.c_str(), tombstone.c_str(), + MOVEFILE_WRITE_THROUGH)) { + return SetLastErrorDetail(error, L"install-journal-retire-rename", + L"terminal journal could not be atomically moved out of active admission"); + } + const DWORD activeAttributes = GetFileAttributesW(directory->active.c_str()); + const DWORD activeError = activeAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (activeAttributes != INVALID_FILE_ATTRIBUTES || + (activeError != ERROR_FILE_NOT_FOUND && + activeError != ERROR_PATH_NOT_FOUND)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return SetError(error, L"install-journal-retire-active-absence", + activeAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : activeError, + L"atomic retirement did not prove active-v2 absent"); + } + WinHandle tombstoneHandle; + if (!OpenStableDirectory( + tombstone, true, &tombstoneHandle, error)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return false; + } + ClearActiveRecoveryEvidence(); + tombstoneHandle.reset(); + + // Once active-v2 is atomically absent, cleanup is intentionally + // best-effort. A power loss may leave a settled-v2-* tombstone, but it is + // outside active admission and its transaction-bound name cannot be + // confused with an unfinished transaction. + std::error_code removalError; + std::filesystem::remove_all(tombstone, removalError); + if (removalError) { + std::wstring diagnostic = + L"VIIPER: settled install journal tombstone retained after cleanup error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +bool PublishInstallRecoveryEvidence( + const std::filesystem::path& active, + uint64_t sequence, + Error* error) { + std::wostringstream name; + name << kInstallRecoveryJournalPrefix << std::setw(8) << std::setfill(L'0') + << sequence << kInstallRecoveryJournalSuffix; + const std::filesystem::path record = active / name.str(); + const std::wstring activeValue = active.wstring(); + const std::wstring recordValue = record.wstring(); + if (activeValue.empty() || recordValue.empty() || + activeValue.size() >= gActiveBackupRoot.size() || + recordValue.size() >= gActiveRecoveryRecord.size()) { + return SetError(error, L"install-journal-evidence", + ERROR_FILENAME_EXCED_RANGE, + L"fixed recovery journal path exceeds the exception-safe reporting bound"); + } + ClearActiveRecoveryEvidence(); + std::copy(activeValue.begin(), activeValue.end(), gActiveBackupRoot.begin()); + std::copy(recordValue.begin(), recordValue.end(), gActiveRecoveryRecord.begin()); + gActiveBackupRootRetained = true; + return true; +} + +bool GetBootIdentifier(std::string* identifier, Error* error) { + using NtQuerySystemInformationFn = LONG(NTAPI*)(ULONG, PVOID, ULONG, PULONG); + struct BootEnvironmentInformation { + GUID bootIdentifier; + ULONG firmwareType; + ULONGLONG bootFlags; + } information{}; + const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + const auto query = ntdll == nullptr ? nullptr + : reinterpret_cast( + GetProcAddress(ntdll, "NtQuerySystemInformation")); + if (query == nullptr || query(90U, &information, + static_cast(sizeof(information)), nullptr) < 0) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_NOT_SUPPORTED, + L"the current boot session could not be identified durably"); + } + wchar_t value[64]{}; + if (StringFromGUID2(information.bootIdentifier, value, + static_cast(std::size(value))) <= 0) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + identifier->clear(); + for (wchar_t character : std::wstring_view(value)) { + if (character == L'{' || character == L'}' || character == L'-') { + continue; + } + if (character > 0x7f) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + identifier->push_back(static_cast( + std::tolower(static_cast(character)))); + } + if (identifier->size() != 32U) { + return SetError(error, L"install-journal-boot-identifier", + ERROR_INVALID_DATA); + } + return true; +} + +bool IsCanonicalBootIdentifier(std::string_view identifier) noexcept { + return identifier.size() == 32U && + std::all_of(identifier.begin(), identifier.end(), + [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'); + }); +} + +struct InstallJournalStateData { + InstallJournalPhase phase = InstallJournalPhase::Prepared; + InstallJournalDirection direction = InstallJournalDirection::Forward; + bool rollbackAuthorized = false; + uint64_t sequence = 0; + std::string previousDigest = std::string(kZeroSha256); + std::string lastDigest; + std::string transactionId; + std::string bootIdentifier; + std::string pendingRebootBootIdentifier; + std::string sourceRevision; + bool production = true; + bool localTest = false; + bool brokerRequired = false; + bool brokerEntered = false; + bool brokerSettled = false; + bool hasBrokerProof = false; + bool brokerProofSuccess = false; + bool brokerProofChanged = false; + bool brokerDriverRollbackAuthorized = false; + std::string brokerProofRollback; + DWORD brokerProofExitCode = ERROR_SUCCESS; + bool hasPriorAbiProfile = false; + AbiCompatibilityProfile priorAbiProfile{}; + bool hasRootRegistrationIntent = false; + std::wstring rootRegistrationInstanceId; + enum class PartialRootRemovalBinding { + None, + Unbound, + Candidate, + } partialRootRemovalBinding = PartialRootRemovalBinding::None; + std::string partialRootRemovalBootIdentifier; + Snapshot prior; + PackageInfo candidate; + PackageInfo publishedCandidate; + bool hasPublishedCandidate = false; + std::vector expectedInventory; + bool packageStagedHere = false; + bool bindingMutationStarted = false; + bool rebootRequired = false; + bool freshRebootRequired = false; + bool callSucceeded = true; + DWORD callError = ERROR_SUCCESS; + bool deadlineOverrun = false; +}; + +bool ValidateInstallJournalTransition( + const InstallJournalStateData* previous, + const InstallJournalStateData& next, + Error* error); + +bool VerifyInstallJournalRawPriorTopology( + const InstallJournalStateData& state, + Error* error); + +bool VerifyInstallJournalRawForwardTopology( + const InstallJournalStateData& state, + Error* error); + +void AppendPackageIdentityJson( + std::string* output, + const PackageInfo& package, + std::wstring_view backupInf) { + output->append("{\"publishedInf\":"); + AppendJsonString(output, package.publishedName); + output->append(",\"version\":"); + AppendJsonString(output, VersionToString(package.version)); + output->append(",\"infSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.infSha256)); + output->append(",\"sysSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.sysSha256)); + output->append(",\"catSha256\":"); + AppendJsonAsciiString(output, LowerAscii(package.catSha256)); + output->append(",\"backupInf\":"); + AppendJsonString(output, backupInf); + output->push_back('}'); +} + +bool BuildInstallJournalPayload( + const InstallJournalStateData& state, + std::string* payload, + Error* error) { + const bool priorRequiresAbiProfile = + state.prior.devices.size() == 1U && + state.prior.devices[0].started && + state.prior.devices[0].problem == 0; + const bool authoritativeRebootReturn = + state.phase == InstallJournalPhase::DiInstallReturned || + state.phase == InstallJournalPhase::RollbackBindingReturned || + state.phase == + InstallJournalPhase::PartialRootRemovalReturned; + const bool partialRootRemovalPhase = + state.phase == InstallJournalPhase::PartialRootRemovalEntered || + state.phase == InstallJournalPhase::PartialRootRemovalReturned || + state.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending; + const bool hasPartialRootRemovalBinding = + state.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + const bool rebootPendingPhase = + state.phase == InstallJournalPhase::ForwardRebootPending || + state.phase == InstallJournalPhase::RestoreRebootPending; + if (!IsSha256Digest(state.previousDigest) || + !IsCanonicalBootIdentifier(state.bootIdentifier) || + (!state.pendingRebootBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.pendingRebootBootIdentifier)) || + (!state.partialRootRemovalBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.partialRootRemovalBootIdentifier)) || + (partialRootRemovalPhase && + (state.partialRootRemovalBootIdentifier.empty() || + !hasPartialRootRemovalBinding)) || + (state.partialRootRemovalBootIdentifier.empty() != + !hasPartialRootRemovalBinding) || + (!state.partialRootRemovalBootIdentifier.empty() && + (!state.hasRootRegistrationIntent || + !state.prior.devices.empty() || + state.direction != InstallJournalDirection::Rollback || + !state.rollbackAuthorized)) || + (!state.rebootRequired && + !state.pendingRebootBootIdentifier.empty()) || + (rebootPendingPhase && + state.pendingRebootBootIdentifier.empty()) || + (state.freshRebootRequired && + (!state.rebootRequired || + state.pendingRebootBootIdentifier.empty() || + !authoritativeRebootReturn)) || + !IsSha256Digest(state.candidate.infSha256) || + !IsSha256Digest(state.candidate.sysSha256) || + !IsSha256Digest(state.candidate.catSha256) || + (state.hasPriorAbiProfile && + !IsKnownAbiCompatibilityProfile(state.priorAbiProfile)) || + (state.hasRootRegistrationIntent && + (!state.prior.devices.empty() || + !state.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + state.rootRegistrationInstanceId, + kRootDeviceName))) || + (!state.hasRootRegistrationIntent && + (!state.rootRegistrationInstanceId.empty() || + state.phase == + InstallJournalPhase::RootRegistrationIntentCaptured || + (state.prior.devices.empty() && + state.bindingMutationStarted))) || + (state.phase == InstallJournalPhase::RootRegistrationIntentCaptured && + (state.direction != InstallJournalDirection::Forward || + state.bindingMutationStarted)) || + (state.hasBrokerProof && + !BrokerProofFieldsAreCanonical( + state.brokerProofSuccess, + state.brokerProofChanged, + state.brokerProofRollback, + state.brokerProofExitCode, + state.brokerDriverRollbackAuthorized)) || + (state.hasBrokerProof && + state.brokerDriverRollbackAuthorized != + state.rollbackAuthorized) || + (state.brokerSettled && !state.hasBrokerProof && + !state.rollbackAuthorized) || + ((state.direction == InstallJournalDirection::Rollback) != + state.rollbackAuthorized) || + (priorRequiresAbiProfile && + (InstallJournalPhaseRequiresPriorAbiProfile(state.phase) || + state.bindingMutationStarted) && + !state.hasPriorAbiProfile) || + state.sequence >= kMaximumInstallRecoveryRecords) { + return SetError(error, L"install-journal-state", ERROR_INVALID_DATA); + } + payload->clear(); + payload->append("{\"sequence\":"); + payload->append(std::to_string(state.sequence)); + payload->append(",\"previousSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(state.previousDigest)); + payload->append(",\"phase\":"); + AppendJsonAsciiString(payload, InstallJournalPhaseName(state.phase)); + payload->append(",\"direction\":"); + AppendJsonAsciiString(payload, + InstallJournalDirectionName(state.direction)); + payload->append(",\"rollbackAuthorized\":"); + payload->append(state.rollbackAuthorized ? "true" : "false"); + payload->append(",\"transactionId\":"); + AppendJsonAsciiString(payload, state.transactionId); + payload->append(",\"bootIdentifier\":"); + AppendJsonAsciiString(payload, state.bootIdentifier); + payload->append(",\"pendingRebootBootIdentifier\":"); + if (state.pendingRebootBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString( + payload, state.pendingRebootBootIdentifier); + } + payload->append(",\"sourceRevision\":"); + AppendJsonAsciiString(payload, LowerAscii(state.sourceRevision)); + payload->append(",\"production\":"); + payload->append(state.production ? "true" : "false"); + payload->append(",\"localTest\":"); + payload->append(state.localTest ? "true" : "false"); + payload->append(",\"brokerRequired\":"); + payload->append(state.brokerRequired ? "true" : "false"); + payload->append(",\"brokerEntered\":"); + payload->append(state.brokerEntered ? "true" : "false"); + payload->append(",\"brokerSettled\":"); + payload->append(state.brokerSettled ? "true" : "false"); + payload->append(",\"brokerProof\":"); + if (state.hasBrokerProof) { + payload->append("{\"success\":"); + payload->append(state.brokerProofSuccess ? "true" : "false"); + payload->append(",\"changed\":"); + payload->append(state.brokerProofChanged ? "true" : "false"); + payload->append(",\"rollback\":"); + AppendJsonAsciiString(payload, state.brokerProofRollback); + payload->append(",\"exitCode\":"); + payload->append(std::to_string(state.brokerProofExitCode)); + payload->append(",\"driverRollbackAuthorized\":"); + payload->append(state.brokerDriverRollbackAuthorized + ? "true" : "false"); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"priorAbiProfile\":"); + if (state.hasPriorAbiProfile) { + payload->append("{\"minor\":"); + payload->append(std::to_string(state.priorAbiProfile.minor)); + payload->append(",\"capabilities\":"); + payload->append(std::to_string(state.priorAbiProfile.capabilities)); + payload->append(",\"statsSize\":"); + payload->append(std::to_string(state.priorAbiProfile.statsSize)); + payload->append(",\"hasReservedPortFields\":"); + payload->append(state.priorAbiProfile.hasReservedPortFields + ? "true" : "false"); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"rootRegistrationInstanceId\":"); + if (state.hasRootRegistrationIntent) { + AppendJsonString(payload, state.rootRegistrationInstanceId); + } else { + payload->append("null"); + } + payload->append(",\"partialRootRemovalBootIdentifier\":"); + if (state.partialRootRemovalBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString( + payload, state.partialRootRemovalBootIdentifier); + } + payload->append(",\"partialRootRemovalBinding\":"); + switch (state.partialRootRemovalBinding) { + case InstallJournalStateData::PartialRootRemovalBinding::None: + payload->append("null"); + break; + case InstallJournalStateData::PartialRootRemovalBinding::Unbound: + AppendJsonAsciiString(payload, "unbound"); + break; + case InstallJournalStateData::PartialRootRemovalBinding::Candidate: + AppendJsonAsciiString(payload, "candidate"); + break; + } + payload->append(",\"packageStagedHere\":"); + payload->append(state.packageStagedHere ? "true" : "false"); + payload->append(",\"bindingMutationStarted\":"); + payload->append(state.bindingMutationStarted ? "true" : "false"); + payload->append(",\"rebootRequired\":"); + payload->append(state.rebootRequired ? "true" : "false"); + payload->append(",\"freshRebootRequired\":"); + payload->append(state.freshRebootRequired ? "true" : "false"); + payload->append(",\"callSucceeded\":"); + payload->append(state.callSucceeded ? "true" : "false"); + payload->append(",\"callError\":"); + payload->append(std::to_string(state.callError)); + payload->append(",\"deadlineOverrun\":"); + payload->append(state.deadlineOverrun ? "true" : "false"); + payload->append(",\"candidate\":"); + AppendPackageIdentityJson(payload, state.candidate, + std::wstring(kInstallRecoveryCandidateDirectory) + L"/ViiperUde.inf"); + payload->append(",\"publishedCandidate\":"); + if (state.hasPublishedCandidate) { + AppendPackageIdentityJson(payload, state.publishedCandidate, + std::wstring(kInstallRecoveryCandidateDirectory) + L"/ViiperUde.inf"); + } else { + payload->append("null"); + } + payload->append(",\"priorPackages\":["); + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.prior.packages[index], + std::wstring(kInstallRecoveryPriorDirectory) + L"/" + + std::to_wstring(index) + L"/ViiperUde.inf"); + } + payload->append("],\"priorDevices\":["); + for (size_t index = 0; index < state.prior.devices.size(); ++index) { + if (index != 0) payload->push_back(','); + const DeviceState& device = state.prior.devices[index]; + payload->append("{\"instanceId\":"); + AppendJsonString(payload, device.instanceId); + payload->append(",\"present\":"); + payload->append(device.present ? "true" : "false"); + payload->append(",\"started\":"); + payload->append(device.started ? "true" : "false"); + payload->append(",\"problem\":"); + payload->append(std::to_string(device.problem)); + payload->append(",\"service\":"); + AppendJsonString(payload, device.service); + payload->append(",\"publishedInf\":"); + AppendJsonString(payload, device.publishedInf); + payload->append(",\"version\":"); + AppendJsonString(payload, VersionToString(device.version)); + payload->append(",\"packageInfSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.infSha256)); + payload->append(",\"packageSysSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.sysSha256)); + payload->append(",\"packageCatSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(device.package.catSha256)); + payload->push_back('}'); + } + payload->append("],\"expectedInventory\":["); + for (size_t index = 0; index < state.expectedInventory.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.expectedInventory[index], L""); + } + payload->append("]}"); + if (payload->size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", ERROR_FILE_TOO_LARGE); + } + return true; +} + +bool WriteInstallJournalRecord( + const std::filesystem::path& active, + InstallJournalStateData* state, + Error* error) { + std::string payload; + std::string digest; + if (!BuildInstallJournalPayload(*state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + std::string record = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&record, kInstallRecoveryKind); + record.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&record, digest); + record.append(",\"payload\":"); + AppendJsonUtf8String(&record, payload); + record.append("}\n"); + if (record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", ERROR_FILE_TOO_LARGE); + } + + std::wostringstream finalName; + finalName << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << state->sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path finalPath = active / finalName.str(); + const std::filesystem::path temporaryPath = + active / (finalName.str() + kInstallRecoveryTemporarySuffix); + LocalSecurityDescriptor security; + if (!security.Initialize( + kRecoveryRecordSecurity, L"install-journal-file-security", error)) { + return false; + } + WinHandle file(CreateFileW( + temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-create"); + } + const auto discard = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-create", ERROR_REPARSE_TAG_MISMATCH); + } + discard(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + DWORD written = 0; + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD code = GetLastError() == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : GetLastError(); + SetError(error, L"install-journal-write", code); + discard(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"install-journal-flush"); + discard(); + return false; + } + file.reset(); + if (!MoveFileExW( + temporaryPath.c_str(), finalPath.c_str(), MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"install-journal-publish", code); + } + file.reset(CreateFileW( + finalPath.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file || !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (!file) SetLastErrorDetail(error, L"install-journal-reopen"); + return false; + } + std::string observed(record.size(), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), observed.data(), static_cast(observed.size()), + &read, nullptr) || read != observed.size() || observed != record) { + return SetError(error, L"install-journal-readback", ERROR_CRC, + L"published journal record does not match the flushed bytes"); + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"install-journal-readback", ERROR_FILE_INVALID, + L"published journal record has trailing bytes"); + } + state->lastDigest = digest; + state->previousDigest = digest; + ++state->sequence; + gActiveRecoveryRecordWritten = true; + return true; +} + +bool GenerateInstallTransactionId(std::string* identifier, Error* error) { + HCRYPTPROV provider = 0; + if (!CryptAcquireContextW( + &provider, nullptr, nullptr, PROV_RSA_AES, + CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) { + return SetLastErrorDetail(error, L"install-journal-transaction-id"); + } + std::array random{}; + const BOOL generated = CryptGenRandom( + provider, static_cast(random.size()), random.data()); + const DWORD code = generated ? ERROR_SUCCESS : GetLastError(); + CryptReleaseContext(provider, 0); + if (!generated) { + return SetError(error, L"install-journal-transaction-id", code); + } + static constexpr char digits[] = "0123456789abcdef"; + identifier->clear(); + identifier->reserve(random.size() * 2U); + for (BYTE value : random) { + identifier->push_back(digits[value >> 4U]); + identifier->push_back(digits[value & 0x0fU]); + } + return true; +} + +bool CopyCandidateIntoInstallJournal( + const std::filesystem::path& sourceDirectory, + const std::filesystem::path& destinationDirectory, + const PackageInfo& expected, + bool localTest, + PackageInfo* verified, + std::vector* locks, + Error* error) { + if (!CopyProtectedBackupFile( + sourceDirectory / L"ViiperUde.inf", + destinationDirectory / L"ViiperUde.inf", error) || + !CopyProtectedBackupFile( + sourceDirectory / kDriverFileName, + destinationDirectory / kDriverFileName, error) || + !CopyProtectedBackupFile( + sourceDirectory / kCatalogName, + destinationDirectory / kCatalogName, error) || + !ValidateExactPackageDirectory(destinationDirectory, error)) { + return false; + } + bool owned = false; + if (!LoadOwnedPackage( + destinationDirectory / L"ViiperUde.inf", true, localTest, + verified, &owned, error) || !owned || + !(verified->version == expected.version) || + !SamePackageBytes(*verified, expected)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-identity", + ERROR_REVISION_MISMATCH, + L"protected candidate copy differs from the reviewed package bytes"); + } + return false; + } + verified->publishedName.clear(); + return LockPackageFiles(destinationDirectory, locks, error); +} + +struct InstallJournal::Impl { + InstallRecoveryDirectory directory; + InstallJournalStateData state; + std::vector priorBackups; + std::vector candidateLocks; + bool preparedRecord = false; + bool retired = false; + bool poisoned = false; + bool evidenceMayBeDurable = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + + ~Impl() noexcept { + if (preparedRecord || retired || poisoned || evidenceMayBeDurable || + !directory.activeCreated || + directory.active.empty()) { + return; + } + candidateLocks.clear(); + priorBackups.clear(); + directory.activeHandle.reset(); + std::error_code ignored; + std::filesystem::remove_all(directory.active, ignored); + std::error_code presenceError; + if (!std::filesystem::exists(directory.active, presenceError) && + !presenceError) { + ClearActiveRecoveryEvidence(); + } + } +}; + +InstallJournal::InstallJournal() = default; +InstallJournal::~InstallJournal() = default; + +bool InstallJournal::Prepare( + const Snapshot& prior, + const PackageInfo& candidate, + const std::filesystem::path& candidateDirectory, + const std::vector& expectedInventory, + const InstallOptions& options, + Error* error) { + impl_ = std::make_unique(); + bool exists = false; + if (!impl_->directory.OpenChain( + true, &options.targetUserSid, &exists, error) || !exists || + !PublishInstallRecoveryEvidence(impl_->directory.active, 0, error)) { + return false; + } + bool created = false; + WinHandle priorDirectoryHandle; + WinHandle candidateDirectoryHandle; + const std::filesystem::path priorDirectory = + impl_->directory.active / kInstallRecoveryPriorDirectory; + const std::filesystem::path protectedCandidateDirectory = + impl_->directory.active / kInstallRecoveryCandidateDirectory; + if (!CreateOrOpenInstallRecoveryDirectory( + priorDirectory, false, true, &priorDirectoryHandle, &created, + error) || + !CreateOrOpenInstallRecoveryDirectory( + protectedCandidateDirectory, false, true, + &candidateDirectoryHandle, &created, error) || + !BackupPackagesIntoDirectory( + prior.packages, priorDirectory, &impl_->priorBackups, error)) { + return false; + } + PackageInfo protectedCandidate; + if (!CopyCandidateIntoInstallJournal( + candidateDirectory, protectedCandidateDirectory, candidate, + options.localTest, &protectedCandidate, &impl_->candidateLocks, + error)) { + return false; + } + + impl_->state.prior = prior; + impl_->state.candidate = candidate; + impl_->state.expectedInventory = expectedInventory; + impl_->state.production = options.production; + impl_->state.localTest = options.localTest; + impl_->state.brokerRequired = !options.brokerExecutable.empty(); + impl_->state.sourceRevision = options.sourceRevision; + if (!GetBootIdentifier(&impl_->state.bootIdentifier, error)) { + return false; + } + if (!options.brokerTokenSha256.empty()) { + impl_->state.transactionId = LowerAscii(options.brokerTokenSha256); + } else if (!GenerateInstallTransactionId( + &impl_->state.transactionId, error)) { + return false; + } + impl_->state.phase = InstallJournalPhase::Prepared; + if (!ValidateInstallJournalTransition( + nullptr, impl_->state, error)) { + return false; + } + impl_->evidenceMayBeDurable = true; + if (!WriteInstallJournalRecord( + impl_->directory.active, &impl_->state, error)) { + impl_->poisoned = true; + return false; + } + impl_->preparedRecord = true; + if (!PublishInstallRecoveryEvidence( + impl_->directory.active, impl_->state.sequence - 1U, error)) { + impl_->poisoned = true; + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool InstallJournal::Record( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + if (!impl_) { + return SetError(error, L"install-journal-state", ERROR_INVALID_STATE, + L"install journal is not armed for a phase transition"); + } + return RecordNext(impl_->state, phase, publishedCandidate, + packageStagedHere, bindingMutationStarted, rebootRequired, + callSucceeded, callError, deadlineOverrun, false, error); +} + +bool InstallJournal::RecordAuthoritativeReturn( + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool freshRebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + if (!impl_ || + (phase != InstallJournalPhase::DiInstallReturned && + phase != InstallJournalPhase::RollbackBindingReturned && + phase != + InstallJournalPhase::PartialRootRemovalReturned)) { + return SetError(error, L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER); + } + InstallJournalStateData next = impl_->state; + if (freshRebootRequired && + !GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + return RecordNext(std::move(next), phase, publishedCandidate, + packageStagedHere, bindingMutationStarted, rebootRequired, + callSucceeded, callError, deadlineOverrun, + freshRebootRequired, error); +} + +bool InstallJournal::RecordNext( + InstallJournalStateData next, + InstallJournalPhase phase, + const PackageInfo* publishedCandidate, + bool packageStagedHere, + bool bindingMutationStarted, + bool rebootRequired, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool freshRebootRequired, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-state", ERROR_INVALID_STATE, + impl_ && impl_->poisoned + ? L"install journal is poisoned after an indeterminate durable append; restart into recovery" + : L"install journal is not armed for a phase transition"); + } + next.phase = phase; + next.packageStagedHere = + next.packageStagedHere || packageStagedHere; + const bool phaseMayMutateBinding = + phase == InstallJournalPhase::RootRegistrationEntered || + phase == InstallJournalPhase::RootRegistrationReturned || + phase == InstallJournalPhase::DiInstallEntered || + phase == InstallJournalPhase::DiInstallReturned; + next.bindingMutationStarted = + next.bindingMutationStarted || bindingMutationStarted || + phaseMayMutateBinding; + next.rebootRequired = next.rebootRequired || rebootRequired; + next.freshRebootRequired = freshRebootRequired; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.deadlineOverrun = next.deadlineOverrun || deadlineOverrun; + if (phase == InstallJournalPhase::RollbackBindingEntered) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (publishedCandidate != nullptr) { + next.publishedCandidate = *publishedCandidate; + next.hasPublishedCandidate = true; + if (packageStagedHere && + !ContainsExactPackage( + next.expectedInventory, *publishedCandidate)) { + next.expectedInventory.push_back(*publishedCandidate); + std::sort(next.expectedInventory.begin(), + next.expectedInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + } + if (phase == InstallJournalPhase::BrokerHandoffEntered || + phase == InstallJournalPhase::BrokerHandoffReturned || + phase == InstallJournalPhase::BrokerChildEntered || + phase == InstallJournalPhase::BrokerChildSettled) { + next.brokerEntered = true; + } + if (phase == InstallJournalPhase::BrokerChildSettled) { + next.brokerSettled = true; + } + if (!ValidateInstallJournalTransition(&impl_->state, next, error) || + !WriteInstallJournalRecord( + impl_->directory.active, &next, error)) { + impl_->poisoned = true; + return false; + } + impl_->state = std::move(next); + if (impl_->state.direction == InstallJournalDirection::Forward && + phase == InstallJournalPhase::RootRegistrationEntered) { + impl_->forwardRootRegistrationEntered = true; + } + if (impl_->state.direction == InstallJournalDirection::Forward && + phase == InstallJournalPhase::DiInstallEntered) { + impl_->forwardDiInstallEntered = true; + } + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + impl_->partialRootRemovalEntered = true; + } + if (!PublishInstallRecoveryEvidence( + impl_->directory.active, impl_->state.sequence - 1U, error)) { + impl_->poisoned = true; + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool InstallJournal::RecordCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + if (!impl_) { + return true; + } + const PackageInfo* publishedCandidate = + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr; + if (phase == InstallJournalPhase::DiInstallReturned || + phase == InstallJournalPhase::RollbackBindingReturned || + phase == InstallJournalPhase::PartialRootRemovalReturned) { + return RecordAuthoritativeReturn(phase, publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired || rebootRequired, + freshRebootRequired, callSucceeded, callError, + deadlineOverrun, error); + } + if (freshRebootRequired) { + return SetError(error, L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER, + L"fresh reboot authority is legal only on an authoritative returned phase"); + } + return Record(phase, publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired || rebootRequired, + callSucceeded, callError, deadlineOverrun, error); +} + +bool InstallJournal::RecordPriorAbiProfile( + const AbiCompatibilityProfile& profile, + const PackageInfo& publishedCandidate, + bool packageStagedHere, + Error* error) { + if (!impl_ || !IsKnownAbiCompatibilityProfile(profile) || + impl_->state.prior.devices.size() != 1U || + !impl_->state.prior.devices[0].started || + impl_->state.prior.devices[0].problem != 0) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_PARAMETER); + } + if (impl_->state.hasPriorAbiProfile && + !SameAbiCompatibilityProfile( + impl_->state.priorAbiProfile, profile)) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"captured prior ABI profile changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.priorAbiProfile = profile; + next.hasPriorAbiProfile = true; + return RecordNext(std::move(next), + InstallJournalPhase::PriorAbiProfileCaptured, + &publishedCandidate, packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, true, ERROR_SUCCESS, false, + false, error); +} + +bool InstallJournal::RecordRootRegistrationIntent( + const std::wstring& instanceId, + Error* error) { + if (!impl_ || !impl_->state.prior.devices.empty() || + !impl_->state.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + instanceId, kRootDeviceName)) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_PARAMETER); + } + if (impl_->state.hasRootRegistrationIntent && + _wcsicmp(impl_->state.rootRegistrationInstanceId.c_str(), + instanceId.c_str()) != 0) { + return SetError(error, L"install-journal-root-intent", + ERROR_REVISION_MISMATCH, + L"generated root identity changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.hasRootRegistrationIntent = true; + next.rootRegistrationInstanceId = instanceId; + return RecordNext(std::move(next), + InstallJournalPhase::RootRegistrationIntentCaptured, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + true, ERROR_SUCCESS, false, false, error); +} + +bool InstallJournal::RecordBrokerProof( + const BrokerCommitProof& proof, + Error* error) { + if (!impl_ || !BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.driverRollbackAuthorized)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (impl_->state.hasBrokerProof && + (impl_->state.brokerProofSuccess != proof.success || + impl_->state.brokerProofChanged != proof.changed || + impl_->state.brokerProofRollback != proof.rollback || + impl_->state.brokerProofExitCode != proof.exitCode || + impl_->state.brokerDriverRollbackAuthorized != + proof.driverRollbackAuthorized)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_REVISION_MISMATCH, + L"settled broker proof changed within one transaction"); + } + InstallJournalStateData next = impl_->state; + next.brokerEntered = true; + next.brokerSettled = true; + next.hasBrokerProof = true; + next.brokerProofSuccess = proof.success; + next.brokerProofChanged = proof.changed; + next.brokerProofRollback = proof.rollback; + next.brokerProofExitCode = proof.exitCode; + next.brokerDriverRollbackAuthorized = + proof.driverRollbackAuthorized; + if (proof.driverRollbackAuthorized) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + return RecordNext(std::move(next), + InstallJournalPhase::BrokerChildSettled, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + proof.success, proof.exitCode, false, false, error); +} + +bool InstallJournal::RecordRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error) { + if (!impl_ || + (phase != InstallJournalPhase::BrokerHandoffReturned && + phase != InstallJournalPhase::BrokerChildSettled)) { + return SetError(error, L"install-journal-rollback-authorization", + ERROR_INVALID_PARAMETER); + } + InstallJournalStateData next = impl_->state; + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + return RecordNext(std::move(next), phase, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, + false, callError, false, false, error); +} + +bool RecordActiveInstallJournalCutpoint( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordCutpoint( + phase, callSucceeded, callError, deadlineOverrun, + false, false, error); +} + +bool RecordActiveInstallJournalCutpointWithReboot( + InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool deadlineOverrun, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordCutpoint( + phase, callSucceeded, callError, deadlineOverrun, + rebootRequired, freshRebootRequired, error); +} + +bool RecordActiveInstallJournalRollbackAuthorization( + InstallJournalPhase phase, + DWORD callError, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordRollbackAuthorization( + phase, callError, error); +} + +bool RecordActiveInstallJournalRootRegistrationIntent( + const std::wstring& instanceId, + Error* error) { + return gActiveInstallJournal == nullptr || + gActiveInstallJournal->RecordRootRegistrationIntent( + instanceId, error); +} + +void InstallJournal::AttachEvidence(Error* error) const { + if (error == nullptr || !impl_) { + return; + } + error->recoveryBackup = impl_->directory.active.wstring(); + error->recoveryBackupRetained = true; + if (gActiveRecoveryRecord[0] != L'\0') { + error->recoveryRecord = gActiveRecoveryRecord.data(); + error->recoveryRecordWritten = gActiveRecoveryRecordWritten; + } +} + +bool InstallJournal::RetireAfterForwardValidation( + const PackageInfo& candidate, + const std::wstring& publishedName, + bool rebootRequired, + uint64_t deadlineUnixMs, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-retire", ERROR_INVALID_STATE); + } + if (rebootRequired) { + if (impl_->state.pendingRebootBootIdentifier.empty()) { + return SetError(error, L"install-journal-forward-reboot-epoch", + ERROR_INVALID_DATA, + L"forward reboot pending lacks an authoritative returned reboot epoch"); + } + return Record(InstallJournalPhase::ForwardRebootPending, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error); + } + std::string expectedBuildIdentity; + if (!DeriveDriverBuildIdentity( + impl_->state.sourceRevision, &expectedBuildIdentity, error) || + !VerifyInstallJournalRawForwardTopology(impl_->state, error) || + !VerifyInstalled(candidate, publishedName, false, deadlineUnixMs, + &expectedBuildIdentity, error) || + !VerifyPackageInventory( + impl_->state.expectedInventory, + L"install-journal-retire-inventory", error) || + !Record(InstallJournalPhase::ForwardValidated, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, false, true, + ERROR_SUCCESS, false, error) || + !VerifyInstallJournalRawForwardTopology(impl_->state, error) || + !VerifyInstalled(candidate, publishedName, false, deadlineUnixMs, + &expectedBuildIdentity, error) || + !VerifyPackageInventory( + impl_->state.expectedInventory, + L"install-journal-retire-revalidation", error)) { + return false; + } + impl_->candidateLocks.clear(); + impl_->priorBackups.clear(); + if (!RetireInstallRecoveryActiveDirectory( + &impl_->directory, impl_->state.transactionId, error)) { + return false; + } + impl_->retired = true; + impl_->preparedRecord = false; + ClearActiveRecoveryEvidence(); + return true; +} + +bool InstallJournal::RetireAfterPriorValidation( + bool rebootRequired, + Error* error) { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-retire", ERROR_INVALID_STATE); + } + if (rebootRequired && + !impl_->state.pendingRebootBootIdentifier.empty()) { + return Record(InstallJournalPhase::RestoreRebootPending, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error); + } + const auto validatePrior = [&]() { + Snapshot observed; + if (!VerifyInstallJournalRawPriorTopology(impl_->state, error) || + !CaptureSnapshot(&observed, error) || + !SameCapturedRootState(impl_->state.prior, observed) || + !SamePackageInventory( + impl_->state.prior.packages, observed.packages)) { + return false; + } + if (impl_->state.bindingMutationStarted && + !impl_->state.prior.devices.empty() && + impl_->state.prior.devices[0].started) { + if (!impl_->state.hasPriorAbiProfile) { + return SetError(error, + L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"started prior root lacks its durable exact ABI profile"); + } + return VerifyAbiHealth( + CurrentUnixMilliseconds() + 15000U, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &impl_->state.priorAbiProfile, nullptr); + } + return true; + }; + if (!validatePrior()) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-revalidation", + ERROR_REVISION_MISMATCH, + L"exact captured root and package inventory were not restored"); + } + return false; + } + if (!Record(InstallJournalPhase::ExactPriorRestored, + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, false, true, + ERROR_SUCCESS, false, error) || + !validatePrior()) { + return false; + } + impl_->candidateLocks.clear(); + impl_->priorBackups.clear(); + if (!RetireInstallRecoveryActiveDirectory( + &impl_->directory, impl_->state.transactionId, error)) { + return false; + } + impl_->retired = true; + impl_->preparedRecord = false; + ClearActiveRecoveryEvidence(); + return true; +} + +bool RequireJournalObject( + const JsonValue& value, + const JsonValue::Object** object, + Error* error) { + *object = std::get_if(&value.value); + return *object != nullptr || SetError( + error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal field must be a JSON object"); +} + +bool RequireJournalArray( + const JsonValue::Object& object, + const char* name, + const JsonValue::Array** array, + Error* error) { + const JsonValue* field = ObjectField(object, name); + *array = field == nullptr ? nullptr + : std::get_if(&field->value); + return *array != nullptr || SetError( + error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal array field is missing or malformed"); +} + +bool RequireJournalString( + const JsonValue::Object& object, + const char* name, + std::string* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const std::string* stringValue = field == nullptr ? nullptr + : std::get_if(&field->value); + if (stringValue == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal string field is missing or malformed"); + } + *value = *stringValue; + return true; +} + +bool RequireJournalBool( + const JsonValue::Object& object, + const char* name, + bool* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const bool* boolValue = field == nullptr ? nullptr + : std::get_if(&field->value); + if (boolValue == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal Boolean field is missing or malformed"); + } + *value = *boolValue; + return true; +} + +bool RequireJournalUnsigned( + const JsonValue::Object& object, + const char* name, + uint64_t maximum, + uint64_t* value, + Error* error) { + const JsonValue* field = ObjectField(object, name); + const int64_t* integer = field == nullptr ? nullptr + : std::get_if(&field->value); + if (integer == nullptr || *integer < 0 || + static_cast(*integer) > maximum) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal integer field is missing or out of range"); + } + *value = static_cast(*integer); + return true; +} + +bool ParseJournalPackageIdentity( + const JsonValue& value, + const std::filesystem::path& active, + bool requirePublishedName, + PackageInfo* package, + std::filesystem::path* backupInf, + Error* error) { + const JsonValue::Object* object = nullptr; + std::string published; + std::string version; + std::string infSha; + std::string sysSha; + std::string catSha; + std::string relative; + if (!RequireJournalObject(value, &object, error) || + !RequireJournalString(*object, "publishedInf", &published, error) || + !RequireJournalString(*object, "version", &version, error) || + !RequireJournalString(*object, "infSha256", &infSha, error) || + !RequireJournalString(*object, "sysSha256", &sysSha, error) || + !RequireJournalString(*object, "catSha256", &catSha, error) || + !RequireJournalString(*object, "backupInf", &relative, error)) { + return false; + } + std::wstring publishedWide; + std::wstring versionWide; + std::wstring relativeWide; + if (!Utf8ToWide(published, &publishedWide, error) || + !Utf8ToWide(version, &versionWide, error) || + !Utf8ToWide(relative, &relativeWide, error) || + !ParseVersion(versionWide, &package->version) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha) || + (requirePublishedName && !IsSafePublishedInfName(publishedWide))) { + return SetError(error, L"install-journal-package", ERROR_INVALID_DATA, + L"journal package identity is malformed"); + } + package->publishedName = std::move(publishedWide); + package->infSha256 = LowerAscii(std::move(infSha)); + package->sysSha256 = LowerAscii(std::move(sysSha)); + package->catSha256 = LowerAscii(std::move(catSha)); + if (relativeWide.empty()) { + backupInf->clear(); + package->infPath.clear(); + return true; + } + const std::filesystem::path relativePath(relativeWide); + if (!IsSafeRecoveryRelativePath(relativePath)) { + return SetError(error, L"install-journal-package-path", + ERROR_INVALID_NAME); + } + *backupInf = (active / relativePath).lexically_normal(); + if (backupInf->lexically_relative(active).empty()) { + return SetError(error, L"install-journal-package-path", + ERROR_INVALID_NAME); + } + package->infPath = *backupInf; + return true; +} + +bool ParseInstallJournalPayload( + std::string_view payload, + const std::filesystem::path& active, + InstallJournalStateData* state, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(payload).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA, + L"journal payload is not canonical JSON: " + message); + } + const JsonValue::Object* object = nullptr; + if (!RequireJournalObject(root, &object, error)) { + return false; + } + uint64_t sequence = 0; + uint64_t callError = 0; + std::string previous; + std::string phase; + std::string direction; + if (!RequireJournalUnsigned(*object, "sequence", + kMaximumInstallRecoveryRecords - 1U, &sequence, error) || + !RequireJournalString(*object, "previousSha256", &previous, error) || + !RequireJournalString(*object, "phase", &phase, error) || + !RequireJournalString(*object, "direction", &direction, error) || + !RequireJournalBool(*object, "rollbackAuthorized", + &state->rollbackAuthorized, error) || + !RequireJournalString(*object, "transactionId", &state->transactionId, error) || + !RequireJournalString(*object, "bootIdentifier", &state->bootIdentifier, error) || + !RequireJournalString(*object, "sourceRevision", &state->sourceRevision, error) || + !RequireJournalBool(*object, "production", &state->production, error) || + !RequireJournalBool(*object, "localTest", &state->localTest, error) || + !RequireJournalBool(*object, "brokerRequired", &state->brokerRequired, error) || + !RequireJournalBool(*object, "brokerEntered", &state->brokerEntered, error) || + !RequireJournalBool(*object, "brokerSettled", &state->brokerSettled, error) || + !RequireJournalBool(*object, "packageStagedHere", &state->packageStagedHere, error) || + !RequireJournalBool(*object, "bindingMutationStarted", &state->bindingMutationStarted, error) || + !RequireJournalBool(*object, "rebootRequired", &state->rebootRequired, error) || + !RequireJournalBool(*object, "freshRebootRequired", + &state->freshRebootRequired, error) || + !RequireJournalBool(*object, "callSucceeded", &state->callSucceeded, error) || + !RequireJournalUnsigned(*object, "callError", MAXDWORD, &callError, error) || + !RequireJournalBool(*object, "deadlineOverrun", &state->deadlineOverrun, error)) { + return false; + } + const JsonValue* pendingRebootNode = + ObjectField(*object, "pendingRebootBootIdentifier"); + if (pendingRebootNode == nullptr) { + return SetError(error, L"install-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + pendingRebootNode->value)) { + state->pendingRebootBootIdentifier.clear(); + } else { + const auto* pendingBoot = + std::get_if(&pendingRebootNode->value); + if (pendingBoot == nullptr || + !IsCanonicalBootIdentifier(*pendingBoot)) { + return SetError(error, L"install-journal-reboot-epoch", + ERROR_INVALID_DATA, + L"pending reboot boot identifier is not one canonical boot epoch"); + } + state->pendingRebootBootIdentifier = *pendingBoot; + } + const JsonValue* brokerProofNode = ObjectField(*object, "brokerProof"); + if (brokerProofNode == nullptr) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(brokerProofNode->value)) { + state->hasBrokerProof = false; + } else { + const JsonValue::Object* brokerProofObject = nullptr; + uint64_t exitCode = 0; + if (!RequireJournalObject( + *brokerProofNode, &brokerProofObject, error) || + brokerProofObject->size() != 5U || + !RequireJournalBool(*brokerProofObject, "success", + &state->brokerProofSuccess, error) || + !RequireJournalBool(*brokerProofObject, "changed", + &state->brokerProofChanged, error) || + !RequireJournalString(*brokerProofObject, "rollback", + &state->brokerProofRollback, error) || + !RequireJournalUnsigned(*brokerProofObject, "exitCode", MAXDWORD, + &exitCode, error) || + !RequireJournalBool(*brokerProofObject, + "driverRollbackAuthorized", + &state->brokerDriverRollbackAuthorized, error)) { + return false; + } + state->brokerProofExitCode = static_cast(exitCode); + if (!BrokerProofFieldsAreCanonical( + state->brokerProofSuccess, + state->brokerProofChanged, + state->brokerProofRollback, + state->brokerProofExitCode, + state->brokerDriverRollbackAuthorized)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA, + L"durable child proof is not a canonical settled outcome"); + } + state->hasBrokerProof = true; + } + const JsonValue* profileNode = ObjectField(*object, "priorAbiProfile"); + if (profileNode == nullptr) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(profileNode->value)) { + state->hasPriorAbiProfile = false; + } else { + const JsonValue::Object* profileObject = nullptr; + uint64_t minor = 0; + uint64_t capabilities = 0; + uint64_t statsSize = 0; + if (!RequireJournalObject(*profileNode, &profileObject, error) || + profileObject->size() != 4U || + !RequireJournalUnsigned(*profileObject, "minor", UINT16_MAX, + &minor, error) || + !RequireJournalUnsigned(*profileObject, "capabilities", UINT32_MAX, + &capabilities, error) || + !RequireJournalUnsigned(*profileObject, "statsSize", MAXDWORD, + &statsSize, error) || + !RequireJournalBool(*profileObject, "hasReservedPortFields", + &state->priorAbiProfile.hasReservedPortFields, error)) { + return false; + } + state->priorAbiProfile.minor = + static_cast(minor); + state->priorAbiProfile.capabilities = + static_cast(capabilities); + state->priorAbiProfile.statsSize = static_cast(statsSize); + if (!IsKnownAbiCompatibilityProfile(state->priorAbiProfile)) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"journal prior ABI profile is not an exact supported contract"); + } + state->hasPriorAbiProfile = true; + } + const JsonValue* rootIntentNode = + ObjectField(*object, "rootRegistrationInstanceId"); + if (rootIntentNode == nullptr) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(rootIntentNode->value)) { + state->hasRootRegistrationIntent = false; + state->rootRegistrationInstanceId.clear(); + } else { + const auto* encodedInstanceId = + std::get_if(&rootIntentNode->value); + if (encodedInstanceId == nullptr || + !Utf8ToWide(*encodedInstanceId, + &state->rootRegistrationInstanceId, error) || + !IsGeneratedRootInstanceIdForDeviceName( + state->rootRegistrationInstanceId, kRootDeviceName)) { + return SetError(error, L"install-journal-root-intent", + ERROR_INVALID_DATA, + L"durable root registration intent is not one exact generated VIIPER instance ID"); + } + state->hasRootRegistrationIntent = true; + } + const JsonValue* rootRemovalBootNode = + ObjectField(*object, + "partialRootRemovalBootIdentifier"); + if (rootRemovalBootNode == nullptr) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + rootRemovalBootNode->value)) { + state->partialRootRemovalBootIdentifier.clear(); + } else { + const auto* removalBoot = + std::get_if(&rootRemovalBootNode->value); + if (removalBoot == nullptr || + !IsCanonicalBootIdentifier(*removalBoot)) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA, + L"partial root removal lacks one canonical attempt boot epoch"); + } + state->partialRootRemovalBootIdentifier = *removalBoot; + } + const JsonValue* rootRemovalBindingNode = + ObjectField(*object, "partialRootRemovalBinding"); + if (rootRemovalBindingNode == nullptr) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + rootRemovalBindingNode->value)) { + state->partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::None; + } else { + const auto* binding = + std::get_if(&rootRemovalBindingNode->value); + if (binding == nullptr || + (*binding != "unbound" && *binding != "candidate")) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_DATA, + L"partial root removal pre-call binding shape is not canonical"); + } + state->partialRootRemovalBinding = *binding == "unbound" + ? InstallJournalStateData::PartialRootRemovalBinding::Unbound + : InstallJournalStateData::PartialRootRemovalBinding::Candidate; + } + const std::optional parsedPhase = + ParseInstallJournalPhase(phase); + const std::optional parsedDirection = + ParseInstallJournalDirection(direction); + const bool partialRootRemovalPhase = parsedPhase && + (*parsedPhase == InstallJournalPhase::PartialRootRemovalEntered || + *parsedPhase == InstallJournalPhase::PartialRootRemovalReturned || + *parsedPhase == InstallJournalPhase:: + PartialRootRemovalRebootPending); + const bool hasPartialRootRemovalBinding = + state->partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + if (!parsedPhase || !parsedDirection || !IsSha256Digest(previous) || + !IsSha256Digest(state->transactionId) || + !IsCanonicalBootIdentifier(state->bootIdentifier) || + (!state->pendingRebootBootIdentifier.empty() && + !state->rebootRequired) || + (partialRootRemovalPhase && + (state->partialRootRemovalBootIdentifier.empty() || + !hasPartialRootRemovalBinding)) || + (state->partialRootRemovalBootIdentifier.empty() != + !hasPartialRootRemovalBinding) || + (!state->partialRootRemovalBootIdentifier.empty() && + (!state->hasRootRegistrationIntent || + !state->prior.devices.empty() || + *parsedDirection != InstallJournalDirection::Rollback || + !state->rollbackAuthorized)) || + ((*parsedPhase == InstallJournalPhase::ForwardRebootPending || + *parsedPhase == InstallJournalPhase::RestoreRebootPending) && + state->pendingRebootBootIdentifier.empty()) || + (state->freshRebootRequired && + (!state->rebootRequired || + state->pendingRebootBootIdentifier.empty() || + (*parsedPhase != InstallJournalPhase::DiInstallReturned && + *parsedPhase != InstallJournalPhase:: + RollbackBindingReturned && + *parsedPhase != InstallJournalPhase:: + PartialRootRemovalReturned))) || + !IsHexRevision(state->sourceRevision) || + (state->production && state->localTest) || + (state->brokerSettled && !state->brokerEntered) || + (state->hasBrokerProof && + (!state->brokerEntered || !state->brokerSettled || + state->brokerDriverRollbackAuthorized != + state->rollbackAuthorized)) || + (state->brokerSettled && !state->hasBrokerProof && + !state->rollbackAuthorized)) { + return SetError(error, L"install-journal-state", ERROR_INVALID_DATA, + L"journal phase or transaction identity is inconsistent"); + } + state->sequence = sequence; + state->previousDigest = LowerAscii(std::move(previous)); + state->phase = *parsedPhase; + state->direction = *parsedDirection; + state->callError = static_cast(callError); + + const JsonValue* candidateNode = ObjectField(*object, "candidate"); + std::filesystem::path candidateBackup; + if (candidateNode == nullptr || + !ParseJournalPackageIdentity(*candidateNode, active, false, + &state->candidate, &candidateBackup, error) || + candidateBackup != active / kInstallRecoveryCandidateDirectory / + L"ViiperUde.inf") { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-path", + ERROR_INVALID_NAME); + } + return false; + } + const JsonValue* publishedNode = ObjectField(*object, "publishedCandidate"); + if (publishedNode == nullptr) { + return SetError(error, L"install-journal-parse", ERROR_INVALID_DATA); + } + if (std::holds_alternative(publishedNode->value)) { + state->hasPublishedCandidate = false; + } else { + std::filesystem::path ignoredBackup; + if (!ParseJournalPackageIdentity(*publishedNode, active, true, + &state->publishedCandidate, &ignoredBackup, error) || + !SamePackageBytes(state->publishedCandidate, state->candidate) || + !(state->publishedCandidate.version == state->candidate.version)) { + return SetError(error, L"install-journal-published-candidate", + ERROR_REVISION_MISMATCH); + } + state->hasPublishedCandidate = true; + } + + const JsonValue::Array* priorPackages = nullptr; + const JsonValue::Array* priorDevices = nullptr; + const JsonValue::Array* expectedInventory = nullptr; + if (!RequireJournalArray(*object, "priorPackages", &priorPackages, error) || + !RequireJournalArray(*object, "priorDevices", &priorDevices, error) || + !RequireJournalArray(*object, "expectedInventory", &expectedInventory, error) || + priorPackages->size() > 32U || priorDevices->size() > 1U || + expectedInventory->size() > 33U) { + return SetError(error, L"install-journal-inventory", + ERROR_INVALID_DATA); + } + state->prior.packages.clear(); + for (size_t index = 0; index < priorPackages->size(); ++index) { + PackageInfo package; + std::filesystem::path backupInf; + const std::filesystem::path expectedBackup = + active / kInstallRecoveryPriorDirectory / + std::to_wstring(index) / L"ViiperUde.inf"; + if (!ParseJournalPackageIdentity( + (*priorPackages)[index], active, true, + &package, &backupInf, error) || backupInf != expectedBackup) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-package-path", + ERROR_INVALID_NAME); + } + return false; + } + state->prior.packages.push_back(std::move(package)); + } + state->expectedInventory.clear(); + for (const JsonValue& packageValue : *expectedInventory) { + PackageInfo package; + std::filesystem::path backupInf; + if (!ParseJournalPackageIdentity( + packageValue, active, true, &package, &backupInf, error) || + !backupInf.empty()) { + return SetError(error, L"install-journal-expected-inventory", + ERROR_INVALID_DATA); + } + state->expectedInventory.push_back(std::move(package)); + } + + state->prior.devices.clear(); + for (const JsonValue& deviceValue : *priorDevices) { + const JsonValue::Object* deviceObject = nullptr; + std::string instanceId; + std::string service; + std::string publishedInf; + std::string versionValue; + std::string infSha; + std::string sysSha; + std::string catSha; + uint64_t problem = 0; + DeviceState device; + if (!RequireJournalObject(deviceValue, &deviceObject, error) || + !RequireJournalString(*deviceObject, "instanceId", &instanceId, error) || + !RequireJournalBool(*deviceObject, "present", &device.present, error) || + !RequireJournalBool(*deviceObject, "started", &device.started, error) || + !RequireJournalUnsigned(*deviceObject, "problem", MAXDWORD, &problem, error) || + !RequireJournalString(*deviceObject, "service", &service, error) || + !RequireJournalString(*deviceObject, "publishedInf", &publishedInf, error) || + !RequireJournalString(*deviceObject, "version", &versionValue, error) || + !RequireJournalString(*deviceObject, "packageInfSha256", &infSha, error) || + !RequireJournalString(*deviceObject, "packageSysSha256", &sysSha, error) || + !RequireJournalString(*deviceObject, "packageCatSha256", &catSha, error) || + !Utf8ToWide(instanceId, &device.instanceId, error) || + !Utf8ToWide(service, &device.service, error) || + !Utf8ToWide(publishedInf, &device.publishedInf, error)) { + return false; + } + std::wstring versionWide; + if (!Utf8ToWide(versionValue, &versionWide, error) || + !ParseVersion(versionWide, &device.version) || + !IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha)) { + return SetError(error, L"install-journal-prior-device", + ERROR_INVALID_DATA); + } + device.problem = static_cast(problem); + size_t matches = 0; + for (const PackageInfo& package : state->prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + _stricmp(package.infSha256.c_str(), infSha.c_str()) == 0 && + _stricmp(package.sysSha256.c_str(), sysSha.c_str()) == 0 && + _stricmp(package.catSha256.c_str(), catSha.c_str()) == 0) { + device.package = package; + ++matches; + } + } + if (matches != 1U) { + return SetError(error, L"install-journal-prior-device-package", + ERROR_REVISION_MISMATCH); + } + state->prior.devices.push_back(std::move(device)); + } + const bool priorRequiresAbiProfile = + state->prior.devices.size() == 1U && + state->prior.devices[0].started && + state->prior.devices[0].problem == 0; + if ((state->hasPriorAbiProfile && !priorRequiresAbiProfile) || + (state->phase == InstallJournalPhase::PriorAbiProfileCaptured && + !state->hasPriorAbiProfile) || + ((state->direction == InstallJournalDirection::Rollback) != + state->rollbackAuthorized) || + (priorRequiresAbiProfile && + (InstallJournalPhaseRequiresPriorAbiProfile(state->phase) || + state->bindingMutationStarted) && + !state->hasPriorAbiProfile) || + (state->hasRootRegistrationIntent && + (!state->prior.devices.empty() || + !state->hasPublishedCandidate)) || + (!state->hasRootRegistrationIntent && + (state->phase == + InstallJournalPhase::RootRegistrationIntentCaptured || + (state->prior.devices.empty() && + state->bindingMutationStarted))) || + (state->phase == InstallJournalPhase::RootRegistrationIntentCaptured && + (state->direction != InstallJournalDirection::Forward || + state->bindingMutationStarted))) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_INVALID_DATA, + L"journal ABI profile or root registration intent does not match the captured prior lifecycle"); + } + std::string canonicalPayload; + if (!BuildInstallJournalPayload(*state, &canonicalPayload, error) || + canonicalPayload != payload) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-canonical-payload", + ERROR_INVALID_DATA, + L"journal payload is not in exact canonical byte form"); + } + return false; + } + return true; +} + +bool ReadInstallJournalFile( + const std::filesystem::path& path, + std::string* record, + Error* error) { + WinHandle file(CreateFileW( + path.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-read"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-read", + ERROR_REPARSE_TAG_MISMATCH); + } + return false; + } + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file.get(), &size) || size.QuadPart <= 0 || + static_cast(size.QuadPart) > kMaximumRecoveryRecordBytes) { + return SetError(error, L"install-journal-size", + ERROR_FILE_TOO_LARGE); + } + record->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), record->data(), + static_cast(record->size()), &read, nullptr) || + static_cast(read) != record->size()) { + return SetLastErrorDetail(error, L"install-journal-read"); + } + return true; +} + +bool ValidateAndDiscardInstallJournalTemporaryFile( + const std::filesystem::path& path, + Error* error) { + WinHandle file(CreateFileW( + path.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, L"install-journal-temp-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + LARGE_INTEGER size{}; + if (!GetFileInformationByHandleEx( + file.get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(file.get(), &identity) || + identity.nNumberOfLinks != 1U || + !GetFileSizeEx(file.get(), &size) || size.QuadPart < 0 || + static_cast(size.QuadPart) > kMaximumRecoveryRecordBytes || + !VerifyProtectedFileSystemSecurity( + file.get(), false, L"install-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-temp-identity", + ERROR_INVALID_DATA, + L"unpublished journal temp must be a bounded, single-link, protected regular file"); + } + return false; + } + file.reset(); + if (!DeleteFileW(path.c_str())) { + return SetLastErrorDetail(error, L"install-journal-temp-discard"); + } + const DWORD remaining = GetFileAttributesW(path.c_str()); + const DWORD absenceError = remaining == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (remaining != INVALID_FILE_ATTRIBUTES || + (absenceError != ERROR_FILE_NOT_FOUND && + absenceError != ERROR_PATH_NOT_FOUND)) { + return SetError(error, L"install-journal-temp-discard", + remaining != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : absenceError, + L"unpublished journal temp absence could not be proven"); + } + return true; +} + +bool SameJournalPackageIdentity( + const PackageInfo& left, + const PackageInfo& right) noexcept { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) == 0 && + left.version == right.version && SamePackageBytes(left, right); +} + +bool SameInstallJournalImmutableState( + const InstallJournalStateData& left, + const InstallJournalStateData& right) noexcept { + if (left.transactionId != right.transactionId || + left.bootIdentifier != right.bootIdentifier || + left.sourceRevision != right.sourceRevision || + left.production != right.production || + left.localTest != right.localTest || + left.brokerRequired != right.brokerRequired || + !SameJournalPackageIdentity(left.candidate, right.candidate) || + !SamePackageInventory(left.prior.packages, right.prior.packages) || + left.prior.devices.size() != right.prior.devices.size()) { + return false; + } + return left.prior.devices.empty() || + (SameRootBinding(left.prior.devices[0], right.prior.devices[0]) && + left.prior.devices[0].started == right.prior.devices[0].started && + left.prior.devices[0].problem == right.prior.devices[0].problem); +} + +bool SameDurableBrokerProof( + const InstallJournalStateData& left, + const InstallJournalStateData& right) noexcept { + return left.hasBrokerProof == right.hasBrokerProof && + (!left.hasBrokerProof || + (left.brokerProofSuccess == right.brokerProofSuccess && + left.brokerProofChanged == right.brokerProofChanged && + left.brokerDriverRollbackAuthorized == + right.brokerDriverRollbackAuthorized && + left.brokerProofRollback == right.brokerProofRollback && + left.brokerProofExitCode == right.brokerProofExitCode)); +} + +int ForwardInstallJournalPhaseRank( + InstallJournalPhase phase) noexcept { + switch (phase) { + case InstallJournalPhase::Prepared: return 0; + case InstallJournalPhase::SetupCopyEntered: return 10; + case InstallJournalPhase::SetupCopyReturned: return 11; + case InstallJournalPhase::StageReceiptCaptured: return 12; + case InstallJournalPhase::QuiesceSignalEntered: return 20; + case InstallJournalPhase::QuiesceSignalReturned: return 21; + case InstallJournalPhase::PriorAbiProfileCaptured: return 30; + case InstallJournalPhase::RootRegistrationIntentCaptured: return 39; + case InstallJournalPhase::RootRegistrationEntered: return 40; + case InstallJournalPhase::RootRegistrationReturned: return 41; + case InstallJournalPhase::DiInstallEntered: return 50; + case InstallJournalPhase::DiInstallReturned: return 51; + case InstallJournalPhase::DriverValidated: return 60; + case InstallJournalPhase::BrokerHandoffEntered: return 70; + case InstallJournalPhase::BrokerHandoffReturned: return 71; + case InstallJournalPhase::BrokerChildEntered: return 80; + case InstallJournalPhase::BrokerChildSettled: return 81; + case InstallJournalPhase::PartialRootRemovalEntered: return 82; + case InstallJournalPhase::PartialRootRemovalReturned: return 83; + case InstallJournalPhase::PartialRootRemovalRebootPending: return 84; + case InstallJournalPhase::ForwardValidated: return 90; + case InstallJournalPhase::ForwardRebootPending: return 90; + case InstallJournalPhase::ExactPriorRestored: return 90; + case InstallJournalPhase::RestoreRebootPending: return 90; + case InstallJournalPhase::ManualReconciliationRequired: return 90; + default: return -1; + } +} + +bool IsInstallJournalTerminalPhase(InstallJournalPhase phase) noexcept { + return phase == InstallJournalPhase::ForwardValidated || + phase == InstallJournalPhase::ExactPriorRestored || + phase == InstallJournalPhase::ForwardRebootPending || + phase == InstallJournalPhase::RestoreRebootPending || + phase == InstallJournalPhase::ManualReconciliationRequired; +} + +bool MatchingInstallJournalReturn( + InstallJournalPhase entered, + InstallJournalPhase returned) noexcept { + return (entered == InstallJournalPhase::SetupCopyEntered && + returned == InstallJournalPhase::SetupCopyReturned) || + (entered == InstallJournalPhase::QuiesceSignalEntered && + returned == InstallJournalPhase::QuiesceSignalReturned) || + (entered == InstallJournalPhase::RootRegistrationEntered && + returned == InstallJournalPhase::RootRegistrationReturned) || + (entered == InstallJournalPhase::DiInstallEntered && + returned == InstallJournalPhase::DiInstallReturned) || + (entered == InstallJournalPhase::BrokerHandoffEntered && + returned == InstallJournalPhase::BrokerHandoffReturned) || + (entered == InstallJournalPhase::BrokerChildEntered && + returned == InstallJournalPhase::BrokerChildSettled) || + (entered == InstallJournalPhase::PartialRootRemovalEntered && + returned == InstallJournalPhase::PartialRootRemovalReturned) || + (entered == InstallJournalPhase::SetupUninstallEntered && + returned == InstallJournalPhase::SetupUninstallReturned); +} + +bool LegalForwardInstallJournalPhaseTransition( + InstallJournalPhase previous, + InstallJournalPhase next) noexcept { + if (previous == next) { + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::SetupCopyReturned || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::RootRegistrationReturned || + previous == InstallJournalPhase::DiInstallReturned || + previous == InstallJournalPhase::BrokerHandoffReturned || + previous == InstallJournalPhase::BrokerChildSettled; + } + switch (next) { + case InstallJournalPhase::SetupCopyEntered: + return previous == InstallJournalPhase::Prepared; + case InstallJournalPhase::SetupCopyReturned: + case InstallJournalPhase::QuiesceSignalReturned: + case InstallJournalPhase::RootRegistrationReturned: + case InstallJournalPhase::DiInstallReturned: + case InstallJournalPhase::BrokerHandoffReturned: + case InstallJournalPhase::BrokerChildSettled: + return MatchingInstallJournalReturn(previous, next); + case InstallJournalPhase::StageReceiptCaptured: + return previous == InstallJournalPhase::SetupCopyReturned; + case InstallJournalPhase::QuiesceSignalEntered: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured; + case InstallJournalPhase::PriorAbiProfileCaptured: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned; + case InstallJournalPhase::RootRegistrationIntentCaptured: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::PriorAbiProfileCaptured; + case InstallJournalPhase::RootRegistrationEntered: + return previous == + InstallJournalPhase::RootRegistrationIntentCaptured; + case InstallJournalPhase::DiInstallEntered: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::StageReceiptCaptured || + previous == InstallJournalPhase::QuiesceSignalReturned || + previous == InstallJournalPhase::PriorAbiProfileCaptured || + previous == InstallJournalPhase::RootRegistrationReturned; + case InstallJournalPhase::DriverValidated: + return previous == InstallJournalPhase::Prepared || + previous == InstallJournalPhase::DiInstallReturned; + case InstallJournalPhase::BrokerHandoffEntered: + return previous == InstallJournalPhase::DriverValidated; + case InstallJournalPhase::BrokerChildEntered: + return previous == InstallJournalPhase::BrokerHandoffReturned; + case InstallJournalPhase::ForwardValidated: + case InstallJournalPhase::ForwardRebootPending: + return previous == InstallJournalPhase::DriverValidated || + previous == InstallJournalPhase::BrokerChildSettled; + default: + return false; + } +} + +bool LegalRollbackInstallJournalPhaseTransition( + InstallJournalPhase previous, + InstallJournalPhase next) noexcept { + if (next == InstallJournalPhase::ManualReconciliationRequired || + next == InstallJournalPhase::ExactPriorRestored || + next == InstallJournalPhase::RestoreRebootPending) { + return true; + } + if (next == InstallJournalPhase::RollbackBindingEntered) { + return !IsInstallJournalTerminalPhase(previous); + } + if (previous == next) return true; + if (previous == InstallJournalPhase::BrokerHandoffReturned || + previous == InstallJournalPhase::BrokerChildSettled) { + return next == InstallJournalPhase::RollbackBindingEntered; + } + if (previous == InstallJournalPhase::RollbackBindingEntered) { + return next == InstallJournalPhase::PartialRootRemovalEntered || + next == InstallJournalPhase::RootRegistrationEntered || + next == InstallJournalPhase::DiInstallEntered || + next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::RootRegistrationEntered || + previous == InstallJournalPhase::DiInstallEntered || + previous == InstallJournalPhase::SetupUninstallEntered) { + return MatchingInstallJournalReturn(previous, next); + } + if (previous == InstallJournalPhase::PartialRootRemovalEntered) { + return next == InstallJournalPhase::PartialRootRemovalReturned || + next == InstallJournalPhase:: + PartialRootRemovalRebootPending; + } + if (previous == InstallJournalPhase::RootRegistrationReturned) { + return next == InstallJournalPhase::DiInstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::DiInstallReturned) { + return next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::PartialRootRemovalReturned) { + return next == InstallJournalPhase::PartialRootRemovalEntered || + next == InstallJournalPhase::PartialRootRemovalRebootPending || + next == InstallJournalPhase::SetupUninstallEntered || + next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == + InstallJournalPhase::PartialRootRemovalRebootPending) { + return next == InstallJournalPhase::RollbackBindingEntered; + } + if (previous == InstallJournalPhase::SetupUninstallReturned) { + return next == InstallJournalPhase::RollbackBindingReturned; + } + if (previous == InstallJournalPhase::RollbackBindingReturned) { + return next == InstallJournalPhase::ExactPriorRestored || + next == InstallJournalPhase::RestoreRebootPending; + } + return false; +} + +bool ValidateInstallJournalTransition( + const InstallJournalStateData* previous, + const InstallJournalStateData& next, + Error* error) { + if (previous == nullptr) { + if (next.sequence != 0U || + next.phase != InstallJournalPhase::Prepared || + next.direction != InstallJournalDirection::Forward || + next.rollbackAuthorized || next.brokerEntered || + next.brokerSettled || next.hasBrokerProof || + next.hasPriorAbiProfile || + next.hasRootRegistrationIntent || + !next.rootRegistrationInstanceId.empty() || + next.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None || + !next.partialRootRemovalBootIdentifier.empty() || + !next.pendingRebootBootIdentifier.empty() || + next.freshRebootRequired || + next.hasPublishedCandidate || + next.packageStagedHere || next.bindingMutationStarted || + next.rebootRequired || next.deadlineOverrun || + !next.callSucceeded || next.callError != ERROR_SUCCESS) { + return SetError(error, L"install-journal-initial-state", + ERROR_INVALID_DATA, + L"first journal record is not the exact immutable Prepared state"); + } + return true; + } + const InstallJournalStateData& prior = *previous; + if (IsInstallJournalTerminalPhase(prior.phase) || + (prior.direction == InstallJournalDirection::Rollback && + next.direction != InstallJournalDirection::Rollback) || + (prior.rollbackAuthorized && !next.rollbackAuthorized) || + (prior.brokerEntered && !next.brokerEntered) || + (prior.brokerSettled && !next.brokerSettled) || + (prior.packageStagedHere && !next.packageStagedHere) || + (prior.bindingMutationStarted && !next.bindingMutationStarted) || + (prior.rebootRequired && !next.rebootRequired) || + (prior.deadlineOverrun && !next.deadlineOverrun) || + (prior.hasPublishedCandidate && !next.hasPublishedCandidate) || + (prior.hasPriorAbiProfile && !next.hasPriorAbiProfile) || + (prior.hasRootRegistrationIntent && + !next.hasRootRegistrationIntent) || + (prior.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None && + next.partialRootRemovalBinding == + InstallJournalStateData::PartialRootRemovalBinding::None) || + (!prior.partialRootRemovalBootIdentifier.empty() && + next.partialRootRemovalBootIdentifier.empty()) || + (prior.hasBrokerProof && !next.hasBrokerProof)) { + return SetError(error, L"install-journal-monotonic-state", + ERROR_INVALID_DATA, + L"terminal, direction, ownership, or diagnostic state regressed"); + } + const bool pendingRebootEpochChanged = + prior.pendingRebootBootIdentifier != + next.pendingRebootBootIdentifier; + const bool authoritativeRebootReturn = + next.phase == InstallJournalPhase::DiInstallReturned || + next.phase == InstallJournalPhase::RollbackBindingReturned || + next.phase == + InstallJournalPhase::PartialRootRemovalReturned; + if ((pendingRebootEpochChanged && + !next.freshRebootRequired) || + (next.freshRebootRequired && + (!authoritativeRebootReturn || + !next.rebootRequired || + !IsCanonicalBootIdentifier( + next.pendingRebootBootIdentifier))) || + (!next.freshRebootRequired && + pendingRebootEpochChanged) || + (!next.rebootRequired && + !next.pendingRebootBootIdentifier.empty()) || + ((next.phase == InstallJournalPhase::ForwardRebootPending || + next.phase == InstallJournalPhase::RestoreRebootPending) && + next.pendingRebootBootIdentifier.empty())) { + return SetError(error, L"install-journal-reboot-epoch-chain", + ERROR_INVALID_DATA, + L"pending reboot epoch changed outside an authoritative fresh-reboot returned record"); + } + if (prior.hasPublishedCandidate && + !SameJournalPackageIdentity( + prior.publishedCandidate, next.publishedCandidate)) { + return SetError(error, L"install-journal-publication-chain", + ERROR_REVISION_MISMATCH, + L"published candidate identity changed across records"); + } + if (!prior.hasPublishedCandidate && next.hasPublishedCandidate && + next.phase != InstallJournalPhase::Prepared && + next.phase != InstallJournalPhase::StageReceiptCaptured) { + return SetError(error, L"install-journal-publication-chain", + ERROR_INVALID_DATA, + L"candidate publication first appeared outside exact prepublication or stage receipt"); + } + if (!prior.packageStagedHere && next.packageStagedHere && + next.phase != InstallJournalPhase::StageReceiptCaptured) { + return SetError(error, L"install-journal-stage-ownership-chain", + ERROR_INVALID_DATA, + L"transaction-owned stage identity first appeared outside its exact durable receipt"); + } + if (!SamePackageInventory( + prior.expectedInventory, next.expectedInventory)) { + std::vector permittedInventory = + prior.expectedInventory; + if (!prior.packageStagedHere && next.packageStagedHere && + next.hasPublishedCandidate && + !ContainsExactPackage( + permittedInventory, next.publishedCandidate)) { + permittedInventory.push_back(next.publishedCandidate); + std::sort(permittedInventory.begin(), permittedInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + } + if (!SamePackageInventory( + permittedInventory, next.expectedInventory)) { + return SetError(error, L"install-journal-inventory-chain", + ERROR_REVISION_MISMATCH, + L"expected package inventory changed outside exact stage publication"); + } + } + if (prior.hasPriorAbiProfile && + !SameAbiCompatibilityProfile( + prior.priorAbiProfile, next.priorAbiProfile)) { + return SetError(error, L"install-journal-prior-abi-profile-chain", + ERROR_REVISION_MISMATCH, + L"durable prior ABI profile changed across records"); + } + if (prior.hasRootRegistrationIntent && + _wcsicmp(prior.rootRegistrationInstanceId.c_str(), + next.rootRegistrationInstanceId.c_str()) != 0) { + return SetError(error, L"install-journal-root-intent-chain", + ERROR_REVISION_MISMATCH, + L"durable generated root registration identity changed across records"); + } + const bool rootRemovalBootChanged = + prior.partialRootRemovalBootIdentifier != + next.partialRootRemovalBootIdentifier; + const bool rootRemovalBindingChanged = + prior.partialRootRemovalBinding != + next.partialRootRemovalBinding; + const bool hasRootRemovalBinding = + next.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None; + if (((rootRemovalBootChanged || rootRemovalBindingChanged) && + next.phase != + InstallJournalPhase::PartialRootRemovalEntered) || + (next.partialRootRemovalBootIdentifier.empty() != + !hasRootRemovalBinding) || + (!next.partialRootRemovalBootIdentifier.empty() && + (!next.hasRootRegistrationIntent || + !next.prior.devices.empty() || + next.direction != InstallJournalDirection::Rollback || + !next.rollbackAuthorized)) || + ((next.phase == + InstallJournalPhase::PartialRootRemovalEntered || + next.phase == + InstallJournalPhase::PartialRootRemovalReturned || + next.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending) && + (next.partialRootRemovalBootIdentifier.empty() || + !hasRootRemovalBinding))) { + return SetError(error, + L"install-journal-partial-root-removal-chain", + ERROR_INVALID_DATA, + L"partial root removal boot authority changed outside its exact rollback entry record"); + } + if (prior.hasBrokerProof && !SameDurableBrokerProof(prior, next)) { + return SetError(error, L"install-journal-broker-proof-chain", + ERROR_REVISION_MISMATCH, + L"settled broker proof changed across records"); + } + if (!prior.hasPriorAbiProfile && next.hasPriorAbiProfile && + next.phase != InstallJournalPhase::PriorAbiProfileCaptured) { + return SetError(error, L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile first appeared outside its capture phase"); + } + if (!prior.hasRootRegistrationIntent && + next.hasRootRegistrationIntent && + (next.phase != + InstallJournalPhase::RootRegistrationIntentCaptured || + next.direction != InstallJournalDirection::Forward || + !next.prior.devices.empty() || + !next.hasPublishedCandidate || + !IsGeneratedRootInstanceIdForDeviceName( + next.rootRegistrationInstanceId, kRootDeviceName))) { + return SetError(error, L"install-journal-root-intent-chain", + ERROR_INVALID_DATA, + L"root registration identity first appeared outside exact forward pre-registration admission"); + } + if (!prior.hasBrokerProof && next.hasBrokerProof && + next.phase != InstallJournalPhase::BrokerChildSettled) { + return SetError(error, L"install-journal-broker-proof-chain", + ERROR_INVALID_DATA, + L"durable broker proof first appeared outside child settlement"); + } + if (!prior.brokerEntered && next.brokerEntered && + next.phase != InstallJournalPhase::BrokerHandoffEntered) { + return SetError(error, L"install-journal-broker-chain", + ERROR_INVALID_DATA, + L"broker ownership first appeared outside handoff admission"); + } + if (!prior.brokerSettled && next.brokerSettled && + next.phase != InstallJournalPhase::BrokerChildSettled) { + return SetError(error, L"install-journal-broker-chain", + ERROR_INVALID_DATA, + L"broker settlement first appeared outside child settlement"); + } + if (!prior.rollbackAuthorized && next.rollbackAuthorized && + (next.direction != InstallJournalDirection::Rollback || + (next.phase != InstallJournalPhase::BrokerHandoffReturned && + next.phase != InstallJournalPhase::BrokerChildSettled && + next.phase != InstallJournalPhase::RollbackBindingEntered))) { + return SetError(error, L"install-journal-rollback-authorization-chain", + ERROR_INVALID_DATA, + L"rollback authority first appeared outside an authoritative admission record"); + } + if ((next.phase == InstallJournalPhase::ExactPriorRestored || + next.phase == InstallJournalPhase::RestoreRebootPending) && + next.brokerEntered && + (next.direction != InstallJournalDirection::Rollback || + !next.rollbackAuthorized)) { + return SetError(error, L"install-journal-terminal-authority", + ERROR_INVALID_DATA, + L"prior terminal phase lacks durable broker-safe rollback authority"); + } + if ((next.phase == InstallJournalPhase::ForwardValidated || + next.phase == InstallJournalPhase::ForwardRebootPending) && + next.brokerRequired && + (!next.brokerEntered || !next.brokerSettled || + !next.hasBrokerProof || !next.brokerProofSuccess || + next.brokerDriverRollbackAuthorized || + next.direction != InstallJournalDirection::Forward || + next.rollbackAuthorized)) { + return SetError(error, L"install-journal-terminal-authority", + ERROR_INVALID_DATA, + L"forward terminal phase lacks exact canonical broker commit authority"); + } + if (prior.direction == InstallJournalDirection::Forward && + next.direction == InstallJournalDirection::Rollback) { + if (!next.rollbackAuthorized || + (next.phase != InstallJournalPhase::BrokerHandoffReturned && + next.phase != InstallJournalPhase::BrokerChildSettled && + next.phase != InstallJournalPhase::RollbackBindingEntered)) { + return SetError(error, L"install-journal-direction-chain", + ERROR_INVALID_DATA, + L"forward ownership changed to rollback without a legal durable admission"); + } + return true; + } + if (next.direction == InstallJournalDirection::Rollback) { + if (!LegalRollbackInstallJournalPhaseTransition( + prior.phase, next.phase)) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"rollback journal phase transition is not legal"); + } + return true; + } + if (next.phase == InstallJournalPhase::RollbackBindingEntered || + next.phase == InstallJournalPhase::RollbackBindingReturned || + next.phase == InstallJournalPhase::PartialRootRemovalEntered || + next.phase == InstallJournalPhase::PartialRootRemovalReturned || + next.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending || + next.phase == InstallJournalPhase::SetupUninstallEntered || + next.phase == InstallJournalPhase::SetupUninstallReturned) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"rollback-only phase was published in forward direction"); + } + if (next.phase == InstallJournalPhase::ManualReconciliationRequired || + next.phase == InstallJournalPhase::ExactPriorRestored || + next.phase == InstallJournalPhase::RestoreRebootPending) { + return true; + } + if (!LegalForwardInstallJournalPhaseTransition( + prior.phase, next.phase)) { + return SetError(error, L"install-journal-phase-chain", + ERROR_INVALID_DATA, + L"forward journal phase transition is not legal"); + } + return true; +} + +struct LoadedInstallJournal { + InstallRecoveryDirectory directory; + InstallJournalStateData state; + bool hasRecord = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + std::vector evidenceLocks; +}; + +bool ParseInstallJournalEnvelope( + std::string_view record, + const std::filesystem::path& active, + InstallJournalStateData* state, + std::string* digest, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(record).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"install-journal-chain", + ERROR_INVALID_DATA, + L"journal envelope is truncated or malformed: " + message); + } + const JsonValue::Object* object = nullptr; + uint64_t schema = 0; + std::string kind; + std::string payloadDigest; + std::string payload; + if (!RequireJournalObject(root, &object, error) || + object->size() != 4U || + !RequireJournalUnsigned(*object, "schema", 2U, &schema, error) || + schema != 2U || + !RequireJournalString(*object, "kind", &kind, error) || + kind != kInstallRecoveryKind || + !RequireJournalString( + *object, "payloadSha256", &payloadDigest, error) || + !RequireJournalString(*object, "payload", &payload, error) || + !IsSha256Digest(payloadDigest)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_INVALID_DATA, + L"journal envelope is not the exact v2 contract"); + } + return false; + } + std::string canonicalEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&canonicalEnvelope, kInstallRecoveryKind); + canonicalEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&canonicalEnvelope, LowerAscii(payloadDigest)); + canonicalEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&canonicalEnvelope, payload); + canonicalEnvelope.append("}\n"); + if (record != canonicalEnvelope) { + return SetError(error, L"install-journal-canonical-envelope", + ERROR_INVALID_DATA, + L"journal envelope is not in exact canonical byte form"); + } + std::string observedDigest; + if (!Sha256Data(payload, &observedDigest, error) || + _stricmp(observedDigest.c_str(), payloadDigest.c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_CRC, + L"journal payload hash does not match its published receipt"); + } + return false; + } + if (!ParseInstallJournalPayload(payload, active, state, error)) { + return false; + } + *digest = LowerAscii(std::move(payloadDigest)); + return true; +} + +bool ParseJournalRecordFileName( + std::wstring_view name, + uint64_t* sequence) noexcept { + const std::wstring_view prefix(kInstallRecoveryJournalPrefix); + const std::wstring_view suffix(kInstallRecoveryJournalSuffix); + if (!name.starts_with(prefix) || !name.ends_with(suffix) || + name.size() != prefix.size() + 8U + suffix.size()) { + return false; + } + uint64_t parsed = 0; + for (size_t index = prefix.size(); index < prefix.size() + 8U; ++index) { + if (name[index] < L'0' || name[index] > L'9') { + return false; + } + parsed = parsed * 10U + static_cast(name[index] - L'0'); + } + *sequence = parsed; + return true; +} + +bool ParseJournalTemporaryFileName( + std::wstring_view name, + uint64_t* sequence) noexcept { + const std::wstring_view temporarySuffix( + kInstallRecoveryTemporarySuffix); + return name.ends_with(temporarySuffix) && + ParseJournalRecordFileName( + name.substr(0, name.size() - temporarySuffix.size()), + sequence); +} + +bool InstallJournalTemporarySequenceIsRecoverable( + uint64_t temporarySequence, + size_t publishedRecordCount) noexcept { + return publishedRecordCount < kMaximumInstallRecoveryRecords && + temporarySequence == publishedRecordCount; +} + +bool ValidateLoadedInstallJournalEvidence( + LoadedInstallJournal* loaded, + Error* error) { + WinHandle priorHandle; + WinHandle candidateHandle; + if (!OpenStableDirectory( + loaded->directory.active / kInstallRecoveryPriorDirectory, + true, &priorHandle, error) || + !OpenStableDirectory( + loaded->directory.active / kInstallRecoveryCandidateDirectory, + true, &candidateHandle, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->evidenceLocks.push_back(std::move(candidateHandle)); + + const std::filesystem::path candidateDirectory = + loaded->directory.active / kInstallRecoveryCandidateDirectory; + PackageInfo candidateCopy; + bool owned = false; + if (!ValidateExactPackageDirectory(candidateDirectory, error) || + !LoadOwnedPackage(candidateDirectory / L"ViiperUde.inf", true, + loaded->state.localTest, &candidateCopy, &owned, error) || + !owned || !(candidateCopy.version == loaded->state.candidate.version) || + !SamePackageBytes(candidateCopy, loaded->state.candidate)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-candidate-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + std::vector candidateLocks; + if (!LockPackageFiles(candidateDirectory, &candidateLocks, error)) { + return false; + } + for (WinHandle& lock : candidateLocks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + for (size_t index = 0; index < loaded->state.prior.packages.size(); ++index) { + PackageInfo& prior = loaded->state.prior.packages[index]; + const std::filesystem::path directory = + loaded->directory.active / kInstallRecoveryPriorDirectory / + std::to_wstring(index); + WinHandle directoryHandle; + if (!OpenStableDirectory(directory, true, &directoryHandle, error) || + !ValidateExactPackageDirectory(directory, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(directoryHandle)); + PackageInfo copy; + owned = false; + if (!LoadOwnedPackage(directory / L"ViiperUde.inf", true, false, + ©, &owned, error) || !owned || + !(copy.version == prior.version) || + !SamePackageBytes(copy, prior)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-prior-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + std::vector locks; + if (!LockPackageFiles(directory, &locks, error)) { + return false; + } + for (WinHandle& lock : locks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + } + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, error)) { + return false; + } + for (PackageInfo& package : loaded->state.prior.packages) { + package.infPath = systemInf / package.publishedName; + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + if (loaded->state.hasPublishedCandidate) { + loaded->state.publishedCandidate.infPath = + systemInf / loaded->state.publishedCandidate.publishedName; + } + return true; +} + +bool LoadInstallJournal( + InstallRecoveryDirectory&& directory, + LoadedInstallJournal* loaded, + Error* error) { + loaded->directory = std::move(directory); + std::map records; + std::optional> temporary; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + loaded->directory.active, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + const std::wstring name = iterator->path().filename().wstring(); + const DWORD attributes = GetFileAttributesW(iterator->path().c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"install-journal-discovery", + ERROR_REPARSE_TAG_MISMATCH); + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (name == kInstallRecoveryPriorDirectory || + name == kInstallRecoveryCandidateDirectory)) { + continue; + } + uint64_t sequence = 0; + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalRecordFileName(name, &sequence)) { + if (sequence >= kMaximumInstallRecoveryRecords || + !records.emplace(sequence, iterator->path()).second) { + return SetError(error, L"install-journal-chain", + ERROR_DUPLICATE_SERVICE_NAME); + } + continue; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalTemporaryFileName(name, &sequence)) { + if (sequence >= kMaximumInstallRecoveryRecords || temporary) { + return SetError(error, L"install-journal-temp-chain", + ERROR_INVALID_DATA, + L"transaction contains more than one canonical unpublished temp or an out-of-range temp"); + } + temporary.emplace(sequence, iterator->path()); + continue; + } + return SetError(error, L"install-journal-discovery", + ERROR_INVALID_DATA, + L"protected transaction directory contains an unexpected entry"); + } + if (enumerationError) { + return SetError(error, L"install-journal-discovery", + static_cast(enumerationError.value())); + } + if ((!records.empty() && + (records.begin()->first != 0U || + records.rbegin()->first + 1U != records.size())) || + (temporary && !InstallJournalTemporarySequenceIsRecoverable( + temporary->first, records.size()))) { + return SetError(error, L"install-journal-chain", ERROR_INVALID_DATA, + L"journal sequence or unpublished temp is missing, stale, or out of order"); + } + if (temporary && + !ValidateAndDiscardInstallJournalTemporaryFile( + temporary->second, error)) { + return false; + } + if (records.empty()) { + loaded->hasRecord = false; + return true; + } + std::string priorDigest(kZeroSha256); + std::optional immutable; + std::optional previousState; + std::optional capturedPriorAbiProfile; + for (const auto& [expectedSequence, path] : records) { + std::string record; + InstallJournalStateData parsed; + std::string digest; + if (!ReadInstallJournalFile(path, &record, error) || + !ParseInstallJournalEnvelope( + record, loaded->directory.active, &parsed, &digest, error) || + parsed.sequence != expectedSequence || + _stricmp(parsed.previousDigest.c_str(), priorDigest.c_str()) != 0 || + (immutable && !SameInstallJournalImmutableState( + *immutable, parsed)) || + !ValidateInstallJournalTransition( + previousState ? &*previousState : nullptr, + parsed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-chain", ERROR_CRC, + L"journal hash chain or immutable transaction identity changed"); + } + return false; + } + if (parsed.hasPriorAbiProfile) { + if (!capturedPriorAbiProfile && + parsed.phase != InstallJournalPhase::PriorAbiProfileCaptured) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile first appeared outside its capture phase"); + } + if (capturedPriorAbiProfile && + !SameAbiCompatibilityProfile( + *capturedPriorAbiProfile, parsed.priorAbiProfile)) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_REVISION_MISMATCH, + L"durable prior ABI profile changed across phase records"); + } + capturedPriorAbiProfile = parsed.priorAbiProfile; + } else if (capturedPriorAbiProfile) { + return SetError(error, + L"install-journal-prior-abi-profile-chain", + ERROR_INVALID_DATA, + L"durable prior ABI profile disappeared from a later phase record"); + } + if (parsed.direction == InstallJournalDirection::Forward) { + loaded->forwardRootRegistrationEntered = + loaded->forwardRootRegistrationEntered || + parsed.phase == InstallJournalPhase::RootRegistrationEntered; + loaded->forwardDiInstallEntered = + loaded->forwardDiInstallEntered || + parsed.phase == InstallJournalPhase::DiInstallEntered; + } + loaded->partialRootRemovalEntered = + loaded->partialRootRemovalEntered || + parsed.phase == + InstallJournalPhase::PartialRootRemovalEntered; + if (!immutable) { + immutable = parsed; + } + previousState = parsed; + priorDigest = digest; + loaded->state = std::move(parsed); + loaded->state.lastDigest = digest; + } + loaded->state.previousDigest = priorDigest; + loaded->state.sequence = records.size(); + loaded->hasRecord = true; + return ValidateLoadedInstallJournalEvidence(loaded, error); +} + +bool RetireLoadedInstallJournal( + LoadedInstallJournal* loaded, + Error* error) { + loaded->evidenceLocks.clear(); + return RetireInstallRecoveryActiveDirectory( + &loaded->directory, loaded->state.transactionId, error); +} + +bool CurrentStateMatchesPrior( + const InstallJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + Snapshot observed; + if (!CaptureSnapshot(&observed, error)) { + return false; + } + if (!SameCapturedRootState(state.prior, observed) || + !SamePackageInventory(state.prior.packages, observed.packages)) { + return SetError(error, L"install-journal-prior-state", + ERROR_REVISION_MISMATCH); + } + if (state.bindingMutationStarted && !state.prior.devices.empty() && + state.prior.devices[0].started) { + if (!state.hasPriorAbiProfile) { + return SetError(error, L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"started prior root lacks its durable exact ABI profile"); + } + return VerifyAbiHealth(deadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &state.priorAbiProfile, nullptr); + } + return true; +} + +bool RootSnapshotIsAuthorizedForInstallRollback( + const InstallJournalStateData& state, + const Snapshot& observed) noexcept { + if (state.prior.devices.empty()) { + if (observed.devices.empty()) return true; + if (observed.devices.size() != 1U || + !state.bindingMutationStarted || + !state.hasPublishedCandidate) { + return false; + } + const DeviceState& current = observed.devices[0]; + if (!IsOwnedGeneratedRootInstanceId(current.instanceId) || + !current.present || + _wcsicmp(current.service.c_str(), kServiceName) != 0 || + _wcsicmp(current.publishedInf.c_str(), + state.publishedCandidate.publishedName.c_str()) != 0 || + !(current.version == state.candidate.version) || + !SamePackageBytes(current.package, state.candidate)) { + return false; + } + return true; + } + if (observed.devices.size() != 1U) { + return false; + } + const DeviceState& prior = state.prior.devices[0]; + const DeviceState& current = observed.devices[0]; + if (_wcsicmp(prior.instanceId.c_str(), current.instanceId.c_str()) != 0 || + !current.present || + _wcsicmp(current.service.c_str(), kServiceName) != 0) { + return false; + } + if (SameRootBinding(prior, current)) return true; + if (state.hasPublishedCandidate && + _wcsicmp(current.publishedInf.c_str(), + state.publishedCandidate.publishedName.c_str()) == 0 && + current.version == state.candidate.version && + SamePackageBytes(current.package, state.candidate)) { + return true; + } + return false; +} + +enum class PartialInstallRootRecoveryAction { + PriorEmpty, + RemoveUnboundExactRoot, + RemoveCandidateBoundExactRoot, + PendingExactRootRemoval, + Manual, +}; + +struct PartialInstallRootRecoveryFacts { + bool priorEmpty = false; + bool bindingMutationStarted = false; + bool forwardRootRegistrationEntered = false; + bool forwardDiInstallEntered = false; + bool partialRootRemovalEntered = false; + size_t relatedRootCount = 0; + bool hardwareIdAbsent = false; + bool exactHardwareId = false; + bool exactClass = false; + bool exactGeneratedInstance = false; + bool present = false; + bool serviceEmpty = false; + bool publishedInfEmpty = false; + bool driverVersionEmpty = false; + bool exactCandidateService = false; + bool exactCandidateInf = false; + bool exactCandidateVersion = false; + bool exactCandidateBytes = false; + bool pendingRemovalLifecycle = false; +}; + +PartialInstallRootRecoveryAction ClassifyPartialInstallRootRecovery( + const PartialInstallRootRecoveryFacts& facts) noexcept { + if (!facts.priorEmpty) { + return PartialInstallRootRecoveryAction::Manual; + } + if (facts.relatedRootCount == 0U) { + return PartialInstallRootRecoveryAction::PriorEmpty; + } + if (facts.relatedRootCount != 1U || + !facts.bindingMutationStarted || + !facts.forwardRootRegistrationEntered || + !facts.exactClass || !facts.exactGeneratedInstance) { + return PartialInstallRootRecoveryAction::Manual; + } + const bool emptyBinding = facts.serviceEmpty && + facts.publishedInfEmpty && facts.driverVersionEmpty; + const bool exactCandidateBinding = + facts.forwardDiInstallEntered && + facts.exactCandidateService && facts.exactCandidateInf && + facts.exactCandidateVersion && facts.exactCandidateBytes; + const bool hasCandidateBindingFragment = + facts.exactCandidateService || facts.exactCandidateInf || + facts.exactCandidateVersion; + const bool canonicalPendingBinding = + (facts.serviceEmpty || facts.exactCandidateService) && + (facts.publishedInfEmpty || facts.exactCandidateInf) && + (facts.driverVersionEmpty || facts.exactCandidateVersion) && + (facts.publishedInfEmpty || facts.exactCandidateBytes) && + (!hasCandidateBindingFragment || facts.forwardDiInstallEntered); + if (facts.partialRootRemovalEntered && + (facts.hardwareIdAbsent || facts.exactHardwareId) && + facts.pendingRemovalLifecycle && + canonicalPendingBinding) { + return PartialInstallRootRecoveryAction::PendingExactRootRemoval; + } + if (!facts.exactHardwareId || !facts.present) { + return PartialInstallRootRecoveryAction::Manual; + } + if (emptyBinding) { + return PartialInstallRootRecoveryAction::RemoveUnboundExactRoot; + } + if (exactCandidateBinding) { + return PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot; + } + return PartialInstallRootRecoveryAction::Manual; +} + +bool InstallJournalRecoveryUsesStrictBindingRestore( + const InstallJournalStateData& state) noexcept { + return !state.prior.devices.empty() && + state.bindingMutationStarted; +} + +bool IsInGeneratedRootDeviceNamespace( + const std::wstring& instanceId, + const wchar_t* deviceName) { + const std::wstring prefix = std::wstring(L"ROOT\\") + deviceName + L"\\"; + return instanceId.size() >= prefix.size() && + _wcsnicmp(instanceId.c_str(), prefix.c_str(), prefix.size()) == 0; +} + +bool ReadInstallRecoveryRootInstanceId( + HDEVINFO set, + SP_DEVINFO_DATA& data, + std::wstring* instanceId, + Error* error) { + DWORD required = 0; + SetupDiGetDeviceInstanceIdW(set, &data, nullptr, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-instance-id"); + } + std::vector value(required); + if (!SetupDiGetDeviceInstanceIdW( + set, &data, value.data(), required, nullptr)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-instance-id"); + } + *instanceId = value.data(); + return true; +} + +struct InstallRecoveryHardwareIdObservation { + bool absent = false; + bool containsExpected = false; + bool exact = false; +}; + +bool ClassifyCanonicalInstallRecoveryHardwareIds( + const std::vector& value, + InstallRecoveryHardwareIdObservation* observation) noexcept { + *observation = InstallRecoveryHardwareIdObservation{}; + if (value.size() < 3U || value.back() != L'\0' || + value[value.size() - 2U] != L'\0' || value.front() == L'\0') { + return false; + } + size_t cursor = 0; + size_t entries = 0; + while (cursor < value.size() - 1U) { + const auto terminator = std::find( + value.begin() + static_cast(cursor), + value.end() - 1, L'\0'); + if (terminator == value.end() - 1 || + terminator == value.begin() + + static_cast(cursor)) { + return false; + } + const size_t length = static_cast( + terminator - (value.begin() + + static_cast(cursor))); + const size_t expectedLength = wcslen(kHardwareId); + if (length == expectedLength && + _wcsnicmp(value.data() + cursor, + kHardwareId, expectedLength) == 0) { + observation->containsExpected = true; + } + ++entries; + cursor += length + 1U; + } + if (cursor != value.size() - 1U) { + return false; + } + observation->exact = entries == 1U && + observation->containsExpected; + return true; +} + +bool ReadInstallRecoveryHardwareIds( + HDEVINFO set, + SP_DEVINFO_DATA& data, + InstallRecoveryHardwareIdObservation* observation, + Error* error) { + *observation = InstallRecoveryHardwareIdObservation{}; + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &type, nullptr, 0, &required)) { + return SetError(error, L"install-journal-raw-root-hardware-id", + ERROR_INVALID_DATA, + L"root hardware ID query returned an invalid zero-length value"); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_INVALID_DATA) { + observation->absent = true; + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || type != REG_MULTI_SZ || + required < 3U * sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, L"install-journal-raw-root-hardware-id", + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root hardware ID is unreadable or not an exact MULTI_SZ value"); + } + std::vector value(required / sizeof(wchar_t)); + DWORD returned = 0; + DWORD returnedType = 0; + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_HARDWAREID, &returnedType, + reinterpret_cast(value.data()), required, &returned)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-hardware-id"); + } + if (returnedType != REG_MULTI_SZ || returned != required || + !ClassifyCanonicalInstallRecoveryHardwareIds( + value, observation)) { + return SetError(error, L"install-journal-raw-root-hardware-id", + ERROR_INVALID_DATA, + L"root hardware ID changed during observation or is not canonical MULTI_SZ data"); + } + return true; +} + +bool DecodeCanonicalInstallRecoveryString( + const std::vector& buffer, + std::wstring* value) noexcept { + if (buffer.empty() || buffer.back() != L'\0' || + std::find(buffer.begin(), buffer.end() - 1, L'\0') != + buffer.end() - 1) { + return false; + } + value->assign(buffer.data(), buffer.size() - 1U); + return true; +} + +bool ReadCanonicalInstallRecoveryService( + HDEVINFO set, + SP_DEVINFO_DATA& data, + std::wstring* service, + Error* error) { + DWORD type = 0; + DWORD required = 0; + if (SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &type, nullptr, 0, &required)) { + return SetError(error, L"install-journal-raw-root-service", + ERROR_INVALID_DATA); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_INVALID_DATA) { + service->clear(); + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || type != REG_SZ || + required < sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, L"install-journal-raw-root-service", + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root service is unreadable or not canonical REG_SZ data"); + } + std::vector buffer(required / sizeof(wchar_t)); + DWORD returned = 0; + DWORD returnedType = 0; + if (!SetupDiGetDeviceRegistryPropertyW( + set, &data, SPDRP_SERVICE, &returnedType, + reinterpret_cast(buffer.data()), required, &returned)) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-service"); + } + if (returnedType != REG_SZ || returned != required || + !DecodeCanonicalInstallRecoveryString(buffer, service)) { + return SetError(error, L"install-journal-raw-root-service", + ERROR_INVALID_DATA, + L"root service changed during observation or contains hidden string data"); + } + return true; +} + +bool ReadCanonicalInstallRecoveryDevicePropertyString( + HDEVINFO set, + SP_DEVINFO_DATA& data, + const DEVPROPKEY& key, + const wchar_t* phase, + std::wstring* value, + Error* error) { + DEVPROPTYPE type = 0; + DWORD required = 0; + if (SetupDiGetDevicePropertyW( + set, &data, &key, &type, nullptr, 0, &required, 0)) { + return SetError(error, phase, ERROR_INVALID_DATA); + } + const DWORD queryError = GetLastError(); + if (queryError == ERROR_NOT_FOUND) { + value->clear(); + return true; + } + if (queryError != ERROR_INSUFFICIENT_BUFFER || + type != DEVPROP_TYPE_STRING || required < sizeof(wchar_t) || + required % sizeof(wchar_t) != 0U || + required > 64U * 1024U) { + return SetError(error, phase, + queryError == ERROR_SUCCESS ? ERROR_INVALID_DATA : queryError, + L"root device property is unreadable or not canonical string data"); + } + std::vector buffer(required / sizeof(wchar_t)); + DWORD returned = 0; + DEVPROPTYPE returnedType = 0; + if (!SetupDiGetDevicePropertyW( + set, &data, &key, &returnedType, + reinterpret_cast(buffer.data()), required, + &returned, 0)) { + return SetLastErrorDetail(error, phase); + } + if (returnedType != DEVPROP_TYPE_STRING || returned != required || + !DecodeCanonicalInstallRecoveryString(buffer, value)) { + return SetError(error, phase, ERROR_INVALID_DATA, + L"root device property changed during observation or contains hidden string data"); + } + return true; +} + +struct InstallRecoveryRootObservation { + PartialInstallRootRecoveryAction action = + PartialInstallRootRecoveryAction::Manual; + DeviceInfoSet set{INVALID_HANDLE_VALUE}; + SP_DEVINFO_DATA data{}; +}; + +bool ObservePriorEmptyInstallRecoveryRoot( + const LoadedInstallJournal& loaded, + InstallRecoveryRootObservation* observation, + Error* error) { + *observation = InstallRecoveryRootObservation{}; + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, L"install-journal-raw-root-open"); + } + struct RelatedRoot { + SP_DEVINFO_DATA data{}; + std::wstring instanceId; + bool hardwareIdAbsent = false; + bool exactHardwareId = false; + }; + std::vector related; + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); + if (!SetupDiEnumDeviceInfo(set.get(), index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail( + error, L"install-journal-raw-root-enumeration"); + } + break; + } + std::wstring instanceId; + if (!ReadInstallRecoveryRootInstanceId( + set.get(), data, &instanceId, error)) { + return false; + } + InstallRecoveryHardwareIdObservation hardwareIds; + if (!ReadInstallRecoveryHardwareIds( + set.get(), data, &hardwareIds, error)) { + return false; + } + const bool inTransactionNamespace = + IsInGeneratedRootDeviceNamespace(instanceId, kRootDeviceName); + if (!hardwareIds.containsExpected && !inTransactionNamespace) { + continue; + } + related.push_back(RelatedRoot{ + data, std::move(instanceId), hardwareIds.absent, + hardwareIds.exact}); + } + + PartialInstallRootRecoveryFacts facts; + facts.priorEmpty = loaded.state.prior.devices.empty(); + facts.bindingMutationStarted = loaded.state.bindingMutationStarted; + facts.forwardRootRegistrationEntered = + loaded.forwardRootRegistrationEntered; + facts.forwardDiInstallEntered = loaded.forwardDiInstallEntered; + facts.partialRootRemovalEntered = + loaded.partialRootRemovalEntered; + facts.relatedRootCount = related.size(); + if (related.size() == 1U) { + RelatedRoot& root = related[0]; + facts.hardwareIdAbsent = root.hardwareIdAbsent; + facts.exactHardwareId = root.exactHardwareId; + if (!ReadDevicePresence( + set.get(), root.data, &facts.present, error)) { + return false; + } + if (!facts.present) { + facts.pendingRemovalLifecycle = true; + } else { + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET configuration = CM_Get_DevNode_Status( + &status, &problem, root.data.DevInst, 0); + if (configuration != CR_SUCCESS) { + return SetError(error, + L"install-journal-raw-root-lifecycle", + ERROR_INVALID_DATA, + L"present receipt-bound root lifecycle could not be observed canonically"); + } + facts.pendingRemovalLifecycle = + problem == CM_PROB_WILL_BE_REMOVED; + } + facts.exactClass = + IsEqualGUID(root.data.ClassGuid, GUID_DEVCLASS_USB) != FALSE; + facts.exactGeneratedInstance = + loaded.state.hasRootRegistrationIntent && + IsGeneratedRootInstanceIdForDeviceName( + root.instanceId, kRootDeviceName) && + _wcsicmp(root.instanceId.c_str(), + loaded.state.rootRegistrationInstanceId.c_str()) == 0; + + std::wstring service; + std::wstring publishedInf; + std::wstring driverVersion; + if (!ReadCanonicalInstallRecoveryService( + set.get(), root.data, &service, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverInfPath, + L"install-journal-raw-root-driver-inf", + &publishedInf, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverVersion, + L"install-journal-raw-root-driver-version", + &driverVersion, error)) { + return false; + } + facts.serviceEmpty = service.empty(); + facts.publishedInfEmpty = publishedInf.empty(); + facts.driverVersionEmpty = driverVersion.empty(); + facts.exactCandidateService = + _wcsicmp(service.c_str(), kServiceName) == 0; + facts.exactCandidateInf = loaded.state.hasPublishedCandidate && + _wcsicmp(publishedInf.c_str(), + loaded.state.publishedCandidate.publishedName.c_str()) == 0; + Version observedVersion; + facts.exactCandidateVersion = !driverVersion.empty() && + ParseVersion(driverVersion, &observedVersion) && + observedVersion == loaded.state.candidate.version; + if (facts.exactCandidateInf) { + PackageInfo verified; + bool owned = false; + if (!LoadOwnedPackage( + loaded.state.publishedCandidate.infPath, + true, false, &verified, &owned, error)) { + return false; + } + verified.publishedName = publishedInf; + facts.exactCandidateBytes = owned && + SameJournalPackageIdentity( + verified, loaded.state.publishedCandidate) && + SamePackageBytes(verified, loaded.state.candidate); + } + } + + observation->action = ClassifyPartialInstallRootRecovery(facts); + if (observation->action == PartialInstallRootRecoveryAction::Manual) { + return SetError(error, L"install-journal-raw-root-authority", + related.size() > 1U + ? ERROR_DUPLICATE_SERVICE_NAME : ERROR_REVISION_MISMATCH, + L"prior-empty recovery observed a foreign, ambiguous, or unauthorized partial root topology"); + } + if (observation->action == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + observation->action == + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + observation->set = std::move(set); + observation->data = related[0].data; + } + return true; +} + +bool VerifyInstallJournalRawPriorTopology( + const InstallJournalStateData& state, + Error* error) { + if (!state.hasRootRegistrationIntent || + !state.prior.devices.empty()) { + return true; + } + LoadedInstallJournal active; + active.state = state; + active.forwardRootRegistrationEntered = true; + active.forwardDiInstallEntered = true; + InstallRecoveryRootObservation observation; + if (!ObservePriorEmptyInstallRecoveryRoot( + active, &observation, error)) { + return false; + } + return observation.action == + PartialInstallRootRecoveryAction::PriorEmpty || + SetError(error, L"install-journal-prior-raw-root", + ERROR_REVISION_MISMATCH, + L"prior-empty retirement still has a related root present"); +} + +bool VerifyInstallJournalRawForwardTopology( + const InstallJournalStateData& state, + Error* error) { + if (!state.hasRootRegistrationIntent || + !state.prior.devices.empty()) { + return true; + } + LoadedInstallJournal active; + active.state = state; + active.forwardRootRegistrationEntered = true; + active.forwardDiInstallEntered = true; + InstallRecoveryRootObservation observation; + if (!ObservePriorEmptyInstallRecoveryRoot( + active, &observation, error)) { + return false; + } + return observation.action == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot || + SetError(error, L"install-journal-forward-raw-root", + ERROR_REVISION_MISMATCH, + L"forward retirement lacks exactly one receipt-bound candidate root and no related extras"); +} + +bool InstallJournal::VerifyPriorTopologyBeforePackageRollback( + Error* error) const { + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned || + impl_->state.direction != InstallJournalDirection::Rollback || + !impl_->state.rollbackAuthorized) { + return SetError(error, + L"install-journal-pre-package-root-authority", + ERROR_INVALID_STATE); + } + return VerifyInstallJournalRawPriorTopology(impl_->state, error); +} + +bool InstallJournal::RemoveAuthorizedPriorEmptyRootAfterAdmission( + uint64_t rollbackDeadlineUnixMs, + bool* rebootRequired, + bool* rootRemovalRebootPending, + Error* error) { + if (rootRemovalRebootPending == nullptr) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_INVALID_PARAMETER); + } + *rootRemovalRebootPending = false; + if (!impl_ || !impl_->preparedRecord || impl_->retired || + impl_->poisoned) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_INVALID_STATE); + } + if (!impl_->state.prior.devices.empty() || + !impl_->state.hasRootRegistrationIntent) { + return true; + } + const auto observe = [&](bool removalMayHaveRun, + InstallRecoveryRootObservation* observed, + Error* observationError) { + LoadedInstallJournal active; + active.state = impl_->state; + active.forwardRootRegistrationEntered = + impl_->forwardRootRegistrationEntered; + active.forwardDiInstallEntered = + impl_->forwardDiInstallEntered; + active.partialRootRemovalEntered = + removalMayHaveRun && impl_->partialRootRemovalEntered; + return ObservePriorEmptyInstallRecoveryRoot( + active, observed, observationError); + }; + InstallRecoveryRootObservation observation; + if (!observe(false, &observation, error)) { + return false; + } + if (observation.action == + PartialInstallRootRecoveryAction::PriorEmpty) { + return true; + } + if (observation.action != + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot && + observation.action != PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"post-admission root topology is outside the exact receipt-bound cleanup authority"); + } + if (!CheckTransactionDeadline(rollbackDeadlineUnixMs, + L"install-rollback-deadline-receipt-root", error)) { + return false; + } + InstallJournalStateData entered = impl_->state; + if (!GetBootIdentifier( + &entered.partialRootRemovalBootIdentifier, error)) { + return false; + } + entered.partialRootRemovalBinding = + observation.action == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot + ? InstallJournalStateData::PartialRootRemovalBinding::Candidate + : InstallJournalStateData::PartialRootRemovalBinding::Unbound; + const PackageInfo* publishedCandidate = + impl_->state.hasPublishedCandidate + ? &impl_->state.publishedCandidate : nullptr; + if (!RecordNext(std::move(entered), + InstallJournalPhase::PartialRootRemovalEntered, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + impl_->state.rebootRequired, true, ERROR_SUCCESS, + false, false, error)) { + return false; + } + if (!VerifyPackageInventory(impl_->state.expectedInventory, + L"install-partial-root-removal-post-admission-inventory", + error)) { + return false; + } + InstallRecoveryRootObservation confirmed; + if (!observe(false, &confirmed, error)) { + return false; + } + if (confirmed.action == PartialInstallRootRecoveryAction::PriorEmpty || + confirmed.action == + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + if (!Record(InstallJournalPhase::PartialRootRemovalRebootPending, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, false, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error)) { + return false; + } + *rebootRequired = true; + *rootRemovalRebootPending = true; + return true; + } + if (confirmed.action != observation.action) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"receipt-bound root topology changed after durable removal admission"); + } + + bool freshRemovalReboot = false; + Error removalError; + const bool removed = RemoveDevice( + confirmed.set.get(), confirmed.data, 0, + L"install-rollback-deadline-receipt-root", + nullptr, rebootRequired, &removalError, + &freshRemovalReboot); + Error returnRecordError; + if (!RecordAuthoritativeReturn( + InstallJournalPhase::PartialRootRemovalReturned, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + *rebootRequired, freshRemovalReboot, + removed, removed ? ERROR_SUCCESS : removalError.code, + false, &returnRecordError)) { + *error = std::move(returnRecordError); + return false; + } + if (!removed) { + *error = std::move(removalError); + return false; + } + InstallRecoveryRootObservation after; + if (!observe(true, &after, error)) { + return false; + } + if (freshRemovalReboot) { + if (after.action != PartialInstallRootRecoveryAction::PriorEmpty && + after.action != PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + return SetError(error, + L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"successful reboot-requiring root removal left a noncanonical topology"); + } + if (!Record(InstallJournalPhase::PartialRootRemovalRebootPending, + publishedCandidate, impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, true, true, + ERROR_SUCCESS_REBOOT_REQUIRED, false, error)) { + return false; + } + *rootRemovalRebootPending = true; + return true; + } + if (after.action != PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, L"install-journal-raw-root-cleanup", + ERROR_REVISION_MISMATCH, + L"successful root removal without a restart did not restore exact prior-empty topology"); + } + return true; +} + +bool CurrentRootIsAuthorizedForInstallRollback( + const LoadedInstallJournal& loaded, + InstallRecoveryRootObservation* observation, + Error* error) { + *observation = InstallRecoveryRootObservation{}; + const InstallJournalStateData& state = loaded.state; + if (state.prior.devices.empty()) { + return ObservePriorEmptyInstallRecoveryRoot( + loaded, observation, error); + } + Snapshot observed; + if (!CaptureSnapshot(&observed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-rollback-root", + ERROR_INVALID_DATA); + } + return false; + } + if (!RootSnapshotIsAuthorizedForInstallRollback(state, observed)) { + return SetError(error, L"install-journal-rollback-root", + ERROR_REVISION_MISMATCH, + L"current root is missing, foreign, duplicated, or bound outside the exact prior/candidate transaction authority"); + } + observation->action = PartialInstallRootRecoveryAction::PriorEmpty; + return true; +} + +bool CurrentStateMatchesForward( + const InstallJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + if (!state.hasPublishedCandidate) { + return SetError(error, L"install-journal-forward-state", + ERROR_INVALID_DATA); + } + std::string expectedBuildIdentity; + return DeriveDriverBuildIdentity( + state.sourceRevision, &expectedBuildIdentity, error) && + VerifyInstalled(state.candidate, + state.publishedCandidate.publishedName, false, + deadlineUnixMs, &expectedBuildIdentity, error) && + VerifyPackageInventory(state.expectedInventory, + L"install-journal-forward-inventory", error); +} + +bool RecoveryStateMatchesPrior( + const LoadedInstallJournal& loaded, + uint64_t deadlineUnixMs, + Error* error) { + if (loaded.state.hasRootRegistrationIntent && + loaded.state.prior.devices.empty()) { + InstallRecoveryRootObservation rawRoot; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &rawRoot, error)) { + return false; + } + if (rawRoot.action != + PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, L"install-journal-prior-raw-root", + ERROR_REVISION_MISMATCH, + L"exact prior-empty restoration still has the transaction root present"); + } + } + return CurrentStateMatchesPrior( + loaded.state, deadlineUnixMs, error); +} + +bool RecoveryStateMatchesForward( + const LoadedInstallJournal& loaded, + uint64_t deadlineUnixMs, + Error* error) { + if (loaded.state.hasRootRegistrationIntent && + loaded.state.prior.devices.empty()) { + InstallRecoveryRootObservation rawRoot; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &rawRoot, error)) { + return false; + } + if (rawRoot.action != PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot) { + return SetError(error, L"install-journal-forward-raw-root", + ERROR_REVISION_MISMATCH, + L"forward validation lacks the exact receipt-bound candidate root topology"); + } + } + return CurrentStateMatchesForward( + loaded.state, deadlineUnixMs, error); +} + +void SetInstallJournalRecoveryOutcome( + LoadedInstallJournal* loaded, + const wchar_t* phase, + DWORD code, + std::wstring message, + ExitCode exitCode, + Outcome* outcome) { + SetError(&outcome->error, phase, code, std::move(message)); + outcome->exitCode = exitCode; + outcome->rebootRequired = exitCode == ExitCode::RebootRequired; + outcome->rollback = exitCode == ExitCode::RollbackFailed + ? L"failed" : L"not-needed"; + outcome->error.recoveryBackup = loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = gActiveRecoveryRecordWritten; + } +} + +bool InstallJournalNeedsRestoreRebootPending( + const InstallJournalStateData& state, + bool sameBoot) noexcept { + return sameBoot && + state.direction == InstallJournalDirection::Rollback && + state.phase == InstallJournalPhase::RollbackBindingReturned && + state.callSucceeded && state.rebootRequired; +} + +bool InstallJournalRollbackRetryRebootSeed( + const InstallJournalStateData& state, + bool sameBoot) noexcept { + return sameBoot && state.rebootRequired; +} + +bool InstallJournalHasAuthoritativeRollbackSettlement( + const InstallJournalStateData& state) noexcept { + return state.bindingMutationStarted && + state.direction == InstallJournalDirection::Rollback && + state.rollbackAuthorized && + state.phase == InstallJournalPhase::RollbackBindingReturned && + state.callSucceeded; +} + +enum class PartialRootRemovalRecoveryDisposition { + ContinueRollback, + RetryRemoval, + RebootPending, + Manual, +}; + +PartialRootRemovalRecoveryDisposition +ClassifyPartialRootRemovalJournalRecovery( + InstallJournalPhase phase, + bool callSucceeded, + bool freshRebootRequired, + bool sameRemovalBoot, + InstallJournalStateData::PartialRootRemovalBinding recordedBinding, + PartialInstallRootRecoveryAction rootAction) noexcept { + const bool absent = rootAction == + PartialInstallRootRecoveryAction::PriorEmpty; + const bool exactOriginal = + rootAction == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + rootAction == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot; + const bool exactPending = rootAction == + PartialInstallRootRecoveryAction::PendingExactRootRemoval; + const bool exactOriginalMatchesRecord = + (recordedBinding == + InstallJournalStateData::PartialRootRemovalBinding::Unbound && + rootAction == PartialInstallRootRecoveryAction:: + RemoveUnboundExactRoot) || + (recordedBinding == InstallJournalStateData:: + PartialRootRemovalBinding::Candidate && + rootAction == PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot); + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + if (sameRemovalBoot) { + if (exactOriginal && exactOriginalMatchesRecord) { + return PartialRootRemovalRecoveryDisposition::RetryRemoval; + } + if (absent || exactPending) { + return PartialRootRemovalRecoveryDisposition::RebootPending; + } + } else if (absent) { + return PartialRootRemovalRecoveryDisposition::ContinueRollback; + } + return PartialRootRemovalRecoveryDisposition::Manual; + } + if (phase == InstallJournalPhase::PartialRootRemovalReturned) { + if (!callSucceeded) { + return PartialRootRemovalRecoveryDisposition::Manual; + } + if (freshRebootRequired && sameRemovalBoot) { + return absent || exactPending + ? PartialRootRemovalRecoveryDisposition::RebootPending + : PartialRootRemovalRecoveryDisposition::Manual; + } + return absent + ? PartialRootRemovalRecoveryDisposition::ContinueRollback + : PartialRootRemovalRecoveryDisposition::Manual; + } + if (phase == + InstallJournalPhase::PartialRootRemovalRebootPending) { + if (sameRemovalBoot) { + return absent || exactPending + ? PartialRootRemovalRecoveryDisposition::RebootPending + : PartialRootRemovalRecoveryDisposition::Manual; + } + return absent + ? PartialRootRemovalRecoveryDisposition::ContinueRollback + : PartialRootRemovalRecoveryDisposition::Manual; + } + return PartialRootRemovalRecoveryDisposition::ContinueRollback; } -bool WriteProtectedRecoveryRecord( - const std::filesystem::path& path, - std::string_view record, - Error* error) { - if (path.filename() != kRecoveryRecordName || - record.empty() || record.size() > kMaximumRecoveryRecordBytes) { - return SetError(error, L"recovery-record-create", ERROR_INVALID_PARAMETER); - } - const std::filesystem::path temporaryPath = - path.parent_path() / kRecoveryRecordTemporaryName; - LocalSecurityDescriptor security; - if (!security.Initialize( - kRecoveryRecordSecurity, L"recovery-record-security", error)) { +bool ReconcileInstallJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome) { + // Automatic admission and the explicit recover command deliberately run + // the same reconciler under the same global transaction mutex. The mode + // changes diagnostics only; it cannot weaken recovery authority. + *outcome = Outcome{}; + InstallRecoveryDirectory directory; + bool exists = false; + Error discoveryError; + if (!directory.OpenChain( + false, nullptr, &exists, &discoveryError)) { + outcome->error = std::move(discoveryError); + outcome->exitCode = ExitCode::RollbackFailed; return false; } - WinHandle file(CreateFileW(temporaryPath.c_str(), - GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ, security.attributes(), CREATE_NEW, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | - FILE_FLAG_OPEN_REPARSE_POINT, - nullptr)); - const DWORD createError = GetLastError(); - if (!file) { - return SetError(error, L"recovery-record-create", createError); + if (!exists) { + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; } - const auto discardTemporary = [&]() noexcept { - file.reset(); - DeleteFileW(temporaryPath.c_str()); - }; - FILE_ATTRIBUTE_TAG_INFO attributes{}; - const BOOL queriedAttributes = GetFileInformationByHandleEx( - file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); - const DWORD attributeError = queriedAttributes ? ERROR_SUCCESS : GetLastError(); - if (!queriedAttributes || - (attributes.FileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { - const DWORD code = queriedAttributes - ? ERROR_REPARSE_TAG_MISMATCH : attributeError; - SetError(error, L"recovery-record-create", code, - L"recovery record must be a regular non-reparse file"); - discardTemporary(); + if (!PublishInstallRecoveryEvidence(directory.active, 0, &discoveryError)) { + outcome->error = std::move(discoveryError); + outcome->exitCode = ExitCode::RollbackFailed; return false; } - if (!VerifyProtectedFileSystemSecurity( - file.get(), false, L"recovery-record-security", error)) { - discardTemporary(); + LoadedInstallJournal loaded; + if (!LoadInstallJournal( + std::move(directory), &loaded, &discoveryError)) { + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-manual-reconciliation", + discoveryError.code == ERROR_SUCCESS + ? ERROR_INVALID_DATA : discoveryError.code, + L"the durable transaction chain or protected package evidence is invalid; no driver mutation was attempted: " + + discoveryError.message, + ExitCode::RollbackFailed, outcome); return false; } - size_t offset = 0; - while (offset < record.size()) { - const DWORD requested = static_cast(std::min( - record.size() - offset, MAXDWORD)); - DWORD written = 0; - if (!WriteFile(file.get(), record.data() + offset, requested, - &written, nullptr) || written == 0) { - const DWORD writeError = GetLastError(); - const DWORD code = writeError == ERROR_SUCCESS - ? ERROR_WRITE_FAULT : writeError; - SetError(error, L"recovery-record-write", code); - discardTemporary(); + if (!loaded.hasRecord) { + if (!GenerateInstallTransactionId( + &loaded.state.transactionId, &outcome->error) || + !RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; return false; } - offset += written; + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; } - if (!FlushFileBuffers(file.get())) { - SetLastErrorDetail(error, L"recovery-record-flush"); - discardTemporary(); + gActiveRecoveryRecordWritten = true; + PublishInstallRecoveryEvidence( + loaded.directory.active, loaded.state.sequence - 1U, nullptr); + gActiveRecoveryRecordWritten = true; + + const auto appendInstallJournalRecord = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + InstallJournalStateData::PartialRootRemovalBinding + partialRootRemovalBinding, + Error* error) { + InstallJournalStateData next = loaded.state; + next.phase = phase; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.rebootRequired = next.rebootRequired || rebootRequired; + next.freshRebootRequired = freshRebootRequired; + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + if (partialRootRemovalBinding == + InstallJournalStateData:: + PartialRootRemovalBinding::None || + !GetBootIdentifier( + &next.partialRootRemovalBootIdentifier, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_PARAMETER); + } + return false; + } + next.partialRootRemovalBinding = + partialRootRemovalBinding; + } else if (partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::None) { + return SetError(error, + L"install-journal-partial-root-removal", + ERROR_INVALID_PARAMETER); + } + if (freshRebootRequired && + !GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + if (phase == InstallJournalPhase::RollbackBindingEntered) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (phase == InstallJournalPhase::RootRegistrationEntered || + phase == InstallJournalPhase::RootRegistrationReturned || + phase == InstallJournalPhase::DiInstallEntered || + phase == InstallJournalPhase::DiInstallReturned) { + next.bindingMutationStarted = true; + } + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (phase == InstallJournalPhase::PartialRootRemovalEntered) { + loaded.partialRootRemovalEntered = true; + } + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; + const auto appendPhase = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + Error* error) { + return appendInstallJournalRecord(phase, callSucceeded, + callError, rebootRequired, false, + InstallJournalStateData::PartialRootRemovalBinding::None, + error); + }; + const auto appendPartialRootRemovalEntered = + [&](InstallJournalStateData::PartialRootRemovalBinding binding, + Error* error) { + return appendInstallJournalRecord( + InstallJournalPhase::PartialRootRemovalEntered, + true, ERROR_SUCCESS, loaded.state.rebootRequired, + false, binding, error); + }; + const auto appendAuthoritativeReturn = + [&](InstallJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + Error* error) { + if (phase != InstallJournalPhase::DiInstallReturned && + phase != InstallJournalPhase::RollbackBindingReturned && + phase != InstallJournalPhase:: + PartialRootRemovalReturned) { + return SetError(error, + L"install-journal-reboot-return", + ERROR_INVALID_PARAMETER); + } + return appendInstallJournalRecord(phase, callSucceeded, + callError, rebootRequired, + freshRebootRequired, + InstallJournalStateData::PartialRootRemovalBinding::None, + error); + }; + const auto finishSuccess = [&](bool changed) { + outcome->success = true; + outcome->changed = changed; + outcome->exitCode = ExitCode::Success; + outcome->rollback = changed ? L"succeeded" : L"not-needed"; + return true; + }; + const auto manual = [&](std::wstring message, const Error* cause = nullptr) { + message.insert(0, explicitRecovery + ? L"explicit recover: " : L"automatic admission recovery: "); + if (cause != nullptr && !cause->message.empty()) { + message.append(L"; observed: "); + message.append(cause->message); + } + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-manual-reconciliation", + cause != nullptr && cause->code != ERROR_SUCCESS + ? cause->code : ERROR_INSTALL_SUSPEND, + std::move(message), ExitCode::RollbackFailed, outcome); return false; - } - file.reset(); - if (!MoveFileExW( - temporaryPath.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH)) { - const DWORD code = GetLastError(); - DeleteFileW(temporaryPath.c_str()); - return SetError(error, L"recovery-record-publish", code); - } - - file.reset(CreateFileW(path.c_str(), - GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | - FILE_FLAG_OPEN_REPARSE_POINT, - nullptr)); - if (!file) { - return SetLastErrorDetail(error, L"recovery-record-reopen"); - } - attributes = {}; - const BOOL queriedPublished = GetFileInformationByHandleEx( - file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); - const DWORD publishedQueryError = queriedPublished - ? ERROR_SUCCESS : GetLastError(); - if (!queriedPublished || - (attributes.FileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { - const DWORD code = queriedPublished - ? ERROR_REPARSE_TAG_MISMATCH : publishedQueryError; - return SetError(error, L"recovery-record-reopen", code, - L"published recovery record must be a regular non-reparse file"); - } - if (!VerifyProtectedFileSystemSecurity( - file.get(), false, L"recovery-record-security", error)) { + }; + const auto rebootPending = [&](const wchar_t* message) { + SetInstallJournalRecoveryOutcome(&loaded, + L"install-journal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, message, + ExitCode::RebootRequired, outcome); return false; - } - offset = 0; - std::array verification{}; - while (offset < record.size()) { - const DWORD requested = static_cast(std::min( - verification.size(), record.size() - offset)); - DWORD read = 0; - if (!ReadFile(file.get(), verification.data(), requested, &read, nullptr)) { - return SetLastErrorDetail(error, L"recovery-record-verify"); + }; + + std::string currentBoot; + Error bootError; + if (!GetBootIdentifier(¤tBoot, &bootError)) { + return manual(L"the current boot session cannot be compared with the durable transaction", &bootError); + } + const bool sameBoot = + !loaded.state.pendingRebootBootIdentifier.empty() && + currentBoot == loaded.state.pendingRebootBootIdentifier; + const bool samePartialRootRemovalBoot = + !loaded.state.partialRootRemovalBootIdentifier.empty() && + currentBoot == loaded.state.partialRootRemovalBootIdentifier; + if (loaded.state.phase == InstallJournalPhase::ManualReconciliationRequired) { + return manual(L"a prior authoritative owner retained the transaction for manual reconciliation"); + } + const bool partialRootRemovalPhase = + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalEntered || + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalReturned || + loaded.state.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending; + if (partialRootRemovalPhase) { + InstallRecoveryRootObservation observedRoot; + Error observationError; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &observedRoot, &observationError)) { + return manual( + L"partial root removal topology is not within the exact durable receipt authority", + &observationError); + } + const PartialRootRemovalRecoveryDisposition disposition = + ClassifyPartialRootRemovalJournalRecovery( + loaded.state.phase, loaded.state.callSucceeded, + loaded.state.freshRebootRequired, + samePartialRootRemovalBoot, + loaded.state.partialRootRemovalBinding, + observedRoot.action); + if (disposition == + PartialRootRemovalRecoveryDisposition::Manual) { + return manual( + L"partial root removal outcome and current topology do not prove a safe automatic continuation"); + } + if (disposition == + PartialRootRemovalRecoveryDisposition::RebootPending) { + if (loaded.state.phase != InstallJournalPhase:: + PartialRootRemovalRebootPending) { + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + loaded.state.phase == InstallJournalPhase:: + PartialRootRemovalReturned && + loaded.state.callSucceeded, + ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"partial root removal requires a restart but its durable pending phase could not be published", + &pendingError); + } + } + return rebootPending( + L"receipt-bound root removal must cross the recorded restart before package rollback can continue"); + } + } + if (loaded.state.phase == InstallJournalPhase::ForwardRebootPending && sameBoot) { + return rebootPending( + L"forward driver activation is still pending the recorded restart"); + } + if (loaded.state.phase == InstallJournalPhase::RestoreRebootPending && sameBoot) { + return rebootPending( + L"exact prior-state restoration is still pending the recorded restart"); + } + if (InstallJournalNeedsRestoreRebootPending( + loaded.state, sameBoot)) { + Error pendingError; + if (!appendPhase(InstallJournalPhase::RestoreRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"rollback returned with a required restart but its pending phase could not be published", + &pendingError); + } + return rebootPending( + L"exact prior-state rollback returned successfully and still requires the recorded restart"); + } + + const bool forwardTerminal = + loaded.state.phase == InstallJournalPhase::ForwardValidated || + loaded.state.phase == InstallJournalPhase::ForwardRebootPending; + if (forwardTerminal) { + const bool exactBrokerCommit = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized; + if ((loaded.state.brokerRequired && !exactBrokerCommit) || + (!loaded.state.brokerRequired && loaded.state.brokerEntered)) { + return manual( + L"terminal forward state lacks its exact canonical broker commit authority"); + } + Error validationError; + if (!RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"a terminal forward record did not revalidate; exact evidence was retained", + &validationError); + } + if (!RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; } - if (read != requested || - std::memcmp(verification.data(), record.data() + offset, read) != 0) { - return SetError(error, L"recovery-record-verify", ERROR_CRC, - L"published recovery record bytes do not match the flushed transaction journal"); + return finishSuccess(false); + } + const bool restoreTerminal = + loaded.state.phase == InstallJournalPhase::ExactPriorRestored || + loaded.state.phase == InstallJournalPhase::RestoreRebootPending; + if (restoreTerminal) { + if (loaded.state.brokerEntered && + (loaded.state.direction != InstallJournalDirection::Rollback || + !loaded.state.rollbackAuthorized)) { + return manual( + L"terminal prior state lacks durable broker-safe rollback authority"); + } + Error validationError; + if (!RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"a terminal prior-state record did not revalidate; exact evidence was retained", + &validationError); + } + if (!RetireLoadedInstallJournal(&loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + return finishSuccess(false); + } + + const bool durableBrokerForwardSuccess = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward; + const bool durableNonBrokerForwardSuccess = + !loaded.state.brokerRequired && !loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::DriverValidated && + loaded.state.direction == InstallJournalDirection::Forward; + if (loaded.state.hasPublishedCandidate && + (durableBrokerForwardSuccess || durableNonBrokerForwardSuccess)) { + Error forwardValidation; + if (RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &forwardValidation)) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the validated forward state could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + if (durableBrokerForwardSuccess) { + return manual( + L"the child durably committed but the forward driver state did not revalidate; no driver rollback is authorized", + &forwardValidation); + } + } + + if (loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::BrokerHandoffReturned && + loaded.state.callSucceeded && !loaded.state.brokerSettled && + !loaded.state.hasBrokerProof && + loaded.state.direction == InstallJournalDirection::Forward) { + Error authorizationError; + if (!appendPhase(InstallJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, &authorizationError)) { + return manual( + L"pre-child rollback authority could not be durably admitted", + &authorizationError); + } + } + if (loaded.state.phase == InstallJournalPhase::BrokerChildEntered && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized && + !loaded.state.hasBrokerProof) { + return manual( + L"broker child creation was admitted without a durable canonical settlement proof; driver evidence was retained and no mutation was attempted"); + } + + const bool priorRequiresAbiProfile = + loaded.state.bindingMutationStarted && + loaded.state.prior.devices.size() == 1U && + loaded.state.prior.devices[0].started && + loaded.state.prior.devices[0].problem == 0; + if (priorRequiresAbiProfile && !loaded.state.hasPriorAbiProfile) { + Snapshot observedBeforeProfile; + Error profileError; + AbiCompatibilityProfile negotiatedProfile{}; + if (!CaptureSnapshot(&observedBeforeProfile, &profileError) || + !SameCapturedRootState( + loaded.state.prior, observedBeforeProfile) || + !VerifyAbiHealth(deadlineUnixMs, nullptr, &profileError, + AbiHealthPurpose::PristineUpgrade, nullptr, + &negotiatedProfile)) { + if (profileError.code == ERROR_SUCCESS) { + SetError(&profileError, + L"install-journal-prior-abi-profile", + ERROR_REVISION_MISMATCH, + L"missing prior ABI profile cannot be negotiated after the captured binding changed"); + } + return manual( + L"started prior root lacks a durable exact ABI profile; no recovery mutation was attempted", + &profileError); + } + loaded.state.priorAbiProfile = negotiatedProfile; + loaded.state.hasPriorAbiProfile = true; + if (!appendPhase(InstallJournalPhase::PriorAbiProfileCaptured, + true, ERROR_SUCCESS, false, &profileError)) { + return manual( + L"the exact prior ABI profile was negotiated but could not be published before recovery mutation", + &profileError); + } + } + + const bool rollbackWasAuthorized = + loaded.state.direction == InstallJournalDirection::Rollback && + loaded.state.rollbackAuthorized; + Error priorValidation; + const bool priorValid = + RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &priorValidation); + if (priorValid && + (!loaded.state.bindingMutationStarted || + InstallJournalHasAuthoritativeRollbackSettlement( + loaded.state))) { + if (loaded.state.brokerEntered && + !rollbackWasAuthorized) { + return manual( + L"the driver resembles the prior state, but broker handoff lacks a durable settled rollback authorization; evidence was retained"); + } + Error appendError; + if (!appendPhase(InstallJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesPrior( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the prior state was present but could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + + std::vector currentPackages; + Error inventoryError; + if (!EnumerateOwnedPackages(¤tPackages, &inventoryError)) { + return manual(L"the current Driver Store inventory could not be classified", &inventoryError); + } + if (!loaded.state.hasPublishedCandidate) { + size_t matches = 0; + for (const PackageInfo& package : currentPackages) { + if (package.version == loaded.state.candidate.version && + SamePackageBytes(package, loaded.state.candidate) && + !ContainsExactPackage(loaded.state.prior.packages, package)) { + loaded.state.publishedCandidate = package; + loaded.state.hasPublishedCandidate = true; + ++matches; + } + } + if (matches > 1U) { + return manual(L"more than one exact candidate publication exists"); + } + if (matches == 1U) { + return manual( + L"an exact candidate publication exists without a durable StageReceiptCaptured ownership record; it may be concurrent and will not be removed automatically"); + } + } + + Error forwardValidation; + const bool forwardValid = loaded.state.hasPublishedCandidate && + RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &forwardValidation); + const bool settledForwardBroker = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.direction == InstallJournalDirection::Forward; + if (forwardValid && + ((!loaded.state.brokerRequired && + !loaded.state.brokerEntered && + loaded.state.phase == InstallJournalPhase::DriverValidated && + loaded.state.direction == InstallJournalDirection::Forward) || + settledForwardBroker)) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError) || + !RetireLoadedInstallJournal(&loaded, &appendError)) { + return manual( + L"the validated forward state could not be terminally recorded and retired", + &appendError); + } + return finishSuccess(false); + } + + if (loaded.state.brokerEntered && + !rollbackWasAuthorized) { + return manual( + L"broker handoff was entered without a durable, settled rollback authorization; driver evidence was retained and no mutation was attempted"); + } + for (const PackageInfo& priorPackage : loaded.state.prior.packages) { + const size_t exactMatches = static_cast(std::count_if( + currentPackages.begin(), currentPackages.end(), + [&](const PackageInfo& current) { + return _wcsicmp(current.publishedName.c_str(), + priorPackage.publishedName.c_str()) == 0 && + current.version == priorPackage.version && + SamePackageBytes(current, priorPackage); + })); + if (exactMatches != 1U) { + return manual( + L"the exact prior published package name and bytes are not available in the Driver Store; protected package bytes were retained, but automatic republishing cannot promise the same OEM identity"); + } + } + + bool stagedCandidateStillPresent = false; + if (loaded.state.packageStagedHere && + loaded.state.hasPublishedCandidate) { + size_t publishedNameMatches = 0; + for (const PackageInfo& current : currentPackages) { + if (_wcsicmp(current.publishedName.c_str(), + loaded.state.publishedCandidate.publishedName.c_str()) != 0) { + continue; + } + ++publishedNameMatches; + if (!SameJournalPackageIdentity( + current, loaded.state.publishedCandidate)) { + return manual( + L"the staged-here published name now identifies different bytes; no automatic removal was attempted"); + } + stagedCandidateStillPresent = true; + } + if (publishedNameMatches > 1U) { + return manual( + L"the staged-here published identity is duplicated; no automatic removal was attempted"); } - offset += read; - } - char trailing = 0; - DWORD trailingRead = 0; - if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr)) { - return SetLastErrorDetail(error, L"recovery-record-verify"); - } - if (trailingRead != 0) { - return SetError(error, L"recovery-record-verify", ERROR_FILE_INVALID, - L"published recovery record contains trailing bytes"); } - if (!FlushFileBuffers(file.get())) { - return SetLastErrorDetail(error, L"recovery-record-published-flush"); + std::vector exactPreRollbackInventory = + loaded.state.prior.packages; + if (stagedCandidateStillPresent) { + exactPreRollbackInventory.push_back( + loaded.state.publishedCandidate); } - return true; + std::sort(exactPreRollbackInventory.begin(), + exactPreRollbackInventory.end(), + [](const PackageInfo& left, const PackageInfo& right) { + return _wcsicmp(left.publishedName.c_str(), + right.publishedName.c_str()) < 0; + }); + if (!SamePackageInventory( + currentPackages, exactPreRollbackInventory)) { + return manual( + L"current Driver Store inventory is not exactly the captured prior set plus the one transaction-owned staged candidate; no rollback mutation was attempted"); + } + Error rootAuthorityError; + InstallRecoveryRootObservation rootAuthority; + if (!CurrentRootIsAuthorizedForInstallRollback( + loaded, &rootAuthority, &rootAuthorityError)) { + return manual( + L"current root topology is outside the exact rollback authority of this transaction; no device mutation was attempted", + &rootAuthorityError); + } + + Error appendError; + if (!appendPhase(InstallJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, &appendError)) { + return manual( + L"write-ahead rollback admission could not be published; no recovery mutation was attempted", + &appendError); + } + Error confirmedInventoryError; + if (!VerifyPackageInventory(exactPreRollbackInventory, + L"install-journal-post-admission-inventory", + &confirmedInventoryError)) { + return manual( + L"Driver Store inventory changed after write-ahead rollback admission; no device mutation was attempted", + &confirmedInventoryError); + } + InstallRecoveryRootObservation confirmedRoot; + Error confirmedRootError; + if (!CurrentRootIsAuthorizedForInstallRollback( + loaded, &confirmedRoot, &confirmedRootError)) { + return manual( + L"current root topology changed after write-ahead rollback admission; no device mutation was attempted", + &confirmedRootError); + } + const PackageInfo* stagedCandidate = + loaded.state.packageStagedHere && + loaded.state.hasPublishedCandidate && stagedCandidateStillPresent + ? &loaded.state.publishedCandidate : nullptr; + bool rollbackReboot = InstallJournalRollbackRetryRebootSeed( + loaded.state, sameBoot); + const bool rollbackRebootAtAdmission = rollbackReboot; + Error rollbackError; + const uint64_t rollbackDeadline = + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; + bool rollbackSucceeded = true; + if (confirmedRoot.action == + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot || + confirmedRoot.action == + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + if (!CheckTransactionDeadline(rollbackDeadline, + L"install-journal-rollback-deadline-partial-root", + &rollbackError)) { + return manual( + L"receipt-bound root removal missed its deadline before durable API admission", + &rollbackError); + } + Error removalEnteredError; + const InstallJournalStateData::PartialRootRemovalBinding + removalBinding = confirmedRoot.action == + PartialInstallRootRecoveryAction:: + RemoveCandidateBoundExactRoot + ? InstallJournalStateData::PartialRootRemovalBinding::Candidate + : InstallJournalStateData::PartialRootRemovalBinding::Unbound; + if (!appendPartialRootRemovalEntered( + removalBinding, &removalEnteredError)) { + return manual( + L"receipt-bound root removal could not publish its exact write-ahead API admission", + &removalEnteredError); + } + Error removalInventoryError; + if (!VerifyPackageInventory(exactPreRollbackInventory, + L"install-journal-partial-root-removal-inventory", + &removalInventoryError)) { + return manual( + L"Driver Store inventory changed after receipt-bound root removal admission; no device API was called", + &removalInventoryError); + } + InstallRecoveryRootObservation removalRoot; + Error removalRootError; + LoadedInstallJournal preCallLoaded; + preCallLoaded.state = loaded.state; + preCallLoaded.forwardRootRegistrationEntered = + loaded.forwardRootRegistrationEntered; + preCallLoaded.forwardDiInstallEntered = + loaded.forwardDiInstallEntered; + preCallLoaded.partialRootRemovalEntered = false; + if (!CurrentRootIsAuthorizedForInstallRollback( + preCallLoaded, &removalRoot, &removalRootError)) { + return manual( + L"root topology changed after receipt-bound root removal admission; no device API was called", + &removalRootError); + } + if (removalRoot.action == + PartialInstallRootRecoveryAction::PriorEmpty || + removalRoot.action == PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + false, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"indeterminate receipt-bound root removal could not publish its conservative reboot boundary", + &pendingError); + } + return rebootPending( + L"receipt-bound root removal changed after admission and must cross the recorded restart before package rollback"); + } + if (removalRoot.action != confirmedRoot.action) { + return manual( + L"receipt-bound root identity changed after durable removal admission; no device API was called"); + } + bool freshRemovalReboot = false; + rollbackSucceeded = RemoveDevice( + removalRoot.set.get(), removalRoot.data, 0, + L"install-journal-rollback-deadline-partial-root", + nullptr, &rollbackReboot, &rollbackError, + &freshRemovalReboot); + Error removalReturnedError; + if (!appendAuthoritativeReturn( + InstallJournalPhase::PartialRootRemovalReturned, + rollbackSucceeded, + rollbackSucceeded ? ERROR_SUCCESS : rollbackError.code, + rollbackReboot, freshRemovalReboot, + &removalReturnedError)) { + return manual( + L"receipt-bound root removal returned but its exact authoritative outcome could not be published", + &removalReturnedError); + } + if (!rollbackSucceeded) { + return manual( + L"receipt-bound root removal returned failure; exact evidence was retained", + &rollbackError); + } + InstallRecoveryRootObservation afterRemoval; + Error afterRemovalError; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &afterRemoval, &afterRemovalError)) { + return manual( + L"receipt-bound root removal returned but its resulting topology is not canonical", + &afterRemovalError); + } + if (freshRemovalReboot) { + if (afterRemoval.action != + PartialInstallRootRecoveryAction::PriorEmpty && + afterRemoval.action != PartialInstallRootRecoveryAction:: + PendingExactRootRemoval) { + return manual( + L"reboot-requiring receipt-bound root removal left an unauthorized topology"); + } + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"receipt-bound root removal requires a restart but its pending phase could not be published", + &pendingError); + } + return rebootPending( + L"receipt-bound root removal returned successfully and requires the recorded restart before package rollback"); + } + if (afterRemoval.action != + PartialInstallRootRecoveryAction::PriorEmpty) { + return manual( + L"receipt-bound root removal returned without a restart but exact prior-empty topology was not restored"); + } + } + if (rollbackSucceeded) { + rollbackSucceeded = VerifyPackageInventory( + exactPreRollbackInventory, + L"install-journal-pre-package-rollback-inventory", + &rollbackError) && + VerifyInstallJournalRawPriorTopology( + loaded.state, &rollbackError); + } + if (rollbackSucceeded) { + const bool restoreBindingThroughStrictSnapshot = + InstallJournalRecoveryUsesStrictBindingRestore( + loaded.state); + rollbackSucceeded = RollbackInstall( + loaded.state.prior, stagedCandidate, + restoreBindingThroughStrictSnapshot, + loaded.state.hasPriorAbiProfile + ? &loaded.state.priorAbiProfile : nullptr, + rollbackDeadline, &rollbackReboot, &rollbackError); + } + if (!rollbackSucceeded) { + Error ignored; + appendAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + false, rollbackError.code, rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + &ignored); + appendPhase(InstallJournalPhase::ManualReconciliationRequired, + false, rollbackError.code, rollbackReboot, &ignored); + return manual(L"authoritative exact-prior recovery failed", &rollbackError); + } + Error returnedError; + if (!appendAuthoritativeReturn( + InstallJournalPhase::RollbackBindingReturned, + true, ERROR_SUCCESS, rollbackReboot, + rollbackReboot && !rollbackRebootAtAdmission, + &returnedError)) { + return manual( + L"rollback returned authoritatively, but its returned phase could not be published", + &returnedError); + } + if (rollbackReboot) { + Error pendingError; + if (!appendPhase(InstallJournalPhase::RestoreRebootPending, + true, ERROR_SUCCESS_REBOOT_REQUIRED, true, + &pendingError)) { + return manual( + L"rollback requires reboot but its pending state could not be published", + &pendingError); + } + return rebootPending( + L"exact prior-state rollback completed with a required restart; evidence remains retained"); + } + Error restoredError; + if (!RecoveryStateMatchesPrior( + loaded, rollbackDeadline, &restoredError) || + !appendPhase(InstallJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, &restoredError) || + !RecoveryStateMatchesPrior( + loaded, rollbackDeadline, &restoredError) || + !RetireLoadedInstallJournal(&loaded, &restoredError)) { + return manual( + L"rollback returned but exact prior-state revalidation or journal retirement failed", + &restoredError); + } + return finishSuccess(true); } bool RollbackRemove( @@ -5608,6 +11554,11 @@ Outcome Remove(const RemoveOptions& options) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } + Outcome recoveryOutcome; + if (!ReconcileInstallJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { + return recoveryOutcome; + } if (!CheckTransactionDeadline( options.transactionDeadlineUnixMs, L"remove-deadline-before-snapshot", &outcome.error)) { outcome.exitCode = ExitCode::PreflightRejected; @@ -5754,6 +11705,1011 @@ Outcome Remove(const RemoveOptions& options) { return outcome; } +Outcome Recover(uint64_t transactionDeadlineUnixMs) { + Outcome outcome; + if (!ValidateTransactionDeadlineBudget( + transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!ReconcileInstallJournal( + true, transactionDeadlineUnixMs, &outcome)) { + return outcome; + } + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; +} + +enum class InstallJournalRecoveryModelAction { + RetirePrior, + RetireForward, + RollbackPrior, + RebootPending, + Manual, +}; + +InstallJournalRecoveryModelAction ClassifyInstallJournalRecoveryModel( + InstallJournalPhase phase, + bool chainValid, + bool securityValid, + bool sameBoot, + bool priorValid, + bool forwardValid, + bool brokerEntered, + bool brokerSettled, + bool brokerSucceeded) noexcept { + if (!chainValid || !securityValid || + phase == InstallJournalPhase::ManualReconciliationRequired) { + return InstallJournalRecoveryModelAction::Manual; + } + if ((phase == InstallJournalPhase::ForwardRebootPending || + phase == InstallJournalPhase::RestoreRebootPending) && + sameBoot) { + return InstallJournalRecoveryModelAction::RebootPending; + } + if (priorValid) { + return InstallJournalRecoveryModelAction::RetirePrior; + } + if (forwardValid && + (phase == InstallJournalPhase::ForwardValidated || + phase == InstallJournalPhase::ForwardRebootPending || + (!brokerEntered && phase == InstallJournalPhase::DriverValidated) || + (brokerSettled && brokerSucceeded))) { + return InstallJournalRecoveryModelAction::RetireForward; + } + if (brokerEntered && !brokerSettled) { + return InstallJournalRecoveryModelAction::Manual; + } + return InstallJournalRecoveryModelAction::RollbackPrior; +} + +bool RunInstallJournalModelSelfTest(Error* error) { + const std::wstring modelTargetUserSid = + L"S-1-5-21-1-2-3-1001"; + std::wstring modelProductSecurity; + if (!ProductDirectoryMaskIsReadExecuteOnly(0x001200a9U) || + !ProductDirectoryMaskIsReadExecuteOnly( + GENERIC_READ | GENERIC_EXECUTE) || + ProductDirectoryMaskIsReadExecuteOnly( + GENERIC_READ | GENERIC_WRITE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | FILE_ADD_FILE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | FILE_DELETE_CHILD) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | DELETE) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | WRITE_DAC) || + ProductDirectoryMaskIsReadExecuteOnly( + 0x001200a9U | WRITE_OWNER) || + !BuildInstallRecoveryProductDirectorySecurity( + modelTargetUserSid, &modelProductSecurity, error) || + modelProductSecurity != + L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + L"(A;OICI;GRGX;;;S-1-5-21-1-2-3-1001)" || + !InstallRecoveryChainHasActive(true, true, true, true) || + InstallRecoveryChainHasActive(true, false, false, false) || + InstallRecoveryChainHasActive(true, true, false, false) || + InstallRecoveryChainHasActive(true, true, true, false) || + InstallRecoveryChainHasActive(false, false, false, false)) { + return SetError(error, + L"self-test-install-journal-product-security", + ERROR_INVALID_DATA); + } + for (InstallJournalPhase phase : { + InstallJournalPhase::Prepared, + InstallJournalPhase::SetupCopyEntered, + InstallJournalPhase::SetupCopyReturned, + InstallJournalPhase::StageReceiptCaptured, + InstallJournalPhase::QuiesceSignalEntered, + InstallJournalPhase::QuiesceSignalReturned, + InstallJournalPhase::RootRegistrationIntentCaptured, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::PriorAbiProfileCaptured, + InstallJournalPhase::DriverValidated, + InstallJournalPhase::BrokerHandoffEntered, + InstallJournalPhase::BrokerHandoffReturned, + InstallJournalPhase::BrokerChildEntered, + InstallJournalPhase::BrokerChildSettled, + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::PartialRootRemovalEntered, + InstallJournalPhase::PartialRootRemovalReturned, + InstallJournalPhase::PartialRootRemovalRebootPending, + InstallJournalPhase::RollbackBindingReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::ForwardValidated, + InstallJournalPhase::ExactPriorRestored, + InstallJournalPhase::ForwardRebootPending, + InstallJournalPhase::RestoreRebootPending, + InstallJournalPhase::ManualReconciliationRequired}) { + const char* name = InstallJournalPhaseName(phase); + const std::optional parsed = + ParseInstallJournalPhase(name); + if (!parsed || *parsed != phase) { + return SetError(error, L"self-test-install-journal-phase", + ERROR_INVALID_DATA); + } + } + if (ParseInstallJournalPhase("setup-copy-entered") || + ParseInstallJournalDirection("Forward") || + ParseInstallJournalDirection("rollback ") || + ParseInstallJournalDirection("forward") != + InstallJournalDirection::Forward || + ParseInstallJournalDirection("rollback") != + InstallJournalDirection::Rollback || + InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase::DriverValidated) || + !InstallJournalPhaseRequiresPriorAbiProfile( + InstallJournalPhase::DiInstallEntered) || + !IsKnownAbiCompatibilityProfile(kAbiCompatibilityProfiles[0]) || + !IsSafeRecoveryRelativePath( + std::filesystem::path(L"candidate") / L"ViiperUde.inf") || + IsSafeRecoveryRelativePath(std::filesystem::path(L"..") / L"escape") || + IsSafeRecoveryRelativePath( + std::filesystem::path(L"C:\\Windows\\INF\\oem1.inf"))) { + return SetError(error, L"self-test-install-journal-security-model", + ERROR_INVALID_DATA); + } + uint64_t recordSequence = 0; + if (!ParseJournalRecordFileName( + L"journal-00000042.json", &recordSequence) || + recordSequence != 42U || + ParseJournalRecordFileName(L"journal-42.json", &recordSequence) || + ParseJournalRecordFileName( + L"journal-00000042.json.tmp", &recordSequence) || + !ParseJournalTemporaryFileName( + L"journal-00000042.json.tmp", &recordSequence) || + recordSequence != 42U || + ParseJournalTemporaryFileName( + L"journal-00000042.json.tmp.tmp", &recordSequence) || + !InstallJournalTemporarySequenceIsRecoverable(42U, 42U) || + InstallJournalTemporarySequenceIsRecoverable(41U, 42U) || + InstallJournalTemporarySequenceIsRecoverable(43U, 42U)) { + return SetError(error, L"self-test-install-journal-cutpoint", + ERROR_INVALID_DATA); + } + + InstallJournalStateData state; + state.transactionId = std::string(64, 'a'); + state.bootIdentifier = std::string(32, 'b'); + state.sourceRevision = std::string(40, 'c'); + state.candidate.version.parts = {1, 2, 3, 4}; + state.candidate.infSha256 = std::string(64, 'd'); + state.candidate.sysSha256 = std::string(64, 'e'); + state.candidate.catSha256 = std::string(64, 'f'); + std::string payload; + std::string digest; + if (!BuildInstallJournalPayload(state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + Error transitionError; + if (!ValidateInstallJournalTransition(nullptr, state, + &transitionError)) { + *error = std::move(transitionError); + return false; + } + InstallJournalStateData entered = state; + entered.sequence = 1U; + entered.phase = InstallJournalPhase::SetupCopyEntered; + InstallJournalStateData returned = entered; + returned.sequence = 2U; + returned.phase = InstallJournalPhase::SetupCopyReturned; + InstallJournalStateData receipt = returned; + receipt.sequence = 3U; + receipt.phase = InstallJournalPhase::StageReceiptCaptured; + if (!ValidateInstallJournalTransition(&state, entered, error) || + !ValidateInstallJournalTransition(&entered, returned, error) || + !ValidateInstallJournalTransition(&returned, receipt, error)) { + return false; + } + InstallJournalStateData illegalSkip = state; + illegalSkip.sequence = 1U; + illegalSkip.phase = InstallJournalPhase::BrokerChildEntered; + Error illegalTransition; + if (ValidateInstallJournalTransition( + &state, illegalSkip, &illegalTransition) || + illegalTransition.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-phase-chain", + ERROR_INVALID_DATA); + } + InstallJournalStateData sticky = returned; + sticky.deadlineOverrun = true; + InstallJournalStateData cleared = sticky; + cleared.phase = InstallJournalPhase::StageReceiptCaptured; + cleared.deadlineOverrun = false; + Error stickyError; + if (ValidateInstallJournalTransition( + &sticky, cleared, &stickyError) || + stickyError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-sticky-chain", + ERROR_INVALID_DATA); + } + for (InstallJournalPhase interrupted : { + InstallJournalPhase::RollbackBindingEntered, + InstallJournalPhase::RootRegistrationEntered, + InstallJournalPhase::RootRegistrationReturned, + InstallJournalPhase::DiInstallEntered, + InstallJournalPhase::DiInstallReturned, + InstallJournalPhase::SetupUninstallEntered, + InstallJournalPhase::SetupUninstallReturned, + InstallJournalPhase::RollbackBindingReturned}) { + InstallJournalStateData interruptedState = state; + interruptedState.direction = InstallJournalDirection::Rollback; + interruptedState.rollbackAuthorized = true; + interruptedState.phase = interrupted; + InstallJournalStateData readmitted = interruptedState; + readmitted.phase = InstallJournalPhase::RollbackBindingEntered; + if (!ValidateInstallJournalTransition( + &interruptedState, readmitted, error)) { + return false; + } + } + struct CanonicalBrokerProofCase { + bool success; + bool changed; + const char* rollback; + DWORD exitCode; + bool rollbackAuthorized; + }; + constexpr std::array brokerProofCases{{ + {true, false, "not-needed", 0U, false}, + {true, true, "not-needed", 0U, false}, + {false, false, "not-needed", 4U, true}, + {false, true, "succeeded", 1U, true}, + {false, true, "failed", 3U, false}, + }}; + for (const CanonicalBrokerProofCase& proof : brokerProofCases) { + if (!BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.rollbackAuthorized)) { + return SetError(error, + L"self-test-install-journal-broker-proof", + ERROR_INVALID_DATA); + } + } + if (BrokerProofFieldsAreCanonical( + false, false, "not-needed", 1U, true)) { + return SetError(error, + L"self-test-install-journal-broker-proof", + ERROR_INVALID_DATA); + } + std::string envelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&envelope, kInstallRecoveryKind); + envelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&envelope, digest); + envelope.append(",\"payload\":"); + AppendJsonUtf8String(&envelope, payload); + envelope.append("}\n"); + InstallJournalStateData parsedState; + std::string parsedDigest; + const std::filesystem::path modelRoot = + std::filesystem::path(L"C:\\ProgramData\\VIIPER\\UdeCx\\Transactions\\active-v2"); + if (!ParseInstallJournalEnvelope( + envelope, modelRoot, &parsedState, &parsedDigest, error) || + parsedDigest != digest || parsedState.sequence != 0U || + parsedState.previousDigest != kZeroSha256 || + !SameJournalPackageIdentity( + parsedState.candidate, state.candidate)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"self-test-install-journal-chain", + ERROR_INVALID_DATA); + } + return false; + } + for (const CanonicalBrokerProofCase& proof : brokerProofCases) { + InstallJournalStateData proofState = state; + proofState.phase = InstallJournalPhase::BrokerChildSettled; + proofState.brokerRequired = true; + proofState.brokerEntered = true; + proofState.brokerSettled = true; + proofState.hasBrokerProof = true; + proofState.brokerProofSuccess = proof.success; + proofState.brokerProofChanged = proof.changed; + proofState.brokerProofRollback = proof.rollback; + proofState.brokerProofExitCode = proof.exitCode; + proofState.brokerDriverRollbackAuthorized = + proof.rollbackAuthorized; + proofState.rollbackAuthorized = proof.rollbackAuthorized; + proofState.direction = proof.rollbackAuthorized + ? InstallJournalDirection::Rollback + : InstallJournalDirection::Forward; + std::string proofPayload; + std::string proofDigest; + if (!BuildInstallJournalPayload(proofState, &proofPayload, error) || + !Sha256Data(proofPayload, &proofDigest, error)) { + return false; + } + std::string proofEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&proofEnvelope, kInstallRecoveryKind); + proofEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&proofEnvelope, proofDigest); + proofEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&proofEnvelope, proofPayload); + proofEnvelope.append("}\n"); + InstallJournalStateData proofRoundTrip; + std::string observedProofDigest; + if (!ParseInstallJournalEnvelope( + proofEnvelope, modelRoot, &proofRoundTrip, + &observedProofDigest, error) || + observedProofDigest != proofDigest || + !SameDurableBrokerProof(proofState, proofRoundTrip) || + proofRoundTrip.direction != proofState.direction || + proofRoundTrip.rollbackAuthorized != + proofState.rollbackAuthorized) { + return SetError(error, + L"self-test-install-journal-broker-proof-roundtrip", + ERROR_INVALID_DATA); + } + } + InstallJournalStateData durableReceipt = returned; + durableReceipt.phase = InstallJournalPhase::StageReceiptCaptured; + durableReceipt.hasPublishedCandidate = true; + durableReceipt.publishedCandidate = durableReceipt.candidate; + durableReceipt.publishedCandidate.publishedName = L"oem42.inf"; + durableReceipt.packageStagedHere = true; + durableReceipt.expectedInventory.push_back( + durableReceipt.publishedCandidate); + if (!ValidateInstallJournalTransition( + &returned, durableReceipt, error)) { + return false; + } + InstallJournalStateData rootIntent = durableReceipt; + rootIntent.phase = + InstallJournalPhase::RootRegistrationIntentCaptured; + rootIntent.hasRootRegistrationIntent = true; + rootIntent.rootRegistrationInstanceId = + L"ROOT\\VIIPERUDE\\0042"; + InstallJournalStateData rootRegistrationEntered = rootIntent; + rootRegistrationEntered.phase = + InstallJournalPhase::RootRegistrationEntered; + rootRegistrationEntered.bindingMutationStarted = true; + if (!ValidateInstallJournalTransition( + &durableReceipt, rootIntent, error) || + !ValidateInstallJournalTransition( + &rootIntent, rootRegistrationEntered, error)) { + return false; + } + InstallJournalStateData changedRootIntent = + rootRegistrationEntered; + changedRootIntent.phase = + InstallJournalPhase::RootRegistrationReturned; + changedRootIntent.rootRegistrationInstanceId = + L"ROOT\\VIIPERUDE\\0043"; + Error changedRootIntentError; + if (ValidateInstallJournalTransition( + &rootRegistrationEntered, changedRootIntent, + &changedRootIntentError) || + changedRootIntentError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-root-intent-chain", + ERROR_INVALID_DATA); + } + std::string rootIntentPayload; + if (!BuildInstallJournalPayload( + rootIntent, &rootIntentPayload, error) || + rootIntentPayload.find( + "\"rootRegistrationInstanceId\":\"ROOT\\\\VIIPERUDE\\\\0042\"") == + std::string::npos) { + return SetError(error, + L"self-test-install-journal-root-intent-roundtrip", + ERROR_INVALID_DATA); + } + + PartialInstallRootRecoveryFacts partialRoot; + partialRoot.priorEmpty = true; + partialRoot.bindingMutationStarted = true; + partialRoot.forwardRootRegistrationEntered = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::PriorEmpty) { + return SetError(error, + L"self-test-install-journal-partial-root-before-register", + ERROR_INVALID_DATA); + } + partialRoot.relatedRootCount = 1U; + partialRoot.exactHardwareId = true; + partialRoot.exactClass = true; + partialRoot.exactGeneratedInstance = true; + partialRoot.present = true; + partialRoot.serviceEmpty = true; + partialRoot.publishedInfEmpty = true; + partialRoot.driverVersionEmpty = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot) { + return SetError(error, + L"self-test-install-journal-partial-root-after-register", + ERROR_INVALID_DATA, + L"failed/timed-out DIF_REGISTER exact receipt root was not cleanup-authorized"); + } + partialRoot.serviceEmpty = false; + partialRoot.publishedInfEmpty = false; + partialRoot.driverVersionEmpty = false; + partialRoot.exactCandidateService = true; + partialRoot.exactCandidateInf = true; + partialRoot.exactCandidateVersion = true; + partialRoot.exactCandidateBytes = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-before-diinstall", + ERROR_INVALID_DATA); + } + partialRoot.forwardDiInstallEntered = true; + if (ClassifyPartialInstallRootRecovery(partialRoot) != + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot) { + return SetError(error, + L"self-test-install-journal-partial-root-after-diinstall", + ERROR_INVALID_DATA); + } + PartialInstallRootRecoveryFacts pendingRoot = partialRoot; + pendingRoot.partialRootRemovalEntered = true; + pendingRoot.present = false; + pendingRoot.pendingRemovalLifecycle = true; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + return SetError(error, + L"self-test-install-journal-partial-root-pending-candidate", + ERROR_INVALID_DATA); + } + pendingRoot.exactHardwareId = false; + pendingRoot.hardwareIdAbsent = true; + pendingRoot.serviceEmpty = true; + pendingRoot.exactCandidateService = false; + pendingRoot.driverVersionEmpty = true; + pendingRoot.exactCandidateVersion = false; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::PendingExactRootRemoval) { + return SetError(error, + L"self-test-install-journal-partial-root-pending-cleared", + ERROR_INVALID_DATA); + } + pendingRoot.pendingRemovalLifecycle = false; + if (ClassifyPartialInstallRootRecovery(pendingRoot) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-not-pending", + ERROR_INVALID_DATA); + } + for (const auto mutateUnauthorized : { + 0, 1, 2, 3, 4, 5}) { + PartialInstallRootRecoveryFacts unauthorized = partialRoot; + switch (mutateUnauthorized) { + case 0: unauthorized.relatedRootCount = 2U; break; + case 1: unauthorized.exactHardwareId = false; break; + case 2: unauthorized.exactClass = false; break; + case 3: unauthorized.exactGeneratedInstance = false; break; + case 4: unauthorized.present = false; break; + case 5: unauthorized.forwardRootRegistrationEntered = false; break; + } + if (ClassifyPartialInstallRootRecovery(unauthorized) != + PartialInstallRootRecoveryAction::Manual) { + return SetError(error, + L"self-test-install-journal-partial-root-manual", + ERROR_INVALID_DATA); + } + } + if (InstallJournalRecoveryUsesStrictBindingRestore( + rootRegistrationEntered)) { + return SetError(error, + L"self-test-install-journal-prior-empty-strict-restore", + ERROR_INVALID_DATA); + } + + InstallJournalStateData partialRollback = rootRegistrationEntered; + partialRollback.sequence += 1U; + partialRollback.phase = InstallJournalPhase::RollbackBindingEntered; + partialRollback.direction = InstallJournalDirection::Rollback; + partialRollback.rollbackAuthorized = true; + InstallJournalStateData partialRemovalEntered = partialRollback; + partialRemovalEntered.sequence += 1U; + partialRemovalEntered.phase = + InstallJournalPhase::PartialRootRemovalEntered; + partialRemovalEntered.partialRootRemovalBootIdentifier = + std::string(32, '1'); + partialRemovalEntered.partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::Unbound; + InstallJournalStateData partialRemovalReturned = + partialRemovalEntered; + partialRemovalReturned.sequence += 1U; + partialRemovalReturned.phase = + InstallJournalPhase::PartialRootRemovalReturned; + InstallJournalStateData partialRemovalPending = + partialRemovalEntered; + partialRemovalPending.sequence += 1U; + partialRemovalPending.phase = InstallJournalPhase:: + PartialRootRemovalRebootPending; + partialRemovalPending.rebootRequired = true; + partialRemovalPending.callSucceeded = false; + partialRemovalPending.callError = ERROR_SUCCESS_REBOOT_REQUIRED; + if (!ValidateInstallJournalTransition( + &rootRegistrationEntered, partialRollback, error) || + !ValidateInstallJournalTransition( + &partialRollback, partialRemovalEntered, error) || + !ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalReturned, error) || + !ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalPending, error)) { + return false; + } + InstallJournalStateData illegalRemovalShape = + partialRemovalReturned; + illegalRemovalShape.partialRootRemovalBinding = + InstallJournalStateData::PartialRootRemovalBinding::Candidate; + Error illegalRemovalShapeError; + if (ValidateInstallJournalTransition( + &partialRemovalEntered, illegalRemovalShape, + &illegalRemovalShapeError) || + illegalRemovalShapeError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-partial-root-shape-chain", + ERROR_INVALID_DATA); + } + std::string partialRemovalPayload; + std::string partialRemovalDigest; + if (!BuildInstallJournalPayload( + partialRemovalEntered, &partialRemovalPayload, error) || + partialRemovalPayload.find( + "\"partialRootRemovalBinding\":\"unbound\"") == + std::string::npos || + !Sha256Data(partialRemovalPayload, + &partialRemovalDigest, error)) { + return false; + } + InstallJournalStateData partialRemovalFreshReturn = + partialRemovalEntered; + partialRemovalFreshReturn.sequence += 1U; + partialRemovalFreshReturn.phase = + InstallJournalPhase::PartialRootRemovalReturned; + partialRemovalFreshReturn.rebootRequired = true; + partialRemovalFreshReturn.freshRebootRequired = true; + partialRemovalFreshReturn.pendingRebootBootIdentifier = + std::string(32, '2'); + InstallJournalStateData partialRemovalFreshPending = + partialRemovalFreshReturn; + partialRemovalFreshPending.sequence += 1U; + partialRemovalFreshPending.phase = InstallJournalPhase:: + PartialRootRemovalRebootPending; + partialRemovalFreshPending.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + &partialRemovalEntered, partialRemovalFreshReturn, error) || + !ValidateInstallJournalTransition( + &partialRemovalFreshReturn, + partialRemovalFreshPending, error)) { + return false; + } + std::string partialFreshPayload; + std::string partialFreshDigest; + if (!BuildInstallJournalPayload( + partialRemovalFreshReturn, &partialFreshPayload, error) || + !Sha256Data(partialFreshPayload, + &partialFreshDigest, error)) { + return false; + } + std::string partialFreshEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&partialFreshEnvelope, kInstallRecoveryKind); + partialFreshEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&partialFreshEnvelope, partialFreshDigest); + partialFreshEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&partialFreshEnvelope, partialFreshPayload); + partialFreshEnvelope.append("}\n"); + InstallJournalStateData partialFreshRoundTrip; + std::string observedPartialFreshDigest; + if (!ParseInstallJournalEnvelope( + partialFreshEnvelope, modelRoot, &partialFreshRoundTrip, + &observedPartialFreshDigest, error) || + observedPartialFreshDigest != partialFreshDigest || + partialFreshRoundTrip.partialRootRemovalBinding != + InstallJournalStateData::PartialRootRemovalBinding::Unbound || + partialFreshRoundTrip.partialRootRemovalBootIdentifier != + partialRemovalFreshReturn.partialRootRemovalBootIdentifier || + partialFreshRoundTrip.pendingRebootBootIdentifier != + partialRemovalFreshReturn.pendingRebootBootIdentifier || + !partialFreshRoundTrip.freshRebootRequired) { + return SetError(error, + L"self-test-install-journal-partial-root-roundtrip", + ERROR_INVALID_DATA); + } + for (const InstallJournalStateData* interruptedPartial : { + &partialRemovalReturned, &partialRemovalFreshPending}) { + InstallJournalStateData readmittedPartial = + *interruptedPartial; + readmittedPartial.sequence += 1U; + readmittedPartial.phase = + InstallJournalPhase::RollbackBindingEntered; + readmittedPartial.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + interruptedPartial, readmittedPartial, error)) { + return false; + } + } + struct PartialRemovalRecoveryCase { + InstallJournalPhase phase; + bool callSucceeded; + bool freshRebootRequired; + bool sameBoot; + InstallJournalStateData::PartialRootRemovalBinding binding; + PartialInstallRootRecoveryAction root; + PartialRootRemovalRecoveryDisposition expected; + }; + const std::array + partialRemovalCases{{ + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot, + PartialRootRemovalRecoveryDisposition::RetryRemoval}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveCandidateBoundExactRoot, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalEntered, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::RemoveUnboundExactRoot, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, true, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalReturned, + true, true, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalReturned, + false, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::Manual}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, true, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::RebootPending}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PriorEmpty, + PartialRootRemovalRecoveryDisposition::ContinueRollback}, + {InstallJournalPhase::PartialRootRemovalRebootPending, + true, false, false, + InstallJournalStateData::PartialRootRemovalBinding::Unbound, + PartialInstallRootRecoveryAction::PendingExactRootRemoval, + PartialRootRemovalRecoveryDisposition::Manual}, + }}; + for (const PartialRemovalRecoveryCase& test : + partialRemovalCases) { + if (ClassifyPartialRootRemovalJournalRecovery( + test.phase, test.callSucceeded, + test.freshRebootRequired, test.sameBoot, + test.binding, test.root) != test.expected) { + return SetError(error, + L"self-test-install-journal-partial-root-recovery-matrix", + ERROR_INVALID_DATA); + } + } + + std::vector canonicalHardwareId( + std::begin(kHardwareId), std::end(kHardwareId)); + canonicalHardwareId.push_back(L'\0'); + InstallRecoveryHardwareIdObservation hardwareObservation; + std::vector malformedHardwareId = canonicalHardwareId; + malformedHardwareId.push_back(L'x'); + if (!ClassifyCanonicalInstallRecoveryHardwareIds( + canonicalHardwareId, &hardwareObservation) || + !hardwareObservation.containsExpected || + !hardwareObservation.exact || + ClassifyCanonicalInstallRecoveryHardwareIds( + malformedHardwareId, &hardwareObservation)) { + return SetError(error, + L"self-test-install-journal-raw-hardware-id", + ERROR_INVALID_DATA); + } + const std::vector canonicalService{ + L'V', L'i', L'i', L'p', L'e', L'r', L'U', L'd', L'e', L'\0'}; + std::vector hiddenService = canonicalService; + hiddenService.push_back(L'x'); + hiddenService.push_back(L'\0'); + const std::vector hiddenAfterEmpty{ + L'\0', L'x', L'\0'}; + std::wstring decodedService; + if (!DecodeCanonicalInstallRecoveryString( + canonicalService, &decodedService) || + decodedService != kServiceName || + DecodeCanonicalInstallRecoveryString( + hiddenService, &decodedService) || + DecodeCanonicalInstallRecoveryString( + hiddenAfterEmpty, &decodedService)) { + return SetError(error, + L"self-test-install-journal-raw-string", + ERROR_INVALID_DATA); + } + InstallJournalStateData ambiguousOwnership = durableReceipt; + ambiguousOwnership.phase = InstallJournalPhase::RollbackBindingEntered; + ambiguousOwnership.direction = InstallJournalDirection::Rollback; + ambiguousOwnership.rollbackAuthorized = true; + Error ownershipError; + if (ValidateInstallJournalTransition( + &returned, ambiguousOwnership, &ownershipError) || + ownershipError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-stage-ownership", + ERROR_INVALID_DATA); + } + + InstallJournalStateData rootAuthority = durableReceipt; + rootAuthority.bindingMutationStarted = true; + DeviceState priorDevice; + priorDevice.instanceId = L"ROOT\\VIIPERUDE\\0000"; + priorDevice.present = true; + priorDevice.service = kServiceName; + priorDevice.publishedInf = L"oem41.inf"; + priorDevice.version.parts = {1, 2, 3, 3}; + priorDevice.package = rootAuthority.candidate; + priorDevice.package.version = priorDevice.version; + priorDevice.package.publishedName = priorDevice.publishedInf; + rootAuthority.prior.devices = {priorDevice}; + Snapshot priorRootSnapshot; + priorRootSnapshot.devices = {priorDevice}; + Snapshot candidateRootSnapshot = priorRootSnapshot; + candidateRootSnapshot.devices[0].publishedInf = + rootAuthority.publishedCandidate.publishedName; + candidateRootSnapshot.devices[0].version = rootAuthority.candidate.version; + candidateRootSnapshot.devices[0].package = rootAuthority.candidate; + candidateRootSnapshot.devices[0].package.publishedName = + rootAuthority.publishedCandidate.publishedName; + Snapshot foreignRootSnapshot = candidateRootSnapshot; + foreignRootSnapshot.devices[0].instanceId = L"ROOT\\VIIPERUDE\\9999"; + Snapshot extraRootSnapshot = candidateRootSnapshot; + extraRootSnapshot.devices.push_back(candidateRootSnapshot.devices[0]); + if (!RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, priorRootSnapshot) || + !RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, candidateRootSnapshot) || + RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, foreignRootSnapshot) || + RootSnapshotIsAuthorizedForInstallRollback( + rootAuthority, extraRootSnapshot)) { + return SetError(error, + L"self-test-install-journal-root-authority", + ERROR_INVALID_DATA); + } + std::vector exactInventory{ + priorDevice.package, rootAuthority.publishedCandidate}; + std::vector extraInventory = exactInventory; + PackageInfo externalPackage = rootAuthority.candidate; + externalPackage.publishedName = L"oem99.inf"; + externalPackage.version.parts[3] += 5; + extraInventory.push_back(externalPackage); + std::vector conflictingInventory = exactInventory; + conflictingInventory[1].sysSha256[0] = '0'; + if (SamePackageInventory(exactInventory, extraInventory) || + SamePackageInventory(exactInventory, conflictingInventory)) { + return SetError(error, + L"self-test-install-journal-inventory-authority", + ERROR_INVALID_DATA); + } + InstallJournalStateData rebootCutpoint = state; + rebootCutpoint.direction = InstallJournalDirection::Rollback; + rebootCutpoint.rollbackAuthorized = true; + rebootCutpoint.bindingMutationStarted = true; + rebootCutpoint.phase = InstallJournalPhase::RollbackBindingReturned; + rebootCutpoint.callSucceeded = true; + rebootCutpoint.rebootRequired = true; + rebootCutpoint.freshRebootRequired = true; + rebootCutpoint.pendingRebootBootIdentifier = + std::string(32, 'd'); + if (!InstallJournalNeedsRestoreRebootPending(rebootCutpoint, true) || + InstallJournalNeedsRestoreRebootPending(rebootCutpoint, false) || + !InstallJournalRollbackRetryRebootSeed(rebootCutpoint, true) || + InstallJournalRollbackRetryRebootSeed(rebootCutpoint, false) || + !InstallJournalHasAuthoritativeRollbackSettlement( + rebootCutpoint)) { + return SetError(error, + L"self-test-install-journal-reboot-cutpoint", + ERROR_INVALID_DATA); + } + InstallJournalStateData rollbackEntered = state; + rollbackEntered.direction = InstallJournalDirection::Rollback; + rollbackEntered.rollbackAuthorized = true; + rollbackEntered.phase = + InstallJournalPhase::RollbackBindingEntered; + InstallJournalStateData firstBootReturn = rollbackEntered; + firstBootReturn.phase = + InstallJournalPhase::RollbackBindingReturned; + firstBootReturn.rebootRequired = true; + firstBootReturn.freshRebootRequired = true; + firstBootReturn.pendingRebootBootIdentifier = + std::string(32, 'd'); + InstallJournalStateData retryEntered = firstBootReturn; + retryEntered.phase = + InstallJournalPhase::RollbackBindingEntered; + retryEntered.freshRebootRequired = false; + InstallJournalStateData laterBootReturn = retryEntered; + laterBootReturn.phase = + InstallJournalPhase::RollbackBindingReturned; + laterBootReturn.freshRebootRequired = true; + laterBootReturn.pendingRebootBootIdentifier = + std::string(32, 'e'); + InstallJournalStateData laterBootPending = laterBootReturn; + laterBootPending.phase = + InstallJournalPhase::RestoreRebootPending; + laterBootPending.freshRebootRequired = false; + if (!ValidateInstallJournalTransition( + &rollbackEntered, firstBootReturn, error) || + !ValidateInstallJournalTransition( + &firstBootReturn, retryEntered, error) || + !ValidateInstallJournalTransition( + &retryEntered, laterBootReturn, error) || + !ValidateInstallJournalTransition( + &laterBootReturn, laterBootPending, error) || + laterBootPending.pendingRebootBootIdentifier == + laterBootPending.bootIdentifier) { + return SetError(error, + L"self-test-install-journal-reboot-epoch", + ERROR_INVALID_DATA, + L"later-boot rollback NeedReboot did not replace and retain the pending epoch"); + } + InstallJournalStateData illegalEpochChange = laterBootPending; + illegalEpochChange.phase = + InstallJournalPhase::ExactPriorRestored; + illegalEpochChange.pendingRebootBootIdentifier = + std::string(32, 'f'); + Error illegalEpochError; + if (ValidateInstallJournalTransition( + &laterBootPending, illegalEpochChange, + &illegalEpochError) || + illegalEpochError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-install-journal-reboot-epoch-chain", + ERROR_INVALID_DATA); + } + std::string rebootPayload; + std::string rebootDigest; + if (!BuildInstallJournalPayload( + laterBootReturn, &rebootPayload, error) || + !Sha256Data(rebootPayload, &rebootDigest, error)) { + return false; + } + std::string rebootEnvelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&rebootEnvelope, kInstallRecoveryKind); + rebootEnvelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&rebootEnvelope, rebootDigest); + rebootEnvelope.append(",\"payload\":"); + AppendJsonUtf8String(&rebootEnvelope, rebootPayload); + rebootEnvelope.append("}\n"); + InstallJournalStateData rebootRoundTrip; + std::string observedRebootDigest; + if (!ParseInstallJournalEnvelope( + rebootEnvelope, modelRoot, &rebootRoundTrip, + &observedRebootDigest, error) || + rebootRoundTrip.pendingRebootBootIdentifier != + laterBootReturn.pendingRebootBootIdentifier || + !rebootRoundTrip.freshRebootRequired || + observedRebootDigest != rebootDigest) { + return SetError(error, + L"self-test-install-journal-reboot-epoch-roundtrip", + ERROR_INVALID_DATA); + } + AbiCompatibilityProfile malformedProfile = + kAbiCompatibilityProfiles[0]; + ++malformedProfile.statsSize; + if (IsKnownAbiCompatibilityProfile(malformedProfile)) { + return SetError(error, + L"self-test-install-journal-abi-profile", + ERROR_INVALID_DATA); + } + std::string truncated = envelope.substr(0, envelope.size() - 3U); + Error truncatedError; + if (ParseInstallJournalEnvelope( + truncated, modelRoot, &parsedState, &parsedDigest, + &truncatedError) || truncatedError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-truncated-chain", + ERROR_INVALID_DATA); + } + std::string tampered = envelope; + const size_t payloadOffset = tampered.find("previousSha256"); + if (payloadOffset == std::string::npos) { + return SetError(error, L"self-test-install-journal-chain", + ERROR_INVALID_DATA); + } + tampered[payloadOffset] = 'P'; + Error tamperedError; + if (ParseInstallJournalEnvelope( + tampered, modelRoot, &parsedState, &parsedDigest, + &tamperedError) || tamperedError.code == ERROR_SUCCESS) { + return SetError(error, L"self-test-install-journal-hash-chain", + ERROR_INVALID_DATA); + } + + struct RecoveryCase { + InstallJournalPhase phase; + bool chainValid; + bool securityValid; + bool sameBoot; + bool priorValid; + bool forwardValid; + bool brokerEntered; + bool brokerSettled; + bool brokerSucceeded; + InstallJournalRecoveryModelAction expected; + }; + const std::array cases{{ + {InstallJournalPhase::Prepared, true, true, true, true, + false, false, false, false, + InstallJournalRecoveryModelAction::RetirePrior}, + {InstallJournalPhase::DriverValidated, true, true, true, false, + true, false, false, false, + InstallJournalRecoveryModelAction::RetireForward}, + {InstallJournalPhase::SetupCopyReturned, true, true, true, false, + false, false, false, false, + InstallJournalRecoveryModelAction::RollbackPrior}, + {InstallJournalPhase::BrokerChildEntered, true, true, true, false, + true, true, false, false, + InstallJournalRecoveryModelAction::Manual}, + {InstallJournalPhase::ForwardRebootPending, true, true, true, false, + false, false, false, false, + InstallJournalRecoveryModelAction::RebootPending}, + {InstallJournalPhase::RestoreRebootPending, true, true, false, true, + false, false, false, false, + InstallJournalRecoveryModelAction::RetirePrior}, + {InstallJournalPhase::ForwardValidated, false, true, false, false, + true, false, false, false, + InstallJournalRecoveryModelAction::Manual}, + {InstallJournalPhase::ForwardValidated, true, false, false, false, + true, false, false, false, + InstallJournalRecoveryModelAction::Manual}, + }}; + for (const RecoveryCase& test : cases) { + if (ClassifyInstallJournalRecoveryModel( + test.phase, test.chainValid, test.securityValid, + test.sameBoot, test.priorValid, test.forwardValid, + test.brokerEntered, test.brokerSettled, + test.brokerSucceeded) != test.expected) { + return SetError(error, L"self-test-install-journal-recovery-model", + ERROR_INVALID_DATA); + } + } + return true; +} + Outcome SelfTest(); Outcome Status() { @@ -5787,6 +12743,9 @@ Outcome Status() { Outcome SelfTest() { Outcome outcome; + if (!RunInstallJournalModelSelfTest(&outcome.error)) { + return outcome; + } InstallOptions brokerCommandOptions; brokerCommandOptions.brokerExecutable = LR"(C:\Program Files\VIIPER\viiper.exe)"; brokerCommandOptions.brokerToken = LR"(C:\ProgramData\VIIPER\package.token)"; @@ -6804,6 +13763,7 @@ void Usage() { L"--expected-cat-sha256 <64 hex> " L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" + << L" ViiperUdeCtl.exe recover [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe status\n" << L" ViiperUdeCtl.exe self-test\n"; } @@ -6855,6 +13815,21 @@ int RunViiperUdeCtl(int argc, wchar_t** argv) { EmitOutcome(L"remove", outcome); return static_cast(outcome.exitCode); } + if (argc >= 2 && _wcsicmp(argv[1], L"recover") == 0) { + RemoveOptions options; + Error argumentError; + if (!ParseRemoveOptions(argc, argv, &options, &argumentError)) { + Usage(); + Outcome outcome; + outcome.error = std::move(argumentError); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"recover", outcome); + return static_cast(outcome.exitCode); + } + Outcome outcome = Recover(options.transactionDeadlineUnixMs); + EmitOutcome(L"recover", outcome); + return static_cast(outcome.exitCode); + } if (argc == 2 && _wcsicmp(argv[1], L"status") == 0) { Outcome outcome = Status(); EmitOutcome(L"status", outcome); @@ -6878,7 +13853,7 @@ const wchar_t* ExceptionOperation(int argc, wchar_t** argv) noexcept { return L"unknown"; } for (const wchar_t* operation : - {L"install", L"verify", L"remove", L"status", L"self-test"}) { + {L"install", L"verify", L"remove", L"recover", L"status", L"self-test"}) { if (_wcsicmp(argv[1], operation) == 0) { return operation; } From f3b694f351f97eec04eddba703c4cd3e25971f2b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 05:17:19 -0500 Subject: [PATCH 236/240] Strengthen latency evidence and priority matrix --- _testing/e2e/latency/profile_contract_test.go | 28 +++ _testing/e2e/latency/report.go | 101 ++++++--- _testing/e2e/latency/report_test.go | 95 ++++++++- _testing/e2e/latency_gate_windows_test.go | 192 +++++++++++++++-- _testing/e2e/pnp_path_windows_test.go | 17 +- _testing/e2e/pnp_windows_test.go | 24 ++- .../scripts/Invoke-ViiperE2ELatencyGate.ps1 | 88 +++++++- .../scripts/Invoke-ViiperE2ELatencyMatrix.ps1 | 193 ++++++++++++++++++ docs/testing/e2e_latency.md | 42 +++- 9 files changed, 713 insertions(+), 67 deletions(-) create mode 100644 _testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 diff --git a/_testing/e2e/latency/profile_contract_test.go b/_testing/e2e/latency/profile_contract_test.go index a09715cc..672ce4b2 100644 --- a/_testing/e2e/latency/profile_contract_test.go +++ b/_testing/e2e/latency/profile_contract_test.go @@ -41,6 +41,9 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { "Win32_PnPEntity", "@($_.HardwareID) -contains 'ROOT\\VIIPER\\UDE'", "$ownedRootDevices[0].PNPDeviceID", "Dropped\\s+Event", "Buffers?\\s+Lost", + "Resolve-ExactExecutablePath", "[Environment]::SystemDirectory", + "VIIPER_E2E_EXPECTED_PRIORITY_CLASS", "git_executable_sha256", + "-buildvcs=false", } { if !strings.Contains(wrapperText, want) { t.Fatalf("production wrapper is missing fail-closed contract %q", want) @@ -59,6 +62,31 @@ func TestProductionTraceAndWrapperFailClosedContract(t *testing.T) { if !strings.Contains(string(liveHarness), "sdl.EnableWindowsRawInput()") { t.Fatal("production harness no longer enables the SDL backend that supplies exact Xbox PnP paths") } + for _, want := range []string{ + "P90NS", "P999NS", "collectMachineProvenance", "GetPriorityClass", + "ProcessorNameString", "RtlGetVersion", "ProcessElevated", + "exec.Command(executable", "runGit(config.gitPath", + } { + if !strings.Contains(string(liveHarness), want) { + t.Fatalf("production harness is missing latency provenance contract %q", want) + } + } + if strings.Contains(string(liveHarness), `exec.Command("git"`) { + t.Fatal("production harness regressed to PATH-resolved Git after verifying a pinned image") + } + matrix, err := os.ReadFile("../scripts/Invoke-ViiperE2ELatencyMatrix.ps1") + if err != nil { + t.Fatal(err) + } + matrixText := string(matrix) + for _, want := range []string{ + "priority = 'Normal'", "priority = 'High'", "Get-ExactEvidenceFile", + "latency-priority-matrix/v1", "process_priority_class", "Flush($true)", + } { + if !strings.Contains(matrixText, want) { + t.Fatalf("priority-matrix wrapper is missing fail-closed contract %q", want) + } + } verifier, err := os.ReadFile("../cmd/verifylatency/main.go") if err != nil { t.Fatal(err) diff --git a/_testing/e2e/latency/report.go b/_testing/e2e/latency/report.go index c1fd84bf..7ee0729b 100644 --- a/_testing/e2e/latency/report.go +++ b/_testing/e2e/latency/report.go @@ -27,8 +27,8 @@ var productionPhaseSweepOffsetsNS = [...]int64{ } const ( - SchemaV1 = "viiper.controller-to-game.latency/v1" - SuiteSchemaV1 = "viiper.controller-to-game.latency-suite/v1" + SchemaV2 = "viiper.controller-to-game.latency/v2" + SuiteSchemaV2 = "viiper.controller-to-game.latency-suite/v2" TransportUSBIP = "usbip" TransportNativeUDE = "native-ude" AuthenticationMode = "password-authenticated-encrypted-stream" @@ -143,8 +143,10 @@ func (c Counters) Total() int { return c.Press + c.Release } type Distribution struct { Count int `json:"count"` P50NS int64 `json:"p50_ns"` + P90NS int64 `json:"p90_ns"` P95NS int64 `json:"p95_ns"` P99NS int64 `json:"p99_ns"` + P999NS int64 `json:"p99_9_ns"` MaxNS int64 `json:"max_ns"` JitterNS float64 `json:"jitter_ns"` } @@ -193,7 +195,9 @@ type ControllerProof struct { VendorID uint16 `json:"vendor_id"` ProductID uint16 `json:"product_id"` PNPInstanceID string `json:"pnp_instance_id"` + PNPContainerID string `json:"pnp_container_id"` PNPAncestorIDs []string `json:"pnp_ancestor_ids"` + PNPAncestorContainerIDs []string `json:"pnp_ancestor_container_ids"` PNPAncestorServices []string `json:"pnp_ancestor_services"` PNPAncestorHardwareIDs [][]string `json:"pnp_ancestor_hardware_ids"` PNPAncestorLocationInfo []string `json:"pnp_ancestor_location_info"` @@ -237,23 +241,41 @@ type Workload struct { Authentication string `json:"authentication"` } +type MachineProvenance struct { + Hostname string `json:"hostname"` + OSProductName string `json:"os_product_name"` + OSDisplayVersion string `json:"os_display_version"` + OSVersion string `json:"os_version"` + CPUModel string `json:"cpu_model"` + LogicalProcessors int `json:"logical_processors"` + ProcessPriorityClass string `json:"process_priority_class"` + ProcessElevated bool `json:"process_elevated"` +} + type Provenance struct { - SourceRevision string `json:"source_revision"` - SDLSourceRevision string `json:"sdl_source_revision"` - SDLBinaryPath string `json:"sdl_binary_path"` - SDLBinarySHA256 string `json:"sdl_binary_sha256"` - NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` - NativeDriverSHA256 string `json:"native_driver_sha256"` - NativeDriverBuildIdentity string `json:"native_driver_build_identity"` - QPCFrequency int64 `json:"qpc_frequency"` - TraceProviderName string `json:"trace_provider_name"` - TraceProviderGUID string `json:"trace_provider_guid"` - TraceProfileSHA256 string `json:"trace_profile_sha256"` - USBIPBaselineMode string `json:"usbip_baseline_mode"` - USBIPBaselineVersion string `json:"usbip_baseline_version"` - GoVersion string `json:"go_version"` - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` + SourceRevision string `json:"source_revision"` + SDLSourceRevision string `json:"sdl_source_revision"` + SDLBinaryPath string `json:"sdl_binary_path"` + SDLBinarySHA256 string `json:"sdl_binary_sha256"` + NativePackageManifestSHA256 string `json:"native_package_manifest_sha256"` + NativeDriverSHA256 string `json:"native_driver_sha256"` + NativeDriverBuildIdentity string `json:"native_driver_build_identity"` + QPCFrequency int64 `json:"qpc_frequency"` + TraceProviderName string `json:"trace_provider_name"` + TraceProviderGUID string `json:"trace_provider_guid"` + TraceProfileSHA256 string `json:"trace_profile_sha256"` + USBIPBaselineMode string `json:"usbip_baseline_mode"` + USBIPBaselineVersion string `json:"usbip_baseline_version"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + GitExecutablePath string `json:"git_executable_path"` + GitExecutableSHA256 string `json:"git_executable_sha256"` + GoExecutablePath string `json:"go_executable_path"` + GoExecutableSHA256 string `json:"go_executable_sha256"` + WPRExecutablePath string `json:"wpr_executable_path"` + WPRExecutableSHA256 string `json:"wpr_executable_sha256"` + Machine MachineProvenance `json:"machine"` } // SampleMarkerID is the canonical cross-artifact identity shared by JSON and @@ -308,8 +330,10 @@ type MetricComparison struct { type DistributionComparison struct { P50 MetricComparison `json:"p50_ns"` + P90 MetricComparison `json:"p90_ns"` P95 MetricComparison `json:"p95_ns"` P99 MetricComparison `json:"p99_ns"` + P999 MetricComparison `json:"p99_9_ns"` Max MetricComparison `json:"max_ns"` Jitter MetricComparison `json:"jitter_ns"` } @@ -343,8 +367,10 @@ type SuiteReport struct { } var ( - revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) - hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + revisionPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + containerPattern = regexp.MustCompile( + `(?i)^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$`) ) // Calculate returns nearest-rank percentiles and population standard deviation. @@ -373,8 +399,10 @@ func Calculate(values []int64) (Distribution, error) { return Distribution{ Count: len(ordered), P50NS: nearestRank(ordered, 0.50), + P90NS: nearestRank(ordered, 0.90), P95NS: nearestRank(ordered, 0.95), P99NS: nearestRank(ordered, 0.99), + P999NS: nearestRank(ordered, 0.999), MaxNS: ordered[len(ordered)-1], JitterNS: math.Sqrt(m2 / float64(len(ordered))), }, nil @@ -565,8 +593,10 @@ func compareSets(usbip, native DistributionSet) ComparisonSet { func compareDistribution(usbip, native Distribution) DistributionComparison { return DistributionComparison{ P50: compareMetric(float64(usbip.P50NS), float64(native.P50NS)), + P90: compareMetric(float64(usbip.P90NS), float64(native.P90NS)), P95: compareMetric(float64(usbip.P95NS), float64(native.P95NS)), P99: compareMetric(float64(usbip.P99NS), float64(native.P99NS)), + P999: compareMetric(float64(usbip.P999NS), float64(native.P999NS)), Max: compareMetric(float64(usbip.MaxNS), float64(native.MaxNS)), Jitter: compareMetric(usbip.JitterNS, native.JitterNS), } @@ -586,7 +616,7 @@ func compareMetric(usbip, native float64) MetricComparison { } func validateBase(report *Report) error { - if report.Schema != SchemaV1 { + if report.Schema != SchemaV2 { return fmt.Errorf("unsupported report schema %q", report.Schema) } if report.GeneratedAt.IsZero() { @@ -621,6 +651,22 @@ func validateBase(report *Report) error { report.Provenance.GOARCH == "" { return errors.New("Windows Go toolchain provenance is incomplete") } + if report.Provenance.GitExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.GitExecutableSHA256) || + report.Provenance.GoExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.GoExecutableSHA256) || + report.Provenance.WPRExecutablePath == "" || + !hashPattern.MatchString(report.Provenance.WPRExecutableSHA256) { + return errors.New("Git, Go, and WPR executable provenance is incomplete") + } + machine := report.Provenance.Machine + if machine.Hostname == "" || machine.OSProductName == "" || + machine.OSDisplayVersion == "" || machine.OSVersion == "" || + machine.CPUModel == "" || machine.LogicalProcessors <= 0 || + (machine.ProcessPriorityClass != "normal" && + machine.ProcessPriorityClass != "high") || !machine.ProcessElevated { + return errors.New("machine, OS, CPU, elevation, and process-priority provenance are incomplete") + } if report.Workload.APIAddress == "" || report.Workload.USBIPAddress == "" || report.Workload.Button != "south/A" || report.Workload.Authentication != AuthenticationMode { @@ -800,16 +846,23 @@ func validateRun(run *Run, workload Workload, provenance Provenance, block Block // ValidateTransportAncestry rejects VID/PID-only substitutions and requires // exactly one transport-specific root anchor in the SDL interface's PnP chain. func ValidateTransportAncestry(transport string, usbipPort int32, proof ControllerProof) error { - if proof.PNPInstanceID == "" || len(proof.PNPAncestorIDs) == 0 || + if proof.PNPInstanceID == "" || !containerPattern.MatchString(proof.PNPContainerID) || + len(proof.PNPAncestorIDs) == 0 || len(proof.PNPAncestorIDs) != len(proof.PNPAncestorServices) || + len(proof.PNPAncestorIDs) != len(proof.PNPAncestorContainerIDs) || len(proof.PNPAncestorIDs) != len(proof.PNPAncestorHardwareIDs) || len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationInfo) || len(proof.PNPAncestorIDs) != len(proof.PNPAncestorLocationPaths) || - !strings.EqualFold(proof.PNPAncestorIDs[0], proof.PNPInstanceID) { + !strings.EqualFold(proof.PNPAncestorIDs[0], proof.PNPInstanceID) || + !strings.EqualFold(proof.PNPAncestorContainerIDs[0], proof.PNPContainerID) { return errors.New("SDL observer lacks an exact, internally consistent Windows PnP ancestry proof") } anchorCount := 0 for index, instanceID := range proof.PNPAncestorIDs { + containerID := proof.PNPAncestorContainerIDs[index] + if containerID != "" && !containerPattern.MatchString(containerID) { + return fmt.Errorf("PnP ancestor %q has malformed container identity %q", instanceID, containerID) + } service := proof.PNPAncestorServices[index] hardwareIDs := proof.PNPAncestorHardwareIDs[index] isAnchor := false @@ -964,7 +1017,7 @@ func FinalizeSuite(suite *SuiteReport) error { if suite == nil { return errors.New("nil latency suite") } - if suite.Schema != SuiteSchemaV1 { + if suite.Schema != SuiteSchemaV2 { return fmt.Errorf("unsupported latency suite schema %q", suite.Schema) } if suite.GeneratedAt.IsZero() { diff --git a/_testing/e2e/latency/report_test.go b/_testing/e2e/latency/report_test.go index 401f0775..1ec3979c 100644 --- a/_testing/e2e/latency/report_test.go +++ b/_testing/e2e/latency/report_test.go @@ -25,8 +25,9 @@ func TestCalculateNearestRankDistributionAndJitter(t *testing.T) { if !reflect.DeepEqual(values, original) { t.Fatal("Calculate reordered the caller's individual samples") } - if got.Count != 100 || got.P50NS != 50 || got.P95NS != 95 || - got.P99NS != 99 || got.MaxNS != 100 { + if got.Count != 100 || got.P50NS != 50 || got.P90NS != 90 || + got.P95NS != 95 || got.P99NS != 99 || got.P999NS != 100 || + got.MaxNS != 100 { t.Fatalf("unexpected distribution: %+v", got) } wantJitter := math.Sqrt(833.25) @@ -67,13 +68,65 @@ func TestCalculateRejectsMissingAndNonPositiveSamples(t *testing.T) { } } +func TestFinalizeRequiresExactMachineAndPriorityProvenance(t *testing.T) { + high := validReport(t) + high.Provenance.Machine.ProcessPriorityClass = "high" + if err := Finalize(high); err != nil { + t.Fatalf("high-priority production run was rejected: %v", err) + } + + for _, test := range []struct { + name string + mutate func(*MachineProvenance) + }{ + {name: "missing host", mutate: func(machine *MachineProvenance) { machine.Hostname = "" }}, + {name: "missing OS", mutate: func(machine *MachineProvenance) { machine.OSVersion = "" }}, + {name: "missing CPU", mutate: func(machine *MachineProvenance) { machine.CPUModel = "" }}, + {name: "zero logical processors", mutate: func(machine *MachineProvenance) { machine.LogicalProcessors = 0 }}, + {name: "unsupported priority", mutate: func(machine *MachineProvenance) { machine.ProcessPriorityClass = "realtime" }}, + {name: "unelevated", mutate: func(machine *MachineProvenance) { machine.ProcessElevated = false }}, + } { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(&report.Provenance.Machine) + if err := Finalize(report); err == nil { + t.Fatal("incomplete or unsupported machine provenance was accepted") + } + }) + } +} + +func TestFinalizeRequiresExactToolExecutableProvenance(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*Provenance) + }{ + {name: "missing Git path", mutate: func(p *Provenance) { p.GitExecutablePath = "" }}, + {name: "invalid Git hash", mutate: func(p *Provenance) { p.GitExecutableSHA256 = "bad" }}, + {name: "missing Go path", mutate: func(p *Provenance) { p.GoExecutablePath = "" }}, + {name: "invalid Go hash", mutate: func(p *Provenance) { p.GoExecutableSHA256 = "bad" }}, + {name: "missing WPR path", mutate: func(p *Provenance) { p.WPRExecutablePath = "" }}, + {name: "invalid WPR hash", mutate: func(p *Provenance) { p.WPRExecutableSHA256 = "bad" }}, + } { + t.Run(test.name, func(t *testing.T) { + report := validReport(t) + test.mutate(&report.Provenance) + if err := Finalize(report); err == nil { + t.Fatal("incomplete tool executable provenance was accepted") + } + }) + } +} + func TestUSBIPAnchorUsesINFHardwareIDAndOSAssignedInstance(t *testing.T) { // usbip-win2's INF binds ROOT\USBIP_WIN2\UDE to usbip2_ude, while live // SetupAPI/pnputil evidence exposes the present OS-assigned instance as // ROOT\USB\####. Preserve all three identities; none substitutes for another. proof := ControllerProof{ PNPInstanceID: `HID\VID_045E&PID_028E\1`, + PNPContainerID: `{11111111-2222-3333-4444-555555555555}`, PNPAncestorIDs: []string{`HID\VID_045E&PID_028E\1`, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`}, + PNPAncestorContainerIDs: []string{`{11111111-2222-3333-4444-555555555555}`, `{11111111-2222-3333-4444-555555555555}`, ""}, PNPAncestorServices: []string{"HidUsb", "usbccgp", "usbip2_ude"}, PNPAncestorHardwareIDs: [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}}, PNPAncestorLocationInfo: []string{"", "Port_#0007.Hub_#0001", ""}, @@ -385,6 +438,23 @@ func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { } }) + t.Run("missing controller container", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Controller.PNPContainerID = "" + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ancestry proof") { + t.Fatalf("missing controller container error=%v", err) + } + }) + + t.Run("mismatched controller container", func(t *testing.T) { + report := validReport(t) + report.Runs[1].Controller.PNPAncestorContainerIDs[0] = + `{99999999-8888-7777-6666-555555555555}` + if err := Finalize(report); err == nil || !strings.Contains(err.Error(), "ancestry proof") { + t.Fatalf("mismatched controller container error=%v", err) + } + }) + t.Run("wrong loaded native build", func(t *testing.T) { report := validReport(t) report.Runs[1].Server.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("2", 64) @@ -397,6 +467,7 @@ func TestFinalizeRejectsWeakenedPolicyAndOutOfOrderSamples(t *testing.T) { report := validReport(t) run := &report.Runs[0] run.Controller.PNPAncestorIDs = append(run.Controller.PNPAncestorIDs, `ROOT\USB\0003`) + run.Controller.PNPAncestorContainerIDs = append(run.Controller.PNPAncestorContainerIDs, "") run.Controller.PNPAncestorServices = append(run.Controller.PNPAncestorServices, "usbip2_ude") run.Controller.PNPAncestorHardwareIDs = append(run.Controller.PNPAncestorHardwareIDs, []string{`ROOT\USBIP_WIN2\UDE`}) run.Controller.PNPAncestorLocationInfo = append(run.Controller.PNPAncestorLocationInfo, "") @@ -466,7 +537,7 @@ func TestFinalizeRejectsSameMachineNativeTailRegression(t *testing.T) { func validReport(t *testing.T) *Report { t.Helper() report := &Report{ - Schema: SchemaV1, + Schema: SchemaV2, GeneratedAt: time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC), Provenance: Provenance{ SourceRevision: strings.Repeat("a", 40), @@ -485,6 +556,18 @@ func validReport(t *testing.T) *Report { GoVersion: "go1.26.2", GOOS: "windows", GOARCH: "amd64", + GitExecutablePath: `C:\Program Files\Git\cmd\git.exe`, + GitExecutableSHA256: strings.Repeat("2", 64), + GoExecutablePath: `C:\Go\bin\go.exe`, + GoExecutableSHA256: strings.Repeat("3", 64), + WPRExecutablePath: `C:\Windows\System32\wpr.exe`, + WPRExecutableSHA256: strings.Repeat("4", 64), + Machine: MachineProvenance{ + Hostname: "bench-host", OSProductName: "Windows 11 Pro", + OSDisplayVersion: "24H2", OSVersion: "10.0.26100.9999", + CPUModel: "Test CPU", LogicalProcessors: 16, + ProcessPriorityClass: "normal", ProcessElevated: true, + }, }, Workload: Workload{ APIAddress: "127.0.0.1:33245", @@ -546,7 +629,9 @@ func validReport(t *testing.T) *Report { if block.Transport == TransportUSBIP { run.Device.USBIPPort = 1 run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\1` + run.Controller.PNPContainerID = `{11111111-2222-3333-4444-555555555555}` run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\1`, `ROOT\USB\0002`} + run.Controller.PNPAncestorContainerIDs = []string{run.Controller.PNPContainerID, run.Controller.PNPContainerID, ""} run.Controller.PNPAncestorServices = []string{"HidUsb", "usbccgp", "usbip2_ude"} run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\USBIP_WIN2\UDE`}} run.Controller.PNPAncestorLocationInfo = []string{"", "Port_#0001.Hub_#0001", ""} @@ -560,7 +645,9 @@ func validReport(t *testing.T) *Report { LoadedDriverBuildIdentity: report.Provenance.NativeDriverBuildIdentity, } run.Controller.PNPInstanceID = `HID\VID_045E&PID_028E\2` + run.Controller.PNPContainerID = `{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}` run.Controller.PNPAncestorIDs = []string{run.Controller.PNPInstanceID, `USB\VID_045E&PID_028E\2`, `ROOT\VIIPERUDE\0000`} + run.Controller.PNPAncestorContainerIDs = []string{run.Controller.PNPContainerID, run.Controller.PNPContainerID, ""} run.Controller.PNPAncestorServices = []string{"HidUsb", "WUDFRd", "ViiperUde"} run.Controller.PNPAncestorHardwareIDs = [][]string{{`HID_DEVICE_SYSTEM_GAME`}, {`USB\VID_045E&PID_028E`}, {`ROOT\VIIPER\UDE`}} run.Controller.PNPAncestorLocationInfo = []string{"", "", ""} @@ -615,7 +702,7 @@ func validSuite(t *testing.T) *SuiteReport { t.Helper() xbox := validReport(t) suite := &SuiteReport{ - Schema: SuiteSchemaV1, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, + Schema: SuiteSchemaV2, GeneratedAt: xbox.GeneratedAt, Provenance: xbox.Provenance, } identities := []struct { controller string diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 4856273b..3f656663 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -36,6 +36,7 @@ import ( "github.com/Alia5/VIIPER/viiperclient" "github.com/Alia5/VIIPER/viipertypes" "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" ) const ( @@ -51,6 +52,13 @@ const ( liveLatencyDriverSHA256 = "VIIPER_E2E_NATIVE_DRIVER_SHA256" liveLatencyTraceProfileSHA = "VIIPER_E2E_TRACE_PROFILE_SHA256" liveLatencyDriverBuildID = "VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY" + liveLatencyExpectedPriority = "VIIPER_E2E_EXPECTED_PRIORITY_CLASS" + liveLatencyGitPath = "VIIPER_E2E_GIT_EXECUTABLE_PATH" + liveLatencyGitSHA256 = "VIIPER_E2E_GIT_EXECUTABLE_SHA256" + liveLatencyGoPath = "VIIPER_E2E_GO_EXECUTABLE_PATH" + liveLatencyGoSHA256 = "VIIPER_E2E_GO_EXECUTABLE_SHA256" + liveLatencyWPRPath = "VIIPER_E2E_WPR_EXECUTABLE_PATH" + liveLatencyWPRSHA256 = "VIIPER_E2E_WPR_EXECUTABLE_SHA256" liveLatencyAPIAddress = "127.0.0.1:33245" liveLatencyUSBIPAddress = "127.0.0.1:33244" liveLatencyPassword = "testpassword1234" @@ -72,6 +80,13 @@ type liveLatencyConfig struct { driverSHA256 string traceProfileSHA256 string driverBuildIdentity string + expectedPriority string + gitPath string + gitSHA256 string + goPath string + goSHA256 string + wprPath string + wprSHA256 string } type liveControllerWorkload struct { @@ -193,6 +208,10 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { t.Fatalf("loaded SDL SHA-256 %s does not match source-bound SHA-256 %s", loadedHash, config.sdlDLLSHA256) } + machine, err := collectMachineProvenance(config.expectedPriority) + if err != nil { + t.Fatalf("collect exact machine and scheduler provenance: %v", err) + } generatedAt := time.Now().UTC() provenance := latency.Provenance{ @@ -212,9 +231,16 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { GoVersion: runtime.Version(), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + GitExecutablePath: config.gitPath, + GitExecutableSHA256: config.gitSHA256, + GoExecutablePath: config.goPath, + GoExecutableSHA256: config.goSHA256, + WPRExecutablePath: config.wprPath, + WPRExecutableSHA256: config.wprSHA256, + Machine: machine, } suite := &latency.SuiteReport{ - Schema: latency.SuiteSchemaV1, GeneratedAt: generatedAt, Provenance: provenance, + Schema: latency.SuiteSchemaV2, GeneratedAt: generatedAt, Provenance: provenance, } gateCtx, cancelGate := context.WithTimeout(context.Background(), 18*time.Minute) @@ -222,7 +248,7 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { for _, controller := range liveControllerWorkloads() { phaseSweepOffsets := latency.ProductionPhaseSweepOffsetsNS() report := latency.Report{ - Schema: latency.SchemaV1, GeneratedAt: generatedAt, Provenance: provenance, + Schema: latency.SchemaV2, GeneratedAt: generatedAt, Provenance: provenance, Workload: latency.Workload{ APIAddress: liveLatencyAPIAddress, USBIPAddress: liveLatencyUSBIPAddress, ControllerType: controller.apiType, @@ -265,19 +291,23 @@ func TestLiveControllerToGameLatencyGate(t *testing.T) { } for _, controllerReport := range suite.Cases { for _, transport := range controllerReport.Transports { - t.Logf("%s/%s controller-to-SDL: press n=%d p50=%s p95=%s p99=%s max=%s jitter=%s; "+ - "release n=%d p50=%s p95=%s p99=%s max=%s jitter=%s; misses=%d duplicates=%d", + t.Logf("%s/%s controller-to-SDL: press n=%d p50=%s p90=%s p95=%s p99=%s p99.9=%s max=%s jitter=%s; "+ + "release n=%d p50=%s p90=%s p95=%s p99=%s p99.9=%s max=%s jitter=%s; misses=%d duplicates=%d", controllerReport.Workload.ControllerType, transport.Transport, transport.Statistics.Press.Count, time.Duration(transport.Statistics.Press.P50NS), + time.Duration(transport.Statistics.Press.P90NS), time.Duration(transport.Statistics.Press.P95NS), time.Duration(transport.Statistics.Press.P99NS), + time.Duration(transport.Statistics.Press.P999NS), time.Duration(transport.Statistics.Press.MaxNS), time.Duration(transport.Statistics.Press.JitterNS), transport.Statistics.Release.Count, time.Duration(transport.Statistics.Release.P50NS), + time.Duration(transport.Statistics.Release.P90NS), time.Duration(transport.Statistics.Release.P95NS), time.Duration(transport.Statistics.Release.P99NS), + time.Duration(transport.Statistics.Release.P999NS), time.Duration(transport.Statistics.Release.MaxNS), time.Duration(transport.Statistics.Release.JitterNS), transport.Misses.Total(), transport.Duplicates.Total()) @@ -304,11 +334,22 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { driverSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverSHA256))), traceProfileSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyTraceProfileSHA))), driverBuildIdentity: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyDriverBuildID))), + expectedPriority: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyExpectedPriority))), + gitPath: strings.TrimSpace(os.Getenv(liveLatencyGitPath)), + gitSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyGitSHA256))), + goPath: strings.TrimSpace(os.Getenv(liveLatencyGoPath)), + goSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyGoSHA256))), + wprPath: strings.TrimSpace(os.Getenv(liveLatencyWPRPath)), + wprSHA256: strings.ToLower(strings.TrimSpace(os.Getenv(liveLatencyWPRSHA256))), } if config.outputPath == "" || config.expectedRevision == "" || config.sdlRevision == "" || config.sdlDLLPath == "" || config.sdlDLLSHA256 == "" || config.packageManifestSHA == "" || config.driverSHA256 == "" || - config.traceProfileSHA256 == "" || config.driverBuildIdentity == "" { + config.traceProfileSHA256 == "" || config.driverBuildIdentity == "" || + (config.expectedPriority != "normal" && config.expectedPriority != "high") || + config.gitPath == "" || config.gitSHA256 == "" || + config.goPath == "" || config.goSHA256 == "" || + config.wprPath == "" || config.wprSHA256 == "" { return liveLatencyConfig{}, errors.New("production latency provenance environment is incomplete") } if !filepath.IsAbs(config.outputPath) || !filepath.IsAbs(config.sdlDLLPath) { @@ -336,15 +377,142 @@ func loadLiveLatencyConfig() (liveLatencyConfig, error) { return liveLatencyConfig{}, fmt.Errorf("resolve source-bound SDL DLL: %w", err) } config.sdlDLLPath = canonicalSDL + for _, executable := range []struct { + name, path, hash string + }{ + {name: "Git", path: config.gitPath, hash: config.gitSHA256}, + {name: "Go", path: config.goPath, hash: config.goSHA256}, + {name: "WPR", path: config.wprPath, hash: config.wprSHA256}, + } { + canonical, resolveErr := canonicalPath(executable.path) + if resolveErr != nil { + return liveLatencyConfig{}, fmt.Errorf("resolve %s executable: %w", executable.name, resolveErr) + } + actualHash, hashErr := fileSHA256(canonical) + if hashErr != nil || actualHash != executable.hash { + return liveLatencyConfig{}, fmt.Errorf( + "%s executable hash changed: path=%s actual=%s expected=%s error=%v", + executable.name, canonical, actualHash, executable.hash, hashErr) + } + switch executable.name { + case "Git": + config.gitPath = canonical + case "Go": + config.goPath = canonical + case "WPR": + config.wprPath = canonical + } + } return config, nil } +func collectMachineProvenance(expectedPriority string) (latency.MachineProvenance, error) { + hostname, err := os.Hostname() + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("resolve host name: %w", err) + } + if strings.TrimSpace(hostname) == "" { + return latency.MachineProvenance{}, errors.New("resolved host name is empty") + } + version := windows.RtlGetVersion() + if version == nil || version.BuildNumber == 0 { + return latency.MachineProvenance{}, errors.New("RtlGetVersion returned an invalid OS version") + } + + osKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("open Windows version registry key: %w", err) + } + defer osKey.Close() + productName, err := requiredRegistryString(osKey, "ProductName") + if err != nil { + return latency.MachineProvenance{}, err + } + displayVersion, err := firstRequiredRegistryString(osKey, "DisplayVersion", "ReleaseId") + if err != nil { + return latency.MachineProvenance{}, err + } + ubr, _, err := osKey.GetIntegerValue("UBR") + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("read Windows UBR: %w", err) + } + + cpuKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("open CPU registry key: %w", err) + } + defer cpuKey.Close() + cpuModel, err := requiredRegistryString(cpuKey, "ProcessorNameString") + if err != nil { + return latency.MachineProvenance{}, err + } + + priority, err := windows.GetPriorityClass(windows.CurrentProcess()) + if err != nil { + return latency.MachineProvenance{}, fmt.Errorf("query process priority class: %w", err) + } + priorityName := "" + switch priority { + case windows.NORMAL_PRIORITY_CLASS: + priorityName = "normal" + case windows.HIGH_PRIORITY_CLASS: + priorityName = "high" + default: + return latency.MachineProvenance{}, fmt.Errorf( + "unsupported process priority class %#x; expected normal or high", priority) + } + if priorityName != expectedPriority { + return latency.MachineProvenance{}, fmt.Errorf( + "process priority class is %s, wrapper required %s", priorityName, expectedPriority) + } + if !windows.GetCurrentProcessToken().IsElevated() { + return latency.MachineProvenance{}, errors.New("latency process token is not elevated") + } + + return latency.MachineProvenance{ + Hostname: strings.TrimSpace(hostname), + OSProductName: productName, + OSDisplayVersion: displayVersion, + OSVersion: fmt.Sprintf("%d.%d.%d.%d", version.MajorVersion, version.MinorVersion, version.BuildNumber, ubr), + CPUModel: cpuModel, + LogicalProcessors: runtime.NumCPU(), + ProcessPriorityClass: priorityName, + ProcessElevated: true, + }, nil +} + +func requiredRegistryString(key registry.Key, name string) (string, error) { + value, _, err := key.GetStringValue(name) + if err != nil { + return "", fmt.Errorf("read non-empty registry value %s: %w", name, err) + } + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("registry value %s is empty", name) + } + return value, nil +} + +func firstRequiredRegistryString(key registry.Key, names ...string) (string, error) { + var lastErr error + for _, name := range names { + value, err := requiredRegistryString(key, name) + if err == nil { + return value, nil + } + lastErr = err + } + return "", lastErr +} + func validateLiveLatencySource(config liveLatencyConfig) error { workingDirectory, err := os.Getwd() if err != nil { return err } - repositoryRoot, err := runGit(workingDirectory, "rev-parse", "--show-toplevel") + repositoryRoot, err := runGit(config.gitPath, workingDirectory, "rev-parse", "--show-toplevel") if err != nil { return fmt.Errorf("latency harness is not an exact Git checkout: %w", err) } @@ -352,21 +520,21 @@ func validateLiveLatencySource(config liveLatencyConfig) error { if err != nil { return err } - head, err := runGit(repositoryRoot, "rev-parse", "--verify", "HEAD") + head, err := runGit(config.gitPath, repositoryRoot, "rev-parse", "--verify", "HEAD") if err != nil { return err } if strings.ToLower(strings.TrimSpace(head)) != config.expectedRevision { return fmt.Errorf("latency harness source is %s, expected %s", strings.TrimSpace(head), config.expectedRevision) } - status, err := runGit(repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all") + status, err := runGit(config.gitPath, repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all") if err != nil { return err } if strings.TrimSpace(status) != "" { return fmt.Errorf("latency source tree is not clean; refusing unreviewed code or data:\n%s", status) } - submodules, err := runGit(repositoryRoot, "submodule", "status", "--recursive") + submodules, err := runGit(config.gitPath, repositoryRoot, "submodule", "status", "--recursive") if err != nil { return err } @@ -376,7 +544,7 @@ func validateLiveLatencySource(config liveLatencyConfig) error { } } sdlRoot := filepath.Join(repositoryRoot, "_testing", "e2e", "deps", "SDL") - sdlRevision, err := runGit(sdlRoot, "rev-parse", "--verify", "HEAD") + sdlRevision, err := runGit(config.gitPath, sdlRoot, "rev-parse", "--verify", "HEAD") if err != nil { return err } @@ -1215,8 +1383,8 @@ func appendLatencyFailure(run *latency.Run, format string, arguments ...any) { } } -func runGit(directory string, arguments ...string) (string, error) { - command := exec.Command("git", append([]string{"-C", directory}, arguments...)...) +func runGit(executable, directory string, arguments ...string) (string, error) { + command := exec.Command(executable, append([]string{"-C", directory}, arguments...)...) output, err := command.CombinedOutput() if err != nil { return "", fmt.Errorf("git %s: %w: %s", strings.Join(arguments, " "), err, diff --git a/_testing/e2e/pnp_path_windows_test.go b/_testing/e2e/pnp_path_windows_test.go index dde1387a..8d41fb60 100644 --- a/_testing/e2e/pnp_path_windows_test.go +++ b/_testing/e2e/pnp_path_windows_test.go @@ -44,18 +44,19 @@ func TestPinnedSDLXboxPathRequiresRawInputForPnPIdentity(t *testing.T) { func TestAppendPnPAncestryRequiresCompleteUnambiguousRootChain(t *testing.T) { const ( - hidID = `HID\VID_045E&PID_028E\1` - usbID = `USB\VID_045E&PID_028E\1` - anchorID = `ROOT\USB\0002` - rootID = `HTREE\ROOT\0` + hidID = `HID\VID_045E&PID_028E\1` + usbID = `USB\VID_045E&PID_028E\1` + anchorID = `ROOT\USB\0002` + rootID = `HTREE\ROOT\0` + containerID = `{11111111-2222-3333-4444-555555555555}` ) valid := map[string]presentDeviceNode{ - hidID: {instanceID: hidID, parentID: usbID, service: "HidUsb"}, - usbID: {instanceID: usbID, parentID: anchorID, service: "usbccgp", locationPaths: []string{`USBROOT(0)#USB(7)`}}, + hidID: {instanceID: hidID, parentID: usbID, service: "HidUsb", containerID: containerID}, + usbID: {instanceID: usbID, parentID: anchorID, service: "usbccgp", containerID: containerID, locationPaths: []string{`USBROOT(0)#USB(7)`}}, anchorID: {instanceID: anchorID, parentID: rootID, service: "usbip2_ude", hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}}, rootID: {instanceID: rootID}, } - proof := latency.ControllerProof{PNPInstanceID: hidID} + proof := latency.ControllerProof{PNPInstanceID: hidID, PNPContainerID: containerID} if err := appendPnPAncestry(valid, hidID, latency.TransportUSBIP, &proof); err != nil { t.Fatal(err) } @@ -97,7 +98,7 @@ func TestAppendPnPAncestryRequiresCompleteUnambiguousRootChain(t *testing.T) { instanceID: spoofID, parentID: anchorID, service: "usbip2_ude", hardwareIDs: []string{`ROOT\USBIP_WIN2\UDE`}, } - candidate := latency.ControllerProof{PNPInstanceID: hidID} + candidate := latency.ControllerProof{PNPInstanceID: hidID, PNPContainerID: containerID} if err := appendPnPAncestry(nodes, hidID, latency.TransportUSBIP, &candidate); err != nil { t.Fatal(err) } diff --git a/_testing/e2e/pnp_windows_test.go b/_testing/e2e/pnp_windows_test.go index e4eba291..0022ed62 100644 --- a/_testing/e2e/pnp_windows_test.go +++ b/_testing/e2e/pnp_windows_test.go @@ -22,6 +22,7 @@ var devPropKeyDeviceParent = windows.DEVPROPKEY{ type presentDeviceNode struct { instanceID string parentID string + containerID string service string hardwareIDs []string locationInfo string @@ -78,6 +79,21 @@ func bindControllerPnP(proof *latency.ControllerProof, transport string, usbipPo if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_SERVICE); propertyErr == nil { node.service, _ = value.(string) } + if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty( + deviceSet, info, windows.SPDRP_BASE_CONTAINERID); propertyErr == nil { + containerID, valid := value.(string) + if !valid { + return fmt.Errorf("PnP container property for %q is not a string", node.instanceID) + } + containerGUID, guidErr := windows.GUIDFromString(containerID) + if guidErr != nil { + return fmt.Errorf("PnP container property for %q is malformed: %w", node.instanceID, guidErr) + } + node.containerID = containerGUID.String() + } else if !errors.Is(propertyErr, windows.ERROR_INVALID_DATA) && + !errors.Is(propertyErr, windows.ERROR_NOT_FOUND) { + return fmt.Errorf("read PnP container property for %q: %w", node.instanceID, propertyErr) + } if value, propertyErr := windows.SetupDiGetDeviceRegistryProperty(deviceSet, info, windows.SPDRP_HARDWAREID); propertyErr == nil { switch typed := value.(type) { case []string: @@ -99,11 +115,16 @@ func bindControllerPnP(proof *latency.ControllerProof, transport string, usbipPo } nodes[node.instanceID] = node } - if _, present := nodes[instanceID]; !present { + controllerNode, present := nodes[instanceID] + if !present { return fmt.Errorf("SDL interface instance %q is not a present Windows PnP devnode", instanceID) } + if controllerNode.containerID == "" { + return fmt.Errorf("SDL interface instance %q has no exact PnP container identity", instanceID) + } proof.PNPInstanceID = instanceID + proof.PNPContainerID = controllerNode.containerID if err := appendPnPAncestry(nodes, instanceID, transport, proof); err != nil { return err } @@ -128,6 +149,7 @@ func appendPnPAncestry(nodes map[string]presentDeviceNode, startID, transport st return fmt.Errorf("PnP ancestor %q is absent from the present-device snapshot", current) } proof.PNPAncestorIDs = append(proof.PNPAncestorIDs, node.instanceID) + proof.PNPAncestorContainerIDs = append(proof.PNPAncestorContainerIDs, node.containerID) proof.PNPAncestorServices = append(proof.PNPAncestorServices, node.service) proof.PNPAncestorHardwareIDs = append(proof.PNPAncestorHardwareIDs, append([]string(nil), node.hardwareIDs...)) diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 index 83c76338..d27d96ec 100644 --- a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1 @@ -23,9 +23,16 @@ param( [ValidateRange(256, 10000)] [int]$Samples = 256, + [ValidateSet('Normal', 'High')] + [string]$PriorityClass = 'Normal', + [string]$RepositoryRoot, - [string]$GoExecutable = 'go.exe' + [Parameter(Mandatory = $true)] + [string]$GitExecutable, + + [Parameter(Mandatory = $true)] + [string]$GoExecutable ) Set-StrictMode -Version Latest @@ -43,6 +50,23 @@ function Resolve-CanonicalPath { return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path } +function Resolve-ExactExecutablePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + + if (-not [IO.Path]::IsPathFullyQualified($Path)) { + throw "$Label must be supplied as an absolute path; PATH lookup is forbidden." + } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + throw "$Label is not a non-empty regular executable: '$Path'." + } + return $item.FullName +} + function Resolve-NewEvidencePath { param( [Parameter(Mandatory = $true)][string]$Path, @@ -91,7 +115,9 @@ if (-not (Test-IsAdministrator)) { throw 'The source-bound latency gate and WPR capture require an elevated PowerShell session.' } $repository = Resolve-CanonicalPath -Path $RepositoryRoot -$git = Get-Command git.exe -ErrorAction Stop +$gitPath = Resolve-ExactExecutablePath -Path $GitExecutable -Label 'Git executable' +$git = [pscustomobject]@{ Source = $gitPath } +$gitHash = (Get-FileHash -LiteralPath $gitPath -Algorithm SHA256).Hash.ToLowerInvariant() $headOutput = @(& $git.Source -C $repository rev-parse --verify HEAD 2>&1) if ($LASTEXITCODE -ne 0 -or $headOutput.Count -eq 0) { throw "The production latency harness is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)" @@ -181,8 +207,14 @@ if ([string]::Equals($output, $trace, [StringComparison]::OrdinalIgnoreCase) -or [string]::Equals($trace, $markers, [StringComparison]::OrdinalIgnoreCase)) { throw 'The latency JSON, WPR trace, and decoded marker evidence must use three different paths.' } -$go = Get-Command $GoExecutable -ErrorAction Stop -$wpr = Get-Command wpr.exe -ErrorAction Stop +$goPath = Resolve-ExactExecutablePath -Path $GoExecutable -Label 'Go executable' +$go = [pscustomobject]@{ Source = $goPath } +$goHash = (Get-FileHash -LiteralPath $goPath -Algorithm SHA256).Hash.ToLowerInvariant() +$wprPath = Resolve-ExactExecutablePath ` + -Path (Join-Path ([Environment]::SystemDirectory) 'wpr.exe') ` + -Label 'System WPR executable' +$wpr = [pscustomobject]@{ Source = $wprPath } +$wprHash = (Get-FileHash -LiteralPath $wprPath -Algorithm SHA256).Hash.ToLowerInvariant() $wprProfilePath = Resolve-CanonicalPath -Path (Join-Path $repository '_testing\e2e\latency\ViiperLatency.wprp') $wprProfileHash = (Get-FileHash -LiteralPath $wprProfilePath -Algorithm SHA256).Hash.ToLowerInvariant() $wprProfile = "$wprProfilePath!ViiperLatency" @@ -212,7 +244,11 @@ $environmentNames = @( 'VIIPER_E2E_EXPECTED_SOURCE_REVISION', 'VIIPER_E2E_SDL_SOURCE_REVISION', 'VIIPER_E2E_SDL_DLL_PATH', 'VIIPER_E2E_SDL_DLL_SHA256', 'VIIPER_E2E_PACKAGE_MANIFEST_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_SHA256', - 'VIIPER_E2E_TRACE_PROFILE_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY' + 'VIIPER_E2E_TRACE_PROFILE_SHA256', 'VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY', + 'VIIPER_E2E_EXPECTED_PRIORITY_CLASS', + 'VIIPER_E2E_GIT_EXECUTABLE_PATH', 'VIIPER_E2E_GIT_EXECUTABLE_SHA256', + 'VIIPER_E2E_GO_EXECUTABLE_PATH', 'VIIPER_E2E_GO_EXECUTABLE_SHA256', + 'VIIPER_E2E_WPR_EXECUTABLE_PATH', 'VIIPER_E2E_WPR_EXECUTABLE_SHA256' ) $savedEnvironment = @{} foreach ($name in $environmentNames) { @@ -224,6 +260,8 @@ $nativeRevisionLDFlag = "-X github.com/Alia5/VIIPER/internal/transport/udecx.nat $wprStarted = $false $wprFailure = $null $testExitCode = -1 +$wrapperProcess = [Diagnostics.Process]::GetCurrentProcess() +$originalPriorityClass = $wrapperProcess.PriorityClass try { $env:CGO_ENABLED = '1' $env:GOENV = 'off' @@ -243,6 +281,13 @@ try { $env:VIIPER_E2E_NATIVE_DRIVER_SHA256 = $installedDriverHash $env:VIIPER_E2E_TRACE_PROFILE_SHA256 = $wprProfileHash $env:VIIPER_E2E_NATIVE_DRIVER_BUILD_IDENTITY = $driverBuildIdentity + $env:VIIPER_E2E_EXPECTED_PRIORITY_CLASS = $PriorityClass.ToLowerInvariant() + $env:VIIPER_E2E_GIT_EXECUTABLE_PATH = $gitPath + $env:VIIPER_E2E_GIT_EXECUTABLE_SHA256 = $gitHash + $env:VIIPER_E2E_GO_EXECUTABLE_PATH = $goPath + $env:VIIPER_E2E_GO_EXECUTABLE_SHA256 = $goHash + $env:VIIPER_E2E_WPR_EXECUTABLE_PATH = $wprPath + $env:VIIPER_E2E_WPR_EXECUTABLE_SHA256 = $wprHash $startOutput = @(& $wpr.Source -start $wprProfile -filemode -instancename $wprInstance 2>&1) if ($LASTEXITCODE -ne 0) { @@ -250,11 +295,25 @@ try { } $wprStarted = $true - & $go.Source -C $repository test -mod=readonly -count=1 -timeout=20m -ldflags $nativeRevisionLDFlag ` + $wrapperProcess.PriorityClass = [Diagnostics.ProcessPriorityClass]::$PriorityClass + & $go.Source -C $repository test -buildvcs=false -mod=readonly -count=1 -timeout=20m ` + -ldflags $nativeRevisionLDFlag ` -run '^TestLiveControllerToGameLatencyGate$' -v ./_testing/e2e $testExitCode = $LASTEXITCODE } finally { + try { + $wrapperProcess.PriorityClass = $originalPriorityClass + } + catch { + $priorityFailure = "Could not restore wrapper process priority to '$originalPriorityClass': $($_.Exception.Message)" + if ($null -eq $wprFailure) { + $wprFailure = $priorityFailure + } + else { + $wprFailure = "$wprFailure $priorityFailure" + } + } if ($wprStarted) { $statusOutput = @(& $wpr.Source -status collectors -details -instancename $wprInstance 2>&1) $statusExitCode = $LASTEXITCODE @@ -314,13 +373,25 @@ if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { throw "The latency gate exited successfully without the required JSON artifact '$output'." } $report = Get-Content -LiteralPath $output -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop -if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v1' -or +if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or [string]$report.provenance.source_revision -cne $headRevision -or [string]$report.provenance.sdl_source_revision -cne $sdlRevision -or [string]$report.provenance.sdl_binary_sha256 -cne $actualSDLHash -or [string]$report.provenance.native_package_manifest_sha256 -cne $manifestHash -or [string]$report.provenance.native_driver_sha256 -cne $installedDriverHash -or [string]$report.provenance.native_driver_build_identity -cne $driverBuildIdentity -or + [string]$report.provenance.git_executable_path -cne $gitPath -or + [string]$report.provenance.git_executable_sha256 -cne $gitHash -or + [string]$report.provenance.go_executable_path -cne $goPath -or + [string]$report.provenance.go_executable_sha256 -cne $goHash -or + [string]$report.provenance.wpr_executable_path -cne $wprPath -or + [string]$report.provenance.wpr_executable_sha256 -cne $wprHash -or + [string]$report.provenance.machine.process_priority_class -cne $PriorityClass.ToLowerInvariant() -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.hostname) -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.os_version) -or + [string]::IsNullOrWhiteSpace([string]$report.provenance.machine.cpu_model) -or + [int]$report.provenance.machine.logical_processors -le 0 -or + -not [bool]$report.provenance.machine.process_elevated -or [string]$report.verdict -cne 'pass' -or @($report.cases).Count -ne 3) { throw "The latency JSON artifact is not a passing source-bound production-controller suite." @@ -438,7 +509,8 @@ try { $env:GOFLAGS = '' $env:GOTOOLCHAIN = 'local' $env:GOWORK = 'off' - $verifyOutput = @(& $go.Source -C $repository run -mod=readonly ./_testing/e2e/cmd/verifylatency ` + $verifyOutput = @(& $go.Source -C $repository run -buildvcs=false -mod=readonly ` + ./_testing/e2e/cmd/verifylatency ` -input $output ` -markers $markers ` -source $headRevision ` diff --git a/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 new file mode 100644 index 00000000..6346f413 --- /dev/null +++ b/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1 @@ -0,0 +1,193 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SignedPackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$SubmissionManifestPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$')] + [string]$ExpectedSourceRevision, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$SDLBinarySHA256, + + [Parameter(Mandatory = $true)] + [string]$EvidenceDirectory, + + [ValidateRange(256, 10000)] + [int]$Samples = 10000, + + [string]$RepositoryRoot, + + [Parameter(Mandatory = $true)] + [string]$GitExecutable, + + [Parameter(Mandatory = $true)] + [string]$GoExecutable +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ExactEvidenceFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + throw "$Label is not a non-empty regular file: '$Path'." + } + return [pscustomobject]@{ + path = $item.FullName + length = [long]$item.Length + sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +$matrixRoot = [IO.Path]::GetFullPath($EvidenceDirectory) +$matrixRootItem = Get-Item -LiteralPath $matrixRoot -Force -ErrorAction Stop +if (-not $matrixRootItem.PSIsContainer -or + ($matrixRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "EvidenceDirectory must be an existing non-reparse directory: '$matrixRoot'." +} + +$gate = Join-Path $PSScriptRoot 'Invoke-ViiperE2ELatencyGate.ps1' +$gate = (Resolve-Path -LiteralPath $gate -ErrorAction Stop).Path +$matrixPath = Join-Path $matrixRoot 'viiper-latency-priority-matrix.json' +$runs = @( + [pscustomobject]@{ + priority = 'Normal' + report = (Join-Path $matrixRoot 'viiper-latency-normal.json') + trace = (Join-Path $matrixRoot 'viiper-latency-normal.etl') + }, + [pscustomobject]@{ + priority = 'High' + report = (Join-Path $matrixRoot 'viiper-latency-high.json') + trace = (Join-Path $matrixRoot 'viiper-latency-high.etl') + } +) + +$allOutputs = [Collections.Generic.List[string]]::new() +$allOutputs.Add($matrixPath) +foreach ($run in $runs) { + $allOutputs.Add([string]$run.report) + $allOutputs.Add([string]$run.trace) + $allOutputs.Add("$($run.report).etl-markers.json") +} +foreach ($path in $allOutputs) { + if (Test-Path -LiteralPath $path) { + throw "Refusing to overwrite latency-matrix evidence '$path'." + } +} + +$common = @{ + SignedPackageDirectory = $SignedPackageDirectory + SubmissionManifestPath = $SubmissionManifestPath + ExpectedSourceRevision = $ExpectedSourceRevision + SDLBinarySHA256 = $SDLBinarySHA256 + Samples = $Samples + GitExecutable = $GitExecutable + GoExecutable = $GoExecutable +} +if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $common.RepositoryRoot = $RepositoryRoot +} + +foreach ($run in $runs) { + & $gate @common ` + -OutputPath $run.report ` + -WprTracePath $run.trace ` + -PriorityClass $run.priority +} + +$matrixRuns = [Collections.Generic.List[object]]::new() +$referenceProvenance = $null +foreach ($run in $runs) { + $reportFile = Get-ExactEvidenceFile -Path $run.report -Label "$($run.priority) report" + $traceFile = Get-ExactEvidenceFile -Path $run.trace -Label "$($run.priority) trace" + $markerFile = Get-ExactEvidenceFile -Path "$($run.report).etl-markers.json" -Label "$($run.priority) markers" + $report = Get-Content -LiteralPath $run.report -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $expectedPriority = ([string]$run.priority).ToLowerInvariant() + if ([string]$report.schema -cne 'viiper.controller-to-game.latency-suite/v2' -or + [string]$report.verdict -cne 'pass' -or + [string]$report.provenance.source_revision -cne $ExpectedSourceRevision.ToLowerInvariant() -or + [string]$report.provenance.machine.process_priority_class -cne $expectedPriority -or + @($report.cases).Count -ne 3) { + throw "$($run.priority) report is not an exact passing priority-bound suite." + } + + $machine = $report.provenance.machine + $provenanceIdentity = @( + [string]$report.provenance.source_revision, + [string]$report.provenance.sdl_source_revision, + [string]$report.provenance.sdl_binary_path, + [string]$report.provenance.sdl_binary_sha256, + [string]$report.provenance.native_package_manifest_sha256, + [string]$report.provenance.native_driver_sha256, + [string]$report.provenance.native_driver_build_identity, + [string]$report.provenance.qpc_frequency, + [string]$report.provenance.trace_provider_name, + [string]$report.provenance.trace_provider_guid, + [string]$report.provenance.trace_profile_sha256, + [string]$report.provenance.usbip_baseline_mode, + [string]$report.provenance.usbip_baseline_version, + [string]$report.provenance.go_version, + [string]$report.provenance.goos, + [string]$report.provenance.goarch, + [string]$report.provenance.git_executable_path, + [string]$report.provenance.git_executable_sha256, + [string]$report.provenance.go_executable_path, + [string]$report.provenance.go_executable_sha256, + [string]$report.provenance.wpr_executable_path, + [string]$report.provenance.wpr_executable_sha256, + [string]$machine.hostname, + [string]$machine.os_product_name, + [string]$machine.os_display_version, + [string]$machine.os_version, + [string]$machine.cpu_model, + [string]$machine.logical_processors, + [string]$machine.process_elevated + ) -join "`n" + if ($null -eq $referenceProvenance) { + $referenceProvenance = $provenanceIdentity + } + elseif (-not [string]::Equals($referenceProvenance, $provenanceIdentity, + [StringComparison]::Ordinal)) { + throw 'Normal and high-priority suites do not have identical source/package/toolchain/machine provenance.' + } + + $matrixRuns.Add([ordered]@{ + priority_class = $expectedPriority + report = $reportFile + trace = $traceFile + decoded_markers = $markerFile + }) +} + +$matrix = [ordered]@{ + schema = 'viiper.controller-to-game.latency-priority-matrix/v1' + generated_at = [DateTime]::UtcNow.ToString('o') + source_revision = $ExpectedSourceRevision.ToLowerInvariant() + sample_pairs_per_transition = $Samples + runs = @($matrixRuns) +} +$matrixJSON = ConvertTo-Json -InputObject $matrix -Depth 8 -Compress +$matrixBytes = [Text.UTF8Encoding]::new($false).GetBytes($matrixJSON) +$stream = [IO.File]::Open($matrixPath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $stream.Write($matrixBytes, 0, $matrixBytes.Length) + $stream.Flush($true) +} +finally { + $stream.Dispose() +} + +$matrixFile = Get-ExactEvidenceFile -Path $matrixPath -Label 'priority matrix manifest' +Write-Host "Validated normal/high-priority latency matrix: '$($matrixFile.path)' (SHA-256 $($matrixFile.sha256))." diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 65ab5c27..0c82a9c2 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -1,6 +1,6 @@ # Controller-to-game latency -VIIPER has two different latency tools. They answer different questions and +VIIPER has several latency tools. They answer different questions and must not be presented as interchangeable evidence. - `_testing/e2e/scripts/lat_bench.go` formats Go benchmark averages. It is a @@ -10,6 +10,10 @@ must not be presented as interchangeable evidence. production gate. It records every press and release observed through SDL, compares authenticated USB/IP and native UDE runs, and emits a strict JSON evidence artifact plus a source-controlled sequential-file WPR trace. +- `_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1` is the release + entry point. It runs the complete gate once at Normal and once at High + process priority, then binds both raw JSON/ETL/decoded-marker sets into one + hash manifest. No live latency result is checked into this document. A passing result exists only when the production command below succeeds on the stated machine and its @@ -56,6 +60,12 @@ transport. ABBA makes both the first/last positions USB/IP and both middle positions native, reducing one-way warm-up and monotonic-drift bias without discarding per-block source identity. +The v2 JSON retains every raw sample and publishes nearest-rank p50, p90, p95, +p99, p99.9, and max values plus population jitter. Its provenance includes the +host name, Windows product/display/build identity, CPU model, logical processor +count, token elevation, and the measured process priority class. Reports from +different machine identities cannot be combined into the priority matrix. + All four blocks use the same API address, credential, bus/device position, input sequence, warm-up count, one-second event timeout, and deterministic unmeasured dwell schedule. Xbox success cannot certify either PlayStation path. @@ -143,7 +153,8 @@ following are true: and (for USB/IP) exact auto-attached import port; - all baseline SDL gamepads remain present and exactly one stable new SDL ID is created; its path, GUID, real type, VID, and PID must match the API device; -- the SDL HID interface resolves to an exact present Windows PnP ancestry. A +- the SDL HID interface resolves to an exact present Windows PnP instance and + container identity plus a cardinality-consistent ancestor chain. A native run must terminate at service `ViiperUde`/hardware ID `ROOT\VIIPER\UDE`; USB/IP must terminate at service `usbip2_ude`/INF hardware ID `ROOT\USBIP_WIN2\UDE` (the OS-assigned devnode instance is commonly @@ -209,24 +220,35 @@ cmake --build .\_testing\e2e\deps\SDL\build --config Debug $sdlHash = (Get-FileHash .\_testing\e2e\deps\SDL\build\Debug\SDL3.dll -Algorithm SHA256).Hash ``` -Choose new evidence paths outside the checkout. Existing files are never -overwritten. `-Samples` is the total pair count per controller/transport and is -bounded to 256–10,000 so the complete three-controller ABBA suite remains -inside its 18-minute fail-closed deadline. +Choose an existing evidence directory outside the checkout. Existing files are +never overwritten. `-Samples` is the total pair count per +controller/transport and is bounded to 256–10,000. The release matrix defaults +to 10,000 and produces independent Normal/High JSON, ETL, and decoded-marker +artifacts. ```powershell $revision = (git rev-parse HEAD).Trim() +$gitExe = 'C:\Program Files\Git\cmd\git.exe' +$goExe = 'C:\Go\bin\go.exe' -.\_testing\e2e\scripts\Invoke-ViiperE2ELatencyGate.ps1 ` +.\_testing\e2e\scripts\Invoke-ViiperE2ELatencyMatrix.ps1 ` -SignedPackageDirectory C:\ViiperUde\MicrosoftSigned ` -SubmissionManifestPath C:\ViiperUde\ViiperUde.cab.sha256.json ` -ExpectedSourceRevision $revision ` -SDLBinarySHA256 $sdlHash ` - -OutputPath C:\ViiperEvidence\controller-latency.json ` - -WprTracePath C:\ViiperEvidence\controller-latency.etl ` - -Samples 256 + -EvidenceDirectory C:\ViiperEvidence ` + -GitExecutable $gitExe ` + -GoExecutable $goExe ` + -Samples 10000 ``` +For a single diagnostic run, call `Invoke-ViiperE2ELatencyGate.ps1` directly +with `-PriorityClass Normal` or `-PriorityClass High` and new `-OutputPath` and +`-WprTracePath` values. Both wrappers require absolute, non-reparse Git and Go +executable paths; the system WPR image is pinned automatically. Their paths and +SHA-256 values are retained in the v2 provenance. A single run is not the +release priority matrix. + The wrapper verifies and uses the checked-in `ViiperLatency.wprp` in sequential file mode, names the recording instance, rejects any reported event/buffer loss, and saves the trace on both pass and test failure. The profile includes From f36dd004d33fb7e0d71d199b032596638804aef4 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 05:21:27 -0500 Subject: [PATCH 237/240] Pin official native UDE source contracts --- .../native-udecx-official-sources.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/architecture/native-udecx-official-sources.md diff --git a/docs/architecture/native-udecx-official-sources.md b/docs/architecture/native-udecx-official-sources.md new file mode 100644 index 00000000..204d3b13 --- /dev/null +++ b/docs/architecture/native-udecx-official-sources.md @@ -0,0 +1,72 @@ +# Native UDE official-source pins + +This file records the primary Microsoft contracts used by the native UDE +implementation and transaction model. Links are pinned to immutable source +commits so a later documentation edit cannot silently change the reviewed +release basis. The corresponding Microsoft Learn pages remain useful for +navigation, but they are not the immutable evidence references. + +Pins captured on 2026-08-15: + +- `MicrosoftDocs/windows-driver-docs`: + `5bf16a2a190814adbda0826aba1daf74faa1d45c` +- `MicrosoftDocs/windows-driver-docs-ddi`: + `7515063cea4c9e98db6a92986c5b4ddb0463fd16` +- `MicrosoftDocs/sdk-api`: + `4502fff176b3b56beddb6a63c9f980377b11ba9b` + +## UDE ownership, completion, and power + +- [Write a UDE client driver](https://github.com/MicrosoftDocs/windows-driver-docs/blob/5bf16a2a190814adbda0826aba1daf74faa1d45c/windows-driver-docs-pr/usbcon/writing-a-ude-client-driver.md) + is the authority for class-extension ownership of the associated endpoint + queue state, the client's forwarded-I/O purge obligation, START reopening, + and separate-DPC URB completion. It is the basis for treating PURGE as the + upstream admission boundary while joining driver-owned callbacks and + forwarded operations without calling WDF queue-state mutation APIs. +- [Asynchronous link-power exit completion](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/udecxusbdevice/nf-udecxusbdevice-udecxusbdevicelinkpowerexitcomplete.md) + requires PASSIVE_LEVEL completion after the client has finished its low-power + transition. This is the basis for the preallocated passive D0-exit worker and + its completion-as-final-object-access rule. +- [WdfWorkItemFlush](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/wdfworkitem/nf-wdfworkitem-wdfworkitemflush.md) + waits for queued and already-running callbacks and is PASSIVE_LEVEL only. +- [WDF object cleanup](https://github.com/MicrosoftDocs/windows-driver-docs-ddi/blob/7515063cea4c9e98db6a92986c5b4ddb0463fd16/wdk-ddi-src/content/wdfobject/nc-wdfobject-evt_wdf_object_context_cleanup.md) + defines child-before-parent cleanup and the work-item callback lifetime fence. + Together, these two contracts require flushing device work before consuming + a UDE device handle and allow an endpoint-parented purge worker to finish its + counted callback before endpoint cleanup. + +## Driver-package transaction + +- [SetupCopyOEMInfW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupcopyoeminfw.md) + supplies the add-only stage operation and the documented + `SP_COPY_NOOVERWRITE`/`ERROR_FILE_EXISTS` receipt behavior. +- [DiInstallDevice](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/newdev/nf-newdev-diinstalldevice.md) + binds an explicitly selected, already preinstalled driver to the exact + present devnode and returns an authoritative reboot requirement. +- [DiUninstallDevice](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/newdev/nf-newdev-diuninstalldevice.md) + removes the selected devnode and returns a reboot requirement that must remain + durable until a later boot proves the requested removal settled. +- [SetupUninstallOEMInfW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupuninstalloeminfw.md) + removes one exact published package. The transaction uses flags zero and + never force-deletes a package still used by a device. +- [SetupDiCreateDeviceInfoW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupdicreatedeviceinfow.md) + and [SetupDiGetDeviceInstanceIdW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/setupapi/nf-setupapi-setupdigetdeviceinstanceidw.md) + establish that a generated root instance identity is available before device + registration. The install journal therefore persists that exact receipt + before registration can leave a partially created root. + +## Broker rollback material + +- [CryptProtectData](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/dpapi/nf-dpapi-cryptprotectdata.md) + defines machine-scoped protected rollback blobs. The journal additionally + relies on protected directories, exact ACLs, per-transaction entropy, hashes, + and no plaintext secret fields; DPAPI alone is not the access-control layer. +- [ReplaceFileW](https://github.com/MicrosoftDocs/sdk-api/blob/4502fff176b3b56beddb6a63c9f980377b11ba9b/sdk-api-src/content/winbase/nf-winbase-replacefilew.md) + supplies the one-name image replacement primitive used after durable capture + of the prior broker image. Journal records and backup files are separately + flushed and read back before any service, key, image, or ownership mutation. + +These sources define API behavior, not the repository's complete safety proof. +The release proof also requires the checked-in state-machine contracts, injected +cut-point models, immutable package manifest, live lifecycle/Verifier matrix, +and the absence of any unresolved recovery journal. From ac3c61f1cf77c2009c4bbb8fe26e6dcfa597f911 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 07:39:23 -0500 Subject: [PATCH 238/240] Harden native UDE lifecycle and recovery --- device/dualsense/device.go | 44 + device/dualsense/ds_handler.go | 14 +- device/dualsense/native_audio_v5_test.go | 11 +- device/dualsense/output_backpressure_test.go | 413 + device/dualsense/output_writer.go | 667 +- device/dualsense/output_writer_test.go | 104 +- device/dualshock4/audio_test.go | 40 +- device/dualshock4/device.go | 72 + device/dualshock4/handler.go | 689 +- device/dualshock4/output_backpressure_test.go | 423 + .../native-udecx-package-install.md | 276 +- docs/architecture/native-udecx-signing.md | 4 +- docs/architecture/native-udecx.md | 143 +- internal/cmd/install_windows.go | 10 + internal/cmd/native_broker_journal_windows.go | 3909 ++++++++++ .../cmd/native_broker_journal_windows_test.go | 979 +++ internal/cmd/native_package.go | 247 +- internal/cmd/native_package_contract_test.go | 299 +- internal/cmd/native_package_other.go | 2 +- internal/cmd/native_package_uninstall.go | 398 +- internal/cmd/native_package_uninstall_test.go | 84 +- .../cmd/native_package_uninstall_windows.go | 7 +- internal/cmd/native_package_windows.go | 491 +- .../cmd/native_service_install_windows.go | 166 +- internal/cmd/service_windows.go | 7 + internal/server/usb/native.go | 77 +- .../usb/native_live_teardown_gate_test.go | 63 +- .../native_playstation_transport_soak_test.go | 19 +- internal/server/usb/native_test.go | 83 + internal/transport/udecx/client_windows.go | 11 +- .../transport/udecx/client_windows_test.go | 14 + .../udecx/driver_dispatch_contract_test.go | 21 +- ...river_endpoint_quiescence_contract_test.go | 44 + .../udecx/driver_lifecycle_contract_test.go | 9 +- .../driver_trace_recorder_contract_test.go | 193 + internal/transport/udecx/host.go | 357 +- internal/transport/udecx/host_test.go | 135 +- internal/transport/udecx/protocol.go | 137 +- .../transport/udecx/protocol_contract_test.go | 124 +- internal/transport/udecx/protocol_test.go | 130 +- native/udecx/README.md | 41 +- native/udecx/driver/Broker.c | 157 +- native/udecx/driver/Controller.c | 85 +- native/udecx/driver/Device.c | 154 +- native/udecx/driver/Ioctl.c | 88 +- native/udecx/driver/Trace.c | 104 +- native/udecx/driver/ViiperUde.h | 44 +- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 43 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 2 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 4 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 282 +- .../tools/Test-ViiperUdeReleaseBundle.ps1 | 4 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 4 +- native/udecx/tools/ViiperUdeCtl.cpp | 6899 ++++++++++++++--- 56 files changed, 16943 insertions(+), 1890 deletions(-) create mode 100644 device/dualsense/output_backpressure_test.go create mode 100644 device/dualshock4/output_backpressure_test.go create mode 100644 internal/cmd/native_broker_journal_windows.go create mode 100644 internal/cmd/native_broker_journal_windows_test.go create mode 100644 internal/transport/udecx/driver_trace_recorder_contract_test.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 55024da9..02ddd556 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -285,6 +285,23 @@ func (d *DualSense) setV5MediaCallbacks( }) } +// detachV5MediaStreamCallbacks is a terminal detach, not an operational audio +// reset. It advances the device media generation, clears its assembled audio, +// and fences/removes every producer without waiting on the old transport's +// reset callback. The stream handler immediately follows with writer Stop. +func (d *DualSense) detachV5MediaStreamCallbacks() { + d.mediaPublishMu.Lock() + defer d.mediaPublishMu.Unlock() + + d.mtx.Lock() + d.mediaRevision++ + d.resetSpeakerAudioLocked() + d.atomicAudioHapticsFunc = nil + d.realtimeHapticsFunc = nil + d.speakerResetFunc = nil + d.mtx.Unlock() +} + // replaceMediaCallbacks is a hard lifecycle boundary. A callback already in // progress finishes before the old transport is flushed; a callback assembled // before this revision can never publish into the replacement transport. @@ -367,19 +384,46 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { res["speakerInterfaceActive"] = d.speakerInterfaceActive speakerState := d.speakerStreamTelemetry.snapshot() res["speakerStreamActive"] = speakerState.Active + res["speakerOrderedFramesReceived"] = speakerState.OrderedReceived + res["speakerOrderedFramesEnqueued"] = speakerState.OrderedEnqueued + res["speakerOrderedFramesRejected"] = speakerState.OrderedRejected + res["speakerOrderedFramesWritten"] = speakerState.OrderedWritten + res["speakerOrderedSaturations"] = speakerState.OrderedSaturations + res["speakerOrderedQueueDepth"] = speakerState.OrderedQueueDepth + res["speakerOrderedQueueHighWater"] = speakerState.OrderedQueueHighWater + res["speakerOrderedLifecycleDiscardedFrames"] = + speakerState.OrderedLifecycleDiscardedFrames + res["speakerOrderedLifecycleDiscardedBytes"] = + speakerState.OrderedLifecycleDiscardedBytes res["speakerPayloadsReceived"] = speakerState.ReceivedPayloads res["speakerBytesReceived"] = speakerState.ReceivedBytes res["speakerPayloadsEnqueued"] = speakerState.EnqueuedPayloads res["speakerBytesEnqueued"] = speakerState.EnqueuedBytes + res["speakerPayloadsRejectedAfterFault"] = speakerState.RejectedPayloads + res["speakerBytesRejectedAfterFault"] = speakerState.RejectedBytes res["speakerPayloadsDropped"] = speakerState.DroppedPayloads res["speakerBytesDropped"] = speakerState.DroppedBytes + res["speakerQueueOverruns"] = speakerState.Overruns + res["speakerQueueUnderruns"] = speakerState.Underruns + res["speakerLateGaps"] = speakerState.LateGaps + res["speakerStalePayloads"] = speakerState.StalePayloads + res["speakerStaleBytes"] = speakerState.StaleBytes + res["speakerLifecycleDiscardedPayloads"] = + speakerState.LifecycleDiscardedPayloads + res["speakerLifecycleDiscardedBytes"] = + speakerState.LifecycleDiscardedBytes res["speakerPayloadsWritten"] = speakerState.WrittenPayloads res["speakerBytesWritten"] = speakerState.WrittenBytes res["speakerWriteFailures"] = speakerState.WriteFailures + res["speakerOrderedWriteFailures"] = speakerState.OrderedWriteFailures res["speakerQueueDepth"] = speakerState.QueueDepth res["speakerQueueHighWater"] = speakerState.QueueHighWater + res["speakerQueueDurationUS"] = speakerState.QueueDurationUS + res["speakerQueueDurationHighWaterUS"] = speakerState.QueueDurationHighUS res["speakerMaxEnqueueGapUS"] = speakerState.MaxEnqueueGapUS res["speakerMaxWriteGapUS"] = speakerState.MaxWriteGapUS + res["speakerTeardownFailures"] = speakerState.TeardownFailures + res["speakerTeardownPending"] = speakerState.TeardownPending res["microphoneInterfaceActive"] = d.microphoneInterfaceActive microphoneState := d.microphoneBuffer.State() res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 799cae05..5a2e2e5f 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -3,6 +3,7 @@ package dualsense import ( "encoding/binary" "encoding/json" + "errors" "fmt" "hash/crc32" "io" @@ -162,13 +163,12 @@ func dualSenseV5StreamHandler(deviceName string) api.StreamHandlerFunc { } dse.setV5MediaCallbacks(atomicAudioHapticsCallback, realtimeHapticsCallback, writer.ResetSpeaker) - defer func() { - dse.setV5MediaCallbacks(nil, nil, nil) - dse.SetOutputCallback(nil) - writer.Stop() - }() - - return readDualSenseV5InputStream(conn, dse, logger) + streamErr := readDualSenseV5InputStream(conn, dse, logger) + // Remove every producer before writer rundown. A join timeout is joined + // into the owning handler result rather than reported as clean shutdown. + dse.detachV5MediaStreamCallbacks() + dse.SetOutputCallback(nil) + return errors.Join(streamErr, writer.Stop()) } } diff --git a/device/dualsense/native_audio_v5_test.go b/device/dualsense/native_audio_v5_test.go index 1759c32b..98642f63 100644 --- a/device/dualsense/native_audio_v5_test.go +++ b/device/dualsense/native_audio_v5_test.go @@ -456,7 +456,9 @@ func TestDualSenseV5WriterPublishesExactAtomicContract(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } } func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { @@ -473,8 +475,8 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { len(writer.audio), dualSenseOutputAudioQueueCapacity) } state := writer.telemetry.snapshot() - if state.ReceivedPayloads != dualSenseOutputAudioQueueCapacity+1 || - state.EnqueuedPayloads != dualSenseOutputAudioQueueCapacity+1 || + if state.ReceivedPayloads != uint64(dualSenseOutputAudioQueueCapacity+1) || + state.EnqueuedPayloads != uint64(dualSenseOutputAudioQueueCapacity+1) || state.DroppedPayloads != 1 || state.DroppedBytes != dualSenseV5SpeakerPayloadSize { t.Fatalf("unexpected V5 bounded telemetry: %+v", state) @@ -482,6 +484,7 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { for expected := 1; expected <= dualSenseOutputAudioQueueCapacity; expected++ { frame := <-writer.audio + writer.recordMediaDequeued(frame) feedbackLength := int(binary.LittleEndian.Uint16(frame.payload[:2])) feedback := frame.payload[2 : 2+feedbackLength] speaker := frame.payload[2+feedbackLength:] @@ -492,7 +495,7 @@ func TestDualSenseV5WriterRetainsNewestGenerationWhenBounded(t *testing.T) { } writer.release(frame) } - if len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("V5 bounded queue leaked buffers: free=%d", len(writer.audioFree)) } } diff --git a/device/dualsense/output_backpressure_test.go b/device/dualsense/output_backpressure_test.go new file mode 100644 index 00000000..31b2a0c8 --- /dev/null +++ b/device/dualsense/output_backpressure_test.go @@ -0,0 +1,413 @@ +package dualsense + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func TestDualSenseOrderedPublicationIsFIFOWithConcurrentProducers(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + const producers = 24 + start := make(chan struct{}) + var wait sync.WaitGroup + wait.Add(producers) + for marker := 0; marker < producers; marker++ { + marker := byte(marker) + go func() { + defer wait.Done() + <-start + writer.EnqueueControl(StreamFrameOutputState, []byte{marker}) + }() + } + close(start) + wait.Wait() + + seen := make(map[byte]bool, producers) + for publication := uint64(1); publication <= producers; publication++ { + frame := <-writer.control + decrementUint64(&writer.telemetry.orderedQueueDepth) + if frame.publication != publication { + t.Fatalf("publication=%d want=%d", frame.publication, publication) + } + if len(frame.payload) != 1 || seen[frame.payload[0]] { + t.Fatalf("invalid or duplicate payload: % x", frame.payload) + } + seen[frame.payload[0]] = true + } + state := writer.telemetry.snapshot() + if state.OrderedReceived != producers || state.OrderedEnqueued != producers || + state.OrderedRejected != 0 || state.OrderedSaturations != 0 { + t.Fatalf("unexpected concurrent publication state: %+v", state) + } +} + +func TestDualSenseMixedMediaWindowUsesExactDurations(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + feedback, speaker := testV5Media(0x31) + for marker := 0; marker < dualSenseOutputAudioQueueCapacity; marker++ { + if marker%2 == 0 { + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + } else { + writer.EnqueueRealtimeHaptics([]byte{byte(marker)}) + } + } + state := writer.telemetry.snapshot() + if state.QueueDurationUS > dualSenseMediaMaximumBufferTime.Microseconds() || + state.QueueDurationHighUS > dualSenseMediaMaximumBufferTime.Microseconds() { + t.Fatalf("media time bound exceeded: %+v", state) + } + if state.Overruns == 0 { + t.Fatal("mixed 10 ms/10.667 ms media did not evict the oldest frame") + } + var lastType byte + for len(writer.audio) != 0 { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + // Input alternated, so retained FIFO order must continue alternating. + if lastType != 0 && frame.frameType == lastType { + t.Fatalf("mixed media FIFO reordered frame type 0x%02x", frame.frameType) + } + lastType = frame.frameType + writer.release(frame) + } + if writer.telemetry.queueDurationNS.Load() != 0 { + t.Fatalf("media duration accounting leaked %d ns", + writer.telemetry.queueDurationNS.Load()) + } +} + +func TestDualSenseResetCountsBothMediaClocksAsStale(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + feedback, speaker := testV5Media(0x44) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + writer.ResetSpeaker() + state := writer.telemetry.snapshot() + if state.StalePayloads != 2 || + state.StaleBytes != dualSenseV5SpeakerPayloadSize+3 || + state.QueueDepth != 0 || state.QueueDurationUS != 0 || + len(writer.audio) != 0 { + t.Fatalf("reset did not retire both media clocks: %+v", state) + } + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { + t.Fatalf("reset leaked media pool: free=%d", len(writer.audioFree)) + } +} + +func TestDualSenseWriterRecordsOnlyObservedGenerationGap(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + writer.telemetry.lastRealtimeEnqueueNS.Store( + time.Now().Add(-35 * time.Millisecond).UnixNano()) + writer.EnqueueRealtimeHaptics([]byte{1}) + state := writer.telemetry.snapshot() + if state.LateGaps != 1 || state.Underruns < 2 { + t.Fatalf("unexpected observed cadence accounting: %+v", state) + } +} + +func TestDualSenseOutputBackpressureTelemetryIsExposed(t *testing.T) { + controller, err := New(nil) + if err != nil { + t.Fatal(err) + } + writer := newDualSenseOutputWriter(nil, controller.beginSpeakerStream(), nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueRealtimeHaptics([]byte{2, 3}) + state := controller.GetDeviceSpecificArgs() + if state["speakerOrderedFramesEnqueued"] != uint64(1) || + state["speakerPayloadsEnqueued"] != uint64(1) || + state["speakerQueueDurationUS"] != + dualSenseRealtimeHapticsCadence.Microseconds() { + t.Fatalf("transport telemetry was not exposed: %+v", state) + } +} + +func TestDualSenseOrderedFaultWakesOwningReadLoop(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 1) + _, err := server.Read(buffer) + readDone <- err + }() + for marker := 0; marker <= dualSenseOutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + select { + case err := <-readDone: + if err == nil { + t.Fatal("owning read loop returned without stream fault") + } + case <-time.After(time.Second): + t.Fatal("ordered saturation did not wake the owning read loop") + } + state := writer.telemetry.snapshot() + if state.ReceivedPayloads != 1 || state.RejectedPayloads != 1 || + state.RejectedBytes != 3 || state.EnqueuedPayloads != 0 { + t.Fatalf("media rejection after stream fault was not accounted: %+v", state) + } + _ = client.Close() +} + +func TestDualSenseLifecycleDrainAccountsEveryAcceptedQueuedFrame(t *testing.T) { + writer := newDualSenseOutputWriter(nil, nil, nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueControl(StreamFrameOutputState, []byte{2, 3}) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + writer.EnqueueRealtimeHaptics([]byte{4, 5}) + writer.requestStop() + go writer.Run() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + state := writer.telemetry.snapshot() + if state.OrderedLifecycleDiscardedFrames != 2 || + state.OrderedLifecycleDiscardedBytes != 3 || + state.LifecycleDiscardedPayloads != 2 || + state.LifecycleDiscardedBytes != 5 || + state.OrderedQueueDepth != 0 || state.QueueDepth != 0 || + state.QueueDurationUS != 0 { + t.Fatalf("lifecycle drain was not fully accounted: %+v", state) + } +} + +type dualSenseWriteGateConn struct { + net.Conn + started chan struct{} + release chan struct{} + once sync.Once +} + +func (c *dualSenseWriteGateConn) Write(payload []byte) (int, error) { + c.once.Do(func() { close(c.started) }) + <-c.release + return len(payload), nil +} + +func TestDualSenseStopLatchesTimeoutAndContinuesAuthoritativeJoin(t *testing.T) { + server, client := net.Pipe() + gate := &dualSenseWriteGateConn{ + Conn: server, started: make(chan struct{}), release: make(chan struct{}), + } + writer := newDualSenseOutputWriter(gate, nil, nil) + writer.EnqueueRealtimeHaptics([]byte{1, 2, 3}) + go writer.Run() + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + + err := writer.Stop() + if !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("Stop error=%v want=%v", err, errDualSenseOutputJoinTimeout) + } + select { + case <-writer.done: + t.Fatal("timeout was treated as completed rundown") + default: + } + state := writer.telemetry.snapshot() + if state.TeardownFailures != 1 || !state.TeardownPending { + t.Fatalf("teardown timeout was not latched: %+v", state) + } + + close(gate.release) + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not finish after in-flight write was released") + } + deadline := time.Now().Add(time.Second) + for writer.telemetry.snapshot().TeardownPending && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if writer.telemetry.snapshot().TeardownPending { + t.Fatal("continued teardown join did not clear pending state") + } + if err := writer.Stop(); !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("latched Stop error=%v want=%v", err, + errDualSenseOutputJoinTimeout) + } + _ = client.Close() +} + +type dualSenseUninterruptibleStreamConn struct { + readRelease chan struct{} + writeStarted chan struct{} + writeRelease chan struct{} + writeOnce sync.Once +} + +func newDualSenseUninterruptibleStreamConn() *dualSenseUninterruptibleStreamConn { + return &dualSenseUninterruptibleStreamConn{ + readRelease: make(chan struct{}), + writeStarted: make(chan struct{}), + writeRelease: make(chan struct{}), + } +} + +func (c *dualSenseUninterruptibleStreamConn) Read([]byte) (int, error) { + <-c.readRelease + return 0, io.EOF +} + +func (c *dualSenseUninterruptibleStreamConn) Write(payload []byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.writeRelease + return len(payload), nil +} + +func (*dualSenseUninterruptibleStreamConn) Close() error { return nil } + +func (*dualSenseUninterruptibleStreamConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualSenseUninterruptibleStreamConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualSenseUninterruptibleStreamConn) SetDeadline(time.Time) error { + return nil +} + +func (*dualSenseUninterruptibleStreamConn) SetReadDeadline(time.Time) error { + return nil +} + +func (*dualSenseUninterruptibleStreamConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestDualSenseHandlerDetachesBeforeAuthoritativeStop(t *testing.T) { + controller, err := New(nil) + if err != nil { + t.Fatal(err) + } + var device usb.Device = controller + conn := newDualSenseUninterruptibleStreamConn() + streamHandler := dualSenseV5StreamHandler("DualSense") + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(conn, &device, logger) + }() + deadline := time.Now().Add(time.Second) + for { + controller.mtx.Lock() + callbacksReady := controller.atomicAudioHapticsFunc != nil && + controller.speakerResetFunc != nil + controller.mtx.Unlock() + if callbacksReady { + break + } + if time.Now().After(deadline) { + t.Fatal("handler did not install media callbacks") + } + time.Sleep(time.Millisecond) + } + + controller.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + pcm := make([]byte, + dualSenseV5SpeakerFrames*USBHapticsAudioFrameSize) + controller.HandleTransfer(context.Background(), EndpointHapticsAudioOut, + usbip.DirOut, pcm) + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("handler writer did not enter the uninterruptible write") + } + close(conn.readRelease) + + select { + case err := <-errCh: + if !errors.Is(err, errDualSenseOutputJoinTimeout) { + t.Fatalf("handler error=%v want=%v", err, + errDualSenseOutputJoinTimeout) + } + case <-time.After(time.Second): + t.Fatal("handler cleanup blocked in reset before Stop could report failure") + } + controller.mtx.Lock() + callbacksDetached := controller.outputFunc == nil && + controller.atomicAudioHapticsFunc == nil && + controller.realtimeHapticsFunc == nil && + controller.speakerResetFunc == nil + controller.mtx.Unlock() + if !callbacksDetached { + t.Fatal("handler retained callbacks after teardown failure") + } + state := controller.GetDeviceSpecificArgs() + if state["speakerTeardownFailures"] != uint64(1) || + state["speakerTeardownPending"] != true { + t.Fatalf("handler did not expose pending teardown: %+v", state) + } + + close(conn.writeRelease) + deadline = time.Now().Add(time.Second) + for { + state = controller.GetDeviceSpecificArgs() + if state["speakerTeardownPending"] == false && + state["speakerStreamActive"] == false { + break + } + if time.Now().After(deadline) { + t.Fatalf("continued writer join did not complete: %+v", state) + } + time.Sleep(time.Millisecond) + } +} + +func TestDualSenseResetCloseAndInFlightWriteCannotDeadlock(t *testing.T) { + server, client := net.Pipe() + conn := newDeadlineTrackingConn(server) + writer := newDualSenseOutputWriter(conn, nil, nil) + feedback, speaker := testV5Media(0x71) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + go writer.Run() + select { + case <-conn.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + resetDone := make(chan struct{}) + stopDone := make(chan error, 1) + go func() { writer.ResetSpeaker(); close(resetDone) }() + go func() { stopDone <- writer.Stop() }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("reset deadlocked with in-flight write") + } + select { + case err := <-stopDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("stop deadlocked with in-flight write") + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + _ = client.Close() +} diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go index 370df777..55048a7d 100644 --- a/device/dualsense/output_writer.go +++ b/device/dualsense/output_writer.go @@ -2,6 +2,7 @@ package dualsense import ( "encoding/binary" + "errors" "log/slog" "net" "sync" @@ -11,50 +12,115 @@ import ( const ( dualSenseOutputControlQueueCapacity = 32 - dualSenseOutputAudioQueueCapacity = 64 + dualSenseMediaMaximumBufferTime = 200 * time.Millisecond + dualSenseSpeakerGenerationCadence = time.Second * dualSenseV5SpeakerFrames / + USBHapticsAudioSampleRate + dualSenseRealtimeHapticsCadence = time.Second * + (BluetoothHapticsSampleSize / 2) / BluetoothHapticsSampleRate + // Twenty 10 ms speaker generations are the largest possible frame count. + // Enqueue also accounts exact per-frame duration, so the 10.667 ms realtime + // lane and mixed media remain within the same 200 ms ceiling. + dualSenseOutputAudioQueueCapacity = int( + dualSenseMediaMaximumBufferTime / dualSenseSpeakerGenerationCadence) + dualSenseRealtimeMediaQueueCapacity = int( + dualSenseMediaMaximumBufferTime / dualSenseRealtimeHapticsCadence) + // One additional buffer belongs to the sole in-flight socket write while + // the full 200 ms queue remains available to producers. + dualSenseOutputAudioPoolCapacity = dualSenseOutputAudioQueueCapacity + 1 // V5 carries one 480-frame V5 generation: the combined feedback and // its matching front-channel stereo PCM. dualSenseSpeakerPayloadCapacity = dualSenseAtomicFeedbackPrefix + OutputStateV5Size + dualSenseV5SpeakerPayloadSize dualSenseSpeakerTraceInterval = 10 * time.Second dualSenseSpeakerResetTimeout = 250 * time.Millisecond + dualSenseOutputJoinTimeout = 300 * time.Millisecond dualSenseAtomicFeedbackPrefix = 2 ) +var errDualSenseOutputJoinTimeout = errors.New( + "DualSense output writer did not stop before the join deadline") + type dualSenseSpeakerStreamTelemetry struct { - receivedPayloads atomic.Uint64 - receivedBytes atomic.Uint64 - enqueuedPayloads atomic.Uint64 - enqueuedBytes atomic.Uint64 - droppedPayloads atomic.Uint64 - droppedBytes atomic.Uint64 - writtenPayloads atomic.Uint64 - writtenBytes atomic.Uint64 - writeFailures atomic.Uint64 - queueDepth atomic.Uint64 - queueHighWater atomic.Uint64 - lastEnqueueNS atomic.Int64 - maxEnqueueGapNS atomic.Int64 - lastWriteNS atomic.Int64 - maxWriteGapNS atomic.Int64 - active atomic.Bool + orderedReceived atomic.Uint64 + orderedEnqueued atomic.Uint64 + orderedRejected atomic.Uint64 + orderedWritten atomic.Uint64 + orderedSaturations atomic.Uint64 + orderedQueueDepth atomic.Uint64 + orderedQueueHighWater atomic.Uint64 + orderedLifecycleDiscardedFrames atomic.Uint64 + orderedLifecycleDiscardedBytes atomic.Uint64 + receivedPayloads atomic.Uint64 + receivedBytes atomic.Uint64 + enqueuedPayloads atomic.Uint64 + enqueuedBytes atomic.Uint64 + rejectedPayloads atomic.Uint64 + rejectedBytes atomic.Uint64 + droppedPayloads atomic.Uint64 + droppedBytes atomic.Uint64 + overruns atomic.Uint64 + underruns atomic.Uint64 + lateGaps atomic.Uint64 + stalePayloads atomic.Uint64 + staleBytes atomic.Uint64 + lifecycleDiscardedPayloads atomic.Uint64 + lifecycleDiscardedBytes atomic.Uint64 + writtenPayloads atomic.Uint64 + writtenBytes atomic.Uint64 + writeFailures atomic.Uint64 + orderedWriteFailures atomic.Uint64 + queueDepth atomic.Uint64 + queueHighWater atomic.Uint64 + queueDurationNS atomic.Int64 + queueDurationHighNS atomic.Int64 + lastEnqueueNS atomic.Int64 + lastRealtimeEnqueueNS atomic.Int64 + maxEnqueueGapNS atomic.Int64 + lastWriteNS atomic.Int64 + maxWriteGapNS atomic.Int64 + active atomic.Bool + teardownFailures atomic.Uint64 + teardownPending atomic.Bool } type dualSenseSpeakerStreamSnapshot struct { - ReceivedPayloads uint64 - ReceivedBytes uint64 - EnqueuedPayloads uint64 - EnqueuedBytes uint64 - DroppedPayloads uint64 - DroppedBytes uint64 - WrittenPayloads uint64 - WrittenBytes uint64 - WriteFailures uint64 - QueueDepth uint64 - QueueHighWater uint64 - MaxEnqueueGapUS int64 - MaxWriteGapUS int64 - Active bool + OrderedReceived uint64 + OrderedEnqueued uint64 + OrderedRejected uint64 + OrderedWritten uint64 + OrderedSaturations uint64 + OrderedQueueDepth uint64 + OrderedQueueHighWater uint64 + OrderedLifecycleDiscardedFrames uint64 + OrderedLifecycleDiscardedBytes uint64 + ReceivedPayloads uint64 + ReceivedBytes uint64 + EnqueuedPayloads uint64 + EnqueuedBytes uint64 + RejectedPayloads uint64 + RejectedBytes uint64 + DroppedPayloads uint64 + DroppedBytes uint64 + Overruns uint64 + Underruns uint64 + LateGaps uint64 + StalePayloads uint64 + StaleBytes uint64 + LifecycleDiscardedPayloads uint64 + LifecycleDiscardedBytes uint64 + WrittenPayloads uint64 + WrittenBytes uint64 + WriteFailures uint64 + OrderedWriteFailures uint64 + QueueDepth uint64 + QueueHighWater uint64 + QueueDurationUS int64 + QueueDurationHighUS int64 + MaxEnqueueGapUS int64 + MaxWriteGapUS int64 + Active bool + TeardownFailures uint64 + TeardownPending bool } func (s *dualSenseSpeakerStreamTelemetry) snapshot() dualSenseSpeakerStreamSnapshot { @@ -62,20 +128,43 @@ func (s *dualSenseSpeakerStreamTelemetry) snapshot() dualSenseSpeakerStreamSnaps return dualSenseSpeakerStreamSnapshot{} } return dualSenseSpeakerStreamSnapshot{ - ReceivedPayloads: s.receivedPayloads.Load(), - ReceivedBytes: s.receivedBytes.Load(), - EnqueuedPayloads: s.enqueuedPayloads.Load(), - EnqueuedBytes: s.enqueuedBytes.Load(), - DroppedPayloads: s.droppedPayloads.Load(), - DroppedBytes: s.droppedBytes.Load(), - WrittenPayloads: s.writtenPayloads.Load(), - WrittenBytes: s.writtenBytes.Load(), - WriteFailures: s.writeFailures.Load(), - QueueDepth: s.queueDepth.Load(), - QueueHighWater: s.queueHighWater.Load(), - MaxEnqueueGapUS: s.maxEnqueueGapNS.Load() / int64(time.Microsecond), - MaxWriteGapUS: s.maxWriteGapNS.Load() / int64(time.Microsecond), - Active: s.active.Load(), + OrderedReceived: s.orderedReceived.Load(), + OrderedEnqueued: s.orderedEnqueued.Load(), + OrderedRejected: s.orderedRejected.Load(), + OrderedWritten: s.orderedWritten.Load(), + OrderedSaturations: s.orderedSaturations.Load(), + OrderedQueueDepth: s.orderedQueueDepth.Load(), + OrderedQueueHighWater: s.orderedQueueHighWater.Load(), + OrderedLifecycleDiscardedFrames: s.orderedLifecycleDiscardedFrames.Load(), + OrderedLifecycleDiscardedBytes: s.orderedLifecycleDiscardedBytes.Load(), + ReceivedPayloads: s.receivedPayloads.Load(), + ReceivedBytes: s.receivedBytes.Load(), + EnqueuedPayloads: s.enqueuedPayloads.Load(), + EnqueuedBytes: s.enqueuedBytes.Load(), + RejectedPayloads: s.rejectedPayloads.Load(), + RejectedBytes: s.rejectedBytes.Load(), + DroppedPayloads: s.droppedPayloads.Load(), + DroppedBytes: s.droppedBytes.Load(), + Overruns: s.overruns.Load(), + Underruns: s.underruns.Load(), + LateGaps: s.lateGaps.Load(), + StalePayloads: s.stalePayloads.Load(), + StaleBytes: s.staleBytes.Load(), + LifecycleDiscardedPayloads: s.lifecycleDiscardedPayloads.Load(), + LifecycleDiscardedBytes: s.lifecycleDiscardedBytes.Load(), + WrittenPayloads: s.writtenPayloads.Load(), + WrittenBytes: s.writtenBytes.Load(), + WriteFailures: s.writeFailures.Load(), + OrderedWriteFailures: s.orderedWriteFailures.Load(), + QueueDepth: s.queueDepth.Load(), + QueueHighWater: s.queueHighWater.Load(), + QueueDurationUS: s.queueDurationNS.Load() / int64(time.Microsecond), + QueueDurationHighUS: s.queueDurationHighNS.Load() / int64(time.Microsecond), + MaxEnqueueGapUS: s.maxEnqueueGapNS.Load() / int64(time.Microsecond), + MaxWriteGapUS: s.maxWriteGapNS.Load() / int64(time.Microsecond), + Active: s.active.Load(), + TeardownFailures: s.teardownFailures.Load(), + TeardownPending: s.teardownPending.Load(), } } @@ -98,37 +187,49 @@ func recordMaximumUint64(target *atomic.Uint64, value uint64) { } type dualSenseOutputFrame struct { - frameType byte - payload []byte - audio bool - generation uint64 + frameType byte + payload []byte + // media participates in the reset generation and bounded-time queue. + // audio marks a preallocated atomic speaker buffer that must be returned. + media bool + audio bool + mediaBytes int + mediaDuration time.Duration + generation uint64 + publication uint64 } // dualSenseOutputWriter serializes controller feedback and virtual speaker // PCM on one framed stream. USB isochronous completion must never wait for TCP // backpressure, so speaker extraction uses a fixed pool and a bounded queue. type dualSenseOutputWriter struct { - conn net.Conn - logger *slog.Logger - telemetry *dualSenseSpeakerStreamTelemetry - control chan dualSenseOutputFrame - realtimeHaptics chan dualSenseOutputFrame - audio chan dualSenseOutputFrame - audioFree chan []byte - stop chan struct{} - done chan struct{} - stopOnce sync.Once - enqueueLock sync.RWMutex - controlEnqueue sync.Mutex - realtimeEnqueue sync.Mutex - audioEnqueue sync.Mutex - audioWrite sync.Mutex - stopped bool - streamViable atomic.Bool - audioGeneration atomic.Uint64 - sequence uint32 - packet []byte - lastTrace time.Time + conn net.Conn + logger *slog.Logger + telemetry *dualSenseSpeakerStreamTelemetry + control chan dualSenseOutputFrame + // audio is the single media FIFO for atomic speaker/haptics and realtime + // haptics. One FIFO preserves callback publication order across both clocks. + audio chan dualSenseOutputFrame + audioFree chan []byte + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + controlEnqueue sync.Mutex + mediaEnqueue sync.Mutex + audioWrite sync.Mutex + stopped bool + streamViable atomic.Bool + accepting atomic.Bool + audioGeneration atomic.Uint64 + orderedPublication uint64 + sequence uint32 + packet []byte + lastTrace time.Time + teardownMu sync.Mutex + teardownErr error + teardownFailureOnce sync.Once + teardownJoinOnce sync.Once } func newDualSenseOutputWriter(conn net.Conn, @@ -137,7 +238,10 @@ func newDualSenseOutputWriter(conn net.Conn, telemetry = &dualSenseSpeakerStreamTelemetry{} } telemetry.queueDepth.Store(0) + telemetry.queueDurationNS.Store(0) + telemetry.orderedQueueDepth.Store(0) telemetry.lastEnqueueNS.Store(0) + telemetry.lastRealtimeEnqueueNS.Store(0) telemetry.lastWriteNS.Store(0) telemetry.active.Store(true) w := &dualSenseOutputWriter{ @@ -145,17 +249,16 @@ func newDualSenseOutputWriter(conn net.Conn, logger: logger, telemetry: telemetry, control: make(chan dualSenseOutputFrame, dualSenseOutputControlQueueCapacity), - realtimeHaptics: make(chan dualSenseOutputFrame, - dualSenseOutputControlQueueCapacity), audio: make(chan dualSenseOutputFrame, dualSenseOutputAudioQueueCapacity), - audioFree: make(chan []byte, dualSenseOutputAudioQueueCapacity), + audioFree: make(chan []byte, dualSenseOutputAudioPoolCapacity), stop: make(chan struct{}), done: make(chan struct{}), packet: make([]byte, 0, StreamFrameHeaderSize+dualSenseSpeakerPayloadCapacity), lastTrace: time.Now(), } w.streamViable.Store(conn != nil) - for range dualSenseOutputAudioQueueCapacity { + w.accepting.Store(true) + for range dualSenseOutputAudioPoolCapacity { w.audioFree <- make([]byte, dualSenseSpeakerPayloadCapacity) } return w @@ -168,16 +271,26 @@ func (w *dualSenseOutputWriter) EnqueueRealtimeHaptics(payload []byte) { if len(payload) == 0 { return } + w.mediaEnqueue.Lock() + defer w.mediaEnqueue.Unlock() + w.recordSpeakerReceive(len(payload), StreamFrameRealtimeHaptics) + if !w.accepting.Load() { + w.recordSpeakerRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordSpeakerRejected(len(payload)) return } - w.realtimeEnqueue.Lock() - defer w.realtimeEnqueue.Unlock() - w.enqueueNewestFrameLocked(w.realtimeHaptics, dualSenseOutputFrame{ - frameType: StreamFrameRealtimeHaptics, - payload: append([]byte(nil), payload...), + w.enqueueMediaDropOldestLocked(dualSenseOutputFrame{ + frameType: StreamFrameRealtimeHaptics, + payload: append([]byte(nil), payload...), + media: true, + mediaBytes: len(payload), + mediaDuration: dualSenseRealtimeHapticsCadence, + generation: w.audioGeneration.Load(), }) } @@ -185,43 +298,43 @@ func (w *dualSenseOutputWriter) EnqueueControl(frameType byte, payload []byte) { if len(payload) == 0 { return } - w.enqueueLock.RLock() - defer w.enqueueLock.RUnlock() - if w.stopped { - return - } w.controlEnqueue.Lock() defer w.controlEnqueue.Unlock() - w.enqueueNewestFrameLocked(w.control, dualSenseOutputFrame{ + w.telemetry.orderedReceived.Add(1) + if !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + return + } + frame := dualSenseOutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), - }) -} - -// enqueueNewestFrameLocked makes bounded state lanes latest-state preserving. -// A release/zero-rumble/lightbar update must not be discarded merely because -// older state filled the queue. The per-lane producer mutex makes eviction and -// replacement atomic with respect to other callback producers; the sole -// consumer may only create more room. -func (w *dualSenseOutputWriter) enqueueNewestFrameLocked( - queue chan dualSenseOutputFrame, frame dualSenseOutputFrame, -) { - select { - case queue <- frame: + } + w.enqueueLock.RLock() + if w.stopped || !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + w.enqueueLock.RUnlock() return - default: } + w.orderedPublication++ + frame.publication = w.orderedPublication + depth := w.telemetry.orderedQueueDepth.Add(1) select { - case <-queue: + case w.control <- frame: + w.telemetry.orderedEnqueued.Add(1) + recordMaximumUint64(&w.telemetry.orderedQueueHighWater, depth) + w.enqueueLock.RUnlock() + return default: + decrementUint64(&w.telemetry.orderedQueueDepth) } - // With producers serialized, either the eviction above or a concurrent - // consumer has made room. Keep a defensive nonblocking send so an output - // callback can never inherit socket backpressure. - select { - case queue <- frame: - default: + w.telemetry.orderedRejected.Add(1) + if !w.accepting.CompareAndSwap(true, false) { + w.enqueueLock.RUnlock() + return } + w.telemetry.orderedSaturations.Add(1) + w.enqueueLock.RUnlock() + w.faultStream("ordered output queue saturated") } // EnqueueAtomicAudioHaptics publishes one V5 generation. A little-endian @@ -236,26 +349,29 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ return } + w.mediaEnqueue.Lock() + defer w.mediaEnqueue.Unlock() + w.recordSpeakerReceive(len(speakerPCM), StreamFrameAtomicAudioHaptics) + if !w.accepting.Load() { + w.recordSpeakerRejected(len(speakerPCM)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordSpeakerRejected(len(speakerPCM)) return } - w.audioEnqueue.Lock() - defer w.audioEnqueue.Unlock() - - w.telemetry.receivedPayloads.Add(1) - w.telemetry.receivedBytes.Add(uint64(len(speakerPCM))) buffer := w.acquireAtomicAudioBuffer() if buffer == nil { - w.recordSpeakerDrop(len(speakerPCM)) + w.recordSpeakerOverrun(len(speakerPCM)) return } length := dualSenseAtomicFeedbackPrefix + len(feedback) + len(speakerPCM) if length > cap(buffer) { w.audioFree <- buffer[:cap(buffer)] - w.recordSpeakerDrop(len(speakerPCM)) + w.recordSpeakerOverrun(len(speakerPCM)) return } buffer = buffer[:length] @@ -264,17 +380,15 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ copy(buffer[dualSenseAtomicFeedbackPrefix:], feedback) copy(buffer[dualSenseAtomicFeedbackPrefix+len(feedback):], speakerPCM) frame := dualSenseOutputFrame{ - frameType: StreamFrameAtomicAudioHaptics, - payload: buffer, - audio: true, - generation: w.audioGeneration.Load(), - } - if !w.enqueueFrameLocked(w.audio, frame) { - w.audioFree <- buffer[:cap(buffer)] - w.recordSpeakerDrop(len(speakerPCM)) - return - } - w.recordSpeakerEnqueue(len(speakerPCM)) + frameType: StreamFrameAtomicAudioHaptics, + payload: buffer, + media: true, + audio: true, + mediaBytes: len(speakerPCM), + mediaDuration: dualSenseSpeakerGenerationCadence, + generation: w.audioGeneration.Load(), + } + w.enqueueMediaDropOldestLocked(frame) } // acquireAtomicAudioBuffer keeps V5 realtime: when TCP momentarily falls @@ -283,57 +397,85 @@ func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM [ // the newest native USB generation can still be published without growing an // unbounded stale-audio reserve. func (w *dualSenseOutputWriter) acquireAtomicAudioBuffer() []byte { - select { - case buffer := <-w.audioFree: - return buffer - default: - } + for { + select { + case buffer := <-w.audioFree: + return buffer + default: + } - select { - case oldest := <-w.audio: - w.recordSpeakerDrop(atomicSpeakerPCMBytes(oldest.payload)) - w.telemetry.queueDepth.Store(uint64(len(w.audio))) - return oldest.payload[:cap(oldest.payload)] - default: - // The sole remaining pool buffer can be owned by an in-flight write. - return nil + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordSpeakerOverrun(oldest.mediaBytes) + w.release(oldest) + default: + // Every preallocated buffer is either queued or in-flight. Only a + // queued frame may be reclaimed; never steal the in-flight buffer. + return nil + } } } -func atomicSpeakerPCMBytes(payload []byte) int { - if len(payload) < dualSenseAtomicFeedbackPrefix { - return len(payload) +func (w *dualSenseOutputWriter) recordSpeakerReceive(length int, frameType byte) { + w.telemetry.receivedPayloads.Add(1) + w.telemetry.receivedBytes.Add(uint64(length)) + now := time.Now().UnixNano() + last := &w.telemetry.lastEnqueueNS + cadence := dualSenseSpeakerGenerationCadence + if frameType == StreamFrameRealtimeHaptics { + last = &w.telemetry.lastRealtimeEnqueueNS + cadence = dualSenseRealtimeHapticsCadence + } + previous := last.Swap(now) + if previous <= 0 || now <= previous { + return } - feedbackLength := int(binary.LittleEndian.Uint16( - payload[:dualSenseAtomicFeedbackPrefix])) - speakerOffset := dualSenseAtomicFeedbackPrefix + feedbackLength - if speakerOffset > len(payload) { - return len(payload) + gap := now - previous + recordMaximumInt64(&w.telemetry.maxEnqueueGapNS, gap) + expected := int64(cadence) + // This is an observed producer-generation gap, not a claim about remote + // playback. A 50% tolerance avoids classifying scheduler jitter as loss. + if gap > expected+expected/2 { + w.telemetry.lateGaps.Add(1) + missing := uint64(gap/expected) - 1 + if missing == 0 { + missing = 1 + } + w.telemetry.underruns.Add(missing) } - return len(payload) - speakerOffset } -func (w *dualSenseOutputWriter) recordSpeakerEnqueue(length int) { +func (w *dualSenseOutputWriter) recordSpeakerEnqueue(length int, + depth uint64, duration int64) { w.telemetry.enqueuedPayloads.Add(1) w.telemetry.enqueuedBytes.Add(uint64(length)) - now := time.Now().UnixNano() - previous := w.telemetry.lastEnqueueNS.Swap(now) - if previous > 0 && now > previous { - recordMaximumInt64(&w.telemetry.maxEnqueueGapNS, now-previous) - } - depth := uint64(len(w.audio)) - w.telemetry.queueDepth.Store(depth) recordMaximumUint64(&w.telemetry.queueHighWater, depth) + recordMaximumInt64(&w.telemetry.queueDurationHighNS, duration) } -func (w *dualSenseOutputWriter) recordSpeakerDrop(length int) { +func (w *dualSenseOutputWriter) recordSpeakerRejected(length int) { + w.telemetry.rejectedPayloads.Add(1) + w.telemetry.rejectedBytes.Add(uint64(length)) +} + +func (w *dualSenseOutputWriter) recordSpeakerOverrun(length int) { if length <= 0 { return } + w.telemetry.overruns.Add(1) w.telemetry.droppedPayloads.Add(1) w.telemetry.droppedBytes.Add(uint64(length)) } +func (w *dualSenseOutputWriter) recordSpeakerStale(length int) { + if length <= 0 { + return + } + w.telemetry.stalePayloads.Add(1) + w.telemetry.staleBytes.Add(uint64(length)) +} + func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) { w.telemetry.writtenPayloads.Add(1) w.telemetry.writtenBytes.Add(uint64(length)) @@ -344,47 +486,74 @@ func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) { } } -// enqueueFrameLocked requires enqueueLock to be held for reading. Shutdown -// takes the write side before draining, so no producer can publish a frame -// after the final drain has observed an empty queue. -func (w *dualSenseOutputWriter) enqueueFrameLocked(queue chan dualSenseOutputFrame, - frame dualSenseOutputFrame) bool { +// enqueueMediaDropOldestLocked retains the newest bounded media horizon. The +// frame removed from the channel is necessarily queued, never in-flight. +func (w *dualSenseOutputWriter) enqueueMediaDropOldestLocked( + frame dualSenseOutputFrame) { + duration := int64(frame.mediaDuration) + for w.telemetry.queueDurationNS.Load()+duration > int64(dualSenseMediaMaximumBufferTime) || + w.telemetry.queueDepth.Load() >= uint64(cap(w.audio)) { + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordSpeakerOverrun(oldest.mediaBytes) + w.release(oldest) + default: + w.recordSpeakerOverrun(frame.mediaBytes) + w.release(frame) + return + } + } + reservedDuration := w.telemetry.queueDurationNS.Add(duration) + depth := w.telemetry.queueDepth.Add(1) select { - case queue <- frame: - return true + case w.audio <- frame: + w.recordSpeakerEnqueue(frame.mediaBytes, depth, reservedDuration) default: - // Do not let TCP backpressure delay a USB/IP isochronous completion. - return false + w.telemetry.queueDurationNS.Add(-duration) + decrementUint64(&w.telemetry.queueDepth) + w.recordSpeakerOverrun(frame.mediaBytes) + w.release(frame) + } +} + +func (w *dualSenseOutputWriter) recordMediaDequeued(frame dualSenseOutputFrame) { + decrementUint64(&w.telemetry.queueDepth) + if frame.mediaDuration > 0 { + w.telemetry.queueDurationNS.Add(-int64(frame.mediaDuration)) + } +} + +func (w *dualSenseOutputWriter) recordSpeakerLifecycleDiscard(length int) { + if length <= 0 { + return } + w.telemetry.lifecycleDiscardedPayloads.Add(1) + w.telemetry.lifecycleDiscardedBytes.Add(uint64(length)) } func (w *dualSenseOutputWriter) Run() { defer func() { w.requestStop() - w.drainAudioQueue() - w.telemetry.queueDepth.Store(0) + w.drainControlQueue() + w.drainAudioQueue(dualSenseMediaDiscardLifecycle) w.telemetry.active.Store(false) w.traceSpeakerState(true) close(w.done) }() preferAudio := false for { - // A complete rear-channel generation has a hard media deadline. It is - // small and arrives slightly less often than speaker media, so servicing - // it first cannot starve the 100 Hz speaker lane. select { - case frame := <-w.realtimeHaptics: - if !w.writeAndRelease(frame) { - return - } - continue + case <-w.stop: + return default: } - // Alternate when both lanes are continuously ready. If the preferred + // Alternate when both traffic classes are continuously ready. If the preferred // lane is empty, immediately service whichever frame arrives next. if preferAudio { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -395,6 +564,7 @@ func (w *dualSenseOutputWriter) Run() { } else { select { case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } @@ -407,16 +577,14 @@ func (w *dualSenseOutputWriter) Run() { select { case <-w.stop: return - case frame := <-w.realtimeHaptics: - if !w.writeAndRelease(frame) { - return - } case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } preferAudio = true case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -426,28 +594,30 @@ func (w *dualSenseOutputWriter) Run() { } func (w *dualSenseOutputWriter) writeAndRelease(frame dualSenseOutputFrame) bool { - if frame.audio { + if frame.media { w.audioWrite.Lock() defer w.audioWrite.Unlock() if frame.generation != w.audioGeneration.Load() { - w.recordSpeakerDrop(len(frame.payload)) + w.recordSpeakerStale(frame.mediaBytes) w.release(frame) - w.telemetry.queueDepth.Store(uint64(len(w.audio))) return true } } ok := w.write(frame) - if frame.audio { - w.telemetry.queueDepth.Store(uint64(len(w.audio))) + if frame.media { if ok { - w.recordSpeakerWrite(len(frame.payload)) + w.recordSpeakerWrite(frame.mediaBytes) } else { w.telemetry.writeFailures.Add(1) } + } else if !ok { + w.telemetry.orderedWriteFailures.Add(1) + } else { + w.telemetry.orderedWritten.Add(1) } w.release(frame) - if frame.audio { + if frame.media { w.traceSpeakerState(false) } return ok @@ -459,7 +629,10 @@ func (w *dualSenseOutputWriter) writeAndRelease(frame dualSenseOutputFrame) bool func (w *dualSenseOutputWriter) ResetSpeaker() { w.enqueueLock.Lock() w.audioGeneration.Add(1) - w.drainAudioQueue() + w.drainAudioQueue(dualSenseMediaDiscardStale) + w.telemetry.lastEnqueueNS.Store(0) + w.telemetry.lastRealtimeEnqueueNS.Store(0) + w.telemetry.lastWriteNS.Store(0) w.enqueueLock.Unlock() // A peer that has stopped reading can otherwise hold audioWrite forever. @@ -467,23 +640,52 @@ func (w *dualSenseOutputWriter) ResetSpeaker() { // stream so the owning handler can return and accept a replacement. if w.conn != nil { if err := w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)); err != nil { - w.invalidateStream() + w.faultStream("speaker reset deadline failed") } } w.audioWrite.Lock() if w.conn != nil && w.streamViable.Load() { if err := w.conn.SetWriteDeadline(time.Time{}); err != nil { - w.invalidateStream() + w.faultStream("speaker reset deadline clear failed") } } w.audioWrite.Unlock() - w.telemetry.queueDepth.Store(0) } -func (w *dualSenseOutputWriter) drainAudioQueue() { +type dualSenseMediaDiscardReason uint8 + +const ( + dualSenseMediaDiscardStale dualSenseMediaDiscardReason = iota + 1 + dualSenseMediaDiscardLifecycle +) + +func (w *dualSenseOutputWriter) drainControlQueue() { + for { + select { + case frame := <-w.control: + decrementUint64(&w.telemetry.orderedQueueDepth) + w.telemetry.orderedLifecycleDiscardedFrames.Add(1) + w.telemetry.orderedLifecycleDiscardedBytes.Add( + uint64(len(frame.payload))) + w.release(frame) + default: + return + } + } +} + +func (w *dualSenseOutputWriter) drainAudioQueue( + reason dualSenseMediaDiscardReason) { for { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) + switch reason { + case dualSenseMediaDiscardStale: + w.recordSpeakerStale(frame.mediaBytes) + case dualSenseMediaDiscardLifecycle: + w.recordSpeakerLifecycleDiscard(frame.mediaBytes) + } w.release(frame) default: return @@ -512,15 +714,37 @@ func (w *dualSenseOutputWriter) traceSpeakerState(final bool) { "receivedBytes", state.ReceivedBytes, "enqueuedPayloads", state.EnqueuedPayloads, "enqueuedBytes", state.EnqueuedBytes, + "rejectedPayloads", state.RejectedPayloads, + "rejectedBytes", state.RejectedBytes, "droppedPayloads", state.DroppedPayloads, "droppedBytes", state.DroppedBytes, + "overruns", state.Overruns, + "underruns", state.Underruns, + "lateGaps", state.LateGaps, + "stalePayloads", state.StalePayloads, + "staleBytes", state.StaleBytes, + "lifecycleDiscardedPayloads", state.LifecycleDiscardedPayloads, + "lifecycleDiscardedBytes", state.LifecycleDiscardedBytes, "writtenPayloads", state.WrittenPayloads, "writtenBytes", state.WrittenBytes, "writeFailures", state.WriteFailures, + "orderedReceived", state.OrderedReceived, + "orderedEnqueued", state.OrderedEnqueued, + "orderedRejected", state.OrderedRejected, + "orderedWritten", state.OrderedWritten, + "orderedSaturations", state.OrderedSaturations, + "orderedWriteFailures", state.OrderedWriteFailures, + "orderedLifecycleDiscardedFrames", + state.OrderedLifecycleDiscardedFrames, + "orderedLifecycleDiscardedBytes", state.OrderedLifecycleDiscardedBytes, "queueDepth", state.QueueDepth, "queueHighWater", state.QueueHighWater, + "queueDurationUS", state.QueueDurationUS, + "queueDurationHighUS", state.QueueDurationHighUS, "maxEnqueueGapUS", state.MaxEnqueueGapUS, - "maxWriteGapUS", state.MaxWriteGapUS) + "maxWriteGapUS", state.MaxWriteGapUS, + "teardownFailures", state.TeardownFailures, + "teardownPending", state.TeardownPending) } func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool { @@ -551,7 +775,7 @@ func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool { for len(remaining) > 0 { n, err := w.conn.Write(remaining) if err != nil || n <= 0 { - w.invalidateStream() + w.faultStream("socket write failed") return false } remaining = remaining[n:] @@ -565,19 +789,48 @@ func (w *dualSenseOutputWriter) release(frame dualSenseOutputFrame) { } } -func (w *dualSenseOutputWriter) Stop() { +func (w *dualSenseOutputWriter) Stop() error { w.requestStop() if w.conn != nil { _ = w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)) _ = w.conn.Close() } + timer := time.NewTimer(dualSenseOutputJoinTimeout) + defer timer.Stop() select { case <-w.done: - case <-time.After(300 * time.Millisecond): + w.telemetry.teardownPending.Store(false) + return w.latchedTeardownError() + case <-timer.C: + w.latchTeardownFailure(errDualSenseOutputJoinTimeout) + w.telemetry.teardownPending.Store(true) + w.teardownJoinOnce.Do(func() { + go func() { + <-w.done + w.telemetry.teardownPending.Store(false) + }() + }) + return w.latchedTeardownError() } } +func (w *dualSenseOutputWriter) latchTeardownFailure(err error) { + w.teardownFailureOnce.Do(func() { + w.teardownMu.Lock() + w.teardownErr = err + w.teardownMu.Unlock() + w.telemetry.teardownFailures.Add(1) + }) +} + +func (w *dualSenseOutputWriter) latchedTeardownError() error { + w.teardownMu.Lock() + defer w.teardownMu.Unlock() + return w.teardownErr +} + func (w *dualSenseOutputWriter) requestStop() { + w.accepting.Store(false) w.stopOnce.Do(func() { w.streamViable.Store(false) w.enqueueLock.Lock() @@ -587,9 +840,27 @@ func (w *dualSenseOutputWriter) requestStop() { }) } -func (w *dualSenseOutputWriter) invalidateStream() { +func (w *dualSenseOutputWriter) faultStream(reason string) { + w.accepting.Store(false) w.streamViable.Store(false) + w.requestStop() + w.telemetry.active.Store(false) + if w.logger != nil { + w.logger.Error("DualSense output stream faulted", "reason", reason) + } if w.conn != nil { _ = w.conn.Close() } } + +func decrementUint64(target *atomic.Uint64) uint64 { + for { + current := target.Load() + if current == 0 { + return 0 + } + if target.CompareAndSwap(current, current-1) { + return current - 1 + } + } +} diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go index 03375d36..2d37e226 100644 --- a/device/dualsense/output_writer_test.go +++ b/device/dualsense/output_writer_test.go @@ -58,7 +58,9 @@ func TestDualSenseV5WriterPublishesOnlyV5AtomicFrames(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } state := writer.telemetry.snapshot() if state.ReceivedPayloads != 1 || state.WrittenPayloads != 1 || state.DroppedPayloads != 0 || state.WriteFailures != 0 || state.Active { @@ -86,7 +88,9 @@ func TestDualSenseV5WriterPublishesRealtimeHapticsFrame(t *testing.T) { } _ = client.Close() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } } func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) { @@ -109,12 +113,15 @@ func TestDualSenseV5WriterAlternatesControlAndMedia(t *testing.T) { t.Fatalf("frame %d type=0x%02X want=0x%02X", index, header[5], want) } } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() } -func TestDualSenseV5WriterRetainsNewestFinalControlState(t *testing.T) { - writer := newDualSenseOutputWriter(nil, nil, nil) +func TestDualSenseV5WriterFaultsOnOrderedSaturationWithoutEviction(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) for marker := 0; marker < dualSenseOutputControlQueueCapacity; marker++ { writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) } @@ -125,32 +132,55 @@ func TestDualSenseV5WriterRetainsNewestFinalControlState(t *testing.T) { } for index := 0; index < dualSenseOutputControlQueueCapacity; index++ { frame := <-writer.control - want := byte(index + 1) - if index == dualSenseOutputControlQueueCapacity-1 { - want = 0xFF - } + decrementUint64(&writer.telemetry.orderedQueueDepth) + want := byte(index) if len(frame.payload) != 1 || frame.payload[0] != want { t.Fatalf("control[%d]=% x want=%02x", index, frame.payload, want) } } + state := writer.telemetry.snapshot() + if state.OrderedReceived != uint64(dualSenseOutputControlQueueCapacity+1) || + state.OrderedEnqueued != uint64(dualSenseOutputControlQueueCapacity) || + state.OrderedRejected != 1 || state.OrderedSaturations != 1 || + state.Active || writer.accepting.Load() { + t.Fatalf("unexpected saturation state: %+v", state) + } + buffer := make([]byte, 1) + if count, err := client.Read(buffer); count != 0 || err == nil { + t.Fatalf("saturation did not close owning stream: count=%d err=%v", count, err) + } + _ = client.Close() } -func TestDualSenseV5WriterRetainsNewestRealtimeHapticsState(t *testing.T) { +func TestDualSenseV5WriterBoundsRealtimeMediaAndDropsOldest(t *testing.T) { writer := newDualSenseOutputWriter(nil, nil, nil) - for marker := 0; marker < dualSenseOutputControlQueueCapacity; marker++ { + for marker := 0; marker < dualSenseRealtimeMediaQueueCapacity; marker++ { writer.EnqueueRealtimeHaptics([]byte{byte(marker)}) } writer.EnqueueRealtimeHaptics([]byte{0xFF}) - for index := 0; index < dualSenseOutputControlQueueCapacity; index++ { - frame := <-writer.realtimeHaptics + if len(writer.audio) != dualSenseRealtimeMediaQueueCapacity { + t.Fatalf("media depth=%d want=%d", len(writer.audio), + dualSenseRealtimeMediaQueueCapacity) + } + for index := 0; index < dualSenseRealtimeMediaQueueCapacity; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) want := byte(index + 1) - if index == dualSenseOutputControlQueueCapacity-1 { + if index == dualSenseRealtimeMediaQueueCapacity-1 { want = 0xFF } if len(frame.payload) != 1 || frame.payload[0] != want { t.Fatalf("realtime[%d]=% x want=%02x", index, frame.payload, want) } } + state := writer.telemetry.snapshot() + if state.Overruns != 1 || state.DroppedPayloads != 1 || + state.DroppedBytes != 1 || + state.QueueHighWater != uint64(dualSenseRealtimeMediaQueueCapacity) || + state.QueueDurationHighUS > + dualSenseMediaMaximumBufferTime.Microseconds() { + t.Fatalf("unexpected media overrun telemetry: %+v", state) + } } func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) { @@ -161,12 +191,14 @@ func TestDualSenseV5WriterShutdownReturnsEveryMediaBuffer(t *testing.T) { writer.EnqueueAtomicAudioHaptics(feedback, speaker) } go writer.Run() - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() state := writer.telemetry.snapshot() if state.Active || state.QueueDepth != 0 || len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("shutdown retained buffers: state=%+v queued=%d free=%d", state, len(writer.audio), len(writer.audioFree)) } @@ -228,9 +260,10 @@ func TestDualSenseV5WriterWriteFailureCannotRaceFinalDrain(t *testing.T) { writer.enqueueLock.RLock() buffer := <-writer.audioFree buffer[0] = 0x55 + writer.telemetry.queueDepth.Add(1) writer.audio <- dualSenseOutputFrame{ frameType: StreamFrameAtomicAudioHaptics, - payload: buffer[:4], audio: true, + payload: buffer[:4], media: true, audio: true, mediaBytes: 4, } _ = client.Close() writer.enqueueLock.RUnlock() @@ -241,10 +274,35 @@ func TestDualSenseV5WriterWriteFailureCannotRaceFinalDrain(t *testing.T) { t.Fatal("writer did not finish after socket failure") } if len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatalf("shutdown retained a pooled buffer: queued=%d free=%d", len(writer.audio), len(writer.audioFree)) } + state := writer.telemetry.snapshot() + if state.OrderedWriteFailures != 1 || state.OrderedWritten != 0 { + t.Fatalf("ordered write failure was not accounted: %+v", state) + } +} + +func TestDualSenseV5WriterAccountsMediaWriteFailure(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, nil, nil) + feedback, speaker := testV5Media(0x61) + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + _ = client.Close() + go writer.Run() + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("media write failure did not stop writer") + } + state := writer.telemetry.snapshot() + if state.WriteFailures != 1 || state.WrittenPayloads != 0 || state.Active { + t.Fatalf("media write failure was not accounted: %+v", state) + } + if len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { + t.Fatalf("media write failure leaked pool: free=%d", len(writer.audioFree)) + } } func TestDualSenseV5WriterResetIsHardGenerationBarrier(t *testing.T) { @@ -285,7 +343,9 @@ func TestDualSenseV5WriterResetIsHardGenerationBarrier(t *testing.T) { if newPayload[2] != 0x22 { t.Fatalf("post-reset frame is stale: % x", newPayload[:4]) } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() } @@ -311,14 +371,16 @@ func TestDualSenseV5WriterResetBoundsBlockedWrite(t *testing.T) { t.Fatal("timed-out write did not stop the stream") } if writer.streamViable.Load() || len(writer.audio) != 0 || - len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + len(writer.audioFree) != dualSenseOutputAudioPoolCapacity { t.Fatal("failed stream retained V5 transport state") } buffer := make([]byte, StreamFrameHeaderSize+4) if count, err := client.Read(buffer); count != 0 || err == nil { t.Fatalf("failed stream replayed stale media: bytes=%d err=%v", count, err) } - writer.Stop() + if err := writer.Stop(); err != nil { + t.Fatal(err) + } _ = client.Close() } diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index 7a3ab97b..c99507d6 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -188,7 +188,7 @@ func TestDuplexWriterFramesSpeakerPCM(t *testing.T) { assert.Equal(t, pcm, payload) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } func TestAudioInterfaceTransitionsDropPreviousGeneration(t *testing.T) { @@ -236,7 +236,7 @@ func TestAudioInterfaceTransitionsDropPreviousGeneration(t *testing.T) { dev.SetSpeakerCallback(nil) dev.SetSpeakerResetCallback(nil) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) { @@ -283,7 +283,7 @@ func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) dev.SetSpeakerCallback(nil) dev.SetSpeakerResetCallback(nil) require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } func TestSpeakerRejectsPublicationFromPreResetRevision(t *testing.T) { @@ -345,8 +345,9 @@ func TestSpeakerResetWaitsForInFlightDevicePublication(t *testing.T) { assert.Equal(t, 1, resetCalls) } -func TestDualShock4WriterRetainsNewestFinalControlState(t *testing.T) { - writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) +func TestDualShock4WriterFaultsOnOrderedSaturationWithoutEviction(t *testing.T) { + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) for marker := 0; marker < cap(writer.control); marker++ { writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) } @@ -355,12 +356,21 @@ func TestDualShock4WriterRetainsNewestFinalControlState(t *testing.T) { require.Len(t, writer.control, depth) for index := 0; index < depth; index++ { frame := <-writer.control - want := byte(index + 1) - if index == depth-1 { - want = 0xFF - } - require.Equal(t, []byte{want}, frame.payload) + decrementDualShock4Uint64(&writer.telemetry.orderedQueueDepth) + require.Equal(t, []byte{byte(index)}, frame.payload) } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(depth+1), state.OrderedReceived) + assert.Equal(t, uint64(depth), state.OrderedEnqueued) + assert.Equal(t, uint64(1), state.OrderedRejected) + assert.Equal(t, uint64(1), state.OrderedSaturations) + assert.False(t, state.Active) + assert.False(t, writer.accepting.Load()) + buffer := make([]byte, 1) + count, err := client.Read(buffer) + assert.Zero(t, count) + assert.Error(t, err, "saturation must close the owning stream") + require.NoError(t, client.Close()) } type dualShock4WriteGateConn struct { @@ -409,7 +419,7 @@ func TestSpeakerResetWaitsForInFlightWrite(t *testing.T) { } require.NoError(t, client.Close()) - writer.Stop() + require.NoError(t, writer.Stop()) } type dualShock4DeadlineBlockConn struct { @@ -467,14 +477,14 @@ func TestSpeakerResetBoundsBlockedWriteAndDropsQueuedGeneration(t *testing.T) { Conn: server, started: make(chan struct{}), unblock: make(chan struct{}), } writer := newDualShock4OutputWriter(conn, StreamFrameVersionV3) - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x11}) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x11, 0x11, 0x11, 0x11}) go writer.Run() select { case <-conn.started: case <-time.After(time.Second): t.Fatal("speaker writer did not enter the blocked write") } - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x22}) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x22, 0x22, 0x22, 0x22}) resetStarted := time.Now() resetDone := make(chan struct{}) @@ -509,7 +519,9 @@ func TestSpeakerResetBoundsBlockedWriteAndDropsQueuedGeneration(t *testing.T) { assert.GreaterOrEqual(t, closeCount, 1, "timed-out stream was not closed for reconnect") assert.Empty(t, writer.audio) - writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x33}) + assert.Equal(t, uint64(1), + writer.telemetry.snapshot().MediaWriteFailures) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x33, 0x33, 0x33, 0x33}) assert.Empty(t, writer.audio, "failed writer accepted audio instead of waiting for reconnect") diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 9e5ddd34..a42e65a0 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -52,6 +52,7 @@ type DualShock4 struct { streamFrameVersion byte microphoneBuffer microphonebuffer.Buffer microphoneSignal chan struct{} + speakerStreamTelemetry *dualShock4OutputStreamTelemetry mtx sync.Mutex } @@ -173,6 +174,21 @@ func (d *DualShock4) setSpeakerCallbacks(speaker func([]byte), reset func()) { }) } +// detachSpeakerStreamCallbacks is the terminal transport boundary. It fences +// every callback already publishing and removes future producers without +// synchronously invoking the old writer's reset callback. The owning handler +// then performs the authoritative writer rundown and reports any join error. +func (d *DualShock4) detachSpeakerStreamCallbacks() { + d.speakerPublishMu.Lock() + defer d.speakerPublishMu.Unlock() + + d.mtx.Lock() + d.speakerRevision++ + d.speakerFunc = nil + d.speakerResetFunc = nil + d.mtx.Unlock() +} + func (d *DualShock4) replaceSpeakerCallbacks(update func()) { d.speakerPublishMu.Lock() defer d.speakerPublishMu.Unlock() @@ -242,6 +258,52 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return map[string]any{} } res["speakerInterfaceActive"] = d.speakerInterfaceActive + speakerState := d.speakerStreamTelemetry.snapshot() + res["speakerStreamActive"] = speakerState.Active + res["speakerOrderedFramesReceived"] = speakerState.OrderedReceived + res["speakerOrderedFramesEnqueued"] = speakerState.OrderedEnqueued + res["speakerOrderedFramesRejected"] = speakerState.OrderedRejected + res["speakerOrderedFramesWritten"] = speakerState.OrderedWritten + res["speakerOrderedSaturations"] = speakerState.OrderedSaturations + res["speakerOrderedQueueDepth"] = speakerState.OrderedQueueDepth + res["speakerOrderedQueueHighWater"] = speakerState.OrderedQueueHighWater + res["speakerOrderedLifecycleDiscardedFrames"] = + speakerState.OrderedLifecycleDiscardedFrames + res["speakerOrderedLifecycleDiscardedBytes"] = + speakerState.OrderedLifecycleDiscardedBytes + res["speakerPayloadsReceived"] = speakerState.MediaReceivedPayloads + res["speakerBytesReceived"] = speakerState.MediaReceivedBytes + res["speakerPayloadsEnqueued"] = speakerState.MediaEnqueuedPayloads + res["speakerBytesEnqueued"] = speakerState.MediaEnqueuedBytes + res["speakerPayloadsRejectedAfterFault"] = speakerState.MediaRejectedPayloads + res["speakerBytesRejectedAfterFault"] = speakerState.MediaRejectedBytes + res["speakerMalformedPayloads"] = speakerState.MediaMalformedPayloads + res["speakerMalformedBytes"] = speakerState.MediaMalformedBytes + res["speakerOversizePayloads"] = speakerState.MediaOversizePayloads + res["speakerOversizeBytes"] = speakerState.MediaOversizeBytes + res["speakerPayloadsDropped"] = speakerState.MediaDroppedPayloads + res["speakerBytesDropped"] = speakerState.MediaDroppedBytes + res["speakerQueueOverruns"] = speakerState.MediaOverruns + res["speakerQueueUnderruns"] = speakerState.MediaUnderruns + res["speakerLateGaps"] = speakerState.MediaLateGaps + res["speakerStalePayloads"] = speakerState.MediaStalePayloads + res["speakerStaleBytes"] = speakerState.MediaStaleBytes + res["speakerLifecycleDiscardedPayloads"] = + speakerState.MediaLifecycleDiscardedPayloads + res["speakerLifecycleDiscardedBytes"] = + speakerState.MediaLifecycleDiscardedBytes + res["speakerPayloadsWritten"] = speakerState.MediaWrittenPayloads + res["speakerBytesWritten"] = speakerState.MediaWrittenBytes + res["speakerOrderedWriteFailures"] = speakerState.OrderedWriteFailures + res["speakerWriteFailures"] = speakerState.MediaWriteFailures + res["speakerQueueDepth"] = speakerState.MediaQueueDepth + res["speakerQueueHighWater"] = speakerState.MediaQueueHighWater + res["speakerQueueDurationUS"] = speakerState.MediaQueueDurationUS + res["speakerQueueDurationHighWaterUS"] = speakerState.MediaQueueDurationHighWaterUS + res["speakerMaxEnqueueGapUS"] = speakerState.MaxMediaEnqueueGapUS + res["speakerMaxWriteGapUS"] = speakerState.MaxMediaWriteGapUS + res["speakerTeardownFailures"] = speakerState.TeardownFailures + res["speakerTeardownPending"] = speakerState.TeardownPending res["microphoneInterfaceActive"] = d.microphoneInterfaceActive microphoneState := d.microphoneBuffer.State() res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes @@ -272,6 +334,16 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return res } +// beginSpeakerStream gives each transport generation independent counters so +// an older writer cannot overwrite the health state of its replacement. +func (d *DualShock4) beginSpeakerStream() *dualShock4OutputStreamTelemetry { + telemetry := &dualShock4OutputStreamTelemetry{} + d.mtx.Lock() + d.speakerStreamTelemetry = telemetry + d.mtx.Unlock() + return telemetry +} + func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { if iface == InterfaceSpeaker { d.resetSpeakerPresentation(func() { diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index ba30720f..8affce0d 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -3,6 +3,7 @@ package dualshock4 import ( "encoding/binary" "encoding/json" + "errors" "fmt" "hash/crc32" "io" @@ -131,7 +132,8 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { var writer *dualShock4OutputWriter if speakerOutput && streamFrameVersion == StreamFrameVersionV3 { - writer = newDualShock4OutputWriter(conn, streamFrameVersion) + writer = newDualShock4OutputWriterForStream(conn, streamFrameVersion, + ds4.beginSpeakerStream(), logger) ds4.SetOutputCallback(func(feedback OutputState) { data, err := feedback.MarshalBinary() if err != nil { @@ -145,119 +147,335 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } ds4.setSpeakerCallbacks(speakerCallback, writer.ResetSpeaker) go writer.Run() - defer func() { - ds4.SetOutputCallback(nil) - ds4.setSpeakerCallbacks(nil, nil) - writer.Stop() - }() - } else { - ds4.SetOutputCallback(func(feedback OutputState) { - data, err := feedback.MarshalBinary() - if err != nil { - logger.Error("failed to marshal feedback", "error", err) - return - } - if _, err := conn.Write(data); err != nil { - logger.Error("failed to send feedback", "error", err) - } - }) - defer ds4.SetOutputCallback(nil) + streamErr := readDualShock4InputStream(conn, ds4, logger, + microphoneInput, streamFrameVersion) + // Detach producers before requesting writer rundown. Stop returns a + // latched failure if the writer cannot authoritatively join. + ds4.SetOutputCallback(nil) + ds4.detachSpeakerStreamCallbacks() + return errors.Join(streamErr, writer.Stop()) } + ds4.SetOutputCallback(func(feedback OutputState) { + data, err := feedback.MarshalBinary() + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send feedback", "error", err) + } + }) + defer ds4.SetOutputCallback(nil) return readDualShock4InputStream(conn, ds4, logger, microphoneInput, streamFrameVersion) } } type dualShock4OutputFrame struct { - frameType byte - payload []byte - pooledBuffer *dualShock4AudioBuffer - audio bool - generation uint64 + frameType byte + payload []byte + pooledBuffer *dualShock4AudioBuffer + audio bool + mediaBytes int + mediaDuration time.Duration + generation uint64 + publication uint64 } type dualShock4AudioBuffer struct { data []byte } -const dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond +const ( + dualShock4OutputControlQueueCapacity = 32 + dualShock4SpeakerFrameBytes = USBSpeakerChannels * USBSpeakerBytesPerSample + // Cadence is retained solely for observed producer-gap telemetry. Queue + // admission derives time from each callback's actual aligned PCM frames. + dualShock4SpeakerGenerationFrames = USBSpeakerSampleRate / 100 + dualShock4SpeakerGenerationCadence = time.Second * + dualShock4SpeakerGenerationFrames / USBSpeakerSampleRate + dualShock4SpeakerMaximumBufferTime = 200 * time.Millisecond + dualShock4SpeakerMaximumBufferFrames = int( + int64(USBSpeakerSampleRate) * int64(dualShock4SpeakerMaximumBufferTime) / + int64(time.Second)) + // Preserve the cadence-derived item ceiling as an independent allocation + // bound. Exact payload duration below additionally enforces the 200 ms cap. + dualShock4OutputAudioQueueCapacity = int( + dualShock4SpeakerMaximumBufferTime / dualShock4SpeakerGenerationCadence) + dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond + dualShock4OutputJoinTimeout = 300 * time.Millisecond +) + +var errDualShock4OutputJoinTimeout = errors.New( + "DualShock 4 output writer did not stop before the join deadline") + +type dualShock4OutputStreamTelemetry struct { + orderedReceived atomic.Uint64 + orderedEnqueued atomic.Uint64 + orderedRejected atomic.Uint64 + orderedWritten atomic.Uint64 + orderedSaturations atomic.Uint64 + orderedQueueDepth atomic.Uint64 + orderedQueueHighWater atomic.Uint64 + orderedLifecycleDiscardedFrames atomic.Uint64 + orderedLifecycleDiscardedBytes atomic.Uint64 + mediaReceivedPayloads atomic.Uint64 + mediaReceivedBytes atomic.Uint64 + mediaEnqueuedPayloads atomic.Uint64 + mediaEnqueuedBytes atomic.Uint64 + mediaRejectedPayloads atomic.Uint64 + mediaRejectedBytes atomic.Uint64 + mediaMalformedPayloads atomic.Uint64 + mediaMalformedBytes atomic.Uint64 + mediaOversizePayloads atomic.Uint64 + mediaOversizeBytes atomic.Uint64 + mediaDroppedPayloads atomic.Uint64 + mediaDroppedBytes atomic.Uint64 + mediaOverruns atomic.Uint64 + mediaUnderruns atomic.Uint64 + mediaLateGaps atomic.Uint64 + mediaStalePayloads atomic.Uint64 + mediaStaleBytes atomic.Uint64 + mediaLifecycleDiscardedPayloads atomic.Uint64 + mediaLifecycleDiscardedBytes atomic.Uint64 + mediaWrittenPayloads atomic.Uint64 + mediaWrittenBytes atomic.Uint64 + orderedWriteFailures atomic.Uint64 + mediaWriteFailures atomic.Uint64 + mediaQueueDepth atomic.Uint64 + mediaQueueHighWater atomic.Uint64 + mediaQueueDurationNS atomic.Int64 + mediaQueueDurationHighNS atomic.Int64 + lastMediaEnqueueNS atomic.Int64 + maxMediaEnqueueGapNS atomic.Int64 + lastMediaWriteNS atomic.Int64 + maxMediaWriteGapNS atomic.Int64 + active atomic.Bool + teardownFailures atomic.Uint64 + teardownPending atomic.Bool +} + +type dualShock4OutputStreamSnapshot struct { + OrderedReceived uint64 + OrderedEnqueued uint64 + OrderedRejected uint64 + OrderedWritten uint64 + OrderedSaturations uint64 + OrderedQueueDepth uint64 + OrderedQueueHighWater uint64 + OrderedLifecycleDiscardedFrames uint64 + OrderedLifecycleDiscardedBytes uint64 + MediaReceivedPayloads uint64 + MediaReceivedBytes uint64 + MediaEnqueuedPayloads uint64 + MediaEnqueuedBytes uint64 + MediaRejectedPayloads uint64 + MediaRejectedBytes uint64 + MediaMalformedPayloads uint64 + MediaMalformedBytes uint64 + MediaOversizePayloads uint64 + MediaOversizeBytes uint64 + MediaDroppedPayloads uint64 + MediaDroppedBytes uint64 + MediaOverruns uint64 + MediaUnderruns uint64 + MediaLateGaps uint64 + MediaStalePayloads uint64 + MediaStaleBytes uint64 + MediaLifecycleDiscardedPayloads uint64 + MediaLifecycleDiscardedBytes uint64 + MediaWrittenPayloads uint64 + MediaWrittenBytes uint64 + OrderedWriteFailures uint64 + MediaWriteFailures uint64 + MediaQueueDepth uint64 + MediaQueueHighWater uint64 + MediaQueueDurationUS int64 + MediaQueueDurationHighWaterUS int64 + MaxMediaEnqueueGapUS int64 + MaxMediaWriteGapUS int64 + Active bool + TeardownFailures uint64 + TeardownPending bool +} + +func (s *dualShock4OutputStreamTelemetry) snapshot() dualShock4OutputStreamSnapshot { + if s == nil { + return dualShock4OutputStreamSnapshot{} + } + return dualShock4OutputStreamSnapshot{ + OrderedReceived: s.orderedReceived.Load(), + OrderedEnqueued: s.orderedEnqueued.Load(), + OrderedRejected: s.orderedRejected.Load(), + OrderedWritten: s.orderedWritten.Load(), + OrderedSaturations: s.orderedSaturations.Load(), + OrderedQueueDepth: s.orderedQueueDepth.Load(), + OrderedQueueHighWater: s.orderedQueueHighWater.Load(), + OrderedLifecycleDiscardedFrames: s.orderedLifecycleDiscardedFrames.Load(), + OrderedLifecycleDiscardedBytes: s.orderedLifecycleDiscardedBytes.Load(), + MediaReceivedPayloads: s.mediaReceivedPayloads.Load(), + MediaReceivedBytes: s.mediaReceivedBytes.Load(), + MediaEnqueuedPayloads: s.mediaEnqueuedPayloads.Load(), + MediaEnqueuedBytes: s.mediaEnqueuedBytes.Load(), + MediaRejectedPayloads: s.mediaRejectedPayloads.Load(), + MediaRejectedBytes: s.mediaRejectedBytes.Load(), + MediaMalformedPayloads: s.mediaMalformedPayloads.Load(), + MediaMalformedBytes: s.mediaMalformedBytes.Load(), + MediaOversizePayloads: s.mediaOversizePayloads.Load(), + MediaOversizeBytes: s.mediaOversizeBytes.Load(), + MediaDroppedPayloads: s.mediaDroppedPayloads.Load(), + MediaDroppedBytes: s.mediaDroppedBytes.Load(), + MediaOverruns: s.mediaOverruns.Load(), + MediaUnderruns: s.mediaUnderruns.Load(), + MediaLateGaps: s.mediaLateGaps.Load(), + MediaStalePayloads: s.mediaStalePayloads.Load(), + MediaStaleBytes: s.mediaStaleBytes.Load(), + MediaLifecycleDiscardedPayloads: s.mediaLifecycleDiscardedPayloads.Load(), + MediaLifecycleDiscardedBytes: s.mediaLifecycleDiscardedBytes.Load(), + MediaWrittenPayloads: s.mediaWrittenPayloads.Load(), + MediaWrittenBytes: s.mediaWrittenBytes.Load(), + OrderedWriteFailures: s.orderedWriteFailures.Load(), + MediaWriteFailures: s.mediaWriteFailures.Load(), + MediaQueueDepth: s.mediaQueueDepth.Load(), + MediaQueueHighWater: s.mediaQueueHighWater.Load(), + MediaQueueDurationUS: s.mediaQueueDurationNS.Load() / + int64(time.Microsecond), + MediaQueueDurationHighWaterUS: s.mediaQueueDurationHighNS.Load() / + int64(time.Microsecond), + MaxMediaEnqueueGapUS: s.maxMediaEnqueueGapNS.Load() / + int64(time.Microsecond), + MaxMediaWriteGapUS: s.maxMediaWriteGapNS.Load() / + int64(time.Microsecond), + Active: s.active.Load(), + TeardownFailures: s.teardownFailures.Load(), + TeardownPending: s.teardownPending.Load(), + } +} // dualShock4OutputWriter keeps USB isochronous completion independent from // local TCP backpressure. Control feedback and speaker PCM share one writer so // their framing sequence is strictly monotonic and conn.Write is never raced. type dualShock4OutputWriter struct { - conn net.Conn - version byte - control chan dualShock4OutputFrame - audio chan dualShock4OutputFrame - stop chan struct{} - done chan struct{} - stopOnce sync.Once - enqueueLock sync.RWMutex - controlEnqueue sync.Mutex - audioWrite sync.Mutex - stopped bool - audioGeneration atomic.Uint64 - sequence uint32 - packet []byte - audioPool sync.Pool + conn net.Conn + version byte + logger *slog.Logger + telemetry *dualShock4OutputStreamTelemetry + control chan dualShock4OutputFrame + audio chan dualShock4OutputFrame + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + controlEnqueue sync.Mutex + audioEnqueue sync.Mutex + audioWrite sync.Mutex + stopped bool + accepting atomic.Bool + audioGeneration atomic.Uint64 + orderedPublication uint64 + sequence uint32 + packet []byte + audioPool sync.Pool + teardownMu sync.Mutex + teardownErr error + teardownFailureOnce sync.Once + teardownJoinOnce sync.Once } func newDualShock4OutputWriter(conn net.Conn, version byte) *dualShock4OutputWriter { - return &dualShock4OutputWriter{ - conn: conn, version: version, - control: make(chan dualShock4OutputFrame, 32), - audio: make(chan dualShock4OutputFrame, 256), - stop: make(chan struct{}), done: make(chan struct{}), + return newDualShock4OutputWriterForStream(conn, version, nil, nil) +} + +func newDualShock4OutputWriterForStream(conn net.Conn, version byte, + telemetry *dualShock4OutputStreamTelemetry, + logger *slog.Logger) *dualShock4OutputWriter { + if telemetry == nil { + telemetry = &dualShock4OutputStreamTelemetry{} + } + telemetry.orderedQueueDepth.Store(0) + telemetry.mediaQueueDepth.Store(0) + telemetry.mediaQueueDurationNS.Store(0) + telemetry.lastMediaEnqueueNS.Store(0) + telemetry.lastMediaWriteNS.Store(0) + telemetry.active.Store(true) + w := &dualShock4OutputWriter{ + conn: conn, version: version, logger: logger, telemetry: telemetry, + control: make(chan dualShock4OutputFrame, + dualShock4OutputControlQueueCapacity), + audio: make(chan dualShock4OutputFrame, + dualShock4OutputAudioQueueCapacity), + stop: make(chan struct{}), done: make(chan struct{}), } + w.accepting.Store(true) + return w } func (w *dualShock4OutputWriter) EnqueueControl(frameType byte, payload []byte) { if len(payload) == 0 { return } - w.enqueueLock.RLock() - defer w.enqueueLock.RUnlock() - if w.stopped { - return - } w.controlEnqueue.Lock() defer w.controlEnqueue.Unlock() - w.enqueueNewestControlLocked(dualShock4OutputFrame{ + w.telemetry.orderedReceived.Add(1) + if !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + return + } + frame := dualShock4OutputFrame{ frameType: frameType, payload: append([]byte(nil), payload...), - }) -} - -// enqueueNewestControlLocked preserves an explicit final controller state -// when the bounded feedback lane is saturated. Old intermediate feedback can -// be coalesced; the newest release/LED/rumble state cannot be silently lost. -func (w *dualShock4OutputWriter) enqueueNewestControlLocked( - frame dualShock4OutputFrame, -) { - select { - case w.control <- frame: - return - default: } - select { - case <-w.control: - default: + w.enqueueLock.RLock() + if w.stopped || !w.accepting.Load() { + w.telemetry.orderedRejected.Add(1) + w.enqueueLock.RUnlock() + return } + w.orderedPublication++ + frame.publication = w.orderedPublication + depth := w.telemetry.orderedQueueDepth.Add(1) select { case w.control <- frame: + w.telemetry.orderedEnqueued.Add(1) + recordDualShock4MaximumUint64(&w.telemetry.orderedQueueHighWater, depth) + w.enqueueLock.RUnlock() + return default: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) } + // Ordered feedback is lossless while the stream is viable. Capacity + // exhaustion is therefore a stream failure, never permission to evict an + // earlier rumble/LED/media-configuration update. + w.telemetry.orderedRejected.Add(1) + if !w.accepting.CompareAndSwap(true, false) { + w.enqueueLock.RUnlock() + return + } + w.telemetry.orderedSaturations.Add(1) + w.enqueueLock.RUnlock() + w.failStream("ordered output queue saturated") } func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { if len(payload) == 0 { return } + w.audioEnqueue.Lock() + defer w.audioEnqueue.Unlock() + w.recordMediaReceive(len(payload)) + duration, valid := w.validateMediaPayload(payload) + if !valid { + return + } + if !w.accepting.Load() { + w.recordMediaRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordMediaRejected(len(payload)) return } var buffer *dualShock4AudioBuffer @@ -276,11 +494,10 @@ func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { copy(owned, payload) frame := dualShock4OutputFrame{ frameType: frameType, payload: owned, pooledBuffer: buffer, audio: true, + mediaBytes: len(payload), mediaDuration: duration, generation: w.audioGeneration.Load(), } - if !w.enqueueFrameLocked(w.audio, frame) { - w.releaseAudioBuffer(buffer) - } + w.enqueueMediaDropOldestLocked(frame) } // EnqueueAudioOwned accepts the immutable buffer transferred by DualShock4's @@ -289,43 +506,82 @@ func (w *dualShock4OutputWriter) EnqueueAudioOwned(frameType byte, payload []byt if len(payload) == 0 { return } + w.audioEnqueue.Lock() + defer w.audioEnqueue.Unlock() + w.recordMediaReceive(len(payload)) + duration, valid := w.validateMediaPayload(payload) + if !valid { + return + } + if !w.accepting.Load() { + w.recordMediaRejected(len(payload)) + return + } w.enqueueLock.RLock() defer w.enqueueLock.RUnlock() - if w.stopped { + if w.stopped || !w.accepting.Load() { + w.recordMediaRejected(len(payload)) return } - w.enqueueFrameLocked(w.audio, dualShock4OutputFrame{ + w.enqueueMediaDropOldestLocked(dualShock4OutputFrame{ frameType: frameType, payload: payload, audio: true, + mediaBytes: len(payload), mediaDuration: duration, generation: w.audioGeneration.Load(), }) } -// enqueueFrameLocked requires enqueueLock to be held for reading. Reset and -// shutdown take the write side before draining, so a producer cannot publish a -// stale frame after the final empty-queue observation. -func (w *dualShock4OutputWriter) enqueueFrameLocked( - queue chan dualShock4OutputFrame, frame dualShock4OutputFrame) bool { +// enqueueMediaDropOldestLocked keeps at most the derived 200 ms media window. +// The only removable item is read from the queue itself, so an in-flight write +// is never selected as the overrun victim. +func (w *dualShock4OutputWriter) enqueueMediaDropOldestLocked( + frame dualShock4OutputFrame) { + duration := int64(frame.mediaDuration) + for w.telemetry.mediaQueueDurationNS.Load()+duration > + int64(dualShock4SpeakerMaximumBufferTime) || + w.telemetry.mediaQueueDepth.Load() >= uint64(cap(w.audio)) { + select { + case oldest := <-w.audio: + w.recordMediaDequeued(oldest) + w.recordMediaOverrun(oldest) + w.release(oldest) + default: + // The sole consumer may have removed the last queued item between + // observations. Retry admission using the exact atomic totals. + continue + } + } + reservedDuration := w.telemetry.mediaQueueDurationNS.Add(duration) + depth := w.telemetry.mediaQueueDepth.Add(1) select { - case queue <- frame: - return true + case w.audio <- frame: + w.recordMediaEnqueue(frame.mediaBytes, depth, reservedDuration) default: - // Never block the USB/IP isochronous or HID callback. The receiver - // bounds its own latency too, so dropping newest under pathological - // backpressure is preferable to stalling the virtual USB device. - return false + w.telemetry.mediaQueueDurationNS.Add(-duration) + decrementDualShock4Uint64(&w.telemetry.mediaQueueDepth) + w.recordMediaOverrun(frame) + w.release(frame) } } func (w *dualShock4OutputWriter) Run() { defer func() { w.requestStop() - w.drainAudioQueue() + w.drainControlQueue() + w.drainAudioQueue(dualShock4MediaDiscardLifecycle) + w.telemetry.active.Store(false) + w.traceOutputState() close(w.done) }() for { + select { + case <-w.stop: + return + default: + } // Give feedback priority without starving speaker packets. select { case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } @@ -337,10 +593,12 @@ func (w *dualShock4OutputWriter) Run() { case <-w.stop: return case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) if !w.writeAndRelease(frame) { return } case frame := <-w.audio: + w.recordMediaDequeued(frame) if !w.writeAndRelease(frame) { return } @@ -353,12 +611,24 @@ func (w *dualShock4OutputWriter) writeAndRelease(frame dualShock4OutputFrame) bo w.audioWrite.Lock() defer w.audioWrite.Unlock() if frame.generation != w.audioGeneration.Load() { + w.recordMediaStale(frame) w.release(frame) return true } } ok := w.write(frame) + if frame.audio { + if ok { + w.recordMediaWrite(len(frame.payload)) + } else { + w.telemetry.mediaWriteFailures.Add(1) + } + } else if !ok { + w.telemetry.orderedWriteFailures.Add(1) + } else { + w.telemetry.orderedWritten.Add(1) + } w.release(frame) return ok } @@ -369,14 +639,16 @@ func (w *dualShock4OutputWriter) writeAndRelease(frame dualShock4OutputFrame) bo func (w *dualShock4OutputWriter) ResetSpeaker() { w.enqueueLock.Lock() w.audioGeneration.Add(1) - w.drainAudioQueue() + w.drainAudioQueue(dualShock4MediaDiscardStale) + w.telemetry.lastMediaEnqueueNS.Store(0) + w.telemetry.lastMediaWriteNS.Store(0) w.enqueueLock.Unlock() deadlineArmed := false if w.conn != nil { if err := w.conn.SetWriteDeadline( time.Now().Add(dualShock4SpeakerResetWriteTimeout)); err != nil { - w.failStream() + w.failStream("speaker reset deadline failed") } else { deadlineArmed = true } @@ -398,14 +670,44 @@ func (w *dualShock4OutputWriter) clearWriteDeadlineIfViable() { err := w.conn.SetWriteDeadline(time.Time{}) w.enqueueLock.RUnlock() if err != nil { - w.failStream() + w.failStream("speaker reset deadline clear failed") } } -func (w *dualShock4OutputWriter) drainAudioQueue() { +type dualShock4MediaDiscardReason uint8 + +const ( + dualShock4MediaDiscardStale dualShock4MediaDiscardReason = iota + 1 + dualShock4MediaDiscardLifecycle +) + +func (w *dualShock4OutputWriter) drainControlQueue() { + for { + select { + case frame := <-w.control: + decrementDualShock4Uint64(&w.telemetry.orderedQueueDepth) + w.telemetry.orderedLifecycleDiscardedFrames.Add(1) + w.telemetry.orderedLifecycleDiscardedBytes.Add( + uint64(len(frame.payload))) + w.release(frame) + default: + return + } + } +} + +func (w *dualShock4OutputWriter) drainAudioQueue( + reason dualShock4MediaDiscardReason) { for { select { case frame := <-w.audio: + w.recordMediaDequeued(frame) + switch reason { + case dualShock4MediaDiscardStale: + w.recordMediaStale(frame) + case dualShock4MediaDiscardLifecycle: + w.recordMediaLifecycleDiscard(frame) + } w.release(frame) default: return @@ -440,7 +742,7 @@ func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { for len(remaining) > 0 { n, err := w.conn.Write(remaining) if err != nil || n <= 0 { - w.failStream() + w.failStream("socket write failed") return false } remaining = remaining[n:] @@ -448,8 +750,20 @@ func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { return true } -func (w *dualShock4OutputWriter) failStream() { +func (w *dualShock4OutputWriter) failStream(reason string) { + w.accepting.Store(false) w.requestStop() + w.telemetry.active.Store(false) + if w.logger != nil { + state := w.telemetry.snapshot() + w.logger.Error("DualShock 4 output stream faulted", + "reason", reason, + "orderedRejected", state.OrderedRejected, + "orderedSaturations", state.OrderedSaturations, + "orderedWriteFailures", state.OrderedWriteFailures, + "mediaOverruns", state.MediaOverruns, + "mediaWriteFailures", state.MediaWriteFailures) + } if w.conn != nil { _ = w.conn.Close() } @@ -466,16 +780,51 @@ func (w *dualShock4OutputWriter) releaseAudioBuffer(buffer *dualShock4AudioBuffe w.audioPool.Put(buffer) } -func (w *dualShock4OutputWriter) Stop() { +func (w *dualShock4OutputWriter) Stop() error { w.requestStop() - _ = w.conn.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) + if w.conn != nil { + _ = w.conn.SetWriteDeadline( + time.Now().Add(dualShock4SpeakerResetWriteTimeout)) + _ = w.conn.Close() + } + timer := time.NewTimer(dualShock4OutputJoinTimeout) + defer timer.Stop() select { case <-w.done: - case <-time.After(300 * time.Millisecond): + w.telemetry.teardownPending.Store(false) + return w.latchedTeardownError() + case <-timer.C: + w.latchTeardownFailure(errDualShock4OutputJoinTimeout) + w.telemetry.teardownPending.Store(true) + // Keep the writer and every dependent queue/buffer alive until Run's + // authoritative final drain closes done. + w.teardownJoinOnce.Do(func() { + go func() { + <-w.done + w.telemetry.teardownPending.Store(false) + }() + }) + return w.latchedTeardownError() } } +func (w *dualShock4OutputWriter) latchTeardownFailure(err error) { + w.teardownFailureOnce.Do(func() { + w.teardownMu.Lock() + w.teardownErr = err + w.teardownMu.Unlock() + w.telemetry.teardownFailures.Add(1) + }) +} + +func (w *dualShock4OutputWriter) latchedTeardownError() error { + w.teardownMu.Lock() + defer w.teardownMu.Unlock() + return w.teardownErr +} + func (w *dualShock4OutputWriter) requestStop() { + w.accepting.Store(false) w.stopOnce.Do(func() { w.enqueueLock.Lock() w.stopped = true @@ -484,6 +833,160 @@ func (w *dualShock4OutputWriter) requestStop() { }) } +func (w *dualShock4OutputWriter) traceOutputState() { + if w.logger == nil { + return + } + state := w.telemetry.snapshot() + w.logger.Info("DualShock 4 output stream stopped", + "orderedReceived", state.OrderedReceived, + "orderedEnqueued", state.OrderedEnqueued, + "orderedRejected", state.OrderedRejected, + "orderedWritten", state.OrderedWritten, + "orderedSaturations", state.OrderedSaturations, + "orderedLifecycleDiscardedFrames", state.OrderedLifecycleDiscardedFrames, + "orderedLifecycleDiscardedBytes", state.OrderedLifecycleDiscardedBytes, + "mediaReceivedPayloads", state.MediaReceivedPayloads, + "mediaEnqueuedPayloads", state.MediaEnqueuedPayloads, + "mediaRejectedPayloads", state.MediaRejectedPayloads, + "mediaMalformedPayloads", state.MediaMalformedPayloads, + "mediaOversizePayloads", state.MediaOversizePayloads, + "mediaDroppedPayloads", state.MediaDroppedPayloads, + "mediaOverruns", state.MediaOverruns, + "mediaUnderruns", state.MediaUnderruns, + "mediaLateGaps", state.MediaLateGaps, + "mediaStalePayloads", state.MediaStalePayloads, + "mediaLifecycleDiscardedPayloads", + state.MediaLifecycleDiscardedPayloads, + "mediaWrittenPayloads", state.MediaWrittenPayloads, + "mediaWriteFailures", state.MediaWriteFailures, + "mediaQueueHighWater", state.MediaQueueHighWater, + "mediaQueueDurationHighWaterUS", state.MediaQueueDurationHighWaterUS, + "teardownFailures", state.TeardownFailures, + "teardownPending", state.TeardownPending) +} + +func (w *dualShock4OutputWriter) validateMediaPayload(payload []byte) ( + time.Duration, bool) { + if len(payload)%dualShock4SpeakerFrameBytes != 0 { + w.telemetry.mediaMalformedPayloads.Add(1) + w.telemetry.mediaMalformedBytes.Add(uint64(len(payload))) + return 0, false + } + frames := int64(len(payload) / dualShock4SpeakerFrameBytes) + // Round up fractional nanoseconds so admission is conservative for any + // future sample rate that does not divide one second exactly. + durationNS := (frames*int64(time.Second) + + int64(USBSpeakerSampleRate) - 1) / int64(USBSpeakerSampleRate) + duration := time.Duration(durationNS) + if duration > dualShock4SpeakerMaximumBufferTime { + w.telemetry.mediaOversizePayloads.Add(1) + w.telemetry.mediaOversizeBytes.Add(uint64(len(payload))) + return 0, false + } + return duration, true +} + +func (w *dualShock4OutputWriter) recordMediaReceive(length int) { + w.telemetry.mediaReceivedPayloads.Add(1) + w.telemetry.mediaReceivedBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastMediaEnqueueNS.Swap(now) + if previous <= 0 || now <= previous { + return + } + gap := now - previous + recordDualShock4MaximumInt64(&w.telemetry.maxMediaEnqueueGapNS, gap) + cadence := int64(dualShock4SpeakerGenerationCadence) + if gap > cadence+cadence/2 { + w.telemetry.mediaLateGaps.Add(1) + missing := uint64(gap/cadence) - 1 + if missing == 0 { + missing = 1 + } + w.telemetry.mediaUnderruns.Add(missing) + } +} + +func (w *dualShock4OutputWriter) recordMediaRejected(length int) { + w.telemetry.mediaRejectedPayloads.Add(1) + w.telemetry.mediaRejectedBytes.Add(uint64(length)) +} + +func (w *dualShock4OutputWriter) recordMediaEnqueue(length int, depth uint64, + duration int64) { + w.telemetry.mediaEnqueuedPayloads.Add(1) + w.telemetry.mediaEnqueuedBytes.Add(uint64(length)) + recordDualShock4MaximumUint64(&w.telemetry.mediaQueueHighWater, depth) + recordDualShock4MaximumInt64(&w.telemetry.mediaQueueDurationHighNS, duration) +} + +func (w *dualShock4OutputWriter) recordMediaDequeued( + frame dualShock4OutputFrame) { + decrementDualShock4Uint64(&w.telemetry.mediaQueueDepth) + if frame.mediaDuration > 0 { + w.telemetry.mediaQueueDurationNS.Add(-int64(frame.mediaDuration)) + } +} + +func (w *dualShock4OutputWriter) recordMediaOverrun(frame dualShock4OutputFrame) { + w.telemetry.mediaOverruns.Add(1) + w.telemetry.mediaDroppedPayloads.Add(1) + w.telemetry.mediaDroppedBytes.Add(uint64(len(frame.payload))) +} + +func (w *dualShock4OutputWriter) recordMediaStale(frame dualShock4OutputFrame) { + w.telemetry.mediaStalePayloads.Add(1) + w.telemetry.mediaStaleBytes.Add(uint64(len(frame.payload))) +} + +func (w *dualShock4OutputWriter) recordMediaLifecycleDiscard( + frame dualShock4OutputFrame) { + w.telemetry.mediaLifecycleDiscardedPayloads.Add(1) + w.telemetry.mediaLifecycleDiscardedBytes.Add(uint64(frame.mediaBytes)) +} + +func (w *dualShock4OutputWriter) recordMediaWrite(length int) { + w.telemetry.mediaWrittenPayloads.Add(1) + w.telemetry.mediaWrittenBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastMediaWriteNS.Swap(now) + if previous > 0 && now > previous { + recordDualShock4MaximumInt64(&w.telemetry.maxMediaWriteGapNS, + now-previous) + } +} + +func recordDualShock4MaximumInt64(target *atomic.Int64, value int64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +func recordDualShock4MaximumUint64(target *atomic.Uint64, value uint64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +func decrementDualShock4Uint64(target *atomic.Uint64) uint64 { + for { + current := target.Load() + if current == 0 { + return 0 + } + if target.CompareAndSwap(current, current-1) { + return current - 1 + } + } +} + func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, logger *slog.Logger, microphoneInput bool, frameVersion byte) error { streamDone := api.StreamDone(conn) diff --git a/device/dualshock4/output_backpressure_test.go b/device/dualshock4/output_backpressure_test.go new file mode 100644 index 00000000..c99ce612 --- /dev/null +++ b/device/dualshock4/output_backpressure_test.go @@ -0,0 +1,423 @@ +package dualshock4 + +import ( + "context" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func TestDualShock4OrderedPublicationIsFIFOWithConcurrentProducers(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + const producers = 24 + start := make(chan struct{}) + var wait sync.WaitGroup + wait.Add(producers) + for marker := 0; marker < producers; marker++ { + marker := byte(marker) + go func() { + defer wait.Done() + <-start + writer.EnqueueControl(StreamFrameOutputState, []byte{marker}) + }() + } + close(start) + wait.Wait() + + seen := make(map[byte]bool, producers) + for publication := uint64(1); publication <= producers; publication++ { + frame := <-writer.control + decrementDualShock4Uint64(&writer.telemetry.orderedQueueDepth) + require.Equal(t, publication, frame.publication) + require.Len(t, frame.payload, 1) + assert.False(t, seen[frame.payload[0]], "payload was duplicated") + seen[frame.payload[0]] = true + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(producers), state.OrderedReceived) + assert.Equal(t, uint64(producers), state.OrderedEnqueued) + assert.Zero(t, state.OrderedRejected) + assert.Zero(t, state.OrderedSaturations) +} + +func dualShock4PacketPayload(packetCount int, marker byte) []byte { + payload := make([]byte, packetCount*USBSpeakerMaxPacketSize) + for index := range payload { + payload[index] = marker + } + return payload +} + +func TestDualShock4MediaDurationUsesActualPCMFrames(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + var expected time.Duration + for packets := 2; packets <= 4; packets++ { + payload := dualShock4PacketPayload(packets, byte(packets)) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, payload) + frames := len(payload) / dualShock4SpeakerFrameBytes + expected += time.Duration(frames) * time.Second / USBSpeakerSampleRate + } + require.Equal(t, 9*time.Millisecond+281250*time.Nanosecond, expected) + assert.Equal(t, int64(expected), + writer.telemetry.mediaQueueDurationNS.Load()) + assert.Equal(t, expected.Microseconds(), + writer.telemetry.snapshot().MediaQueueDurationUS) + + for packets := 2; packets <= 4; packets++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(packets), frame.payload[0]) + } + assert.Zero(t, writer.telemetry.mediaQueueDurationNS.Load()) +} + +func TestDualShock4MediaWindowIsTwoHundredMillisecondsAndDropsOldest(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + require.Equal(t, 20, cap(writer.audio)) + const payloadFrames = USBSpeakerSampleRate / 50 + payloadDuration := time.Duration(payloadFrames) * time.Second / + USBSpeakerSampleRate + frameCount := int(dualShock4SpeakerMaximumBufferTime / payloadDuration) + require.Equal(t, 10, frameCount) + for marker := 0; marker <= frameCount; marker++ { + payload := make([]byte, payloadFrames*dualShock4SpeakerFrameBytes) + for index := range payload { + payload[index] = byte(marker) + } + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, payload) + } + require.Len(t, writer.audio, frameCount) + for index := 0; index < frameCount; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(index+1), frame.payload[0]) + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(frameCount+1), + state.MediaReceivedPayloads) + assert.Equal(t, uint64(frameCount+1), + state.MediaEnqueuedPayloads) + assert.Equal(t, uint64(1), state.MediaOverruns) + assert.Equal(t, uint64(1), state.MediaDroppedPayloads) + assert.Equal(t, uint64(payloadFrames*dualShock4SpeakerFrameBytes), + state.MediaDroppedBytes) + assert.Equal(t, uint64(frameCount), + state.MediaQueueHighWater) + assert.LessOrEqual(t, state.MediaQueueDurationHighWaterUS, + dualShock4SpeakerMaximumBufferTime.Microseconds()) + assert.Zero(t, state.MediaQueueDepth) +} + +func TestDualShock4MediaItemBoundDropsOldestBeforeAllocationsGrow(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + for marker := 0; marker <= dualShock4OutputAudioQueueCapacity; marker++ { + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, + dualShock4PacketPayload(2, byte(marker))) + } + require.Len(t, writer.audio, dualShock4OutputAudioQueueCapacity) + for index := 0; index < dualShock4OutputAudioQueueCapacity; index++ { + frame := <-writer.audio + writer.recordMediaDequeued(frame) + require.Equal(t, byte(index+1), frame.payload[0]) + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaOverruns) + assert.Equal(t, uint64(1), state.MediaDroppedPayloads) + assert.Equal(t, uint64(dualShock4OutputAudioQueueCapacity), + state.MediaQueueHighWater) + assert.Less(t, state.MediaQueueDurationHighWaterUS, + dualShock4SpeakerMaximumBufferTime.Microseconds()) + assert.Zero(t, state.MediaQueueDepth) +} + +func TestDualShock4MediaRejectsMalformedAndSinglePayloadOverLimit(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3}) + tooLarge := make([]byte, + (dualShock4SpeakerMaximumBufferFrames+1)*dualShock4SpeakerFrameBytes) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, tooLarge) + + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.MediaReceivedPayloads) + assert.Equal(t, uint64(1), state.MediaMalformedPayloads) + assert.Equal(t, uint64(3), state.MediaMalformedBytes) + assert.Equal(t, uint64(1), state.MediaOversizePayloads) + assert.Equal(t, uint64(len(tooLarge)), state.MediaOversizeBytes) + assert.Zero(t, state.MediaEnqueuedPayloads) + assert.Zero(t, state.MediaQueueDepth) + assert.Empty(t, writer.audio) +} + +func TestDualShock4ResetCountsStaleGenerationAndClearsCadence(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{5, 6, 7, 8}) + writer.ResetSpeaker() + + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.MediaStalePayloads) + assert.Equal(t, uint64(8), state.MediaStaleBytes) + assert.Zero(t, state.MediaQueueDepth) + assert.Empty(t, writer.audio) + assert.Zero(t, writer.telemetry.lastMediaEnqueueNS.Load()) +} + +func TestDualShock4WriterRecordsOnlyObservedProducerCadenceGap(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.telemetry.lastMediaEnqueueNS.Store( + time.Now().Add(-35 * time.Millisecond).UnixNano()) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaLateGaps) + assert.GreaterOrEqual(t, state.MediaUnderruns, uint64(2)) +} + +func TestDualShock4OutputBackpressureTelemetryIsExposed(t *testing.T) { + controller, err := New(nil) + require.NoError(t, err) + writer := newDualShock4OutputWriterForStream(nil, StreamFrameVersionV3, + controller.beginSpeakerStream(), nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{2, 3, 4, 5}) + state := controller.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["speakerOrderedFramesEnqueued"]) + assert.Equal(t, uint64(1), state["speakerPayloadsEnqueued"]) + assert.Equal(t, int64(31), state["speakerQueueDurationUS"]) + assert.Equal(t, int64(31), + state["speakerQueueDurationHighWaterUS"]) +} + +func TestDualShock4OrderedFaultWakesOwningReadLoop(t *testing.T) { + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 1) + _, err := server.Read(buffer) + readDone <- err + }() + for marker := 0; marker <= dualShock4OutputControlQueueCapacity; marker++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(marker)}) + } + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + select { + case err := <-readDone: + assert.Error(t, err) + case <-time.After(time.Second): + t.Fatal("ordered saturation did not wake the owning read loop") + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.MediaReceivedPayloads) + assert.Equal(t, uint64(1), state.MediaRejectedPayloads) + assert.Equal(t, uint64(4), state.MediaRejectedBytes) + assert.Zero(t, state.MediaEnqueuedPayloads) + require.NoError(t, client.Close()) +} + +func TestDualShock4LifecycleDrainAccountsEveryAcceptedQueuedFrame(t *testing.T) { + writer := newDualShock4OutputWriter(nil, StreamFrameVersionV3) + writer.EnqueueControl(StreamFrameOutputState, []byte{1}) + writer.EnqueueControl(StreamFrameOutputState, []byte{2, 3}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, []byte{5, 6, 7, 8}) + writer.requestStop() + go writer.Run() + require.NoError(t, writer.Stop()) + + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(2), state.OrderedLifecycleDiscardedFrames) + assert.Equal(t, uint64(3), state.OrderedLifecycleDiscardedBytes) + assert.Equal(t, uint64(2), state.MediaLifecycleDiscardedPayloads) + assert.Equal(t, uint64(8), state.MediaLifecycleDiscardedBytes) + assert.Zero(t, state.OrderedQueueDepth) + assert.Zero(t, state.MediaQueueDepth) + assert.Zero(t, state.MediaQueueDurationUS) +} + +func TestDualShock4StopLatchesTimeoutAndContinuesAuthoritativeJoin(t *testing.T) { + server, client := net.Pipe() + gate := &dualShock4WriteGateConn{ + Conn: server, started: make(chan struct{}), release: make(chan struct{}), + } + writer := newDualShock4OutputWriter(gate, StreamFrameVersionV3) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + go writer.Run() + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + + err := writer.Stop() + require.ErrorIs(t, err, errDualShock4OutputJoinTimeout) + select { + case <-writer.done: + t.Fatal("timeout was treated as completed rundown") + default: + } + state := writer.telemetry.snapshot() + assert.Equal(t, uint64(1), state.TeardownFailures) + assert.True(t, state.TeardownPending) + + close(gate.release) + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not finish after in-flight write was released") + } + assert.Eventually(t, func() bool { + return !writer.telemetry.snapshot().TeardownPending + }, time.Second, time.Millisecond) + require.ErrorIs(t, writer.Stop(), errDualShock4OutputJoinTimeout) + require.NoError(t, client.Close()) +} + +type dualShock4UninterruptibleStreamConn struct { + readRelease chan struct{} + writeStarted chan struct{} + writeRelease chan struct{} + writeOnce sync.Once +} + +func newDualShock4UninterruptibleStreamConn() *dualShock4UninterruptibleStreamConn { + return &dualShock4UninterruptibleStreamConn{ + readRelease: make(chan struct{}), + writeStarted: make(chan struct{}), + writeRelease: make(chan struct{}), + } +} + +func (c *dualShock4UninterruptibleStreamConn) Read([]byte) (int, error) { + <-c.readRelease + return 0, io.EOF +} + +func (c *dualShock4UninterruptibleStreamConn) Write(payload []byte) (int, error) { + c.writeOnce.Do(func() { close(c.writeStarted) }) + <-c.writeRelease + return len(payload), nil +} + +func (*dualShock4UninterruptibleStreamConn) Close() error { return nil } + +func (*dualShock4UninterruptibleStreamConn) LocalAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualShock4UninterruptibleStreamConn) RemoteAddr() net.Addr { + return &net.TCPAddr{} +} + +func (*dualShock4UninterruptibleStreamConn) SetDeadline(time.Time) error { + return nil +} + +func (*dualShock4UninterruptibleStreamConn) SetReadDeadline(time.Time) error { + return nil +} + +func (*dualShock4UninterruptibleStreamConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestDualShock4HandlerDetachesBeforeAuthoritativeStop(t *testing.T) { + controller, err := New(nil) + require.NoError(t, err) + var device usb.Device = controller + conn := newDualShock4UninterruptibleStreamConn() + streamHandler := (&handler{ + speakerOutput: true, streamFrameVersion: StreamFrameVersionV3, + }).StreamHandler() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(conn, &device, logger) + }() + require.Eventually(t, func() bool { + controller.mtx.Lock() + defer controller.mtx.Unlock() + return controller.speakerFunc != nil && controller.speakerResetFunc != nil + }, time.Second, time.Millisecond) + + controller.SetInterfaceAltSetting(InterfaceSpeaker, 1) + controller.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, dualShock4PacketPayload(2, 0x5A)) + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("handler writer did not enter the uninterruptible write") + } + close(conn.readRelease) + + select { + case err := <-errCh: + require.ErrorIs(t, err, errDualShock4OutputJoinTimeout) + case <-time.After(time.Second): + t.Fatal("handler cleanup blocked in reset before Stop could report failure") + } + controller.mtx.Lock() + callbacksDetached := controller.outputFunc == nil && + controller.speakerFunc == nil && controller.speakerResetFunc == nil + controller.mtx.Unlock() + assert.True(t, callbacksDetached) + state := controller.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["speakerTeardownFailures"]) + assert.Equal(t, true, state["speakerTeardownPending"]) + + close(conn.writeRelease) + require.Eventually(t, func() bool { + state := controller.GetDeviceSpecificArgs() + return state["speakerTeardownPending"] == false && + state["speakerStreamActive"] == false + }, time.Second, time.Millisecond) +} + +func TestDualShock4ResetCloseAndInFlightWriteCannotDeadlock(t *testing.T) { + server, client := net.Pipe() + conn := &dualShock4DeadlineBlockConn{ + Conn: server, started: make(chan struct{}), unblock: make(chan struct{}), + } + writer := newDualShock4OutputWriter(conn, StreamFrameVersionV3) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{1, 2, 3, 4}) + go writer.Run() + select { + case <-conn.started: + case <-time.After(time.Second): + t.Fatal("media write did not become in-flight") + } + resetDone := make(chan struct{}) + stopDone := make(chan error, 1) + go func() { writer.ResetSpeaker(); close(resetDone) }() + go func() { stopDone <- writer.Stop() }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("reset deadlocked with in-flight write") + } + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("stop deadlocked with in-flight write") + } + select { + case <-writer.done: + default: + t.Fatal("Stop returned before writer rundown completed") + } + _ = client.Close() +} diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index 037503d8..5ad9289a 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -58,56 +58,90 @@ of the source-provenance evidence without becoming a user-machine dependency. an administrator/SYSTEM-only DACL, and pass its installer-bound SHA-256 to `ViiperUdeCtl install`. The helper independently reopens and verifies the source manifest and all three runtime driver hashes, acquires its private - driver mutex, and snapshots the exact Driver Store/devnode topology. + driver mutex, and snapshots the exact Driver Store/devnode topology. Before + its first SetupAPI or broker mutation, it creates the protected fixed + `%ProgramData%\VIIPER\UdeCx\Transactions\active-v2` journal, copies and + revalidates immutable prior/candidate recovery material, and publishes the + first write-through record in a bounded canonical SHA-256 chain. Every later + staging, quiescence, binding, rollback, reboot, and broker handoff cut point + is durably appended before the next mutation. Startup admission reconciles + this journal from exact current state; unknown transitions, identities, + package inventories, or partial topology fail closed and retain evidence. 3. Classify the driver under that mutex. Exact package bytes plus an exact - started binding cause no SetupAPI mutation. Exact bytes with missing, - stopped, or stale topology select the already-published driver for the exact - devnode and call `DiInstallDevice`; they never replace same-version Driver - Store content. An absent or newer candidate uses `DiInstallDriverW` under the - monotonic version policy. Same-version INF/SYS/CAT conflicts and implicit - downgrades fail before mutation. Only after classification proves a SetupAPI - mutation is required, the helper signals its inherited quiescence-request - event and waits for the outer transaction. The outer transaction stops only - a trusted formerly-running broker (or acknowledges an absent/already-stopped - trusted service) while retaining the broker-service mutex. Weak service - ownership aborts before driver mutation because it is not a safe rollback - source. A newer package never updates a live root bus in place: with the - trusted broker quiescent, the helper removes only the captured exact owned devnode, - proves its child topology absent, stages the candidate, recreates the same - root instance ID, and binds the exact published candidate with - `DiInstallDevice`. The captured snapshot remains authoritative until broker - commit and recreates the prior identity/package on any failure. + started binding cause no SetupAPI mutation. Same-version INF/SYS/CAT + conflicts and implicit downgrades fail before mutation. A missing candidate + is add-only staged with `SetupCopyOEMInfW`; the helper validates its returned + published name, bytes, catalog, signer, and complete package inventory, then + proves that staging did not alter the captured root. Only after that proof + does it signal the inherited quiescence request. The outer transaction stops + only a trusted broker while retaining the broker-service mutex. Weak service + ownership aborts because it is not a safe rollback source. After quiescence, + the helper re-enumerates global topology, repeats exact package/root-byte and + pristine-runtime proofs, prepares the compatible-driver list, and switches + only the captured devnode in place with `DiInstallDevice`. There is no + forward remove/recreate gap. If the captured topology had no root, the helper + obtains the generated instance ID, durably records that exact receipt before + setting its hardware ID or registering it, and can therefore reconcile a + crash-partial root without adopting a lookalike. The captured snapshot and + exact staging receipt remain authoritative until broker commit; rollback + restores the prior binding before removing only a package proved staged by + this transaction. 4. After the exact binding is verified, the helper signals its inherited broker handoff event. The outer transaction releases its protected prior-image and SCM handles, then releases the broker-service mutex on the same pinned OS thread. Only then does the helper launch the immutable package broker's hidden `native-package-broker-commit` command while still holding the driver mutex and snapshot. That command reopens the token, requires its exact - DACL/hash/path, proves the separate outer process still owns the package + DACL/hash/path, proves that the separate outer process still owns the package mutex, then acquires the broker-service mutex. An exact driver no-op skips - service quiescence but uses the same handoff before broker health/repair. -5. The nested command first checks for a true no-op: canonical protected - service/image/credential state, no live legacy owner, stable service PID, and - authenticated `ping` with `Ready=true`, ABI 1.10, the exact capability mask, - package version, and loaded-kernel build identity. If any part is unhealthy, - it transactionally publishes the exact broker through a flushed protected - sibling, creates or repairs the LocalSystem service, rotates its credential, - and repeats authenticated health before and after removing legacy - Run/task/process ownership. A weak pre-existing service is deleted and - recreated; its unsafe ACL is never repaired in place or restored. -6. The child emits one newline-terminated canonical result. A broker failure - first rolls back SCM, credential, and legacy state, then restores the prior - broker image and run-state inside the child. Only a pre-mutation proof or a - fully settled child rollback authorizes the still-running helper to restore - its captured driver packages/devnode. Crash, malformed/missing proof, exit 3, - pipe/wait ambiguity, or an over-budget child leaves driver rollback - unauthorized and reports external reconciliation. If the outer transaction - stopped a trusted prior broker and the helper fails before handoff, it keeps - the service mutex and restores the exact snapshotted service/image/run-state - only after a settled driver proof. After handoff, it reacquires that mutex - and performs the same exact revalidation/restart only when the nested and - driver rollback proof is settled; indeterminate proof leaves the service - stopped. USB/IP itself is never directly removed by this transaction. + service quiescence but uses the same handoff before broker health or repair. +5. Before its first broker mutation, the nested command builds all rollback + material below a protected + `%ProgramData%\VIIPER\BrokerTransactions\preparing-` directory. + Its bounded canonical snapshot binds the outer token, candidate and prior + image, exact service state, target SID, and encrypted prior credential and + legacy-registration artifacts. Only after every file is flushed, reopened, + hash-verified, and protected does an atomic no-replace rename publish the + fixed `active-v1` journal. A write-through SHA-256 chain then records intent + and return phases around service stop, atomic image replacement, legacy-owner + stop, credential rotation, SCM configuration, start, authentication, legacy + removal, and reauthentication. +6. The nested command accepts a true no-op only when the protected + service/image/credential state is canonical, no legacy owner is live, the + service PID is stable, and authenticated `ping` proves `Ready=true`, ABI + 1.13, the exact capability mask, package version, and loaded-kernel build + identity. Otherwise it performs the journaled repair. Exact forward health + ends at durable `nested-ready`; it does not delete rollback material or claim + outer success. A broker failure restores SCM, credential, image, legacy + state, and prior run-state in dependency order before recording an exact + rollback result. Missing, malformed, ambiguous, or corrupt evidence latches + manual reconciliation and never authorizes an independent driver rollback. +7. Driver and broker success settle through a two-phase receipt. The helper + durably records `BrokerOuterSettlementPending` and emits one canonical + binding containing both transaction IDs, both pending journal digests, the + candidate and outer-token identity, a fresh settlement nonce, and the + protected request hash. The Go parent revalidates live forward state, records + `outer-settlement-pending`, atomically publishes the protected request, and + calls the hash-pinned helper's `broker-settlement-ack` command while it still + owns the package mutex. The helper authenticates both journals and the + request, records `BrokerOuterSettled`, atomically retires its active journal + to an exact settled tombstone, and returns the final driver digest. Go binds + that receipt into `outer-settled`, publishes the protected final receipt, + atomically retires its journal, and only then asks the helper to atomically + rename the driver tombstone to an inert discarding name. Recursive deletion + is best-effort after those authoritative renames. +8. Every ordinary process or power cut re-enters the same transaction. A + protected `nested-ready`, pending settlement, active final state, or settled + tombstone is replayed idempotently using the original token and journal + identities; a new broker child is not started while old settlement exists. + The caller retries its requested new transaction only after the old one is + exactly settled. A pre-mutation proof or fully settled child rollback is the + only authority for restoring the captured driver packages/devnode. If the + outer transaction stopped a trusted prior broker and failure occurred before + handoff, it restores the exact snapshot only after settled driver proof. + After handoff, indeterminate proof leaves the service stopped and preserves + both journals for reconciliation. The legacy transport itself is never + directly removed by this transaction. The mutating broker process is never hard-terminated. The outer absolute four-minute deadline is passed through the helper into the nested broker, so it @@ -172,23 +206,23 @@ the historical broker-only uninstall routine. Exit 3, a crash, a missing/malformed proof, or any ambiguous wait cannot prove a safe binding, so the broker remains stopped and the command reports that external reconciliation is required. - Before mutation, an exact three-file INF/SYS/CAT rollback tree is placed - below the non-reparse Windows temporary directory in a cryptographically - unpredictable location. Every directory and file is created with, and then - verified against, an explicit protected Administrators/LocalSystem-only ACL; - payload writes are write-through, explicitly flushed, signature/hash - revalidated, and locked against write/delete sharing. A canonical journal - binds every captured devnode to one package index and every package to its - relative backup paths and exact hashes. It is written to a private temporary - name, flushed, atomically published with a write-through rename, reopened, - ACL/byte verified, and flushed again before the first SetupAPI mutation. - Journal presence means manual reconciliation may be required; it never - authorizes automatic restoration. Preservation is armed before mutation and - therefore survives C++ exceptions or process failure. It is disarmed only - after explicit, verified deletion following either committed removal or a - verified rollback; cleanup failure is surfaced and retains the journal and - backup tree. A backup-preparation failure whose tree cannot be deleted emits - the retained root and planned journal path with `recoveryRecordWritten=0`. + Before mutation, the helper creates the fixed protected + `%ProgramData%\VIIPER-UdeCx-RemoveTransactions\active-v2` recovery root and + stores the exact prior devnode plus every INF/SYS/CAT package in immutable + write-through backups. A canonical, bounded, append-only SHA-256 chain binds + those backups, boot/reboot epochs, and device/package entered, returned, and + committed cut points. Every directory and file is non-reparse, single-link, + Administrators/LocalSystem-only, explicitly flushed, reopened, byte-compared, + and held against write/delete sharing. Startup recovery uses exact raw-root, + package-inventory, and cut-point authority to finish removal or restore the + prior state; unknown, mixed, or concurrent topology latches manual + reconciliation without broad mutation. Terminal validation releases evidence + handles, atomically renames `active-v2` to a transaction-bound settled + tombstone, proves active admission absent, and only then makes deletion + best-effort. A retained tombstone is a successful but explicitly surfaced + cleanup warning, never hidden evidence loss. Preservation is armed before + mutation and survives exceptions, process failure, power loss, and + reboot-required SetupAPI returns. The allocation-free exception outcome separately tracks whether transaction mutation actually started: pre-mutation exceptions remain exit 4 with `changed=0`, while post-mutation exceptions require exit 3 reconciliation. @@ -215,12 +249,10 @@ which removes a selected devnode and its child topology, and which removes a specified package from devices and then the Driver Store. Both APIs return a `NeedReboot` result; the caller must aggregate that result while it finishes its other required uninstall operations. VIIPER therefore preserves -3010 only after exact owned cleanup has reconciled. The -[usbip-win2 uninstall sequence](https://github.com/vadimgrn/usbip-win2#uninstallation-of-usbip) -is used only as the devnode-before-package lifecycle reference, while -[ViGEmBus releases](https://github.com/ViGEm/ViGEmBus/releases) are used only as -the root-bus installer lifecycle reference. Neither product's broad package or -registration cleanup is treated as VIIPER ownership authority. + 3010 only after exact owned cleanup has reconciled. The transaction follows + the generic Windows devnode-before-package lifecycle and root-bus ownership + model; no third-party package, registration, service, or cleanup convention is + treated as VIIPER ownership authority. ## Reference-backed Windows invariants @@ -239,23 +271,22 @@ registration cleanup is treated as VIIPER ownership authority. and [CancelIoEx](https://learn.microsoft.com/windows/win32/fileio/cancelioex-func) lifetime rule. - SetupAPI upgrade and rollback preserve the captured root device instance ID. - A newer package follows the same devnode-before-package lifecycle used for - exact removal so Windows does not treat the operation as an in-place update - of a loaded kernel bus. Per + Upgrade add-only stages the exact candidate before broker quiescence, then + performs an exact selected-driver switch on the captured devnode. Root + creation is needed only when no prior root exists. Per [`SetupDiCreateDeviceInfoW`](https://learn.microsoft.com/windows/win32/api/setupapi/nf-setupapi-setupdicreatedeviceinfow), forward creation passes the VIIPER-owned `VIIPERUDE` device name with `DICD_GENERATE_ID` and verifies the returned `ROOT\VIIPERUDE\####` identity. - Upgrade recreation and rollback omit `DICD_GENERATE_ID`, making `DeviceName` - the complete captured instance ID. They accept only that namespace or the exact legacy + Rollback recreation omits `DICD_GENERATE_ID`, making `DeviceName` the complete + captured instance ID. It accepts only that namespace or the exact legacy `ROOT\USB\####` form produced when older builds incorrectly passed the USB class name, after the existing service/package ownership proof. The helper then verifies the restored identity, topology, and signed package hashes rather than deleting every matching devnode and manufacturing a replacement. -- ViGEmBus's root-enumerated bus architecture is used only as the lifecycle - reference: the bus owns its exact child identities and separates user-mode - submission from PnP mutation. usbip-win2 remains an untouched legacy - fallback until authenticated native health succeeds; package rollback never - treats its service or driver store as installer-owned. +- The root-enumerated bus owns its exact child identities and separates + user-mode submission from PnP mutation. The configured legacy transport + remains untouched until authenticated native health succeeds; package + rollback never treats its service or Driver Store packages as installer-owned. Every public native install, repair, or uninstall takes locks in the same machine-wide order: package mutex, then broker-service mutex. During install, @@ -264,49 +295,72 @@ replacement, then performs an explicit service-lock handoff to the nested broker commit while continuing to own the package mutex. The nested callback does not reacquire the package mutex (which would deadlock); the protected one-time token and zero-time ownership check authorize that one service -transaction. The token is removed on commit or rollback and is inert without a -live outer mutex owner. Because Win32 mutexes are thread-owned, each Go -acquisition pins its goroutine to that OS thread until the matching release; -scheduler migration cannot strand either global lock. +transaction. Once handoff occurs, an unsettled helper exit closes but preserves +the exact token so journal replay can prove the original outer identity. It is +deleted only after exact rollback or completed two-phase forward settlement and +is inert without the matching package-mutex owner. A later outer package run +may reconcile that old transaction under the same package-to-service lock order +but must return retry instead of beginning a new child in the same admission. +Because Win32 mutexes are thread-owned, each Go acquisition pins its goroutine +to that OS thread until the matching release; scheduler migration cannot strand +either global lock. ## Restart boundary -The normal newer-package path first quiesces the trusted native broker while -holding the service mutex, then removes the captured exact root devnode before -staging and recreates its identity afterward. This removes the broker's open -UdeCx ownership from the PnP boundary, avoids an in-place update of the loaded -bus, and should commit without a restart. If Windows still reports -that either removal, package staging, or exact binding requires a restart, the -helper does not start the broker or remove legacy ownership. It rolls the -attempted driver transaction back, the outer transaction restores the prior -executable/service, and preserves Windows `ERROR_SUCCESS_REBOOT_REQUIRED` -(3010) through the Go bootstrapper for the signed installer. After restart, the -installer retries the complete preflight and transaction from the beginning. -No cross-reboot journal is trusted as executable authority. +The normal newer-package path add-only stages and verifies the candidate before +quiescence, then switches only the captured root in place. Every SetupAPI return +that requests a restart is durably recorded with the boot identifier that +produced it before control can leave the mutation boundary. On that same boot, +reconciliation returns the pending restart without repeating the mutation, +starting the broker, removing legacy ownership, or retiring recovery evidence. +The production composition attempts exact driver rollback when a forward +activation requests a restart; if restoring the prior binding also needs a +restart, the journal enters `RestoreRebootPending` and the prior broker remains +stopped. A changed forward state that has not completed broker settlement is an +unsettled reconciliation result, never a success-shaped 3010. -For removal, 3010 means the helper accepted the exact devnode/package removal -but Windows needs a restart to finish it. The service and exact managed -ownership are cleaned first, then 3010 is returned. A retry before or after the -restart performs a fresh exact inventory and is idempotent; no pending-removal -journal is trusted as authority. If owned cleanup itself fails, the command -reports failure (including that Windows still requires restart) rather than -misrepresenting a partial uninstall as 3010 success. +After Windows crosses the recorded boot boundary, the helper treats current +root/package/service state as authority and the protected journal as the narrow +ownership receipt. It revalidates exact bytes, topology, phase history, and +reboot epoch before finishing forward, finishing rollback, or latching manual +reconciliation. A new install cannot start while `active-v2` or a pending +cross-journal broker settlement exists. Only an exact terminal state is +atomically retired; rerunning the signed installer then starts a fresh +transaction if the requested update still remains. + +Removal uses the same rule. Device and package API returns record their fresh +restart bit and generating boot before any subsequent step. Same-boot recovery +does not repeat a pending device removal or mutate packages. After a later boot, +the raw root namespace and exact package inventory must prove either the +expected removed prefix or the exact rollback state before work continues. +Forward 3010 authorizes only the cleanup associated with that exact committed +removal; rollback 3010 preserves the prior managed files and leaves the service +stopped until restoration settles. A crossed restart that still exposes an +indeterminate pending root, any extra package/root, or any mismatched epoch +latches manual reconciliation instead of requesting restart forever. Cleanup +failure is reported separately and never turns a partial uninstall into +terminal success. ## Deterministic gates -The normal Go suite runs a failpoint matrix for every transaction phase, -including partial preparation, authenticated-health failure, commit failure, -caller cancellation, rollback failure, and close failure. A source-contract -test requires the immutable-input locks, read-only helper verification, -protected ACLs, weak-service delete/recreate path, atomic publication, inner -driver/broker rollback, protected nested-commit token, global lock ordering, -and authenticated proof. It also rejects hard process -termination, context-killed helper processes, recursive deletion, or direct -legacy/USB-IP removal in the outer layer. +The normal Go suite and compiled helper self-test run a failpoint matrix for +every driver, broker, cross-journal settlement, rollback, reboot, and retirement +phase. Coverage includes partial protected preparation, every atomic record +publication cut, authenticated-health failure, child exit before parent proof, +both sides of the settlement acknowledgement, final-receipt publication, +active-to-settled and settled-to-discarding renames, caller cancellation, +rollback failure, and retained cleanup. Source contracts require immutable +input locks, read-only helper verification, protected ACLs, exact service +ownership, atomic image publication, exact package/root mutation, nested token +binding, global lock ordering, and both-journal authenticated proof. They reject +hard process termination, context-killed helper processes, in-place recursive +deletion of authoritative evidence, or direct legacy-transport removal in the +outer layer. The removal matrix independently covers both mutex acquisitions, immutable preflight, service inventory, partial stop, helper launch/outcome, exact cleanup, -restore failure, close failure, 3010, structured preflight, verified rollback, -unverified rollback, malformed proof, idempotent absence, and exact ownership. -The targeted matrix is also run repeatedly to catch state leakage and ordering -regressions. +restore failure, close failure, every 3010 cut and boot epoch, structured +preflight, verified and unverified rollback, malformed proof, concurrent root or +package appearance, idempotent absence, evidence-lock release, tombstone cleanup +warnings, and exact ownership. The targeted matrices are also run repeatedly to +catch state leakage and ordering regressions. diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index b34d6f1b..421ae1e0 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -17,7 +17,7 @@ disposable-machine acknowledgement, elevation, the exact source revision and interactive-user SID, and a current boot entry reporting `TESTSIGNING Yes`. It imports only the artifact-bound certificate and then executes the normal package-to-service transaction through `viiper.exe native-package-install`; -the helper is never invoked as a standalone mutation. Authenticated ABI 1.10, +the helper is never invoked as a standalone mutation. Authenticated ABI 1.13, capability, package-version, and loaded-kernel identity health must succeed before the transaction commits. @@ -81,7 +81,7 @@ mode. That mode rejects the attestation EKU and requires a release-eligible names only `ViiperUde.cat`. - The schema-2 submission manifest identifies the exact reviewed bits and the SHA-256 build identity derived from source revision, four-part DriverVer, - ABI 1.10, and the exact capability mask. That same identity is compiled into + ABI 1.13, and the exact capability mask. That same identity is compiled into the SYS that the signed catalog seals and is returned by the loaded kernel. - Returned packages contain only the canonical INF, SYS, PDB, and CAT in one directory. The unchanged INF/PDB must match the submission manifest, and diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index d3c4a73a..d7ba2568 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -15,22 +15,18 @@ after transfer ordering, cancellation, teardown, and recovery are proven. - Microsoft's UdeCx contract owns USB device creation, endpoint queues, reset, start, purge, and power lifecycle. Purge is asynchronous: pending work must be cancelled before `UdecxUsbEndpointPurgeComplete` is called. -- The local usbip-win2 0.9.7.8 reference proves that UdeCx can expose VIIPER's - bidirectional isochronous PlayStation audio topology on Windows. -- Its WHLK-released UDE lineage invokes normal-response URB completion at - `DISPATCH_LEVEL`; current upstream moved every terminal path to a real WDF - DPC. - ViGEmBus is not a UdeCx driver, so its mixed request-completion contexts are - evidence for manual-queue/cache ownership only, not for UDE completion IRQL. -- Its controller contract also reports chained-MDL, high-speed, and SuperSpeed - compatibility for a root controller with USB 2 and USB 3 ports. VIIPER - mirrors that capability set. UdeCx owns the mandatory post-enumeration child - reset; configuration replacement enters VIIPER's generation-owned lifecycle - stream after Windows has finished enumerating the child. -- ViGEmBus provides the lifecycle north star: explicit protocol negotiation, - handle-scoped ownership, bounded manual queues, cancel-safe requests, - generation-aware target teardown, and synchronization per target rather than - one global lock. +- Released UdeCx behavior demonstrates that Windows can expose VIIPER's + bidirectional isochronous PlayStation audio topology. Its normal-response URB + completion behavior also supports using a real WDF DPC for every terminal + path; the Microsoft UDE contract remains the authority for completion IRQL. +- The controller supports chained MDLs plus high-speed and SuperSpeed devices + on separate USB 2 and USB 3 ports. UdeCx owns the mandatory + post-enumeration child reset; configuration replacement enters VIIPER's + generation-owned lifecycle stream only after Windows finishes enumerating + the child. +- The lifecycle model uses explicit protocol negotiation, handle-scoped + ownership, bounded manual queues, cancel-safe requests, generation-aware + target teardown, and per-target synchronization rather than one global lock. Reference code is used for architecture and documented protocol behavior. New VIIPER code is independently named and implemented. See @@ -59,20 +55,23 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. 1. Every device is owned by exactly one open broker handle. 2. Every device identity includes a monotonically increasing generation. -3. Every operation token completes exactly once or is cancelled exactly once. -4. A completion from an old generation is rejected without touching a new - device that reused the numeric identifier. -5. Purge stops admission, cancels queued and in-flight work, waits for ownership +3. Every endpoint incarnation includes a monotonically increasing generation; + an address reused within one device generation cannot inherit requests, + publications, workers, cancels, or completions from its predecessor. +4. Every operation token completes exactly once or is cancelled exactly once. +5. A completion from an old device or endpoint generation is rejected without + touching a replacement that reused the numeric identifier or address. +6. Purge stops admission, cancels queued and in-flight work, waits for ownership to settle, then acknowledges UdeCx. -6. Driver unload and file cleanup leave no UDE device, request, or worker alive. -7. Endpoint queues are bounded. The broker enforces both its controller-wide +7. Driver unload and file cleanup leave no UDE device, request, or worker alive. +8. Endpoint queues are bounded. The broker enforces both its controller-wide ceiling and each child's negotiated pending-operation quota, so one busy media device cannot starve another controller. Saturation is observable and never overwrites live media or state silently. -8. Shared report state is snapshotted atomically before encoding. Media and +9. Shared report state is snapshotted atomically before encoding. Media and state never share mutable buffers. -9. No raw user pointer crosses the ABI. -10. The ABI is size- and version-negotiated before any mutating operation. +10. No raw user pointer crosses the ABI. +11. The ABI is size- and version-negotiated before any mutating operation. A revision mismatch has a distinct status that directs the service or installer to the exact matching native-driver package. The service also recognizes the parameter/length errors returned by native previews from @@ -84,10 +83,12 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. inputs. A stale loaded image is rejected even when its on-disk replacement, ABI, and capability mask otherwise look correct. -11. Every packed wire structure has a compiler-independent size guard. The - 72-byte completion header carries two explicit reserved words; its size - never depends on compiler tail padding. Every field offset is guarded too, - so a same-size reorder cannot silently desynchronize the C and Go layouts. +12. Every packed wire structure has a compiler-independent size guard. ABI + 1.13 carries endpoint generation in the 108-byte operation, 72-byte + completion, and 52-byte input-report records; the completion's final + 32-bit word remains explicitly reserved. Sizes never depend on compiler + tail padding, and every field offset is guarded so a same-size reorder + cannot silently desynchronize the C and Go layouts. ## Kernel/user transport @@ -97,7 +98,9 @@ The transport is intentionally split by USB semantics: stays parked in the endpoint queue; one versioned `SUBMIT_INPUT_REPORT` call completes a waiting URB without an allocation or broker round trip. ABI 1.10 classifies newly queued controller states separately from deadline-generated - cadence snapshots. Each endpoint holds a bounded preallocated transition + cadence snapshots. ABI 1.13 additionally binds every report, operation, + cancel, completion, publisher, and worker to the exact endpoint incarnation. + Each endpoint holds a bounded preallocated transition FIFO and one latest-state snapshot: Windows consumes every accepted edge in order, while idle 1 ms DS4/DualSense reports update only the snapshot and cannot crowd edges out. The passive ready callback copies one report directly @@ -140,9 +143,8 @@ guidance by listing `PASSIVE_LEVEL`; the current WDK declarations carry no IRQL SAL annotation that resolves the conflict (verified against the project's pinned WDK 10.0.28000.1839). VIIPER follows the UDE-specific compatibility rule because it explicitly covers terminal and cancellation -behavior and agrees with usbip-win2's WHLK-released DISPATCH behavior. Current -usbip-win2 upstream uses a WDF DPC; VIIPER does not copy the older reference's -synthetic IRQL raise. +behavior and agrees with released UdeCx behavior. VIIPER uses a real WDF DPC +and never synthesizes an IRQL raise. One preallocated controller WDF DPC is the only function that calls either UdeCx URB completion API. A request-context intrusive queue holds request and @@ -180,9 +182,9 @@ endpoint that was never configured. The broker owner session is deliberately one-shot. Stopping the user-mode host cancels endpoint lanes that may already own dequeued kernel requests; those requests cannot be reconstructed safely in a restarted goroutine. VIIPER must -close that driver handle and negotiate a fresh `Client`/`Host` session, matching -ViGEmBus's file-session ownership model, rather than guessing a new endpoint -sequence baseline and risking an abandoned USB request. +close that driver handle and negotiate a fresh `Client`/`Host` session. It never +guesses a new endpoint sequence baseline or risks abandoning a USB request from +the retired file session. Session shutdown owns a cancellation context before `Serve` is scheduled, so even an immediate stop cannot miss host cancellation. The client waits for all @@ -211,7 +213,7 @@ Every operation carries: - device ID and generation; - a globally unique token for that generation; -- endpoint address and transfer direction; +- endpoint address, endpoint-incarnation generation, and transfer direction; - endpoint attributes, interval, and maximum packet size copied from the UdeCx endpoint descriptor; - operation kind and URB function; @@ -251,10 +253,9 @@ unplug all converge on the same idempotent purge path. ### Composite alternate-setting identity -The usbip-win2 0.9.7.8 UdeCx implementation documents that UdeCx can report -incorrect `InterfaceNumber` and `NewInterfaceSetting` values for composite -device alternate-setting changes. usbip-win2 compensates with an upper filter -on every USB 3 root hub. VIIPER does not install that system-wide filter. +UdeCx can report unreliable `InterfaceNumber` and `NewInterfaceSetting` values +for composite-device alternate-setting changes. VIIPER does not install a +system-wide root-hub upper filter to compensate. Every endpoint callback already supplies the authoritative endpoint descriptor. The kernel copies its address, attributes, interval, and maximum packet size @@ -379,13 +380,15 @@ a wedged provider cannot retain the installer mutex indefinitely. gate, then purges user-mode queues, aborts every admitted broker operation, and uses KMDF's preceding non-power-managed queue purge as its terminal endpoint fence. While a shared device-index lock still pins every endpoint, - it observes each UdeCx-owned queue as nonaccepting, nondispatching, and - `WDF_IO_QUEUE_IDLE`, with `ActiveOperations == 0` under the broker lock. This - includes a callback already delivered by WDF but preempted before its first - driver instruction. Only after that proof does cleanup join tracked and - untracked completion counts and the final DPC, then revoke and consume UDE - handles. The final controller `EvtCleanupCallback` performs only invariant - checks because KMDF has already cleaned up child objects by then. + it requires `WdfIoQueueDriverNoRequests` and `ActiveOperations == 0` under + the broker lock. The former closes the callback-delivered/pre-first-driver- + instruction window; the latter joins forwarded work and its terminal DPC. + Queued host polls are deliberately not part of this predicate because UdeCx + owns those requests and issues the endpoint-purge transition while consuming + the child. Only after that proof does cleanup join tracked and untracked + completion counts and the final DPC, then revoke and consume UDE handles. + The final controller `EvtCleanupCallback` performs only invariant checks + because KMDF has already cleaned up child objects by then. - UdeCx USB-device deletion remains asynchronous. Shutdown snapshots and revokes each device under the embedded shared/exclusive push lock, invokes `UdecxUsbDevicePlugOutAndDelete` after dropping the lock, and never waits for @@ -426,12 +429,14 @@ a wedged provider cannot retain the installer mutex indefinitely. endpoint queue; VIIPER never starts or purges that queue. The purge callback closes admission and cancels only the requests already forwarded into VIIPER-owned paths. A passive work item only observes the associated queue: - `WDF_IO_QUEUE_IDLE` proves both that no request remains queued and that every - WDF-delivered request has completed or been canceled, while the broker-lock - rundown proves its terminal DPC has released the endpoint. Only then may the - work item call `UdecxUsbEndpointPurgeComplete`. A pipe can therefore never - restart or disappear across a live or pre-callback-delivery request, and the - client never mutates UdeCx-owned queue state. + `WdfIoQueueDriverNoRequests` proves that every WDF-delivered request has + returned to framework ownership, while the broker-lock rundown proves every + forwarded request and terminal DPC has released the endpoint. It does not + wait for UdeCx-owned queued host polls or for the queue's READY bookkeeping + to clear. Only then may the work item call + `UdecxUsbEndpointPurgeComplete`. A pipe can therefore never restart or + disappear across a live or pre-callback-delivery request, and the client + never mutates UdeCx-owned queue state. - Endpoint reset and endpoint-configuration callbacks are asynchronous UdeCx management requests, not notifications. ABI 1.10 preserves the generation-bound management tokens introduced in ABI 1.8 and adds the @@ -564,8 +569,8 @@ a wedged provider cannot retain the installer mutex indefinitely. reset, purge, and start clear it so an old pipe lifetime cannot skew a new media stream. -This follows the useful ViGEmBus pattern of per-target ownership and manual -request queues while accounting for UdeCx's endpoint-specific purge contract. +This uses per-target ownership and manual request queues while accounting for +UdeCx's endpoint-specific purge contract. Host-side create/remove gates are keyed by stable device ID: generations of one controller cannot cross, while a slow PnP transition for one pad cannot stall an independent pad's registration or removal. @@ -612,11 +617,22 @@ stall an independent pad's registration or removal. waived cancellation/IRQL failure is not a pass. - Repeated create/remove, service kill, process crash, sleep/resume, and device reconnect leave zero stale children and zero stuck requests. +- The driver retains a bounded, nonpaged lifecycle recorder partitioned by + processor. Each shard can retain the full public 512-record window, and the + query path merges only stable published records into the global latest + suffix. Monotonic per-slot claims prevent a preempted writer from overwriting + a newer wrap when processors collide on a shard; an active-slot collision is + dropped rather than waited on and sets a sticky failure flag. Lifecycle + writers take no locks, allocate no memory, and never wait. Any endpoint, + completion, controller, or owner rundown watchdog also sets a sticky status + flag, so rolling its record out of the public window cannot hide it from the + release gate. Retained records include the active count and queue state needed + to diagnose the stalled ownership boundary. - Descriptor and protocol fuzzing rejects malformed inputs without a bugcheck. - HID report ordering has no duplication or regression across generations. - DualSense and DualShock 4 media survive concurrent state and feedback traffic. -- Native latency and CPU are measured against the current USB/IP path and - ViGEmBus-style virtual input under the same workload. +- Native latency and CPU are measured against the current USB/IP path and a + comparable virtual-input baseline under the same workload. - The overlapped owner handle uses Microsoft's `FILE_SKIP_COMPLETION_PORT_ON_SUCCESS` contract. A direct input IOCTL which the kernel completes inline returns on its publisher goroutine without an @@ -678,10 +694,9 @@ authenticated commit order are documented in - Microsoft, `EVT_UDECX_USB_ENDPOINT_RESET` (asynchronous reset request) - Microsoft, `WdfIoQueueGetState`, `WDF_IO_QUEUE_STATE`, and - `WDF_IO_QUEUE_IDLE` (idle includes requests delivered to the driver) + `WdfIoQueueDriverNoRequests` (no requests are owned by driver callbacks) - - Microsoft, `UdecxUrbComplete` and `UdecxUrbCompleteWithNtStatus` @@ -691,14 +706,6 @@ authenticated commit order are documented in -- usbip-win2, separate UDE completion-DPC change - -- usbip-win2 `v.0.9.7.8` source at `74f5a7f` (WHLK-released DISPATCH - behavior) - -- ViGEmBus source archive at `d986e1d` (manual-queue and target-lifecycle - reference; not UDE) - - Microsoft, *KMDF Version History* - Microsoft, *Install the WDK using NuGet* - Microsoft Windows Driver Samples CI guidance diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index e20e7c20..7b2338be 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -30,6 +30,9 @@ const ( func install(logger *slog.Logger, transport, targetUserSID string) error { if transport == "native-ude" { + if err := requireDeveloperStandaloneNativeInstall(); err != nil { + return err + } release, err := acquireNamedNativePackageMutex( nativePackageMutexName, nativePackageTransactionTimeout, ) @@ -112,6 +115,13 @@ func install(logger *slog.Logger, transport, targetUserSID string) error { return nil } +func requireDeveloperStandaloneNativeInstall() error { + if os.Getenv("VIIPER_DEVELOPER_STANDALONE") != "1" { + return errors.New("standalone native UDE installation is developer-only on Windows; use the signed package installer or set VIIPER_DEVELOPER_STANDALONE=1 for an explicitly unsupported test machine") + } + return nil +} + func serverArguments(transport, logFile string) []string { return []string{"server", "--transport", transport, "--log.file", logFile} } diff --git a/internal/cmd/native_broker_journal_windows.go b/internal/cmd/native_broker_journal_windows.go new file mode 100644 index 00000000..dfe2f03a --- /dev/null +++ b/internal/cmd/native_broker_journal_windows.go @@ -0,0 +1,3909 @@ +//go:build windows + +package cmd + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + nativeBrokerJournalSchema = 1 + nativeBrokerJournalMaximumRecords = 96 + nativeBrokerJournalMaximumLine = 16 * 1024 + nativeBrokerJournalMaximumSnapshot = 128 * 1024 + nativeBrokerJournalMaximumSecret = 512 * 1024 + nativeBrokerJournalMaximumImage = 128 * 1024 * 1024 + nativeBrokerJournalMaximumSettlement = 16 * 1024 + nativeBrokerJournalRootName = "BrokerTransactions" + nativeBrokerJournalActiveName = "active-v1" + nativeBrokerJournalPreparingPrefix = "preparing-" + nativeBrokerJournalSettledPrefix = "settled-" + nativeBrokerJournalSnapshotName = "snapshot.json" + nativeBrokerJournalRecordsName = "journal.jsonl" + nativeBrokerJournalCredentialName = "prior-key.dpapi" + nativeBrokerJournalLegacyName = "prior-legacy.dpapi" + nativeBrokerJournalPriorImageName = "prior-image.exe" + nativeBrokerJournalSettlementName = "outer-settlement.json" + nativeBrokerJournalSettledReceiptName = "outer-settled.json" + nativeBrokerJournalSDDL = "O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + nativeBrokerJournalFileSDDL = "O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)" +) + +const ( + nativeBrokerCutRecordPartialWrite = "record-stage-partial-write" + nativeBrokerCutRecordWriteDone = "record-stage-write-complete" + nativeBrokerCutRecordSyncDone = "record-stage-sync-complete" + nativeBrokerCutRecordReadbackDone = "record-stage-readback-complete" + nativeBrokerCutRecordBeforePublish = "record-stage-before-publish" + nativeBrokerCutAfterBindingOutput = "settlement-after-binding-output" + nativeBrokerCutBeforePending = "settlement-before-broker-pending" + nativeBrokerCutAfterPending = "settlement-after-broker-pending" + nativeBrokerCutAfterRequest = "settlement-after-request-published" + nativeBrokerCutBeforeDriverAck = "settlement-before-driver-ack" + nativeBrokerCutAfterDriverAck = "settlement-after-driver-ack" + nativeBrokerCutBeforeBrokerFinal = "settlement-before-broker-final" + nativeBrokerCutAfterBrokerFinal = "settlement-after-broker-final" + nativeBrokerCutBeforeRetirement = "settlement-before-broker-retirement" + nativeBrokerCutAfterRetirement = "settlement-after-broker-retirement" + nativeBrokerCutAfterDiscard = "settlement-after-best-effort-discard" + nativeBrokerCutSettlementPartialWrite = "settlement-request-partial-write" + nativeBrokerCutSettlementWriteDone = "settlement-request-write-complete" + nativeBrokerCutSettlementSyncDone = "settlement-request-sync-complete" + nativeBrokerCutSettlementReadbackDone = "settlement-request-readback-complete" + nativeBrokerCutSettlementBeforePublish = "settlement-request-before-publish" +) + +type nativeBrokerJournalPhase string + +const ( + nativeBrokerPhasePrepared nativeBrokerJournalPhase = "prepared" + nativeBrokerPhaseServiceStopIntent nativeBrokerJournalPhase = "service-stop-intent" + nativeBrokerPhaseServiceStopped nativeBrokerJournalPhase = "service-stopped" + nativeBrokerPhaseImageSwitchIntent nativeBrokerJournalPhase = "image-switch-intent" + nativeBrokerPhaseImageSwitched nativeBrokerJournalPhase = "image-switched" + nativeBrokerPhaseLegacyStopIntent nativeBrokerJournalPhase = "legacy-stop-intent" + nativeBrokerPhaseLegacyStopped nativeBrokerJournalPhase = "legacy-stopped" + nativeBrokerPhaseCredentialWriteIntent nativeBrokerJournalPhase = "credential-write-intent" + nativeBrokerPhaseCredentialWritten nativeBrokerJournalPhase = "credential-written" + nativeBrokerPhaseServiceConfigIntent nativeBrokerJournalPhase = "service-config-intent" + nativeBrokerPhaseServiceConfigured nativeBrokerJournalPhase = "service-configured" + nativeBrokerPhaseServiceStartIntent nativeBrokerJournalPhase = "service-start-intent" + nativeBrokerPhaseServiceStarted nativeBrokerJournalPhase = "service-started" + nativeBrokerPhaseAuthenticated nativeBrokerJournalPhase = "authenticated" + nativeBrokerPhaseLegacyRemoveIntent nativeBrokerJournalPhase = "legacy-remove-intent" + nativeBrokerPhaseLegacyRemoved nativeBrokerJournalPhase = "legacy-removed" + nativeBrokerPhaseReauthenticated nativeBrokerJournalPhase = "reauthenticated" + nativeBrokerPhaseNestedReady nativeBrokerJournalPhase = "nested-ready" + nativeBrokerPhaseOuterSettlementPending nativeBrokerJournalPhase = "outer-settlement-pending" + nativeBrokerPhaseOuterSettled nativeBrokerJournalPhase = "outer-settled" + nativeBrokerPhaseRollbackIntent nativeBrokerJournalPhase = "rollback-intent" + nativeBrokerPhaseRollbackService nativeBrokerJournalPhase = "rollback-service" + nativeBrokerPhaseRollbackImage nativeBrokerJournalPhase = "rollback-image" + nativeBrokerPhaseRollbackCredential nativeBrokerJournalPhase = "rollback-credential" + nativeBrokerPhaseRollbackLegacy nativeBrokerJournalPhase = "rollback-legacy" + nativeBrokerPhaseRollbackSettled nativeBrokerJournalPhase = "rollback-settled" + nativeBrokerPhaseManual nativeBrokerJournalPhase = "manual" +) + +var nativeBrokerForwardPhaseOrder = []nativeBrokerJournalPhase{ + nativeBrokerPhasePrepared, + nativeBrokerPhaseServiceStopIntent, + nativeBrokerPhaseServiceStopped, + nativeBrokerPhaseImageSwitchIntent, + nativeBrokerPhaseImageSwitched, + nativeBrokerPhaseLegacyStopIntent, + nativeBrokerPhaseLegacyStopped, + nativeBrokerPhaseCredentialWriteIntent, + nativeBrokerPhaseCredentialWritten, + nativeBrokerPhaseServiceConfigIntent, + nativeBrokerPhaseServiceConfigured, + nativeBrokerPhaseServiceStartIntent, + nativeBrokerPhaseServiceStarted, + nativeBrokerPhaseAuthenticated, + nativeBrokerPhaseLegacyRemoveIntent, + nativeBrokerPhaseLegacyRemoved, + nativeBrokerPhaseReauthenticated, + nativeBrokerPhaseNestedReady, + nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled, +} + +var nativeBrokerRollbackPhaseOrder = []nativeBrokerJournalPhase{ + nativeBrokerPhaseRollbackIntent, + nativeBrokerPhaseRollbackService, + nativeBrokerPhaseRollbackCredential, + nativeBrokerPhaseRollbackImage, + nativeBrokerPhaseRollbackLegacy, + nativeBrokerPhaseRollbackSettled, +} + +type nativeBrokerJournalService struct { + Exists bool `json:"exists"` + WasRunning bool `json:"wasRunning"` + Config mgr.Config `json:"config"` + SecurityDescriptor string `json:"securityDescriptor"` + RecoveryActions []mgr.RecoveryAction `json:"recoveryActions"` + RecoveryResetSeconds uint32 `json:"recoveryResetSeconds"` + RecoverNonCrash bool `json:"recoverNonCrash"` +} + +type nativeBrokerJournalSnapshot struct { + Schema int `json:"schema"` + TransactionID string `json:"transactionId"` + OuterTransactionID string `json:"outerTransactionId"` + OuterTokenPath string `json:"outerTokenPath"` + TargetUserSID string `json:"targetUserSid"` + CandidatePath string `json:"candidatePath"` + CandidateSHA256 string `json:"candidateSha256"` + PriorImageExists bool `json:"priorImageExists"` + PriorImagePath string `json:"priorImagePath"` + PriorImageSHA256 string `json:"priorImageSha256"` + PriorCredentialSHA256 string `json:"priorCredentialSha256"` + PriorCredentialExists bool `json:"priorCredentialExists"` + PriorCredentialArtifact string `json:"priorCredentialArtifactSha256"` + PriorLegacyArtifact string `json:"priorLegacyArtifactSha256"` + Service nativeBrokerJournalService `json:"service"` +} + +type nativeBrokerJournalSnapshotEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerJournalSnapshot `json:"payload"` +} + +type nativeBrokerOuterSettlementBinding struct { + Schema int `json:"schema"` + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerOuterTransactionID string `json:"brokerOuterTransactionId"` + BrokerCandidateSHA256 string `json:"brokerCandidateSha256"` + BrokerNestedDigest string `json:"brokerNestedDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + SettlementNonce string `json:"settlementNonce"` +} + +type nativeBrokerOuterSettlementRequest struct { + Schema int `json:"schema"` + BindingSHA256 string `json:"bindingSha256"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + Binding nativeBrokerOuterSettlementBinding `json:"binding"` +} + +type nativeBrokerOuterSettlementEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerOuterSettlementRequest `json:"payload"` +} + +type nativeBrokerOuterSettlementPrepared struct { + Request nativeBrokerOuterSettlementRequest + RequestPath string + RequestSHA256 string + contents []byte +} + +type nativeBrokerOuterSettlementFinal struct { + Schema int `json:"schema"` + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + BrokerSettledDigest string `json:"brokerSettledDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + DriverSettledDigest string `json:"driverSettledDigest"` + SettlementNonce string `json:"settlementNonce"` + RequestSHA256 string `json:"requestSha256"` + State string `json:"state"` +} + +type nativeBrokerOuterSettlementFinalEnvelope struct { + Schema int `json:"schema"` + PayloadSHA256 string `json:"payloadSha256"` + Payload nativeBrokerOuterSettlementFinal `json:"payload"` +} + +type nativeBrokerOuterSettlementFinalPrepared struct { + Receipt nativeBrokerOuterSettlementFinal + ReceiptPath string + ReceiptSHA256 string + contents []byte +} + +func nativeBrokerDriverReceiptFromFinal( + receipt nativeBrokerOuterSettlementFinal, +) nativePackageBrokerSettlementReceipt { + return nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: receipt.BrokerTransactionID, + BrokerPendingDigest: receipt.BrokerPendingDigest, + DriverTransactionID: receipt.DriverTransactionID, + DriverPendingDigest: receipt.DriverPendingDigest, + SettlementNonce: receipt.SettlementNonce, + RequestSHA256: receipt.RequestSHA256, + State: receipt.State, + Digest: receipt.DriverSettledDigest, + } +} + +type nativeBrokerJournalRecordUnsigned struct { + Schema int `json:"schema"` + Sequence uint32 `json:"sequence"` + TransactionID string `json:"transactionId"` + Phase nativeBrokerJournalPhase `json:"phase"` + PreviousSHA256 string `json:"previousSha256"` + SnapshotSHA256 string `json:"snapshotSha256"` + DetailSHA256 string `json:"detailSha256"` +} + +type nativeBrokerJournalRecord struct { + Schema int `json:"schema"` + Sequence uint32 `json:"sequence"` + TransactionID string `json:"transactionId"` + Phase nativeBrokerJournalPhase `json:"phase"` + PreviousSHA256 string `json:"previousSha256"` + SnapshotSHA256 string `json:"snapshotSha256"` + DetailSHA256 string `json:"detailSha256"` + RecordSHA256 string `json:"recordSha256"` +} + +type nativeBrokerJournalCredentialSnapshot struct { + Schema int `json:"schema"` + Exists bool `json:"exists"` + Bytes []byte `json:"bytes"` +} + +type nativeBrokerJournalLegacySnapshot struct { + Schema int `json:"schema"` + UserSID string `json:"userSid"` + RunKeyExisted bool `json:"runKeyExisted"` + RunValue *nativeRunRegistration `json:"-"` + RunValueText *string `json:"runValue,omitempty"` + RunValueType uint32 `json:"runValueType"` + ScheduledXML *string `json:"scheduledXml,omitempty"` + ScheduledActive bool `json:"scheduledActive"` + ScheduledEnabled bool `json:"scheduledEnabled"` + Commands []nativeLegacyCommand `json:"-"` + SerializableCmds []nativeBrokerCommand `json:"commands"` +} + +type nativeBrokerCommand struct { + Executable string `json:"executable"` + Arguments []string `json:"arguments"` + WorkingDirectory string `json:"workingDirectory"` + Source uint8 `json:"source"` + WasRunning bool `json:"wasRunning"` +} + +type nativeBrokerJournal struct { + directory string + snapshot nativeBrokerJournalSnapshot + snapshotDigest string + records []nativeBrokerJournalRecord + priorLegacy *nativeBrokerJournalLegacySnapshot + cutpoint func(string) error + appendRecord func([]byte) error +} + +type nativeBrokerJournalManualError struct { + cause error +} + +type nativeBrokerJournalRetirementOperations struct { + rename func() error + proveActiveAbsent func() error + proveTombstone func() error + discardTombstone func() error + cutpoint func(string) error +} + +type nativeBrokerJournalRecordPublicationOperations struct { + loadCurrent func() ([]byte, error) + discardStaging func() error + stage func([]byte) error + beforePublish func() error + publish func() error +} + +type nativeBrokerJournalPreparationOperations struct { + createDirectory func() error + writeCredential func() error + writeLegacy func() error + writePriorImage func() error + writeSnapshot func() error + createRecordStream func() error + writePrepared func() error + publishActive func() error + cutpoint func(string) error +} + +type nativeBrokerOuterSettlementOperations struct { + recordPending func() error + publishRequest func() error + acknowledgeDriver func() error + recordBrokerSettled func() error + retireBrokerJournal func() error + discardInertState func() error + observeDiscardError func(error) + cutpoint func(string) error +} + +type nativeBrokerSettlementPublicationOperations struct { + loadPublished func() ([]byte, bool, error) + loadStaging func() ([]byte, bool, error) + discardStaging func() error + publishStaging func() error + writeNew func() error + readback func() ([]byte, error) +} + +func executeNativeBrokerJournalPreparation( + operations nativeBrokerJournalPreparationOperations, +) error { + steps := []struct { + name string + run func() error + }{ + {"directory-created", operations.createDirectory}, + {"credential-written", operations.writeCredential}, + {"legacy-written", operations.writeLegacy}, + {"prior-image-written", operations.writePriorImage}, + {"snapshot-written", operations.writeSnapshot}, + {"record-stream-created", operations.createRecordStream}, + {"prepared-written", operations.writePrepared}, + {"active-published", operations.publishActive}, + } + for _, step := range steps { + if step.run == nil { + return fmt.Errorf("native broker journal preparation operation %s is missing", step.name) + } + if err := step.run(); err != nil { + return err + } + if operations.cutpoint != nil { + if err := operations.cutpoint("prepare-" + step.name); err != nil { + return err + } + } + } + return nil +} + +func executeNativeBrokerOuterSettlement( + operations nativeBrokerOuterSettlementOperations, +) error { + if operations.recordPending == nil || operations.publishRequest == nil || + operations.acknowledgeDriver == nil || operations.recordBrokerSettled == nil || + operations.retireBrokerJournal == nil || operations.discardInertState == nil { + return errors.New("native broker outer settlement operations are incomplete") + } + cut := func(name string) error { + if operations.cutpoint == nil { + return nil + } + return operations.cutpoint(name) + } + if err := cut(nativeBrokerCutAfterBindingOutput); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforePending); err != nil { + return err + } + if err := operations.recordPending(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterPending); err != nil { + return err + } + if err := operations.publishRequest(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterRequest); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeDriverAck); err != nil { + return err + } + if err := operations.acknowledgeDriver(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterDriverAck); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeBrokerFinal); err != nil { + return err + } + if err := operations.recordBrokerSettled(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterBrokerFinal); err != nil { + return err + } + // The protected broker-final receipt is now authoritative. The driver + // tombstone may only leave exact settled discovery after validating that + // receipt; any recursive cleanup after its atomic rename is inert. + if err := operations.discardInertState(); err != nil { + if operations.observeDiscardError != nil { + operations.observeDiscardError(err) + } + return err + } + if err := cut(nativeBrokerCutAfterDiscard); err != nil { + return err + } + if err := cut(nativeBrokerCutBeforeRetirement); err != nil { + return err + } + if err := operations.retireBrokerJournal(); err != nil { + return err + } + if err := cut(nativeBrokerCutAfterRetirement); err != nil { + return err + } + return nil +} + +func executeNativeBrokerSettlementPublication( + expected []byte, + operations nativeBrokerSettlementPublicationOperations, +) error { + if len(expected) == 0 || operations.loadPublished == nil || + operations.loadStaging == nil || operations.discardStaging == nil || + operations.publishStaging == nil || operations.writeNew == nil || + operations.readback == nil { + return errors.New("native broker settlement publication operations are incomplete") + } + published, publishedExists, err := operations.loadPublished() + if err != nil { + if publishedExists { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "read published broker settlement request: %w", err, + )} + } + return err + } + staged, stagingExists, stagingErr := operations.loadStaging() + if stagingExists { + if !publishedExists && stagingErr == nil && bytes.Equal(staged, expected) { + if err := operations.publishStaging(); err != nil { + return err + } + published, publishedExists = expected, true + } else if err := operations.discardStaging(); err != nil { + return err + } + } else if stagingErr != nil { + return stagingErr + } + if publishedExists { + if !bytes.Equal(published, expected) { + return &nativeBrokerJournalManualError{cause: errors.New( + "published broker settlement request differs from the authoritative binding", + )} + } + } else if err := operations.writeNew(); err != nil { + return err + } + readback, err := operations.readback() + if err != nil { + return err + } + if !bytes.Equal(readback, expected) { + return errors.New("broker settlement request failed write-through readback") + } + return nil +} + +func (e *nativeBrokerJournalManualError) Error() string { + return "native broker recovery requires manual reconciliation: " + e.cause.Error() +} + +func (e *nativeBrokerJournalManualError) Unwrap() error { return e.cause } + +func nativeBrokerJournalHash(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func isCanonicalNativeBrokerJournalSHA256(value string) bool { + return value == strings.ToLower(value) && nativePackageSHA256.MatchString(value) +} + +func nativeBrokerJournalCanonicalJSON(value any) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + if bytes.IndexByte(data, '\n') >= 0 || len(data) > nativeBrokerJournalMaximumSnapshot { + return nil, errors.New("native broker journal canonical payload exceeds its bound") + } + return data, nil +} + +func decodeCanonicalNativeBrokerJSON(data []byte, value any, maximum int) error { + if len(data) == 0 || len(data) > maximum || bytes.IndexByte(data, '\n') >= 0 { + return errors.New("native broker journal payload has an invalid length or framing") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if decoder.More() { + return errors.New("native broker journal payload has trailing JSON") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("native broker journal payload has trailing data") + } + canonical, err := json.Marshal(value) + if err != nil { + return err + } + if !bytes.Equal(canonical, data) { + return errors.New("native broker journal payload is not in canonical byte form") + } + return nil +} + +func nativeBrokerJournalPhaseIndex(phases []nativeBrokerJournalPhase, phase nativeBrokerJournalPhase) int { + return slices.Index(phases, phase) +} + +func validateNativeBrokerJournalTransition(previous, next nativeBrokerJournalPhase) error { + if next == nativeBrokerPhaseManual { + if previous == nativeBrokerPhaseOuterSettled || previous == nativeBrokerPhaseRollbackSettled { + return errors.New("settled native broker journal cannot become manual") + } + return nil + } + if previous == nativeBrokerPhaseManual || previous == nativeBrokerPhaseOuterSettled || + previous == nativeBrokerPhaseRollbackSettled { + return fmt.Errorf("native broker journal phase %s is terminal", previous) + } + if next == nativeBrokerPhaseOuterSettlementPending && previous != nativeBrokerPhaseNestedReady { + return errors.New("native broker outer settlement must be armed directly from nested-ready") + } + if next == nativeBrokerPhaseOuterSettled && previous != nativeBrokerPhaseOuterSettlementPending { + return errors.New("native broker outer settlement requires the durable pending handshake") + } + previousForward := nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, previous) + nextForward := nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, next) + previousRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, previous) + nextRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, next) + if next == nativeBrokerPhaseRollbackIntent && previousForward >= 0 { + return nil + } + if previousRollback >= 0 && nextRollback > previousRollback { + return nil + } + if previousForward >= 0 && nextForward > previousForward { + return nil + } + return fmt.Errorf("invalid native broker journal transition %s -> %s", previous, next) +} + +func (j *nativeBrokerJournal) lastPhase() nativeBrokerJournalPhase { + if len(j.records) == 0 { + return "" + } + return j.records[len(j.records)-1].Phase +} + +func (j *nativeBrokerJournal) proof() nativeBrokerJournalProof { + digest := "" + if len(j.records) != 0 { + digest = j.records[len(j.records)-1].RecordSHA256 + } + return nativeBrokerJournalProof{ + TransactionID: j.snapshot.TransactionID, + OuterTransactionID: j.snapshot.OuterTransactionID, + CandidateSHA256: j.snapshot.CandidateSHA256, + State: string(j.lastPhase()), + Digest: digest, + } +} + +func (j *nativeBrokerJournal) appendPhase(phase nativeBrokerJournalPhase, detailSHA256 string) error { + if j == nil { + return nil + } + if detailSHA256 != "" && !isCanonicalNativeBrokerJournalSHA256(detailSHA256) { + return errors.New("native broker journal detail digest is malformed") + } + if (phase == nativeBrokerPhaseOuterSettlementPending || + phase == nativeBrokerPhaseOuterSettled) && detailSHA256 == "" { + return errors.New("native broker two-phase settlement record requires a bound detail digest") + } + if len(j.records) == 0 { + if phase != nativeBrokerPhasePrepared { + return errors.New("native broker journal must begin with prepared") + } + } else { + if j.lastPhase() == phase && j.records[len(j.records)-1].DetailSHA256 == detailSHA256 { + return nil + } + previousRollback := nativeBrokerJournalPhaseIndex( + nativeBrokerRollbackPhaseOrder, j.lastPhase(), + ) + nextRollback := nativeBrokerJournalPhaseIndex(nativeBrokerRollbackPhaseOrder, phase) + if previousRollback >= 0 && nextRollback >= 0 && nextRollback <= previousRollback { + return nil + } + if err := validateNativeBrokerJournalTransition(j.lastPhase(), phase); err != nil { + return err + } + } + if len(j.records) >= nativeBrokerJournalMaximumRecords { + return errors.New("native broker journal record bound exhausted") + } + if j.cutpoint != nil { + if err := j.cutpoint("before-record-" + string(phase)); err != nil { + return err + } + } + previous := strings.Repeat("0", 64) + if len(j.records) != 0 { + previous = j.records[len(j.records)-1].RecordSHA256 + } + unsigned := nativeBrokerJournalRecordUnsigned{ + Schema: nativeBrokerJournalSchema, Sequence: uint32(len(j.records) + 1), + TransactionID: j.snapshot.TransactionID, Phase: phase, + PreviousSHA256: previous, SnapshotSHA256: j.snapshotDigest, + DetailSHA256: detailSHA256, + } + unsignedData, err := nativeBrokerJournalCanonicalJSON(unsigned) + if err != nil { + return err + } + record := nativeBrokerJournalRecord{ + Schema: unsigned.Schema, Sequence: unsigned.Sequence, + TransactionID: unsigned.TransactionID, Phase: unsigned.Phase, + PreviousSHA256: unsigned.PreviousSHA256, SnapshotSHA256: unsigned.SnapshotSHA256, + DetailSHA256: unsigned.DetailSHA256, RecordSHA256: nativeBrokerJournalHash(unsignedData), + } + line, err := nativeBrokerJournalCanonicalJSON(record) + if err != nil { + return err + } + if len(line)+1 > nativeBrokerJournalMaximumLine { + return errors.New("native broker journal record exceeds its bound") + } + appendRecord := j.appendRecord + if appendRecord == nil { + appendRecord = func(record []byte) error { + return appendNativeBrokerJournalRecord( + filepath.Join(j.directory, nativeBrokerJournalRecordsName), record, j.cutpoint, + ) + } + } + if err := appendRecord(line); err != nil { + return err + } + j.records = append(j.records, record) + if j.cutpoint != nil { + if err := j.cutpoint("after-record-" + string(phase)); err != nil { + return err + } + } + return nil +} + +func nativeBrokerJournalPaths(userSID string) (string, string, error) { + if _, err := validateNativeInstallingUserSID(userSID); err != nil { + return "", "", err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", "", fmt.Errorf("resolve ProgramData for native broker journal: %w", err) + } + programData = filepath.Clean(programData) + product := filepath.Join(programData, "VIIPER") + root := filepath.Join(product, nativeBrokerJournalRootName) + active := filepath.Join(root, nativeBrokerJournalActiveName) + if !strings.EqualFold(filepath.Dir(root), product) || !strings.EqualFold(filepath.Dir(active), root) { + return "", "", errors.New("native broker journal path escaped its fixed ProgramData root") + } + return root, active, nil +} + +func nativeBrokerJournalActivePathUnbound() (string, error) { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return "", err + } + programData = filepath.Clean(programData) + root := filepath.Join(programData, "VIIPER", nativeBrokerJournalRootName) + active := filepath.Join(root, nativeBrokerJournalActiveName) + if !strings.EqualFold(filepath.Dir(active), root) { + return "", errors.New("native broker active journal escaped its fixed root") + } + return active, nil +} + +func createOrOpenProtectedNativeBrokerJournalDirectory(path string, create bool) (windows.Handle, bool, error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalSDDL) + if err != nil { + return 0, false, err + } + created := false + if create { + pointer, pointerErr := windows.UTF16PtrFromString(path) + if pointerErr != nil { + return 0, false, pointerErr + } + if createErr := windows.CreateDirectory(pointer, security); createErr == nil { + created = true + } else if !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + return 0, false, createErr + } + } + handle, err := openNativePathWithoutReparse( + path, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return 0, created, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerJournalSDDL); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return 0, created, err + } + return handle, created, nil +} + +func ensureNativeBrokerJournalRoot(userSID string) (string, error) { + root, _, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return "", err + } + product := filepath.Dir(root) + productHandle, err := secureNativeCredentialDirectory(product, userSID) + if err != nil { + return "", fmt.Errorf("validate native broker journal product root: %w", err) + } + defer windows.CloseHandle(productHandle) //nolint:errcheck + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, true) + if err != nil { + return "", fmt.Errorf("create or validate native broker journal root: %w", err) + } + windows.CloseHandle(rootHandle) //nolint:errcheck + return root, nil +} + +func isNativeBrokerJournalTransactionID(value string) bool { + if len(value) != 32 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func nativeBrokerJournalSiblingPath(root, prefix, transactionID string) (string, error) { + if !isNativeBrokerJournalTransactionID(transactionID) { + return "", errors.New("native broker journal directory transaction identifier is malformed") + } + path := filepath.Join(root, prefix+transactionID) + if !strings.EqualFold(filepath.Dir(path), root) || filepath.Base(path) != prefix+transactionID { + return "", errors.New("native broker journal transaction directory escaped its fixed root") + } + return path, nil +} + +func createNativeBrokerJournalPreparingDirectory(userSID, transactionID string) (string, error) { + root, err := ensureNativeBrokerJournalRoot(userSID) + if err != nil { + return "", err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return "", err + } + if _, err := nativePathAttributes(active); err == nil { + return "", &nativeBrokerJournalManualError{cause: errors.New( + "an active protected broker journal already exists", + )} + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return "", err + } + preparing, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalPreparingPrefix, transactionID, + ) + if err != nil { + return "", err + } + handle, created, err := createOrOpenProtectedNativeBrokerJournalDirectory(preparing, true) + if err != nil { + return "", fmt.Errorf("create native broker preparing journal: %w", err) + } + windows.CloseHandle(handle) //nolint:errcheck + if !created { + return "", &nativeBrokerJournalManualError{cause: errors.New( + "a transaction-identical protected preparing journal already exists", + )} + } + return preparing, nil +} + +func validateNativeBrokerJournalPublishedArtifacts(j *nativeBrokerJournal) error { + if j == nil || !isNativeBrokerJournalTransactionID(j.snapshot.TransactionID) { + return errors.New("native broker journal publication lacks an exact transaction") + } + expected := map[string]bool{ + nativeBrokerJournalSnapshotName: true, + nativeBrokerJournalRecordsName: true, + nativeBrokerJournalCredentialName: true, + nativeBrokerJournalLegacyName: true, + } + if j.snapshot.PriorImageExists { + expected[nativeBrokerJournalPriorImageName] = true + } + entries, err := os.ReadDir(j.directory) + if err != nil { + return err + } + if len(entries) != len(expected) { + return errors.New("native broker journal publication contains missing or extra artifacts") + } + for _, entry := range entries { + if entry.IsDir() || !expected[entry.Name()] { + return fmt.Errorf("native broker journal publication contains unexpected artifact %q", entry.Name()) + } + handle, err := openNativeBrokerJournalFile( + filepath.Join(j.directory, entry.Name()), windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return err + } + windows.CloseHandle(handle) //nolint:errcheck + } + return nil +} + +func publishNativeBrokerJournalActive( + userSID string, + j *nativeBrokerJournal, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhasePrepared { + return errors.New("native broker journal is not durably prepared for publication") + } + root, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return err + } + expectedPreparing, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalPreparingPrefix, j.snapshot.TransactionID, + ) + if err != nil || !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(expectedPreparing)) { + return errors.Join(err, errors.New("native broker preparing directory identity changed before publication")) + } + loaded, err := loadNativeBrokerJournal(j.directory) + if err != nil { + return fmt.Errorf("read back prepared native broker journal: %w", err) + } + if loaded.lastPhase() != nativeBrokerPhasePrepared || + loaded.proof() != j.proof() || loaded.snapshotDigest != j.snapshotDigest { + return errors.New("prepared native broker journal readback changed before publication") + } + if _, _, err := loaded.loadProtectedArtifacts(); err != nil { + return fmt.Errorf("verify protected native broker recovery artifacts before publication: %w", err) + } + if err := validateNativeBrokerJournalPublishedArtifacts(loaded); err != nil { + return err + } + if err := moveNativePackageFile(j.directory, active, false); err != nil { + return fmt.Errorf("atomically publish prepared native broker journal: %w", err) + } + j.directory = active + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return fmt.Errorf("validate published native broker journal directory: %w", err) + } + windows.CloseHandle(handle) //nolint:errcheck + reloaded, err := loadNativeBrokerJournal(active) + if err != nil { + return fmt.Errorf("read back published native broker journal: %w", err) + } + if reloaded.proof() != j.proof() || reloaded.snapshotDigest != j.snapshotDigest { + return errors.New("published native broker journal differs from its prepared receipt") + } + return nil +} + +func openNativeBrokerJournalFile(path string, access uint32, disposition uint32) (windows.Handle, error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalFileSDDL) + if err != nil { + return 0, err + } + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile( + pointer, access|windows.READ_CONTROL, windows.FILE_SHARE_READ, + security, disposition, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + return 0, err + } + fail := func(failErr error) (windows.Handle, error) { + windows.CloseHandle(handle) //nolint:errcheck + return 0, failErr + } + attribute := nativeFileAttributeTagInfo{} + if err := windows.GetFileInformationByHandleEx( + handle, windows.FileAttributeTagInfo, (*byte)(unsafe.Pointer(&attribute)), + uint32(unsafe.Sizeof(attribute)), + ); err != nil { + return fail(err) + } + if attribute.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY| + windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return fail(errors.New("native broker journal artifact is not a regular file")) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return fail(err) + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerJournalFileSDDL); err != nil { + return fail(err) + } + return handle, nil +} + +func writeNativeBrokerJournalFile(path string, contents []byte, maximum int) error { + if len(contents) == 0 || len(contents) > maximum { + return errors.New("native broker journal artifact has an invalid length") + } + next := path + ".next" + handle, err := openNativeBrokerJournalFile( + next, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return fmt.Errorf("create native broker journal staging artifact: %w", err) + } + file := os.NewFile(uintptr(handle), next) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return errors.New("wrap native broker journal staging artifact") + } + cleanup := true + defer func() { + file.Close() //nolint:errcheck + if cleanup { + os.Remove(next) //nolint:errcheck + } + }() + if _, err := file.Write(contents); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + readback, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + if err != nil { + return err + } + if !bytes.Equal(readback, contents) { + return errors.New("native broker journal artifact failed write-through readback") + } + if err := file.Close(); err != nil { + return err + } + if err := moveNativePackageFile(next, path, false); err != nil { + return err + } + cleanup = false + return nil +} + +func buildNativeBrokerJournalRecordStream(current, line []byte) ([]byte, error) { + if len(line) == 0 || len(line)+1 > nativeBrokerJournalMaximumLine || bytes.IndexByte(line, '\n') >= 0 { + return nil, errors.New("native broker journal record framing is invalid") + } + if len(current) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine || + (len(current) != 0 && current[len(current)-1] != '\n') { + return nil, errors.New("native broker journal published record stream is not exactly framed") + } + if bytes.Count(current, []byte{'\n'}) >= nativeBrokerJournalMaximumRecords { + return nil, errors.New("native broker journal record stream exceeds its bound") + } + next := make([]byte, 0, len(current)+len(line)+1) + next = append(next, current...) + next = append(next, line...) + next = append(next, '\n') + if len(next) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine { + return nil, errors.New("native broker journal record stream exceeds its bound") + } + return next, nil +} + +func discardUnpublishedNativeBrokerJournalFile(path string) error { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.DELETE, windows.OPEN_EXISTING, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(handle) //nolint:errcheck + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func stageNativeBrokerJournalRecordStream( + path string, + contents []byte, + cutpoint func(string) error, +) (resultErr error) { + if len(contents) == 0 || len(contents) > nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine { + return errors.New("native broker journal staged record stream has an invalid length") + } + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return errors.New("wrap native broker journal staged record stream") + } + defer func() { + if closeErr := file.Close(); resultErr == nil && closeErr != nil { + resultErr = closeErr + } + }() + partial := len(contents) / 2 + if partial == 0 { + partial = len(contents) + } + if written, err := file.Write(contents[:partial]); err != nil || written != partial { + if err == nil { + err = io.ErrShortWrite + } + return err + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordPartialWrite); err != nil { + return err + } + } + if partial < len(contents) { + if written, err := file.Write(contents[partial:]); err != nil || written != len(contents)-partial { + if err == nil { + err = io.ErrShortWrite + } + return err + } + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordWriteDone); err != nil { + return err + } + } + if err := file.Sync(); err != nil { + return err + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordSyncDone); err != nil { + return err + } + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + readback := make([]byte, len(contents)) + if _, err := io.ReadFull(file, readback); err != nil { + return err + } + if !bytes.Equal(readback, contents) { + return errors.New("native broker journal record failed write-through readback") + } + if cutpoint != nil { + if err := cutpoint(nativeBrokerCutRecordReadbackDone); err != nil { + return err + } + } + return nil +} + +func executeNativeBrokerJournalRecordPublication( + line []byte, + operations nativeBrokerJournalRecordPublicationOperations, +) error { + if operations.loadCurrent == nil || operations.discardStaging == nil || + operations.stage == nil || operations.beforePublish == nil || operations.publish == nil { + return errors.New("native broker journal record publication operations are incomplete") + } + current, err := operations.loadCurrent() + if err != nil { + return err + } + next, err := buildNativeBrokerJournalRecordStream(current, line) + if err != nil { + return err + } + if err := operations.discardStaging(); err != nil { + return err + } + if err := operations.stage(next); err != nil { + return err + } + if err := operations.beforePublish(); err != nil { + return err + } + return operations.publish() +} + +func appendNativeBrokerJournalRecord( + path string, + line []byte, + cutpoint func(string) error, +) error { + maximum := nativeBrokerJournalMaximumRecords * nativeBrokerJournalMaximumLine + staging := path + ".next" + published := false + defer func() { + if !published { + discardUnpublishedNativeBrokerJournalFile(staging) //nolint:errcheck + } + }() + return executeNativeBrokerJournalRecordPublication( + line, + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return nil, fmt.Errorf("open native broker journal record stream: %w", err) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, errors.New("wrap native broker journal record stream") + } + current, readErr := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + closeErr := file.Close() + return current, errors.Join(readErr, closeErr) + }, + discardStaging: func() error { + if err := discardUnpublishedNativeBrokerJournalFile(staging); err != nil { + return fmt.Errorf("discard stale unpublished broker journal record stream: %w", err) + } + return nil + }, + stage: func(next []byte) error { + return stageNativeBrokerJournalRecordStream(staging, next, cutpoint) + }, + beforePublish: func() error { + if cutpoint == nil { + return nil + } + return cutpoint(nativeBrokerCutRecordBeforePublish) + }, + publish: func() error { + if err := replaceNativePackageFileAtomically(staging, path, true); err != nil { + return fmt.Errorf("atomically publish native broker journal record stream: %w", err) + } + published = true + return nil + }, + }, + ) +} + +func readNativeBrokerJournalFile(path string, maximum int) ([]byte, error) { + handle, err := openNativeBrokerJournalFile(path, windows.GENERIC_READ, windows.OPEN_EXISTING) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, errors.New("wrap native broker journal artifact") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1)) + if err != nil { + return nil, err + } + if len(contents) == 0 || len(contents) > maximum { + return nil, errors.New("native broker journal artifact exceeds its bound") + } + return contents, nil +} + +func loadNativeBrokerJournal(directory string) (*nativeBrokerJournal, error) { + snapshotBytes, err := readNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalSnapshotName), nativeBrokerJournalMaximumSnapshot, + ) + if err != nil { + return nil, fmt.Errorf("read native broker journal snapshot: %w", err) + } + var envelope nativeBrokerJournalSnapshotEnvelope + if err := decodeCanonicalNativeBrokerJSON(snapshotBytes, &envelope, nativeBrokerJournalMaximumSnapshot); err != nil { + return nil, fmt.Errorf("decode native broker journal snapshot: %w", err) + } + payloadBytes, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nil, err + } + if envelope.Schema != nativeBrokerJournalSchema || envelope.Payload.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payloadBytes) { + return nil, errors.New("native broker journal snapshot digest or schema is invalid") + } + if err := validateNativeBrokerJournalSnapshot(envelope.Payload); err != nil { + return nil, err + } + recordBytes, err := readNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalRecordsName), + nativeBrokerJournalMaximumRecords*nativeBrokerJournalMaximumLine, + ) + if err != nil { + return nil, fmt.Errorf("read native broker journal records: %w", err) + } + if recordBytes[len(recordBytes)-1] != '\n' { + return nil, errors.New("native broker journal published record stream has a torn trailing record") + } + j := &nativeBrokerJournal{ + directory: directory, snapshot: envelope.Payload, snapshotDigest: envelope.PayloadSHA256, + } + scanner := bufio.NewScanner(bytes.NewReader(recordBytes)) + scanner.Buffer(make([]byte, 1024), nativeBrokerJournalMaximumLine) + for scanner.Scan() { + if len(j.records) >= nativeBrokerJournalMaximumRecords { + return nil, errors.New("native broker journal contains too many records") + } + line := append([]byte(nil), scanner.Bytes()...) + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON(line, &record, nativeBrokerJournalMaximumLine); err != nil { + return nil, fmt.Errorf("decode native broker journal record: %w", err) + } + if err := j.validateLoadedRecord(record); err != nil { + return nil, err + } + j.records = append(j.records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(j.records) == 0 || j.records[0].Phase != nativeBrokerPhasePrepared { + return nil, errors.New("native broker journal has no durable prepared record") + } + return j, nil +} + +func (j *nativeBrokerJournal) validateLoadedRecord(record nativeBrokerJournalRecord) error { + if record.Schema != nativeBrokerJournalSchema || + record.Sequence != uint32(len(j.records)+1) || + record.TransactionID != j.snapshot.TransactionID || + record.SnapshotSHA256 != j.snapshotDigest { + return errors.New("native broker journal record identity is inconsistent") + } + previous := strings.Repeat("0", 64) + if len(j.records) != 0 { + previous = j.records[len(j.records)-1].RecordSHA256 + if err := validateNativeBrokerJournalTransition(j.records[len(j.records)-1].Phase, record.Phase); err != nil { + return err + } + } + if record.PreviousSHA256 != previous || + (record.DetailSHA256 != "" && !isCanonicalNativeBrokerJournalSHA256(record.DetailSHA256)) { + return errors.New("native broker journal hash chain is inconsistent") + } + if (record.Phase == nativeBrokerPhaseOuterSettlementPending || + record.Phase == nativeBrokerPhaseOuterSettled) && record.DetailSHA256 == "" { + return errors.New("native broker two-phase settlement record is unbound") + } + unsigned := nativeBrokerJournalRecordUnsigned{ + Schema: record.Schema, Sequence: record.Sequence, TransactionID: record.TransactionID, + Phase: record.Phase, PreviousSHA256: record.PreviousSHA256, + SnapshotSHA256: record.SnapshotSHA256, DetailSHA256: record.DetailSHA256, + } + data, err := nativeBrokerJournalCanonicalJSON(unsigned) + if err != nil { + return err + } + if !isCanonicalNativeBrokerJournalSHA256(record.RecordSHA256) || + record.RecordSHA256 != nativeBrokerJournalHash(data) { + return errors.New("native broker journal record digest is invalid") + } + return nil +} + +func validateNativeBrokerJournalOuterTokenPath(snapshot nativeBrokerJournalSnapshot) error { + tokenBase := strings.ToLower(filepath.Base(snapshot.OuterTokenPath)) + if !filepath.IsAbs(snapshot.OuterTokenPath) || strings.IndexByte(snapshot.OuterTokenPath, 0) >= 0 || + !strings.EqualFold(filepath.Dir(snapshot.OuterTokenPath), filepath.Dir(snapshot.CandidatePath)) || + !strings.HasPrefix(tokenBase, ".viiper.transaction.") || + !strings.HasSuffix(tokenBase, ".token") { + return errors.New("native broker journal outer token path is malformed") + } + return nil +} + +func validateNativeBrokerJournalSnapshot(snapshot nativeBrokerJournalSnapshot) error { + if snapshot.Schema != nativeBrokerJournalSchema || + !isNativeBrokerJournalTransactionID(snapshot.TransactionID) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.OuterTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.CandidateSHA256) || + !filepath.IsAbs(snapshot.CandidatePath) || strings.IndexByte(snapshot.CandidatePath, 0) >= 0 { + return errors.New("native broker journal snapshot identity is malformed") + } + if err := validateNativeBrokerJournalOuterTokenPath(snapshot); err != nil { + return err + } + if _, err := validateNativeInstallingUserSID(snapshot.TargetUserSID); err != nil { + return fmt.Errorf("validate journal target user SID: %w", err) + } + for _, digest := range []string{ + snapshot.PriorCredentialArtifact, snapshot.PriorLegacyArtifact, + } { + if !isCanonicalNativeBrokerJournalSHA256(digest) { + return errors.New("native broker journal artifact digest is malformed") + } + } + if snapshot.PriorCredentialExists && !isCanonicalNativeBrokerJournalSHA256(snapshot.PriorCredentialSHA256) { + return errors.New("native broker journal prior credential digest is malformed") + } + if !snapshot.PriorCredentialExists && snapshot.PriorCredentialSHA256 != "" { + return errors.New("absent prior credential carried a digest") + } + if snapshot.PriorImageExists { + if !filepath.IsAbs(snapshot.PriorImagePath) || + !isCanonicalNativeBrokerJournalSHA256(snapshot.PriorImageSHA256) { + return errors.New("native broker journal prior image identity is malformed") + } + } else if snapshot.PriorImagePath != "" || snapshot.PriorImageSHA256 != "" { + return errors.New("absent prior image carried identity") + } + if snapshot.Service.Exists { + if strings.TrimSpace(snapshot.Service.Config.BinaryPathName) == "" || + strings.IndexByte(snapshot.Service.Config.BinaryPathName, 0) >= 0 || + strings.TrimSpace(snapshot.Service.SecurityDescriptor) == "" || + snapshot.Service.Config.Password != "" || + len(snapshot.Service.Config.BinaryPathName) > 32767 || + len(snapshot.Service.SecurityDescriptor) > 64*1024 || + len(snapshot.Service.Config.Dependencies) > 64 || + len(snapshot.Service.RecoveryActions) > 16 { + return errors.New("native broker journal prior service snapshot is incomplete") + } + for _, value := range append( + append([]string(nil), snapshot.Service.Config.Dependencies...), + snapshot.Service.Config.LoadOrderGroup, + snapshot.Service.Config.ServiceStartName, + snapshot.Service.Config.DisplayName, + snapshot.Service.Config.Description, + ) { + if strings.IndexByte(value, 0) >= 0 || len(value) > 32767 { + return errors.New("native broker journal prior service string is malformed") + } + } + } else if snapshot.Service.WasRunning || snapshot.Service.Config.BinaryPathName != "" || + snapshot.Service.SecurityDescriptor != "" || len(snapshot.Service.RecoveryActions) != 0 || + snapshot.Service.RecoveryResetSeconds != 0 || snapshot.Service.RecoverNonCrash { + return errors.New("absent prior service carried mutable state") + } + return nil +} + +func protectNativeBrokerJournalData(transactionID, outerID, kind string, plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 || len(plaintext) > nativeBrokerJournalMaximumSecret { + return nil, errors.New("native broker recovery secret has an invalid length") + } + entropy := sha256.Sum256([]byte("VIIPER/native-broker-journal/v1\x00" + transactionID + "\x00" + outerID + "\x00" + kind)) + input := windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]} + entropyBlob := windows.DataBlob{Size: uint32(len(entropy)), Data: &entropy[0]} + var output windows.DataBlob + if err := windows.CryptProtectData( + &input, nil, &entropyBlob, 0, nil, + windows.CRYPTPROTECT_LOCAL_MACHINE|windows.CRYPTPROTECT_UI_FORBIDDEN, + &output, + ); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(output.Data))) //nolint:errcheck + if output.Size == 0 || output.Size > nativeBrokerJournalMaximumSecret || output.Data == nil { + return nil, errors.New("DPAPI returned an invalid native broker recovery artifact") + } + return append([]byte(nil), unsafe.Slice(output.Data, output.Size)...), nil +} + +func unprotectNativeBrokerJournalData(transactionID, outerID, kind string, ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 || len(ciphertext) > nativeBrokerJournalMaximumSecret { + return nil, errors.New("native broker recovery ciphertext has an invalid length") + } + entropy := sha256.Sum256([]byte("VIIPER/native-broker-journal/v1\x00" + transactionID + "\x00" + outerID + "\x00" + kind)) + input := windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]} + entropyBlob := windows.DataBlob{Size: uint32(len(entropy)), Data: &entropy[0]} + var output windows.DataBlob + if err := windows.CryptUnprotectData( + &input, nil, &entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &output, + ); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(output.Data))) //nolint:errcheck + if output.Size == 0 || output.Size > nativeBrokerJournalMaximumSecret || output.Data == nil { + return nil, errors.New("DPAPI returned invalid native broker recovery plaintext") + } + return append([]byte(nil), unsafe.Slice(output.Data, output.Size)...), nil +} + +func snapshotNativeBrokerCredentialReadOnly(userSID string) ([]byte, bool, error) { + path, err := nativeServiceKeyFilePath() + if err != nil { + return nil, false, err + } + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, windows.KF_FLAG_DEFAULT) + if err != nil { + return nil, false, err + } + programData = filepath.Clean(programData) + product := filepath.Join(programData, "VIIPER") + if !strings.EqualFold(filepath.Dir(path), product) { + return nil, false, errors.New("native broker credential escaped its fixed ProgramData root") + } + if _, err := nativePathAttributes(product); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + productHandle, err := openNativePathWithoutReparse( + product, windows.FILE_READ_ATTRIBUTES|windows.READ_CONTROL, true, + ) + if err != nil { + return nil, false, err + } + defer windows.CloseHandle(productHandle) //nolint:errcheck + if err := validateNativeSecurityDescriptor(productHandle, nativeCredentialDirectorySDDL(userSID)); err != nil { + return nil, false, fmt.Errorf("validate credential directory before recovery snapshot: %w", err) + } + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + handle, err := openNativePathWithoutReparse(path, windows.GENERIC_READ|windows.READ_CONTROL, false) + if err != nil { + return nil, false, err + } + if err := requireSingleNativeFileLink(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + if err := validateNativeSecurityDescriptor(handle, nativeCredentialFileSDDL(userSID)); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + windows.CloseHandle(handle) //nolint:errcheck + return nil, false, errors.New("wrap prior native broker credential") + } + defer file.Close() //nolint:errcheck + contents, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + if err != nil { + return nil, false, err + } + if len(contents) == 0 || len(contents) > 64*1024 { + return nil, false, errors.New("prior native broker credential has an invalid length") + } + return contents, true, nil +} + +func captureNativeBrokerLegacySnapshot(ctx context.Context, userSID string) (nativeBrokerJournalLegacySnapshot, error) { + legacy, err := snapshotNativeLegacyStartup(ctx, userSID) + if err != nil { + return nativeBrokerJournalLegacySnapshot{}, err + } + if legacy.release != nil { + defer legacy.release() + } + for index := range legacy.commands { + processes, processErr := openLegacyProcessesByExecutable( + legacy.commands[index].executable, legacy.userSID, + ) + if processErr != nil { + return nativeBrokerJournalLegacySnapshot{}, processErr + } + legacy.commands[index].running = len(processes) != 0 + for _, process := range processes { + windows.CloseHandle(process.handle) //nolint:errcheck + } + } + result := nativeBrokerJournalLegacySnapshot{ + Schema: nativeBrokerJournalSchema, UserSID: legacy.userSID, + RunKeyExisted: legacy.runKeyExisted, ScheduledActive: legacy.scheduledActive, + ScheduledEnabled: legacy.scheduledEnabled, + } + if legacy.runValue != nil { + value := legacy.runValue.value + result.RunValueText = &value + result.RunValueType = legacy.runValue.valueType + } + if legacy.scheduledXML != nil { + value := *legacy.scheduledXML + result.ScheduledXML = &value + } + for _, command := range legacy.commands { + result.SerializableCmds = append(result.SerializableCmds, nativeBrokerCommand{ + Executable: command.executable, Arguments: append([]string(nil), command.arguments...), + WorkingDirectory: command.workingDirectory, Source: uint8(command.source), + WasRunning: command.running, + }) + } + if err := validateNativeBrokerLegacySnapshot(result); err != nil { + return nativeBrokerJournalLegacySnapshot{}, err + } + return result, nil +} + +func validateNativeBrokerLegacySnapshot(snapshot nativeBrokerJournalLegacySnapshot) error { + if snapshot.Schema != nativeBrokerJournalSchema { + return errors.New("native broker legacy snapshot schema is invalid") + } + if _, err := validateNativeInstallingUserSID(snapshot.UserSID); err != nil { + return err + } + if snapshot.RunValueText == nil { + if snapshot.RunValueType != 0 { + return errors.New("absent legacy Run value carried a type") + } + } else if snapshot.RunValueType != registry.SZ && snapshot.RunValueType != registry.EXPAND_SZ { + return errors.New("legacy Run value type is unsupported") + } + if snapshot.ScheduledActive && (!snapshot.ScheduledEnabled || snapshot.ScheduledXML == nil) { + return errors.New("active legacy task snapshot is not restorable") + } + if snapshot.ScheduledXML != nil && strings.TrimSpace(*snapshot.ScheduledXML) == "" { + return errors.New("legacy task snapshot contains empty XML") + } + if len(snapshot.SerializableCmds) > 8 { + return errors.New("legacy command snapshot exceeds its bound") + } + for _, command := range snapshot.SerializableCmds { + if !filepath.IsAbs(command.Executable) || strings.IndexByte(command.Executable, 0) >= 0 || + !strings.EqualFold(filepath.Base(command.Executable), "viiper.exe") || + command.Source != uint8(legacyCommandRun) || len(command.Arguments) > 64 { + return errors.New("legacy command snapshot is malformed") + } + } + return nil +} + +func copyNativeBrokerJournalImage(source windows.Handle, destination, expectedHash string) (resultErr error) { + security, err := nativeSecurityAttributes(nativeBrokerJournalFileSDDL) + if err != nil { + return err + } + pointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + target, err := windows.CreateFile( + pointer, windows.GENERIC_READ|windows.GENERIC_WRITE|windows.READ_CONTROL, + windows.FILE_SHARE_READ, security, windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT| + windows.FILE_FLAG_WRITE_THROUGH, + 0, + ) + if err != nil { + return err + } + defer func() { + windows.CloseHandle(target) //nolint:errcheck + if resultErr != nil { + deleteNativePackageFile(destination) //nolint:errcheck + } + }() + if _, err := windows.SetFilePointer(source, 0, nil, windows.FILE_BEGIN); err != nil { + return err + } + var total int64 + buffer := make([]byte, 64*1024) + for { + var read uint32 + if err := windows.ReadFile(source, buffer, &read, nil); err != nil { + return err + } + if read == 0 { + break + } + total += int64(read) + if total > nativeBrokerJournalMaximumImage { + return errors.New("prior native broker image exceeds the recovery bound") + } + var written uint32 + if err := windows.WriteFile(target, buffer[:read], &written, nil); err != nil { + return err + } + if written != read { + return io.ErrShortWrite + } + } + if err := windows.FlushFileBuffers(target); err != nil { + return err + } + if err := validateNativeSecurityDescriptor(target, nativeBrokerJournalFileSDDL); err != nil { + return err + } + if err := requireSingleNativeFileLink(target); err != nil { + return err + } + hash, err := hashNativePackageHandle(target) + if err != nil { + return err + } + if !strings.EqualFold(hash, expectedHash) { + return errors.New("prior native broker image changed during durable capture") + } + return nil +} + +func createEmptyNativeBrokerJournalRecords(path string) error { + handle, err := openNativeBrokerJournalFile( + path, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.CREATE_NEW, + ) + if err != nil { + return err + } + if err := windows.FlushFileBuffers(handle); err != nil { + windows.CloseHandle(handle) //nolint:errcheck + return err + } + return windows.CloseHandle(handle) +} + +func beginNativeBrokerJournal( + ctx context.Context, + t *windowsNativePackageTransaction, +) (_ *nativeBrokerJournal, resultErr error) { + if t == nil || !t.nestedBrokerCommit || t.tokenSHA256 == "" || + t.boundOuterTokenPath == "" { + return nil, errors.New("native broker journal requires a bound nested package transaction") + } + if t.serviceSnapshot.disposition == nativePackageServiceWeakExactOwned { + return nil, &nativeBrokerJournalManualError{cause: errors.New( + "weak service or image ownership is not a trustworthy recovery source", + )} + } + if t.serviceSnapshot.disposition == nativePackageServiceTrusted && + !strings.EqualFold(filepath.Clean(t.priorServiceExecutable), filepath.Clean(t.destination)) { + return nil, &nativeBrokerJournalManualError{cause: errors.New( + "prior service uses a noncanonical image path that cannot be durably switched", + )} + } + priorCredential, credentialExists, err := snapshotNativeBrokerCredentialReadOnly(t.request.targetUserSID) + if err != nil { + return nil, fmt.Errorf("snapshot prior native broker credential: %w", err) + } + legacy, err := captureNativeBrokerLegacySnapshot(ctx, t.request.targetUserSID) + if err != nil { + return nil, fmt.Errorf("snapshot prior legacy ownership for recovery: %w", err) + } + var transactionBytes [16]byte + if _, err := io.ReadFull(rand.Reader, transactionBytes[:]); err != nil { + return nil, err + } + transactionID := hex.EncodeToString(transactionBytes[:]) + + credentialPlain, err := nativeBrokerJournalCanonicalJSON(nativeBrokerJournalCredentialSnapshot{ + Schema: nativeBrokerJournalSchema, Exists: credentialExists, + Bytes: append([]byte(nil), priorCredential...), + }) + if err != nil { + return nil, err + } + credentialCipher, err := protectNativeBrokerJournalData( + transactionID, t.tokenSHA256, "prior-key", credentialPlain, + ) + if err != nil { + return nil, fmt.Errorf("protect prior native broker credential: %w", err) + } + legacyPlain, err := nativeBrokerJournalCanonicalJSON(legacy) + if err != nil { + return nil, err + } + legacyCipher, err := protectNativeBrokerJournalData( + transactionID, t.tokenSHA256, "prior-legacy", legacyPlain, + ) + if err != nil { + return nil, fmt.Errorf("protect prior legacy recovery state: %w", err) + } + + priorImageExists := false + priorImageHash := "" + var priorImageHandle windows.Handle + if handle, openErr := openNativePathWithoutReparse( + t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, + ); openErr == nil { + priorImageHandle = handle + defer windows.CloseHandle(priorImageHandle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + return nil, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerExecutableSDDL); err != nil { + return nil, fmt.Errorf("validate prior broker image before durable capture: %w", err) + } + priorImageHash, err = hashNativePackageHandle(handle) + if err != nil { + return nil, err + } + priorImageExists = true + } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { + return nil, openErr + } + if t.serviceSnapshot.disposition == nativePackageServiceTrusted && + (!priorImageExists || !strings.EqualFold(priorImageHash, t.priorExecutableSHA256)) { + return nil, errors.New("prior service image identity differs from the canonical image snapshot") + } + + priorImagePath := "" + if priorImageExists { + priorImagePath = t.destination + } + serviceSnapshot := nativeBrokerJournalService{} + if t.serviceSnapshot.disposition == nativePackageServiceTrusted { + serviceSnapshot = nativeBrokerJournalService{ + Exists: true, WasRunning: t.serviceSnapshot.wasRunning, + Config: t.priorServiceConfig, SecurityDescriptor: t.priorServiceDACL, + RecoveryActions: append([]mgr.RecoveryAction(nil), t.priorServiceRecovery...), + RecoveryResetSeconds: t.priorServiceReset, + RecoverNonCrash: t.priorServiceNonCrash, + } + } + credentialHash := "" + if credentialExists { + credentialHash = nativeBrokerJournalHash(priorCredential) + } + snapshot := nativeBrokerJournalSnapshot{ + Schema: nativeBrokerJournalSchema, TransactionID: transactionID, + OuterTransactionID: t.tokenSHA256, OuterTokenPath: t.boundOuterTokenPath, + TargetUserSID: t.request.targetUserSID, + CandidatePath: t.destination, CandidateSHA256: t.request.expectedBrokerSHA256, + PriorImageExists: priorImageExists, PriorImagePath: priorImagePath, + PriorImageSHA256: priorImageHash, PriorCredentialExists: credentialExists, + PriorCredentialSHA256: credentialHash, + PriorCredentialArtifact: nativeBrokerJournalHash(credentialCipher), + PriorLegacyArtifact: nativeBrokerJournalHash(legacyCipher), Service: serviceSnapshot, + } + if err := validateNativeBrokerJournalSnapshot(snapshot); err != nil { + return nil, err + } + payload, err := nativeBrokerJournalCanonicalJSON(snapshot) + if err != nil { + return nil, err + } + snapshotDigest := nativeBrokerJournalHash(payload) + envelope, err := nativeBrokerJournalCanonicalJSON(nativeBrokerJournalSnapshotEnvelope{ + Schema: nativeBrokerJournalSchema, PayloadSHA256: snapshotDigest, Payload: snapshot, + }) + if err != nil { + return nil, err + } + + directory := "" + j := &nativeBrokerJournal{ + snapshot: snapshot, snapshotDigest: snapshotDigest, + priorLegacy: &legacy, cutpoint: t.brokerJournalCutpoint, + } + cleanup := true + defer func() { + if cleanup && directory != "" { + if cleanupErr := discardNativeBrokerJournalDirectory(directory); cleanupErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("clean incomplete native broker journal: %w", cleanupErr)) + } + } + }() + if err := executeNativeBrokerJournalPreparation(nativeBrokerJournalPreparationOperations{ + createDirectory: func() error { + var createErr error + directory, createErr = createNativeBrokerJournalPreparingDirectory( + t.request.targetUserSID, transactionID, + ) + j.directory = directory + return createErr + }, + writeCredential: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalCredentialName), credentialCipher, + nativeBrokerJournalMaximumSecret, + ) + }, + writeLegacy: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalLegacyName), legacyCipher, + nativeBrokerJournalMaximumSecret, + ) + }, + writePriorImage: func() error { + if !priorImageExists { + return nil + } + return copyNativeBrokerJournalImage( + priorImageHandle, filepath.Join(directory, nativeBrokerJournalPriorImageName), + priorImageHash, + ) + }, + writeSnapshot: func() error { + return writeNativeBrokerJournalFile( + filepath.Join(directory, nativeBrokerJournalSnapshotName), envelope, + nativeBrokerJournalMaximumSnapshot, + ) + }, + createRecordStream: func() error { + return createEmptyNativeBrokerJournalRecords( + filepath.Join(directory, nativeBrokerJournalRecordsName), + ) + }, + writePrepared: func() error { + return j.appendPhase(nativeBrokerPhasePrepared, "") + }, + publishActive: func() error { + return publishNativeBrokerJournalActive(t.request.targetUserSID, j) + }, + cutpoint: t.brokerJournalCutpoint, + }); err != nil { + return nil, err + } + cleanup = false + return j, nil +} + +func (j *nativeBrokerJournal) validatePriorCredential(exists bool, contents []byte) error { + if j == nil { + return nil + } + if exists != j.snapshot.PriorCredentialExists { + return errors.New("native broker credential existence changed after durable snapshot") + } + if exists && !strings.EqualFold( + nativeBrokerJournalHash(contents), j.snapshot.PriorCredentialSHA256, + ) { + return errors.New("native broker credential changed after durable snapshot") + } + return nil +} + +func (j *nativeBrokerJournal) validatePriorOwnership( + service nativeServiceSnapshot, + legacy nativeLegacyState, +) error { + if j == nil { + return nil + } + expected := j.snapshot.Service + if service.exists != expected.Exists { + return errors.New("native broker service existence changed after durable snapshot") + } + if service.exists { + expectedOperational := expected.WasRunning + if nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, j.lastPhase()) >= + nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, nativeBrokerPhaseServiceStopIntent) { + expectedOperational = false + } + if serviceWasOperational(service.status.State) != expectedOperational || + !nativeServiceConfigsEqual(service.config, expected.Config) || + compareNativeSecurityDescriptorStrings( + service.securityDescriptor, expected.SecurityDescriptor, + ) != nil || !slices.Equal(service.recoveryActions, expected.RecoveryActions) || + service.recoveryResetSeconds != expected.RecoveryResetSeconds || + service.recoverNonCrash != expected.RecoverNonCrash { + return errors.New("native broker service changed after durable snapshot") + } + } + if j.priorLegacy == nil { + return errors.New("native broker journal lost its retained prior legacy snapshot") + } + prior := j.priorLegacy + if !strings.EqualFold(legacy.userSID, prior.UserSID) || + legacy.runKeyExisted != prior.RunKeyExisted || + legacy.scheduledActive != prior.ScheduledActive || + legacy.scheduledEnabled != prior.ScheduledEnabled { + return errors.New("legacy startup ownership changed after durable snapshot") + } + if (legacy.runValue == nil) != (prior.RunValueText == nil) { + return errors.New("legacy Run registration changed after durable snapshot") + } + if legacy.runValue != nil && (legacy.runValue.value != *prior.RunValueText || + legacy.runValue.valueType != prior.RunValueType) { + return errors.New("legacy Run registration changed after durable snapshot") + } + if (legacy.scheduledXML == nil) != (prior.ScheduledXML == nil) || + (legacy.scheduledXML != nil && *legacy.scheduledXML != *prior.ScheduledXML) { + return errors.New("legacy scheduled task changed after durable snapshot") + } + if len(legacy.commands) != len(prior.SerializableCmds) { + return errors.New("legacy startup command set changed after durable snapshot") + } + for index := range legacy.commands { + command := prior.SerializableCmds[index] + if !nativeLegacyCommandsEqual(legacy.commands[index], nativeLegacyCommand{ + executable: command.Executable, arguments: command.Arguments, + workingDirectory: command.WorkingDirectory, source: nativeLegacyCommandSource(command.Source), + }) { + return errors.New("legacy startup command changed after durable snapshot") + } + } + return nil +} + +func isNativeBrokerJournalInactiveDirectoryName(name, prefix string) bool { + return strings.HasPrefix(name, prefix) && + isNativeBrokerJournalTransactionID(strings.TrimPrefix(name, prefix)) +} + +func discardNativeBrokerJournalDirectory(directory string) error { + name := filepath.Base(filepath.Clean(directory)) + if name != nativeBrokerJournalActiveName && + !isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalPreparingPrefix) && + !isNativeBrokerJournalInactiveDirectoryName(name, nativeBrokerJournalSettledPrefix) { + return errors.New("refusing to discard an unrecognized native broker journal directory") + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(directory, false) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(handle) //nolint:errcheck + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + allowed := map[string]bool{ + nativeBrokerJournalSnapshotName: true, nativeBrokerJournalRecordsName: true, + nativeBrokerJournalCredentialName: true, nativeBrokerJournalLegacyName: true, + nativeBrokerJournalPriorImageName: true, + nativeBrokerJournalSettlementName: true, + nativeBrokerJournalSettledReceiptName: true, + nativeBrokerJournalSnapshotName + ".next": true, + nativeBrokerJournalRecordsName + ".next": true, + nativeBrokerJournalCredentialName + ".next": true, + nativeBrokerJournalLegacyName + ".next": true, + nativeBrokerJournalPriorImageName + ".next": true, + nativeBrokerJournalSettlementName + ".next": true, + nativeBrokerJournalSettledReceiptName + ".next": true, + } + for _, entry := range entries { + if entry.IsDir() || !allowed[entry.Name()] { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "protected broker journal contains unexpected artifact %q", entry.Name(), + )} + } + } + var cleanupErrors []error + for _, entry := range entries { + path := filepath.Join(directory, entry.Name()) + file, openErr := openNativeBrokerJournalFile(path, windows.DELETE|windows.READ_CONTROL, windows.OPEN_EXISTING) + if openErr != nil { + cleanupErrors = append(cleanupErrors, openErr) + continue + } + windows.CloseHandle(file) //nolint:errcheck + if deleteErr := deleteNativePackageFile(path); deleteErr != nil && + !errors.Is(deleteErr, windows.ERROR_FILE_NOT_FOUND) { + cleanupErrors = append(cleanupErrors, deleteErr) + } + } + if len(cleanupErrors) != 0 { + return errors.Join(cleanupErrors...) + } + pointer, err := windows.UTF16PtrFromString(directory) + if err != nil { + return err + } + if err := windows.RemoveDirectory(pointer); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func executeNativeBrokerJournalRetirement(operations nativeBrokerJournalRetirementOperations) error { + if operations.rename == nil || operations.proveActiveAbsent == nil || + operations.proveTombstone == nil || operations.discardTombstone == nil { + return errors.New("native broker journal retirement operations are incomplete") + } + cut := func(name string) error { + if operations.cutpoint == nil { + return nil + } + return operations.cutpoint(name) + } + if err := cut("retire-before-rename"); err != nil { + return err + } + if err := operations.rename(); err != nil { + return err + } + if err := cut("retire-after-rename"); err != nil { + return err + } + if err := operations.proveActiveAbsent(); err != nil { + return err + } + if err := cut("retire-active-absence-proven"); err != nil { + return err + } + if err := operations.proveTombstone(); err != nil { + return err + } + if err := cut("retire-tombstone-proven"); err != nil { + return err + } + // Admission no longer observes this transaction. Deletion is deliberately + // non-authoritative and may be retried by inactive-directory discovery. + _ = operations.discardTombstone() + return nil +} + +func retireNativeBrokerJournal(j *nativeBrokerJournal) error { + if j == nil || (j.lastPhase() != nativeBrokerPhaseOuterSettled && + j.lastPhase() != nativeBrokerPhaseRollbackSettled) { + return errors.New("native broker journal cannot retire before exact terminal settlement") + } + active := filepath.Clean(j.directory) + root := filepath.Dir(active) + if filepath.Base(active) != nativeBrokerJournalActiveName || + filepath.Base(root) != nativeBrokerJournalRootName { + return errors.New("native broker active directory identity changed before retirement") + } + tombstone, err := nativeBrokerJournalSiblingPath( + root, nativeBrokerJournalSettledPrefix, j.snapshot.TransactionID, + ) + if err != nil { + return err + } + return executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + cutpoint: j.cutpoint, + rename: func() error { + if err := moveNativePackageFile(active, tombstone, false); err != nil { + return fmt.Errorf("atomically retire native broker active journal: %w", err) + } + j.directory = tombstone + return nil + }, + proveActiveAbsent: func() error { + if _, err := nativePathAttributes(active); err == nil { + return errors.New("native broker active journal still exists after terminal retirement") + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil + }, + proveTombstone: func() error { + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(tombstone, false) + if err != nil { + return fmt.Errorf("validate settled native broker journal tombstone: %w", err) + } + return windows.CloseHandle(handle) + }, + discardTombstone: func() error { + return discardNativeBrokerJournalDirectory(tombstone) + }, + }) +} + +func reconcileNativeBrokerJournalInactiveDirectories( + logger *slog.Logger, + userSID string, +) error { + root, _, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return err + } + rootHandle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(root, false) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + windows.CloseHandle(rootHandle) //nolint:errcheck + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if name == nativeBrokerJournalActiveName { + if !entry.IsDir() { + return &nativeBrokerJournalManualError{cause: errors.New( + "native broker active journal is not a protected directory", + )} + } + continue + } + preparing := isNativeBrokerJournalInactiveDirectoryName( + name, nativeBrokerJournalPreparingPrefix, + ) + settled := isNativeBrokerJournalInactiveDirectoryName( + name, nativeBrokerJournalSettledPrefix, + ) + if (!preparing && !settled) || !entry.IsDir() { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "native broker journal root contains unknown transaction artifact %q", name, + )} + } + path := filepath.Join(root, name) + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(path, false) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + if err := discardNativeBrokerJournalDirectory(path); err != nil { + if preparing { + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "discard incomplete unpublished broker preparation: %w", err, + )} + } + logger.Warn("Retaining protected settled broker journal tombstone", + "transactionDirectory", name, "error", err) + } + } + return nil +} + +func nativeBrokerJournalPathHash(path string) (string, bool, error) { + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL, false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return "", false, nil + } + return "", false, err + } + defer windows.CloseHandle(handle) //nolint:errcheck + if err := requireSingleNativeFileLink(handle); err != nil { + return "", false, err + } + if err := validateNativeSecurityDescriptor(handle, nativeBrokerExecutableSDDL); err != nil { + return "", false, err + } + hash, err := hashNativePackageHandle(handle) + return hash, true, err +} + +func restoreNativeBrokerJournalImage(j *nativeBrokerJournal) error { + if j == nil { + return errors.New("native broker image recovery has no journal") + } + snapshot := j.snapshot + transactionStaging := filepath.Join( + filepath.Dir(snapshot.CandidatePath), + ".viiper.staging."+snapshot.TransactionID+".tmp", + ) + if stagingHash, stagingExists, err := nativeBrokerJournalPathHash(transactionStaging); err != nil { + return err + } else if stagingExists { + if !strings.EqualFold(stagingHash, snapshot.CandidateSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "broker staging artifact differs from the transaction candidate identity", + )} + } + if err := deleteNativePackageFile(transactionStaging); err != nil { + return err + } + } + recoveryStaging := filepath.Join( + filepath.Dir(snapshot.CandidatePath), + ".viiper.recovery."+snapshot.TransactionID+".tmp", + ) + if _, stagingExists, err := nativeBrokerJournalPathHash(recoveryStaging); err != nil { + return err + } else if stagingExists { + if err := deleteNativePackageFile(recoveryStaging); err != nil { + return err + } + } + currentHash, currentExists, err := nativeBrokerJournalPathHash(snapshot.CandidatePath) + if err != nil { + return err + } + if snapshot.PriorImageExists && currentExists && + strings.EqualFold(currentHash, snapshot.PriorImageSHA256) { + return nil + } + if currentExists && !strings.EqualFold(currentHash, snapshot.CandidateSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "canonical broker image differs from both durable prior and candidate identities", + )} + } + if !snapshot.PriorImageExists { + if !currentExists { + return nil + } + return deleteNativePackageFile(snapshot.CandidatePath) + } + artifact := filepath.Join(j.directory, nativeBrokerJournalPriorImageName) + artifactHandle, err := openNativeBrokerJournalFile( + artifact, windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return err + } + defer windows.CloseHandle(artifactHandle) //nolint:errcheck + artifactHash, err := hashNativePackageHandle(artifactHandle) + if err != nil { + return err + } + if !strings.EqualFold(artifactHash, snapshot.PriorImageSHA256) { + return &nativeBrokerJournalManualError{cause: errors.New( + "protected prior broker image artifact failed identity validation", + )} + } + staging := recoveryStaging + if err := copyNativePackageHandleAtomically(artifactHandle, staging, artifactHash); err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + deleteNativePackageFile(staging) //nolint:errcheck + } + }() + if err := replaceNativePackageFileAtomically(staging, snapshot.CandidatePath, currentExists); err != nil { + return err + } + cleanup = false + restoredHash, restoredExists, err := nativeBrokerJournalPathHash(snapshot.CandidatePath) + if err != nil { + return err + } + if !restoredExists || !strings.EqualFold(restoredHash, snapshot.PriorImageSHA256) { + return errors.New("restored prior broker image did not verify") + } + return nil +} + +func (j *nativeBrokerJournal) loadProtectedArtifacts() ( + nativeBrokerJournalCredentialSnapshot, + nativeBrokerJournalLegacySnapshot, + error, +) { + credentialCipher, err := readNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalCredentialName), + nativeBrokerJournalMaximumSecret, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold( + nativeBrokerJournalHash(credentialCipher), j.snapshot.PriorCredentialArtifact, + ) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior credential artifact digest is invalid") + } + credentialPlain, err := unprotectNativeBrokerJournalData( + j.snapshot.TransactionID, j.snapshot.OuterTransactionID, "prior-key", credentialCipher, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + var credential nativeBrokerJournalCredentialSnapshot + if err := decodeCanonicalNativeBrokerJSON( + credentialPlain, &credential, nativeBrokerJournalMaximumSecret, + ); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if credential.Schema != nativeBrokerJournalSchema || + credential.Exists != j.snapshot.PriorCredentialExists || + (credential.Exists && (!strings.EqualFold( + nativeBrokerJournalHash(credential.Bytes), j.snapshot.PriorCredentialSHA256, + ) || len(credential.Bytes) == 0)) || + (!credential.Exists && len(credential.Bytes) != 0) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior credential plaintext does not match the journal snapshot") + } + + legacyCipher, err := readNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalLegacyName), + nativeBrokerJournalMaximumSecret, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold(nativeBrokerJournalHash(legacyCipher), j.snapshot.PriorLegacyArtifact) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected prior legacy artifact digest is invalid") + } + legacyPlain, err := unprotectNativeBrokerJournalData( + j.snapshot.TransactionID, j.snapshot.OuterTransactionID, "prior-legacy", legacyCipher, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + var legacy nativeBrokerJournalLegacySnapshot + if err := decodeCanonicalNativeBrokerJSON( + legacyPlain, &legacy, nativeBrokerJournalMaximumSecret, + ); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if err := validateNativeBrokerLegacySnapshot(legacy); err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + if !strings.EqualFold(legacy.UserSID, j.snapshot.TargetUserSID) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.New("protected legacy artifact target SID differs from the journal") + } + j.priorLegacy = &legacy + if j.snapshot.PriorImageExists { + artifact, err := openNativeBrokerJournalFile( + filepath.Join(j.directory, nativeBrokerJournalPriorImageName), + windows.GENERIC_READ, windows.OPEN_EXISTING, + ) + if err != nil { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, err + } + artifactHash, hashErr := hashNativePackageHandle(artifact) + closeErr := windows.CloseHandle(artifact) + if hashErr != nil || closeErr != nil || + !strings.EqualFold(artifactHash, j.snapshot.PriorImageSHA256) { + return nativeBrokerJournalCredentialSnapshot{}, nativeBrokerJournalLegacySnapshot{}, + errors.Join(hashErr, closeErr, errors.New("protected prior image artifact digest is invalid")) + } + } + return credential, legacy, nil +} + +func currentNativeBrokerJournalCandidateCredentialDigest(j *nativeBrokerJournal) string { + if j == nil { + return "" + } + for index := len(j.records) - 1; index >= 0; index-- { + switch j.records[index].Phase { + case nativeBrokerPhaseCredentialWriteIntent, nativeBrokerPhaseCredentialWritten: + return j.records[index].DetailSHA256 + } + } + return "" +} + +func restoreNativeBrokerJournalCredential( + j *nativeBrokerJournal, + prior nativeBrokerJournalCredentialSnapshot, +) error { + current, exists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + currentDigest := "" + if exists { + currentDigest = nativeBrokerJournalHash(current) + } + if exists == prior.Exists && (!exists || strings.EqualFold( + currentDigest, j.snapshot.PriorCredentialSHA256, + )) { + return nil + } + candidateDigest := currentNativeBrokerJournalCandidateCredentialDigest(j) + if candidateDigest == "" || !exists || !strings.EqualFold(currentDigest, candidateDigest) { + return &nativeBrokerJournalManualError{cause: errors.New( + "native broker credential differs from durable prior and candidate identities", + )} + } + path, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + if prior.Exists { + if err := writeNativeCredentialAtomically(path, prior.Bytes, j.snapshot.TargetUserSID); err != nil { + return err + } + } else if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + verified, verifiedExists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + if verifiedExists != prior.Exists || (verifiedExists && !strings.EqualFold( + nativeBrokerJournalHash(verified), j.snapshot.PriorCredentialSHA256, + )) { + return errors.New("prior native broker credential did not verify after durable restoration") + } + return nil +} + +func exactNativeBrokerJournalServiceState(service nativeManagedService) ( + mgr.Config, + string, + []mgr.RecoveryAction, + uint32, + bool, + svc.Status, + error, +) { + config, err := service.Config() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + dacl, err := service.SecurityDescriptor() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + recovery, err := service.RecoveryActions() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + reset, err := service.ResetPeriod() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + nonCrash, err := service.RecoveryActionsOnNonCrashFailures() + if err != nil { + return mgr.Config{}, "", nil, 0, false, svc.Status{}, err + } + status, err := service.Query() + return config, dacl, recovery, reset, nonCrash, status, err +} + +func restoreNativeBrokerJournalService( + ctx context.Context, + j *nativeBrokerJournal, +) (nativeManagedService, nativeSCM, error) { + managerRaw, err := mgr.Connect() + if err != nil { + return nil, nil, err + } + manager := &windowsNativeSCM{manager: managerRaw} + service, err := manager.OpenService(NativeBrokerServiceName) + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + if j.snapshot.Service.Exists { + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "prior native broker service disappeared during recovery", + )} + } + return nil, manager, nil + } + if err != nil { + manager.Close() //nolint:errcheck + return nil, nil, err + } + config, dacl, recovery, reset, nonCrash, status, err := + exactNativeBrokerJournalServiceState(service) + if err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + executable, err := nativeServiceExecutableFromCommandLine(config.BinaryPathName) + if err != nil || !strings.EqualFold(filepath.Clean(executable), filepath.Clean(j.snapshot.CandidatePath)) || + !isLocalSystemServiceAccount(config.ServiceStartName) || + compareNativeSecurityDescriptorStrings(dacl, nativeBrokerServiceSDDL) != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "current service is not an exact transaction-owned broker service", + )} + } + if status.State != svc.Stopped { + if err := stopNativeService(ctx, service, waitContext); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + } + prior := j.snapshot.Service + if !prior.Exists { + candidateConfig, _, err := nativeBrokerServiceConfiguration( + j.snapshot.CandidatePath, mustNativeBrokerJournalCredentialPath(), + ) + if err != nil || !nativeServiceConfigsEqual(config, candidateConfig) || + !slices.Equal(recovery, nativeServiceRecoveryActions) || + reset != nativeServiceRecoveryResetSecond || !nonCrash { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "new broker service is only partially configured; exact deletion is unsafe", + )} + } + if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + service.Close() //nolint:errcheck + if err := waitForNativePackageServiceDeletion(ctx, manager); err != nil { + manager.Close() //nolint:errcheck + return nil, nil, err + } + return nil, manager, nil + } + if !nativeServiceConfigsEqual(config, prior.Config) { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, &nativeBrokerJournalManualError{cause: errors.New( + "existing broker service configuration is outside the durable prior identity", + )} + } + if err := service.UpdateConfig(prior.Config); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetSecurityDescriptor(prior.SecurityDescriptor); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetRecoveryActionsExact( + prior.RecoveryActions, prior.RecoveryResetSeconds, + ); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + if err := service.SetRecoveryActionsOnNonCrashFailures(prior.RecoverNonCrash); err != nil { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, err + } + verifiedConfig, verifiedDACL, verifiedRecovery, verifiedReset, verifiedNonCrash, verifiedStatus, err := + exactNativeBrokerJournalServiceState(service) + if err != nil || !nativeServiceConfigsEqual(verifiedConfig, prior.Config) || + compareNativeSecurityDescriptorStrings(verifiedDACL, prior.SecurityDescriptor) != nil || + !slices.Equal(verifiedRecovery, prior.RecoveryActions) || + verifiedReset != prior.RecoveryResetSeconds || verifiedNonCrash != prior.RecoverNonCrash || + verifiedStatus.State != svc.Stopped { + service.Close() //nolint:errcheck + manager.Close() //nolint:errcheck + return nil, nil, errors.Join(err, errors.New( + "prior native broker service did not verify after durable restoration", + )) + } + return service, manager, nil +} + +func mustNativeBrokerJournalCredentialPath() string { + path, _ := nativeServiceKeyFilePath() + return path +} + +func restoreNativeBrokerJournalLegacy( + ctx context.Context, + j *nativeBrokerJournal, + prior nativeBrokerJournalLegacySnapshot, +) error { + hive, err := registry.OpenKey(registry.USERS, prior.UserSID, registry.READ) + if err != nil { + return fmt.Errorf("open target user hive for broker recovery: %w", err) + } + defer hive.Close() //nolint:errcheck + runKey, err := registry.OpenKey(hive, runKeyPath, registry.QUERY_VALUE|registry.SET_VALUE) + if errors.Is(err, registry.ErrNotExist) { + if prior.RunValueText != nil || prior.RunKeyExisted { + return &nativeBrokerJournalManualError{cause: errors.New( + "prior target-user Run key disappeared during broker recovery", + )} + } + } else if err != nil { + return err + } + if runKey != 0 { + defer runKey.Close() //nolint:errcheck + current, found, err := readNativeRunRegistration(runKey) + if err != nil { + return err + } + if prior.RunValueText == nil { + if found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy Run registration appeared during broker recovery", + )} + } + } else { + expected := nativeRunRegistration{value: *prior.RunValueText, valueType: prior.RunValueType} + if found && !nativeRunRegistrationsEqual(current, expected) { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy Run registration changed outside the broker transaction", + )} + } + if !found { + if err := setNativeRunRegistration(runKey, expected); err != nil { + return err + } + } + } + } + _, currentXML, currentActive, _, found, err := currentScheduledTaskCommand(ctx) + if err != nil { + return err + } + if prior.ScheduledXML == nil { + if found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy scheduled task appeared during broker recovery", + )} + } + } else { + if !found { + return &nativeBrokerJournalManualError{cause: errors.New( + "legacy scheduled task disappeared during broker recovery", + )} + } + if currentXML != *prior.ScheduledXML { + if err := validateNativeTaskDisabledOnly(*prior.ScheduledXML, currentXML); err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + if err := restoreNativeScheduledTask(ctx, *prior.ScheduledXML, currentXML); err != nil { + return err + } + } + if prior.ScheduledActive && !currentActive { + if err := startNativeScheduledTask(ctx, *prior.ScheduledXML); err != nil { + return err + } + } + } + for _, command := range prior.SerializableCmds { + if !command.WasRunning || command.Source != uint8(legacyCommandRun) { + continue + } + processes, err := openLegacyProcessesByExecutable(command.Executable, prior.UserSID) + if err != nil { + return err + } + alreadyRunning := len(processes) != 0 + for _, process := range processes { + windows.CloseHandle(process.handle) //nolint:errcheck + } + if alreadyRunning { + continue + } + verify, release, err := lockNativeLegacyTaskExecutable(command.Executable) + if err != nil { + return err + } + if err := verify(); err != nil { + release() + return err + } + err = startNativeLegacyCommandAsShellUser(nativeLegacyCommand{ + executable: command.Executable, arguments: command.Arguments, + workingDirectory: command.WorkingDirectory, source: legacyCommandRun, + }, prior.UserSID) + release() + if err != nil { + return err + } + } + return nil +} + +func rollbackNativeBrokerJournal(ctx context.Context, j *nativeBrokerJournal) (resultErr error) { + priorCredential, priorLegacy, err := j.loadProtectedArtifacts() + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + if nativeBrokerJournalPhaseIndex(nativeBrokerForwardPhaseOrder, j.lastPhase()) >= 0 { + if err := j.appendPhase(nativeBrokerPhaseRollbackIntent, ""); err != nil { + return err + } + } + service, manager, err := restoreNativeBrokerJournalService(ctx, j) + if err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if manager != nil { + defer manager.Close() //nolint:errcheck + } + if service != nil { + defer service.Close() //nolint:errcheck + } + if err := j.appendPhase(nativeBrokerPhaseRollbackService, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalCredential(j, priorCredential); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackCredential, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalImage(j); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackImage, ""); err != nil { + return err + } + if err := restoreNativeBrokerJournalLegacy(ctx, j, priorLegacy); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := j.appendPhase(nativeBrokerPhaseRollbackLegacy, ""); err != nil { + return err + } + if service != nil && j.snapshot.Service.WasRunning { + if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + if err := waitForNativeServiceState(ctx, service, svc.Running, waitContext); err != nil { + j.appendPhase(nativeBrokerPhaseManual, "") //nolint:errcheck + return err + } + } + if err := j.appendPhase(nativeBrokerPhaseRollbackSettled, ""); err != nil { + return err + } + return retireNativeBrokerJournal(j) +} + +func reconcileNativeBrokerJournalBeforeAdmission( + ctx context.Context, + logger *slog.Logger, + userSID string, +) error { + _, err := reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx, logger, userSID, false, + ) + return err +} + +func reconcileNativeBrokerJournalBeforeOuterPackage( + ctx context.Context, + logger *slog.Logger, + userSID string, +) (bool, error) { + return reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx, logger, userSID, true, + ) +} + +func reconcileNativeBrokerJournalBeforeAdmissionInternal( + ctx context.Context, + logger *slog.Logger, + userSID string, + allowNestedReady bool, +) (bool, error) { + if err := reconcileNativeBrokerJournalInactiveDirectories(logger, userSID); err != nil { + return false, err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return false, err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, nil + } + return false, err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return false, &nativeBrokerJournalManualError{cause: errors.New( + "active broker journal belongs to a different target user SID", + )} + } + switch j.lastPhase() { + case nativeBrokerPhaseRollbackSettled: + return false, retireNativeBrokerJournal(j) + case nativeBrokerPhaseNestedReady, nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled: + if !allowNestedReady { + return false, &nativeBrokerJournalManualError{cause: errors.New( + "broker readiness lacks a completed authoritative outer package settlement", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "pending outer broker settlement failed exact forward verification: %w", err, + )} + } + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + finalPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + if _, err := nativePathAttributes(finalPath); err == nil { + if _, err := loadNativeBrokerOuterSettlementFinalForReconciliation(j); err != nil { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "terminal broker settlement has an invalid protected final receipt: %w", err, + )} + } + } else if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "inspect terminal broker settlement final receipt: %w", err, + )} + } + } + return true, nil + case nativeBrokerPhaseManual: + return false, &nativeBrokerJournalManualError{cause: errors.New( + "prior broker recovery is latched manual", + )} + default: + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return false, &nativeBrokerJournalManualError{cause: err} + } + logger.Warn("Recovering an interrupted native broker transaction", + "transactionId", j.snapshot.TransactionID, + "phase", j.lastPhase()) + return false, rollbackNativeBrokerJournal(ctx, j) + } +} + +func verifyNativeBrokerJournalForwardState( + ctx context.Context, + j *nativeBrokerJournal, +) error { + imageHash, imageExists, err := nativeBrokerJournalPathHash(j.snapshot.CandidatePath) + if err != nil { + return err + } + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) { + return errors.New("outer settlement found a different canonical broker image") + } + credential, exists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !exists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return errors.New("outer settlement found a different native broker credential") + } + managerRaw, err := mgr.Connect() + if err != nil { + return err + } + manager := &windowsNativeSCM{manager: managerRaw} + defer manager.Close() //nolint:errcheck + service, err := manager.OpenService(NativeBrokerServiceName) + if err != nil { + return err + } + defer service.Close() //nolint:errcheck + config, dacl, recovery, reset, nonCrash, status, err := exactNativeBrokerJournalServiceState(service) + if err != nil { + return err + } + credentialPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + expectedConfig, _, err := nativeBrokerServiceConfiguration( + j.snapshot.CandidatePath, credentialPath, + ) + if err != nil { + return err + } + if !nativeServiceConfigsEqual(config, expectedConfig) || + compareNativeSecurityDescriptorStrings(dacl, nativeBrokerServiceSDDL) != nil || + !slices.Equal(recovery, nativeServiceRecoveryActions) || + reset != nativeServiceRecoveryResetSecond || !nonCrash || status.State != svc.Running { + return errors.New("outer settlement found a noncanonical native broker service") + } + legacy, err := snapshotNativeLegacyStartup(ctx, j.snapshot.TargetUserSID) + if err != nil { + return err + } + if legacy.release != nil { + defer legacy.release() + } + if nativeLegacyStartupOwnsRuntime(legacy) { + return errors.New("outer settlement found active legacy startup ownership") + } + return nil +} + +func validateNativeBrokerNestedReplayBinding( + j *nativeBrokerJournal, + userSID, outerTokenPath, outerTransactionID, candidateSHA256 string, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhaseNestedReady { + return errors.New("active broker journal is not at nested-ready") + } + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) || + !strings.EqualFold(filepath.Clean(j.snapshot.OuterTokenPath), filepath.Clean(outerTokenPath)) || + !strings.EqualFold(j.snapshot.OuterTransactionID, outerTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, candidateSHA256) { + return errors.New("active nested broker journal does not match the exact outer token, candidate, and target user") + } + proof := j.proof() + if proof.TransactionID != j.snapshot.TransactionID || + proof.State != string(nativeBrokerPhaseNestedReady) || + !isCanonicalNativeBrokerJournalSHA256(proof.Digest) { + return errors.New("durable nested-ready proof is not canonical") + } + return nil +} + +func replayNativeBrokerNestedReadyProof( + ctx context.Context, + logger *slog.Logger, + userSID, outerTokenPath, outerTransactionID, candidateSHA256 string, +) (nativeBrokerJournalProof, bool, bool, error) { + if err := reconcileNativeBrokerJournalInactiveDirectories(logger, userSID); err != nil { + return nativeBrokerJournalProof{}, false, false, err + } + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nativeBrokerJournalProof{}, false, false, err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nativeBrokerJournalProof{}, false, false, nil + } + return nativeBrokerJournalProof{}, false, false, err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return nativeBrokerJournalProof{}, false, true, &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nativeBrokerJournalProof{}, false, true, &nativeBrokerJournalManualError{cause: err} + } + proof := j.proof() + if !strings.EqualFold(j.snapshot.TargetUserSID, userSID) || + !strings.EqualFold(filepath.Clean(j.snapshot.OuterTokenPath), filepath.Clean(outerTokenPath)) || + !strings.EqualFold(j.snapshot.OuterTransactionID, outerTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, candidateSHA256) { + return proof, false, true, &nativeBrokerJournalManualError{cause: errors.New( + "active broker journal does not match the exact outer token, candidate, and target user", + )} + } + switch j.lastPhase() { + case nativeBrokerPhaseManual, nativeBrokerPhaseOuterSettled: + return proof, false, true, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "active broker journal phase %s cannot be replayed or rolled back by the child", + j.lastPhase(), + )} + case nativeBrokerPhaseRollbackSettled: + if err := retireNativeBrokerJournal(j); err != nil { + return proof, false, true, err + } + return proof, false, true, nil + case nativeBrokerPhaseNestedReady: + // Exact forward replay is verified below without changing the journal. + case nativeBrokerPhaseOuterSettlementPending: + return proof, false, true, errors.New( + "broker child journal is already pending outer acknowledgement; replay the retained outer binding", + ) + default: + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if err := rollbackNativeBrokerJournal(ctx, j); err != nil { + return j.proof(), false, true, err + } + return j.proof(), false, true, nil + } + if err := validateNativeBrokerNestedReplayBinding( + j, userSID, outerTokenPath, outerTransactionID, candidateSHA256, + ); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return proof, false, true, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "durable nested-ready state failed exact replay verification: %w", err, + )} + } + return proof, true, true, nil +} + +func nativeBrokerJournalOuterSettlementIdentity( + outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (string, string) { + if proof.success && proof.journal.TransactionID != "" && + (proof.journalRecovery == "fresh" || proof.journalRecovery == "replayed") { + return proof.journal.OuterTransactionID, proof.journal.CandidateSHA256 + } + return outerTransactionID, candidateSHA256 +} + +func discardSettledNativeBrokerOuterToken(j *nativeBrokerJournal) error { + if j == nil { + return errors.New("settled broker token cleanup has no journal") + } + path := j.snapshot.OuterTokenPath + handle, err := openNativePathWithoutReparse( + path, windows.GENERIC_READ|windows.READ_CONTROL|windows.DELETE, false, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + closeWith := func(result error) error { + return errors.Join(result, windows.CloseHandle(handle)) + } + if err := requireSingleNativeFileLink(handle); err != nil { + return closeWith(err) + } + if err := validateNativeSecurityDescriptor(handle, nativePackageTokenSDDL); err != nil { + return closeWith(err) + } + hash, err := hashNativePackageHandle(handle) + if err != nil { + return closeWith(err) + } + if hash != j.snapshot.OuterTransactionID { + return closeWith(errors.New("retained outer token no longer matches its settled transaction")) + } + if err := windows.CloseHandle(handle); err != nil { + return err + } + if err := deleteNativePackageFile(path); err != nil && + !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + return nil +} + +func validateNativeBrokerOuterSettlementBinding( + j *nativeBrokerJournal, + proof nativePackageInstallProof, +) (nativeBrokerOuterSettlementBinding, string, error) { + if j == nil || (j.lastPhase() != nativeBrokerPhaseNestedReady && + j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled) { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "active broker journal is not eligible for outer settlement", + ) + } + nestedIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettlementPending { + nestedIndex-- + } else if j.lastPhase() == nativeBrokerPhaseOuterSettled { + nestedIndex -= 2 + } + if nestedIndex < 0 || j.records[nestedIndex].Phase != nativeBrokerPhaseNestedReady { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "outer settlement lacks the immediately preceding nested-ready record", + ) + } + nestedDigest := j.records[nestedIndex].RecordSHA256 + if !proof.success || !proof.changed || proof.exitCode != 0 || + (proof.journalRecovery != "fresh" && proof.journalRecovery != "replayed") || + proof.journal.TransactionID != j.snapshot.TransactionID || + proof.journal.OuterTransactionID != j.snapshot.OuterTransactionID || + proof.journal.CandidateSHA256 != j.snapshot.CandidateSHA256 || + proof.journal.State != string(nativeBrokerPhaseNestedReady) || + proof.journal.Digest != nestedDigest || + !isCanonicalNativeBrokerJournalSHA256(proof.driverTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(proof.driverPendingDigest) || + !isCanonicalNativeBrokerJournalSHA256(proof.settlementNonce) { + return nativeBrokerOuterSettlementBinding{}, "", errors.New( + "outer driver proof omitted or mismatched the durable two-phase journal binding", + ) + } + binding := nativeBrokerOuterSettlementBinding{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: j.snapshot.TransactionID, + BrokerOuterTransactionID: j.snapshot.OuterTransactionID, + BrokerCandidateSHA256: j.snapshot.CandidateSHA256, + BrokerNestedDigest: nestedDigest, + DriverTransactionID: proof.driverTransactionID, + DriverPendingDigest: proof.driverPendingDigest, + SettlementNonce: proof.settlementNonce, + } + bindingBytes, err := nativeBrokerJournalCanonicalJSON(binding) + if err != nil { + return nativeBrokerOuterSettlementBinding{}, "", err + } + return binding, nativeBrokerJournalHash(bindingBytes), nil +} + +func validateNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, + request nativeBrokerOuterSettlementRequest, +) error { + if j == nil || (j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled) { + return errors.New("broker settlement request has no exact pending journal state") + } + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + nestedIndex := pendingIndex - 1 + if nestedIndex < 0 || + j.records[pendingIndex].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[nestedIndex].Phase != nativeBrokerPhaseNestedReady { + return errors.New("broker settlement request has no exact pending journal state") + } + binding := request.Binding + if request.Schema != nativeBrokerJournalSchema || binding.Schema != nativeBrokerJournalSchema || + binding.BrokerTransactionID != j.snapshot.TransactionID || + binding.BrokerOuterTransactionID != j.snapshot.OuterTransactionID || + binding.BrokerCandidateSHA256 != j.snapshot.CandidateSHA256 || + binding.BrokerNestedDigest != j.records[nestedIndex].RecordSHA256 || + !isCanonicalNativeBrokerJournalSHA256(binding.DriverTransactionID) || + !isCanonicalNativeBrokerJournalSHA256(binding.DriverPendingDigest) || + !isCanonicalNativeBrokerJournalSHA256(binding.SettlementNonce) || + !isCanonicalNativeBrokerJournalSHA256(request.BindingSHA256) || + !isCanonicalNativeBrokerJournalSHA256(request.BrokerPendingDigest) || + request.BrokerPendingDigest != j.records[pendingIndex].RecordSHA256 || + request.BindingSHA256 != j.records[pendingIndex].DetailSHA256 { + return errors.New("broker settlement request identity does not match its journal chain") + } + bindingBytes, err := nativeBrokerJournalCanonicalJSON(binding) + if err != nil { + return err + } + if request.BindingSHA256 != nativeBrokerJournalHash(bindingBytes) { + return errors.New("broker settlement request binding digest is invalid") + } + return nil +} + +func encodeNativeBrokerOuterSettlementEnvelope( + request nativeBrokerOuterSettlementRequest, +) ([]byte, string, error) { + payload, err := nativeBrokerJournalCanonicalJSON(request) + if err != nil { + return nil, "", err + } + envelope := nativeBrokerOuterSettlementEnvelope{ + Schema: nativeBrokerJournalSchema, + PayloadSHA256: nativeBrokerJournalHash(payload), + Payload: request, + } + contents, err := nativeBrokerJournalCanonicalJSON(envelope) + if err != nil { + return nil, "", err + } + if len(contents) > nativeBrokerJournalMaximumSettlement { + return nil, "", errors.New("broker settlement envelope exceeds its bound") + } + return contents, nativeBrokerJournalHash(contents), nil +} + +func decodeNativeBrokerOuterSettlementEnvelope( + contents []byte, +) (nativeBrokerOuterSettlementEnvelope, error) { + var envelope nativeBrokerOuterSettlementEnvelope + if err := decodeCanonicalNativeBrokerJSON( + contents, &envelope, nativeBrokerJournalMaximumSettlement, + ); err != nil { + return nativeBrokerOuterSettlementEnvelope{}, err + } + payload, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nativeBrokerOuterSettlementEnvelope{}, err + } + if envelope.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payload) { + return nativeBrokerOuterSettlementEnvelope{}, errors.New( + "broker settlement envelope schema or payload digest is invalid", + ) + } + return envelope, nil +} + +func nativeBrokerOuterSettlementRequestPath(j *nativeBrokerJournal) (string, error) { + if j == nil { + return "", errors.New("broker settlement request has no journal") + } + _, active, err := nativeBrokerJournalPaths(j.snapshot.TargetUserSID) + if err != nil { + return "", err + } + if !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(active)) { + return "", errors.New("broker settlement request escaped the exact active journal") + } + path := filepath.Join(active, nativeBrokerJournalSettlementName) + if !strings.EqualFold(filepath.Dir(path), active) || + filepath.Base(path) != nativeBrokerJournalSettlementName { + return "", errors.New("broker settlement request path escaped its active journal") + } + return path, nil +} + +func loadNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, +) (nativeBrokerOuterSettlementPrepared, error) { + path, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementRequest(j, envelope.Payload); err != nil { + return nativeBrokerOuterSettlementPrepared{}, err + } + return nativeBrokerOuterSettlementPrepared{ + Request: envelope.Payload, RequestPath: path, + RequestSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func publishNativeBrokerOuterSettlementRequest( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, +) error { + expectedPath, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(prepared.RequestPath), filepath.Clean(expectedPath)) || + prepared.RequestSHA256 != nativeBrokerJournalHash(prepared.contents) { + return errors.New("broker settlement publication identity changed") + } + if envelope, err := decodeNativeBrokerOuterSettlementEnvelope(prepared.contents); err != nil { + return err + } else if envelope.Payload != prepared.Request { + return errors.New("broker settlement publication payload changed") + } + if err := validateNativeBrokerOuterSettlementRequest(j, prepared.Request); err != nil { + return err + } + staging := expectedPath + ".next" + loadOptional := func(path string) ([]byte, bool, error) { + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + return contents, true, err + } + if err := executeNativeBrokerSettlementPublication( + prepared.contents, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { + return loadOptional(expectedPath) + }, + loadStaging: func() ([]byte, bool, error) { + return loadOptional(staging) + }, + discardStaging: func() error { + return discardUnpublishedNativeBrokerJournalFile(staging) + }, + publishStaging: func() error { + return moveNativePackageFile(staging, expectedPath, false) + }, + writeNew: func() error { + return writeNativeBrokerJournalFile( + expectedPath, prepared.contents, nativeBrokerJournalMaximumSettlement, + ) + }, + readback: func() ([]byte, error) { + return readNativeBrokerJournalFile( + expectedPath, nativeBrokerJournalMaximumSettlement, + ) + }, + }, + ); err != nil { + return fmt.Errorf("publish broker settlement request: %w", err) + } + loaded, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return fmt.Errorf("read back broker settlement request: %w", err) + } + if loaded.Request != prepared.Request || loaded.RequestSHA256 != prepared.RequestSHA256 || + !bytes.Equal(loaded.contents, prepared.contents) { + return errors.New("published broker settlement request differs from its durable receipt") + } + return nil +} + +func armNativeBrokerOuterSettlement( + ctx context.Context, + userSID, outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (*nativeBrokerJournal, nativeBrokerOuterSettlementPrepared, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, err + } + if _, err := nativePathAttributes(active); err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, errors.Join( + err, errors.New("authoritative outer success expected a durable broker journal"), + ) + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nil, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + expectedOuterTransactionID, expectedCandidateSHA256 := + nativeBrokerJournalOuterSettlementIdentity(outerTransactionID, candidateSHA256, proof) + if !strings.EqualFold(j.snapshot.OuterTransactionID, expectedOuterTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, expectedCandidateSHA256) || + !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "outer package proof does not match the durable broker journal binding", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + binding, bindingDigest, err := validateNativeBrokerOuterSettlementBinding(j, proof) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if j.lastPhase() == nativeBrokerPhaseNestedReady { + if err := j.appendPhase(nativeBrokerPhaseOuterSettlementPending, bindingDigest); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + } else { + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + if pendingIndex < 0 || + j.records[pendingIndex].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[pendingIndex].DetailSHA256 != bindingDigest { + return j, nativeBrokerOuterSettlementPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "replayed outer settlement binding differs from the durable pending record", + )} + } + } + pendingIndex := len(j.records) - 1 + if j.lastPhase() == nativeBrokerPhaseOuterSettled { + pendingIndex-- + } + request := nativeBrokerOuterSettlementRequest{ + Schema: nativeBrokerJournalSchema, + BindingSHA256: bindingDigest, + BrokerPendingDigest: j.records[pendingIndex].RecordSHA256, + Binding: binding, + } + if err := validateNativeBrokerOuterSettlementRequest(j, request); err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + contents, requestDigest, err := encodeNativeBrokerOuterSettlementEnvelope(request) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + requestPath, err := nativeBrokerOuterSettlementRequestPath(j) + if err != nil { + return j, nativeBrokerOuterSettlementPrepared{}, err + } + return j, nativeBrokerOuterSettlementPrepared{ + Request: request, RequestPath: requestPath, + RequestSHA256: requestDigest, contents: contents, + }, nil +} + +func validateNativeBrokerOuterSettlementReceipt( + prepared nativeBrokerOuterSettlementPrepared, + receipt nativePackageBrokerSettlementReceipt, +) error { + binding := prepared.Request.Binding + if receipt.BrokerTransactionID != binding.BrokerTransactionID || + receipt.BrokerPendingDigest != prepared.Request.BrokerPendingDigest || + receipt.DriverTransactionID != binding.DriverTransactionID || + receipt.DriverPendingDigest != binding.DriverPendingDigest || + receipt.SettlementNonce != binding.SettlementNonce || + receipt.RequestSHA256 != prepared.RequestSHA256 || + receipt.State != string(nativeBrokerPhaseOuterSettled) || + !isCanonicalNativeBrokerJournalSHA256(receipt.Digest) || + receipt.Digest == receipt.DriverPendingDigest { + return errors.New("driver settlement receipt does not match both pending journal identities") + } + return nil +} + +func nativeBrokerOuterSettlementFinalPath(j *nativeBrokerJournal) (string, error) { + if j == nil { + return "", errors.New("broker final receipt has no journal") + } + _, active, err := nativeBrokerJournalPaths(j.snapshot.TargetUserSID) + if err != nil { + return "", err + } + if !strings.EqualFold(filepath.Clean(j.directory), filepath.Clean(active)) { + return "", errors.New("broker final receipt escaped the exact active journal") + } + path := filepath.Join(active, nativeBrokerJournalSettledReceiptName) + if !strings.EqualFold(filepath.Dir(path), active) || + filepath.Base(path) != nativeBrokerJournalSettledReceiptName { + return "", errors.New("broker final receipt path escaped its active journal") + } + return path, nil +} + +func encodeNativeBrokerOuterSettlementFinalEnvelope( + receipt nativeBrokerOuterSettlementFinal, +) ([]byte, string, error) { + payload, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + return nil, "", err + } + envelope := nativeBrokerOuterSettlementFinalEnvelope{ + Schema: nativeBrokerJournalSchema, + PayloadSHA256: nativeBrokerJournalHash(payload), + Payload: receipt, + } + contents, err := nativeBrokerJournalCanonicalJSON(envelope) + if err != nil { + return nil, "", err + } + if len(contents) > nativeBrokerJournalMaximumSettlement { + return nil, "", errors.New("broker final receipt envelope exceeds its bound") + } + return contents, nativeBrokerJournalHash(contents), nil +} + +func decodeNativeBrokerOuterSettlementFinalEnvelope( + contents []byte, +) (nativeBrokerOuterSettlementFinalEnvelope, error) { + var envelope nativeBrokerOuterSettlementFinalEnvelope + if err := decodeCanonicalNativeBrokerJSON( + contents, &envelope, nativeBrokerJournalMaximumSettlement, + ); err != nil { + return nativeBrokerOuterSettlementFinalEnvelope{}, err + } + payload, err := nativeBrokerJournalCanonicalJSON(envelope.Payload) + if err != nil { + return nativeBrokerOuterSettlementFinalEnvelope{}, err + } + if envelope.Schema != nativeBrokerJournalSchema || + !isCanonicalNativeBrokerJournalSHA256(envelope.PayloadSHA256) || + envelope.PayloadSHA256 != nativeBrokerJournalHash(payload) { + return nativeBrokerOuterSettlementFinalEnvelope{}, errors.New( + "broker final receipt envelope schema or payload digest is invalid", + ) + } + return envelope, nil +} + +func validateNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, + receipt nativeBrokerOuterSettlementFinal, +) error { + if j == nil || j.lastPhase() != nativeBrokerPhaseOuterSettled || + len(j.records) < 3 || + j.records[len(j.records)-2].Phase != nativeBrokerPhaseOuterSettlementPending || + j.records[len(j.records)-3].Phase != nativeBrokerPhaseNestedReady { + return errors.New("broker final receipt has no exact terminal journal state") + } + driverReceiptBytes, err := nativeBrokerJournalCanonicalJSON(driverReceipt) + if err != nil { + return err + } + binding := prepared.Request.Binding + if receipt.Schema != nativeBrokerJournalSchema || + receipt.BrokerTransactionID != binding.BrokerTransactionID || + receipt.BrokerPendingDigest != prepared.Request.BrokerPendingDigest || + receipt.BrokerSettledDigest != j.records[len(j.records)-1].RecordSHA256 || + receipt.DriverTransactionID != binding.DriverTransactionID || + receipt.DriverPendingDigest != binding.DriverPendingDigest || + receipt.DriverSettledDigest != driverReceipt.Digest || + receipt.SettlementNonce != binding.SettlementNonce || + receipt.RequestSHA256 != prepared.RequestSHA256 || + receipt.State != string(nativeBrokerPhaseOuterSettled) || + j.records[len(j.records)-1].DetailSHA256 != nativeBrokerJournalHash(driverReceiptBytes) { + return errors.New("broker final receipt does not bind both terminal journal digests") + } + for _, digest := range []string{ + receipt.BrokerPendingDigest, receipt.BrokerSettledDigest, + receipt.DriverTransactionID, receipt.DriverPendingDigest, + receipt.DriverSettledDigest, receipt.SettlementNonce, + receipt.RequestSHA256, + } { + if !isCanonicalNativeBrokerJournalSHA256(digest) { + return errors.New("broker final receipt contains a malformed digest") + } + } + return nil +} + +func loadNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, +) (nativeBrokerOuterSettlementFinalPrepared, error) { + path, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, driverReceipt, envelope.Payload, + ); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + return nativeBrokerOuterSettlementFinalPrepared{ + Receipt: envelope.Payload, ReceiptPath: path, + ReceiptSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func loadNativeBrokerOuterSettlementFinalForReconciliation( + j *nativeBrokerJournal, +) (nativeBrokerOuterSettlementFinalPrepared, error) { + prepared, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + path, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(contents) + if err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + driverReceipt := nativeBrokerDriverReceiptFromFinal(envelope.Payload) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, driverReceipt); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, driverReceipt, envelope.Payload, + ); err != nil { + return nativeBrokerOuterSettlementFinalPrepared{}, err + } + return nativeBrokerOuterSettlementFinalPrepared{ + Receipt: envelope.Payload, ReceiptPath: path, + ReceiptSHA256: nativeBrokerJournalHash(contents), contents: contents, + }, nil +} + +func publishNativeBrokerOuterSettlementFinal( + j *nativeBrokerJournal, + prepared nativeBrokerOuterSettlementFinalPrepared, + driverRequest nativeBrokerOuterSettlementPrepared, + driverReceipt nativePackageBrokerSettlementReceipt, +) error { + expectedPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(prepared.ReceiptPath), filepath.Clean(expectedPath)) || + prepared.ReceiptSHA256 != nativeBrokerJournalHash(prepared.contents) { + return errors.New("broker final receipt publication identity changed") + } + if envelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(prepared.contents); err != nil { + return err + } else if envelope.Payload != prepared.Receipt { + return errors.New("broker final receipt publication payload changed") + } + if err := validateNativeBrokerOuterSettlementFinal( + j, driverRequest, driverReceipt, prepared.Receipt, + ); err != nil { + return err + } + staging := expectedPath + ".next" + loadOptional := func(path string) ([]byte, bool, error) { + if _, err := nativePathAttributes(path); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil, false, nil + } + return nil, false, err + } + contents, err := readNativeBrokerJournalFile(path, nativeBrokerJournalMaximumSettlement) + return contents, true, err + } + if err := executeNativeBrokerSettlementPublication( + prepared.contents, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { return loadOptional(expectedPath) }, + loadStaging: func() ([]byte, bool, error) { return loadOptional(staging) }, + discardStaging: func() error { + return discardUnpublishedNativeBrokerJournalFile(staging) + }, + publishStaging: func() error { + return moveNativePackageFile(staging, expectedPath, false) + }, + writeNew: func() error { + return writeNativeBrokerJournalFile( + expectedPath, prepared.contents, nativeBrokerJournalMaximumSettlement, + ) + }, + readback: func() ([]byte, error) { + return readNativeBrokerJournalFile(expectedPath, nativeBrokerJournalMaximumSettlement) + }, + }, + ); err != nil { + return fmt.Errorf("publish broker final receipt: %w", err) + } + loaded, err := loadNativeBrokerOuterSettlementFinal(j, driverRequest, driverReceipt) + if err != nil { + return fmt.Errorf("read back broker final receipt: %w", err) + } + if loaded.Receipt != prepared.Receipt || loaded.ReceiptSHA256 != prepared.ReceiptSHA256 || + !bytes.Equal(loaded.contents, prepared.contents) { + return errors.New("published broker final receipt differs from its durable bytes") + } + return nil +} + +func recordNativeBrokerOuterSettlement( + ctx context.Context, + userSID string, + receipt nativePackageBrokerSettlementReceipt, +) (*nativeBrokerJournal, nativeBrokerOuterSettlementFinalPrepared, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nil, nativeBrokerOuterSettlementFinalPrepared{}, err + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nil, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if j.lastPhase() != nativeBrokerPhaseOuterSettlementPending && + j.lastPhase() != nativeBrokerPhaseOuterSettled { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: fmt.Errorf( + "broker settlement acknowledgement observed phase %s", j.lastPhase(), + )} + } + prepared, err := loadNativeBrokerOuterSettlementRequest(j) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + if err := verifyNativeBrokerJournalForwardState(ctx, j); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: err} + } + receiptBytes, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + receiptDigest := nativeBrokerJournalHash(receiptBytes) + if j.lastPhase() == nativeBrokerPhaseOuterSettlementPending { + if err := j.appendPhase(nativeBrokerPhaseOuterSettled, receiptDigest); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + } else if j.records[len(j.records)-1].DetailSHA256 != receiptDigest { + return j, nativeBrokerOuterSettlementFinalPrepared{}, &nativeBrokerJournalManualError{cause: errors.New( + "replayed driver settlement receipt differs from the terminal broker record", + )} + } + finalReceipt := nativeBrokerOuterSettlementFinal{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: prepared.Request.Binding.BrokerTransactionID, + BrokerPendingDigest: prepared.Request.BrokerPendingDigest, + BrokerSettledDigest: j.records[len(j.records)-1].RecordSHA256, + DriverTransactionID: receipt.DriverTransactionID, + DriverPendingDigest: receipt.DriverPendingDigest, + DriverSettledDigest: receipt.Digest, + SettlementNonce: receipt.SettlementNonce, + RequestSHA256: receipt.RequestSHA256, + State: string(nativeBrokerPhaseOuterSettled), + } + if err := validateNativeBrokerOuterSettlementFinal( + j, prepared, receipt, finalReceipt, + ); err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + contents, finalDigest, err := encodeNativeBrokerOuterSettlementFinalEnvelope(finalReceipt) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + finalPath, err := nativeBrokerOuterSettlementFinalPath(j) + if err != nil { + return j, nativeBrokerOuterSettlementFinalPrepared{}, err + } + finalPrepared := nativeBrokerOuterSettlementFinalPrepared{ + Receipt: finalReceipt, ReceiptPath: finalPath, + ReceiptSHA256: finalDigest, contents: contents, + } + if err := publishNativeBrokerOuterSettlementFinal( + j, finalPrepared, prepared, receipt, + ); err != nil { + return j, finalPrepared, err + } + return j, finalPrepared, nil +} + +func nativeBrokerJournalAbsenceIsSettled(proof nativePackageInstallProof) bool { + if proof.success { + // The driver can change while the child proves an exact healthy no-op. + // In that case the child correctly owns no journal; any advertised child + // identity still requires its exact active journal. + return proof.journal.TransactionID == "" + } + return !proof.changed || proof.rollback == "succeeded" +} + +func reconcileNativeBrokerJournalAfterOuterFailure( + ctx context.Context, + userSID, outerTransactionID, candidateSHA256 string, + proof nativePackageInstallProof, +) (nativeBrokerJournalProof, error) { + _, active, err := nativeBrokerJournalPaths(userSID) + if err != nil { + return nativeBrokerJournalProof{}, err + } + if _, err := nativePathAttributes(active); err != nil { + if (errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) && + nativeBrokerJournalAbsenceIsSettled(proof) { + return nativeBrokerJournalProof{}, nil + } + return nativeBrokerJournalProof{}, err + } + j, err := loadNativeBrokerJournal(active) + if err != nil { + return nativeBrokerJournalProof{}, &nativeBrokerJournalManualError{cause: err} + } + expectedOuterTransactionID, expectedCandidateSHA256 := + nativeBrokerJournalOuterSettlementIdentity(outerTransactionID, candidateSHA256, proof) + if !strings.EqualFold(j.snapshot.OuterTransactionID, expectedOuterTransactionID) || + !strings.EqualFold(j.snapshot.CandidateSHA256, expectedCandidateSHA256) || + !strings.EqualFold(j.snapshot.TargetUserSID, userSID) { + return j.proof(), &nativeBrokerJournalManualError{cause: errors.New( + "outer failure proof does not match the durable broker journal binding", + )} + } + if _, _, err := j.loadProtectedArtifacts(); err != nil { + return j.proof(), &nativeBrokerJournalManualError{cause: err} + } + if proof.rollback == "succeeded" || (!proof.changed && proof.rollback == "not-needed") { + if j.lastPhase() != nativeBrokerPhaseRollbackSettled { + if err := rollbackNativeBrokerJournal(ctx, j); err != nil { + return j.proof(), err + } + } + return j.proof(), nil + } + return j.proof(), &nativeBrokerJournalManualError{cause: errors.New( + "outer package proof did not authorize broker journal settlement or rollback", + )} +} + +func admitNativeBrokerServiceStartup(executable, credentialPath string) error { + active, err := nativeBrokerJournalActivePathUnbound() + if err != nil { + return err + } + if _, err := nativePathAttributes(active); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return nil + } + return err + } + handle, _, err := createOrOpenProtectedNativeBrokerJournalDirectory(active, false) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + windows.CloseHandle(handle) //nolint:errcheck + j, err := loadNativeBrokerJournal(active) + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + priorCredential, _, err := j.loadProtectedArtifacts() + if err != nil { + return &nativeBrokerJournalManualError{cause: err} + } + executable = filepath.Clean(executable) + credentialPath = filepath.Clean(credentialPath) + expectedCredentialPath, err := nativeServiceKeyFilePath() + if err != nil { + return err + } + if !strings.EqualFold(executable, filepath.Clean(j.snapshot.CandidatePath)) || + !strings.EqualFold(credentialPath, filepath.Clean(expectedCredentialPath)) { + return &nativeBrokerJournalManualError{cause: errors.New( + "service startup identity differs from the active broker journal", + )} + } + imageHash, imageExists, err := nativeBrokerJournalPathHash(executable) + if err != nil { + return err + } + credential, credentialExists, err := snapshotNativeBrokerCredentialReadOnly(j.snapshot.TargetUserSID) + if err != nil { + return err + } + switch j.lastPhase() { + case nativeBrokerPhaseServiceStartIntent: + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) || + !credentialExists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return &nativeBrokerJournalManualError{cause: errors.New( + "candidate broker startup did not match its durable image/key identities", + )} + } + return nil + case nativeBrokerPhaseRollbackLegacy, nativeBrokerPhaseRollbackSettled: + if !j.snapshot.Service.Exists || !j.snapshot.PriorImageExists || !imageExists || + !strings.EqualFold(imageHash, j.snapshot.PriorImageSHA256) || + credentialExists != priorCredential.Exists || + (credentialExists && !strings.EqualFold( + nativeBrokerJournalHash(credential), j.snapshot.PriorCredentialSHA256, + )) { + return &nativeBrokerJournalManualError{cause: errors.New( + "rollback broker startup did not match its durable prior image/key identities", + )} + } + return nil + case nativeBrokerPhaseNestedReady, nativeBrokerPhaseOuterSettlementPending, + nativeBrokerPhaseOuterSettled: + candidateCredential := currentNativeBrokerJournalCandidateCredentialDigest(j) + if !imageExists || !strings.EqualFold(imageHash, j.snapshot.CandidateSHA256) || + !credentialExists || candidateCredential == "" || + !strings.EqualFold(nativeBrokerJournalHash(credential), candidateCredential) { + return &nativeBrokerJournalManualError{cause: errors.New( + "settled broker startup did not match its durable image/key identities", + )} + } + return nil + default: + return &nativeBrokerJournalManualError{cause: fmt.Errorf( + "service startup is not admitted while broker journal phase is %s", j.lastPhase(), + )} + } +} diff --git a/internal/cmd/native_broker_journal_windows_test.go b/internal/cmd/native_broker_journal_windows_test.go new file mode 100644 index 00000000..cbbc1388 --- /dev/null +++ b/internal/cmd/native_broker_journal_windows_test.go @@ -0,0 +1,979 @@ +//go:build windows + +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +var errNativeBrokerJournalCutpoint = errors.New("simulated process loss") + +func newNativeBrokerJournalModel(t *testing.T) (*nativeBrokerJournal, *[][]byte) { + t.Helper() + snapshot := nativeBrokerJournalSnapshot{ + Schema: nativeBrokerJournalSchema, TransactionID: strings.Repeat("a", 32), + OuterTransactionID: strings.Repeat("b", 64), + OuterTokenPath: `C:\Program Files\VIIPER\.viiper.transaction.test.token`, + TargetUserSID: "S-1-5-21-1-2-3-1001", + CandidatePath: `C:\Program Files\VIIPER\viiper.exe`, + CandidateSHA256: strings.Repeat("c", 64), + PriorCredentialArtifact: strings.Repeat("d", 64), + PriorLegacyArtifact: strings.Repeat("e", 64), + } + payload, err := nativeBrokerJournalCanonicalJSON(snapshot) + if err != nil { + t.Fatal(err) + } + var persisted [][]byte + j := &nativeBrokerJournal{ + snapshot: snapshot, snapshotDigest: nativeBrokerJournalHash(payload), + appendRecord: func(record []byte) error { + persisted = append(persisted, append([]byte(nil), record...)) + return nil + }, + } + return j, &persisted +} + +func TestNativeBrokerJournalCutpointsLeaveCanonicalPrefix(t *testing.T) { + t.Parallel() + phases := append([]nativeBrokerJournalPhase(nil), + nativeBrokerForwardPhaseOrder[:len(nativeBrokerForwardPhaseOrder)-1]..., + ) + phases = append(phases, + nativeBrokerPhaseRollbackIntent, + nativeBrokerPhaseRollbackService, + nativeBrokerPhaseRollbackCredential, + nativeBrokerPhaseRollbackImage, + nativeBrokerPhaseRollbackLegacy, + nativeBrokerPhaseRollbackSettled, + ) + for cutIndex := range phases { + cutIndex := cutIndex + t.Run(string(phases[cutIndex]), func(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + journal.cutpoint = func(name string) error { + if name == "after-record-"+string(phases[cutIndex]) { + return errNativeBrokerJournalCutpoint + } + return nil + } + for index, phase := range phases { + detail := "" + if phase == nativeBrokerPhaseOuterSettlementPending || + phase == nativeBrokerPhaseOuterSettled { + detail = strings.Repeat("f", 64) + } + err := journal.appendPhase(phase, detail) + if index < cutIndex && err != nil { + t.Fatalf("phase %s failed before cutpoint: %v", phase, err) + } + if index == cutIndex { + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("phase %s error=%v", phase, err) + } + break + } + } + if len(*persisted) != cutIndex+1 || len(journal.records) != cutIndex+1 { + t.Fatalf("persisted=%d records=%d want=%d", + len(*persisted), len(journal.records), cutIndex+1) + } + reloaded := &nativeBrokerJournal{ + snapshot: journal.snapshot, snapshotDigest: journal.snapshotDigest, + } + for _, line := range *persisted { + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON( + line, &record, nativeBrokerJournalMaximumLine, + ); err != nil { + t.Fatalf("decode persisted prefix: %v", err) + } + if err := reloaded.validateLoadedRecord(record); err != nil { + t.Fatalf("validate persisted prefix: %v", err) + } + reloaded.records = append(reloaded.records, record) + } + if reloaded.lastPhase() != phases[cutIndex] { + t.Fatalf("last phase=%s want=%s", reloaded.lastPhase(), phases[cutIndex]) + } + }) + } +} + +func TestNativeBrokerJournalRejectsTamperAndIllegalDirection(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseImageSwitchIntent, strings.Repeat("f", 64)); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err == nil { + t.Fatal("forward journal accepted a backward transition") + } + tampered := append([]byte(nil), (*persisted)[1]...) + index := bytes.Index(tampered, []byte(strings.Repeat("f", 64))) + if index < 0 { + t.Fatal("detail digest not found in canonical record") + } + tampered[index] = '0' + var record nativeBrokerJournalRecord + if err := decodeCanonicalNativeBrokerJSON(tampered, &record, nativeBrokerJournalMaximumLine); err != nil { + t.Fatalf("tamper should remain canonical JSON: %v", err) + } + reloaded := &nativeBrokerJournal{ + snapshot: journal.snapshot, snapshotDigest: journal.snapshotDigest, + records: []nativeBrokerJournalRecord{journal.records[0]}, + } + if err := reloaded.validateLoadedRecord(record); err == nil { + t.Fatal("hash-chain validation accepted tampered detail digest") + } +} + +func TestNativeBrokerJournalPreparationCutsNeverExposeIncompleteActiveState(t *testing.T) { + t.Parallel() + cutpoints := []string{ + "prepare-directory-created", + "prepare-credential-written", + "prepare-legacy-written", + "prepare-prior-image-written", + "prepare-snapshot-written", + "prepare-record-stream-created", + "prepare-prepared-written", + "prepare-active-published", + } + for cutIndex, cutpoint := range cutpoints { + cutIndex, cutpoint := cutIndex, cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + preparing, active := false, false + var completed []string + step := func(name string) func() error { + return func() error { + if name == "directory-created" { + preparing = true + } else if !preparing || active { + return errors.New("preparation escaped its unpublished directory") + } + completed = append(completed, name) + return nil + } + } + err := executeNativeBrokerJournalPreparation(nativeBrokerJournalPreparationOperations{ + createDirectory: step("directory-created"), + writeCredential: step("credential-written"), + writeLegacy: step("legacy-written"), + writePriorImage: step("prior-image-written"), + writeSnapshot: step("snapshot-written"), + createRecordStream: step("record-stream-created"), + writePrepared: step("prepared-written"), + publishActive: func() error { + if !preparing || len(completed) != len(cutpoints)-1 { + return errors.New("active publication preceded complete preparation") + } + preparing, active = false, true + completed = append(completed, "active-published") + return nil + }, + cutpoint: func(name string) error { + if name == cutpoint { + return errNativeBrokerJournalCutpoint + } + return nil + }, + }) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || len(completed) != cutIndex+1 { + t.Fatalf("cut=%s completed=%v err=%v", cutpoint, completed, err) + } + if cutpoint == "prepare-active-published" { + if !active || preparing { + t.Fatal("post-publication cut lost authoritative active state") + } + } else if active || !preparing { + t.Fatal("pre-publication cut exposed active state or lost disposable preparation") + } + }) + } +} + +func TestNativeBrokerJournalAtomicRecordCutsKeepPublishedPrefix(t *testing.T) { + t.Parallel() + journal, persisted := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseServiceStopIntent, ""); err != nil { + t.Fatal(err) + } + current := append(append([]byte(nil), (*persisted)[0]...), '\n') + next, err := buildNativeBrokerJournalRecordStream(current, (*persisted)[1]) + if err != nil { + t.Fatal(err) + } + cutpoints := []string{ + nativeBrokerCutRecordPartialWrite, + nativeBrokerCutRecordWriteDone, + nativeBrokerCutRecordSyncDone, + nativeBrokerCutRecordReadbackDone, + nativeBrokerCutRecordBeforePublish, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + published := append([]byte(nil), current...) + var staged []byte + err := executeNativeBrokerJournalRecordPublication( + (*persisted)[1], + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { + return append([]byte(nil), published...), nil + }, + discardStaging: func() error { staged = nil; return nil }, + stage: func(candidate []byte) error { + staged = append([]byte(nil), candidate...) + if cutpoint == nativeBrokerCutRecordPartialWrite { + staged = staged[:len(staged)/2] + } + if cutpoint != nativeBrokerCutRecordBeforePublish { + return errNativeBrokerJournalCutpoint + } + return nil + }, + beforePublish: func() error { + if cutpoint == nativeBrokerCutRecordBeforePublish { + return errNativeBrokerJournalCutpoint + } + return nil + }, + publish: func() error { + published = append([]byte(nil), staged...) + return nil + }, + }, + ) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || len(staged) == 0 { + t.Fatalf("cutpoint %s was not observed before publication: err=%v", cutpoint, err) + } + if !bytes.Equal(published, current) { + t.Fatalf("cutpoint %s changed the authoritative published prefix", cutpoint) + } + }) + } + published := append([]byte(nil), current...) + var staged []byte + if err := executeNativeBrokerJournalRecordPublication( + (*persisted)[1], + nativeBrokerJournalRecordPublicationOperations{ + loadCurrent: func() ([]byte, error) { return append([]byte(nil), published...), nil }, + discardStaging: func() error { staged = nil; return nil }, + stage: func(candidate []byte) error { staged = append([]byte(nil), candidate...); return nil }, + beforePublish: func() error { return nil }, + publish: func() error { published = append([]byte(nil), staged...); return nil }, + }, + ); err != nil || !bytes.Equal(published, next) { + t.Fatalf("fully read-back record stream was not atomically published: err=%v", err) + } + if _, err := buildNativeBrokerJournalRecordStream( + current[:len(current)-1], (*persisted)[1], + ); err == nil { + t.Fatal("a torn published trailing record was accepted as an append base") + } +} + +func TestNativeBrokerSettlementRequestPublicationRecoversEveryStagingCut(t *testing.T) { + t.Parallel() + expected := []byte(`{"schema":1,"request":"exact"}`) + cutpoints := []string{ + nativeBrokerCutSettlementPartialWrite, + nativeBrokerCutSettlementWriteDone, + nativeBrokerCutSettlementSyncDone, + nativeBrokerCutSettlementReadbackDone, + nativeBrokerCutSettlementBeforePublish, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + var published, staged []byte + publishedExists, stagingExists := false, false + operations := func(cut string) nativeBrokerSettlementPublicationOperations { + return nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { + return append([]byte(nil), published...), publishedExists, nil + }, + loadStaging: func() ([]byte, bool, error) { + return append([]byte(nil), staged...), stagingExists, nil + }, + discardStaging: func() error { + staged, stagingExists = nil, false + return nil + }, + publishStaging: func() error { + published = append([]byte(nil), staged...) + publishedExists, stagingExists = true, false + return nil + }, + writeNew: func() error { + stagingExists = true + staged = append([]byte(nil), expected...) + if cut == nativeBrokerCutSettlementPartialWrite { + staged = staged[:len(staged)/2] + } + if cut != "" { + return errNativeBrokerJournalCutpoint + } + published = append([]byte(nil), staged...) + publishedExists, stagingExists = true, false + return nil + }, + readback: func() ([]byte, error) { + if !publishedExists { + return nil, errors.New("no published request") + } + return append([]byte(nil), published...), nil + }, + } + } + err := executeNativeBrokerSettlementPublication(expected, operations(cutpoint)) + if !errors.Is(err, errNativeBrokerJournalCutpoint) || publishedExists || !stagingExists { + t.Fatalf("cut=%s published=%v staging=%v err=%v", + cutpoint, publishedExists, stagingExists, err) + } + if err := executeNativeBrokerSettlementPublication(expected, operations("")); err != nil { + t.Fatalf("resume after %s: %v", cutpoint, err) + } + if !publishedExists || stagingExists || !bytes.Equal(published, expected) { + t.Fatalf("resume after %s did not publish the exact request", cutpoint) + } + }) + } + tampered := []byte(`{"schema":1,"request":"different"}`) + err := executeNativeBrokerSettlementPublication(expected, + nativeBrokerSettlementPublicationOperations{ + loadPublished: func() ([]byte, bool, error) { return tampered, true, nil }, + loadStaging: func() ([]byte, bool, error) { return nil, false, nil }, + discardStaging: func() error { return nil }, + publishStaging: func() error { return nil }, + writeNew: func() error { return nil }, + readback: func() ([]byte, error) { return tampered, nil }, + }) + var manual *nativeBrokerJournalManualError + if !errors.As(err, &manual) { + t.Fatalf("published interior corruption was not latched unsafe: %v", err) + } +} + +func TestNativeBrokerTwoPhaseSettlementCutsAreReplayable(t *testing.T) { + t.Parallel() + cutpoints := []string{ + nativeBrokerCutAfterBindingOutput, + nativeBrokerCutBeforePending, + nativeBrokerCutAfterPending, + nativeBrokerCutAfterRequest, + nativeBrokerCutBeforeDriverAck, + nativeBrokerCutAfterDriverAck, + nativeBrokerCutBeforeBrokerFinal, + nativeBrokerCutAfterBrokerFinal, + nativeBrokerCutBeforeRetirement, + nativeBrokerCutAfterRetirement, + nativeBrokerCutAfterDiscard, + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + driverPending := true + brokerPending, requestPublished := false, false + driverSettled, brokerSettled := false, false + brokerRetired, discardAttempted := false, false + operations := func(cut string) nativeBrokerOuterSettlementOperations { + return nativeBrokerOuterSettlementOperations{ + recordPending: func() error { + if !driverPending { + return errors.New("broker pending preceded driver pending") + } + brokerPending = true + return nil + }, + publishRequest: func() error { + if !brokerPending { + return errors.New("request preceded broker pending") + } + requestPublished = true + return nil + }, + acknowledgeDriver: func() error { + if !requestPublished { + return errors.New("driver acknowledgement preceded request") + } + driverSettled = true + return nil + }, + recordBrokerSettled: func() error { + if !driverSettled { + return errors.New("broker final preceded driver final") + } + brokerSettled = true + return nil + }, + retireBrokerJournal: func() error { + if !discardAttempted { + return errors.New("broker retirement preceded authenticated driver discard") + } + brokerRetired = true + return nil + }, + discardInertState: func() error { + if !brokerSettled { + return errors.New("driver discard preceded the protected broker-final receipt") + } + discardAttempted = true + return nil + }, + cutpoint: func(name string) error { + if name == cut { + return errNativeBrokerJournalCutpoint + } + return nil + }, + } + } + err := executeNativeBrokerOuterSettlement(operations(cutpoint)) + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("cutpoint %s was not reached: %v", cutpoint, err) + } + if driverSettled && !requestPublished || brokerSettled && !driverSettled || + discardAttempted && !brokerSettled || brokerRetired && !discardAttempted { + t.Fatalf("cutpoint %s violated settlement ordering", cutpoint) + } + if !brokerRetired { + if err := executeNativeBrokerOuterSettlement(operations("")); err != nil { + t.Fatalf("idempotent replay after %s: %v", cutpoint, err) + } + if !driverSettled || !brokerSettled || !brokerRetired || !discardAttempted { + t.Fatalf("replay after %s did not reach complete settlement", cutpoint) + } + } + }) + } + observed := false + err := executeNativeBrokerOuterSettlement(nativeBrokerOuterSettlementOperations{ + recordPending: func() error { return nil }, publishRequest: func() error { return nil }, + acknowledgeDriver: func() error { return nil }, recordBrokerSettled: func() error { return nil }, + retireBrokerJournal: func() error { return nil }, + discardInertState: func() error { return errors.New("inert cleanup retained") }, + observeDiscardError: func(error) { observed = true }, + }) + if err == nil || !observed { + t.Fatalf("unverified driver discard did not retain broker evidence: observed=%v err=%v", + observed, err) + } +} + +func TestNativeBrokerSettlementEnvelopeBindsBothJournalChains(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + t.Fatal(err) + } + proof := nativePackageInstallProof{ + success: true, changed: true, exitCode: 0, journalRecovery: "fresh", + journal: journal.proof(), driverTransactionID: strings.Repeat("1", 64), + driverPendingDigest: strings.Repeat("2", 64), settlementNonce: strings.Repeat("3", 64), + } + binding, bindingDigest, err := validateNativeBrokerOuterSettlementBinding(journal, proof) + if err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseOuterSettlementPending, bindingDigest); err != nil { + t.Fatal(err) + } + request := nativeBrokerOuterSettlementRequest{ + Schema: nativeBrokerJournalSchema, BindingSHA256: bindingDigest, + BrokerPendingDigest: journal.proof().Digest, Binding: binding, + } + if err := validateNativeBrokerOuterSettlementRequest(journal, request); err != nil { + t.Fatal(err) + } + contents, requestDigest, err := encodeNativeBrokerOuterSettlementEnvelope(request) + if err != nil { + t.Fatal(err) + } + envelope, err := decodeNativeBrokerOuterSettlementEnvelope(contents) + if err != nil || envelope.Payload != request { + t.Fatalf("canonical settlement envelope did not round trip: envelope=%+v err=%v", envelope, err) + } + prepared := nativeBrokerOuterSettlementPrepared{ + Request: request, RequestPath: `C:\ProgramData\VIIPER\BrokerTransactions\active-v1\outer-settlement.json`, + RequestSHA256: requestDigest, contents: contents, + } + receipt := nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: binding.BrokerTransactionID, + BrokerPendingDigest: request.BrokerPendingDigest, + DriverTransactionID: binding.DriverTransactionID, + DriverPendingDigest: binding.DriverPendingDigest, + SettlementNonce: binding.SettlementNonce, + RequestSHA256: requestDigest, State: string(nativeBrokerPhaseOuterSettled), + Digest: strings.Repeat("4", 64), + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + t.Fatal(err) + } + receiptBytes, err := nativeBrokerJournalCanonicalJSON(receipt) + if err != nil { + t.Fatal(err) + } + if err := journal.appendPhase( + nativeBrokerPhaseOuterSettled, nativeBrokerJournalHash(receiptBytes), + ); err != nil { + t.Fatal(err) + } + if err := validateNativeBrokerOuterSettlementRequest(journal, request); err != nil { + t.Fatalf("terminal broker journal lost its exact pending request: %v", err) + } + finalReceipt := nativeBrokerOuterSettlementFinal{ + Schema: nativeBrokerJournalSchema, + BrokerTransactionID: binding.BrokerTransactionID, + BrokerPendingDigest: request.BrokerPendingDigest, + BrokerSettledDigest: journal.proof().Digest, + DriverTransactionID: binding.DriverTransactionID, + DriverPendingDigest: binding.DriverPendingDigest, + DriverSettledDigest: receipt.Digest, + SettlementNonce: binding.SettlementNonce, + RequestSHA256: requestDigest, + State: string(nativeBrokerPhaseOuterSettled), + } + if err := validateNativeBrokerOuterSettlementFinal( + journal, prepared, receipt, finalReceipt, + ); err != nil { + t.Fatal(err) + } + if replayedReceipt := nativeBrokerDriverReceiptFromFinal(finalReceipt); replayedReceipt != receipt { + t.Fatalf("protected final receipt did not reconstruct the exact driver acknowledgement: got=%+v want=%+v", + replayedReceipt, receipt) + } + finalContents, finalDigest, err := encodeNativeBrokerOuterSettlementFinalEnvelope(finalReceipt) + if err != nil || !isCanonicalNativeBrokerJournalSHA256(finalDigest) { + t.Fatalf("encode protected final receipt: digest=%q err=%v", finalDigest, err) + } + finalEnvelope, err := decodeNativeBrokerOuterSettlementFinalEnvelope(finalContents) + if err != nil || finalEnvelope.Payload != finalReceipt { + t.Fatalf("canonical final receipt did not round trip: envelope=%+v err=%v", finalEnvelope, err) + } + mixedFinal := finalReceipt + mixedFinal.BrokerSettledDigest = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementFinal( + journal, prepared, receipt, mixedFinal, + ); err == nil { + t.Fatal("unrelated canonical broker-final digest authorized driver retirement") + } + mutations := []func(*nativePackageBrokerSettlementReceipt){ + func(value *nativePackageBrokerSettlementReceipt) { value.BrokerTransactionID = strings.Repeat("5", 32) }, + func(value *nativePackageBrokerSettlementReceipt) { value.BrokerPendingDigest = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.DriverTransactionID = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.DriverPendingDigest = strings.Repeat("5", 64) }, + func(value *nativePackageBrokerSettlementReceipt) { value.RequestSHA256 = strings.Repeat("5", 64) }, + } + for index, mutate := range mutations { + changed := receipt + mutate(&changed) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, changed); err == nil { + t.Fatalf("settlement receipt mix-up mutation %d was accepted", index) + } + } + changedNonce := receipt + changedNonce.SettlementNonce = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementReceipt(prepared, changedNonce); err == nil { + t.Fatal("settlement nonce mix-up was accepted") + } + changedBinding := request + changedBinding.Binding.DriverPendingDigest = strings.Repeat("5", 64) + if err := validateNativeBrokerOuterSettlementRequest(journal, changedBinding); err == nil { + t.Fatal("request accepted a changed driver chain with the same nonce") + } +} + +func TestNativeBrokerJournalRetirementCutsPreserveAdmissionAuthority(t *testing.T) { + t.Parallel() + cutpoints := []string{ + "retire-before-rename", + "retire-after-rename", + "retire-active-absence-proven", + "retire-tombstone-proven", + } + for _, cutpoint := range cutpoints { + cutpoint := cutpoint + t.Run(cutpoint, func(t *testing.T) { + t.Parallel() + active, tombstone := true, false + err := executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + rename: func() error { + if !active || tombstone { + return errors.New("invalid model rename") + } + active, tombstone = false, true + return nil + }, + proveActiveAbsent: func() error { + if active { + return errors.New("active still present") + } + return nil + }, + proveTombstone: func() error { + if !tombstone { + return errors.New("tombstone absent") + } + return nil + }, + discardTombstone: func() error { + tombstone = false + return nil + }, + cutpoint: func(name string) error { + if name == cutpoint { + return errNativeBrokerJournalCutpoint + } + return nil + }, + }) + if !errors.Is(err, errNativeBrokerJournalCutpoint) { + t.Fatalf("cutpoint error=%v", err) + } + if cutpoint == "retire-before-rename" { + if !active || tombstone { + t.Fatal("pre-rename cut lost the still-authoritative terminal active journal") + } + } else if active || !tombstone { + t.Fatal("post-rename cut republished active admission or lost its protected tombstone") + } + }) + } + active, tombstone := true, false + if err := executeNativeBrokerJournalRetirement(nativeBrokerJournalRetirementOperations{ + rename: func() error { active, tombstone = false, true; return nil }, + proveActiveAbsent: func() error { + if active { + return errors.New("active still present") + } + return nil + }, + proveTombstone: func() error { + if !tombstone { + return errors.New("tombstone absent") + } + return nil + }, + discardTombstone: func() error { return errors.New("simulated cleanup failure") }, + }); err != nil || active || !tombstone { + t.Fatalf("non-authoritative tombstone cleanup blocked settlement: active=%v tombstone=%v err=%v", + active, tombstone, err) + } +} + +func TestNativeBrokerJournalNestedReadyProofReplaysAfterParentCut(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := journal.appendPhase(nativeBrokerPhasePrepared, ""); err != nil { + t.Fatal(err) + } + if err := journal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + t.Fatal(err) + } + beforeParentRecord := journal.proof() + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, journal.snapshot.OuterTokenPath, + journal.snapshot.OuterTransactionID, + journal.snapshot.CandidateSHA256, + ); err != nil { + t.Fatal(err) + } + afterReplay := journal.proof() + if beforeParentRecord != afterReplay || afterReplay.State != string(nativeBrokerPhaseNestedReady) { + t.Fatalf("replayed proof changed across parent cut: before=%+v after=%+v", + beforeParentRecord, afterReplay) + } + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, journal.snapshot.OuterTokenPath, + strings.Repeat("0", 64), + journal.snapshot.CandidateSHA256, + ); err == nil { + t.Fatal("nested-ready replay accepted a different outer transaction identity") + } + if err := validateNativeBrokerNestedReplayBinding( + journal, journal.snapshot.TargetUserSID, + `C:\Program Files\VIIPER\.viiper.transaction.other.token`, + journal.snapshot.OuterTransactionID, journal.snapshot.CandidateSHA256, + ); err == nil { + t.Fatal("nested-ready replay accepted a different outer token path") + } +} + +func TestNativeBrokerJournalReplayedOuterBindingSelectsOldTransactionIdentity(t *testing.T) { + t.Parallel() + currentOuter := strings.Repeat("1", 64) + currentCandidate := strings.Repeat("2", 64) + oldOuter := strings.Repeat("3", 64) + oldCandidate := strings.Repeat("4", 64) + proof := nativePackageInstallProof{ + success: true, changed: true, exitCode: 0, journalRecovery: "replayed", + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("5", 32), OuterTransactionID: oldOuter, + CandidateSHA256: oldCandidate, State: string(nativeBrokerPhaseNestedReady), + Digest: strings.Repeat("6", 64), + }, + } + outer, candidate := nativeBrokerJournalOuterSettlementIdentity( + currentOuter, currentCandidate, proof, + ) + if outer != oldOuter || candidate != oldCandidate { + t.Fatalf("replayed identity=(%s,%s) want old=(%s,%s)", + outer, candidate, oldOuter, oldCandidate) + } + proof.journalRecovery = "" + outer, candidate = nativeBrokerJournalOuterSettlementIdentity( + currentOuter, currentCandidate, proof, + ) + if outer != currentOuter || candidate != currentCandidate { + t.Fatal("unbound proof replaced the current outer transaction identity") + } +} + +func TestNativeBrokerJournalTransactionDirectoryNamesAreCanonical(t *testing.T) { + t.Parallel() + valid := strings.Repeat("a", 32) + if !isNativeBrokerJournalInactiveDirectoryName(nativeBrokerJournalPreparingPrefix+valid, + nativeBrokerJournalPreparingPrefix) || + !isNativeBrokerJournalInactiveDirectoryName(nativeBrokerJournalSettledPrefix+valid, + nativeBrokerJournalSettledPrefix) { + t.Fatal("canonical transaction directory name was rejected") + } + for _, invalid := range []string{ + strings.ToUpper(valid), valid[:31], valid + "0", strings.Repeat("z", 32), + } { + if isNativeBrokerJournalTransactionID(invalid) { + t.Fatalf("noncanonical transaction directory identity was accepted: %q", invalid) + } + } +} + +func TestNativeBrokerJournalSnapshotBindsExactOuterTokenPath(t *testing.T) { + t.Parallel() + journal, _ := newNativeBrokerJournalModel(t) + if err := validateNativeBrokerJournalOuterTokenPath(journal.snapshot); err != nil { + t.Fatalf("canonical snapshot was rejected: %v", err) + } + journal.snapshot.OuterTokenPath = + `C:\Program Files\Other\.viiper.transaction.test.token` + if err := validateNativeBrokerJournalOuterTokenPath(journal.snapshot); err == nil { + t.Fatal("snapshot accepted an outer token outside the candidate image directory") + } +} + +func TestStandaloneNativeBrokerInstallIsFailClosedByDefault(t *testing.T) { + t.Setenv("VIIPER_DEVELOPER_STANDALONE", "") + err := requireDeveloperStandaloneNativeInstall() + if err == nil || !strings.Contains(err.Error(), "developer-only") { + t.Fatalf("default standalone native install did not fail before mutation: %v", err) + } +} + +func TestStandaloneNativeBrokerInstallRequiresExactDeveloperOptIn(t *testing.T) { + for _, value := range []string{"true", "01", " 1", "1 "} { + t.Run(value, func(t *testing.T) { + t.Setenv("VIIPER_DEVELOPER_STANDALONE", value) + if err := requireDeveloperStandaloneNativeInstall(); err == nil { + t.Fatalf("noncanonical developer opt-in %q was accepted", value) + } + }) + } + t.Setenv("VIIPER_DEVELOPER_STANDALONE", "1") + if err := requireDeveloperStandaloneNativeInstall(); err != nil { + t.Fatalf("exact developer opt-in was rejected: %v", err) + } +} + +func TestRecoveredPriorPackageRequiresExplicitRetry(t *testing.T) { + t.Parallel() + err := error(&nativePackageRecoveryRetryError{}) + if !strings.Contains(err.Error(), "retry") || !strings.Contains(err.Error(), "settled") { + t.Fatalf("recovery retry error is not explicit: %v", err) + } +} + +func TestNativeBrokerJournalAbsenceRequiresSettledChildOutcome(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + proof nativePackageInstallProof + want bool + }{ + { + name: "successful healthy child no-op after driver mutation", + proof: nativePackageInstallProof{ + success: true, + changed: true, + }, + want: true, + }, + { + name: "successful child advertises durable identity", + proof: nativePackageInstallProof{ + success: true, + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("a", 32), + }, + }, + want: false, + }, + { + name: "failed changed child completed rollback", + proof: nativePackageInstallProof{ + changed: true, + rollback: "succeeded", + }, + want: true, + }, + { + name: "failed child performed no mutation", + proof: nativePackageInstallProof{ + rollback: "not-needed", + }, + want: true, + }, + { + name: "failed changed child has unsettled rollback", + proof: nativePackageInstallProof{ + changed: true, + rollback: "failed", + }, + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := nativeBrokerJournalAbsenceIsSettled(test.proof); got != test.want { + t.Fatalf("nativeBrokerJournalAbsenceIsSettled()=%t want %t", got, test.want) + } + }) + } +} + +func TestNativeBrokerJournalProofContainsNoProtectedPayload(t *testing.T) { + t.Parallel() + result := nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + journal: nativeBrokerJournalProof{ + TransactionID: strings.Repeat("a", 32), OuterTransactionID: strings.Repeat("c", 64), + CandidateSHA256: strings.Repeat("d", 64), State: string(nativeBrokerPhaseNestedReady), + Digest: strings.Repeat("b", 64), + }, + } + line := result.journalProofLine() + if !strings.Contains(line, "transactionId=") || !strings.Contains(line, "outerTransactionId=") || + !strings.Contains(line, "candidateSha256=") || + !strings.Contains(line, "digest=") || + strings.Contains(strings.ToLower(line), "password") || strings.Contains(line, "scheduledXml") { + t.Fatalf("unsafe journal proof=%q", line) + } +} + +func TestNativePackageInstallProofRequiresCanonicalJournalBinding(t *testing.T) { + t.Parallel() + base := "result=success operation=install changed=1 rebootRequired=0 rollback=not-needed exitCode=0\n" + proof, err := parseNativePackageInstallProof(base, 0) + if err != nil { + t.Fatal(err) + } + if proof.journal.TransactionID != "" { + t.Fatal("install proof invented an absent journal binding") + } + binding := "journal-binding operation=install transactionId=" + strings.Repeat("a", 32) + + " outerTransactionId=" + strings.Repeat("c", 64) + + " candidateSha256=" + strings.Repeat("d", 64) + + " state=nested-ready digest=" + strings.Repeat("b", 64) + + " driverTransactionId=" + strings.Repeat("e", 64) + + " driverDigest=" + strings.Repeat("f", 64) + + " settlementNonce=" + strings.Repeat("0", 64) + " recovery=fresh\n" + proof, err = parseNativePackageInstallProof(base+binding, 0) + if err != nil { + t.Fatal(err) + } + if proof.journal.TransactionID != strings.Repeat("a", 32) || + proof.journal.OuterTransactionID != strings.Repeat("c", 64) || + proof.journal.CandidateSHA256 != strings.Repeat("d", 64) || + proof.journal.State != "nested-ready" || proof.journal.Digest != strings.Repeat("b", 64) || + proof.driverTransactionID != strings.Repeat("e", 64) || + proof.driverPendingDigest != strings.Repeat("f", 64) || + proof.settlementNonce != strings.Repeat("0", 64) || + proof.journalRecovery != "fresh" { + t.Fatalf("journal binding=%+v", proof.journal) + } + replayed := strings.Replace(binding, "recovery=fresh", "recovery=replayed", 1) + proof, err = parseNativePackageInstallProof(base+replayed, 0) + if err != nil || proof.journalRecovery != "replayed" { + t.Fatalf("canonical replayed binding was rejected: proof=%+v err=%v", proof, err) + } + if _, err := parseNativePackageInstallProof(base+binding+binding, 0); err == nil { + t.Fatal("duplicate journal binding was accepted") + } + for _, malformed := range []string{ + strings.TrimSuffix(binding, "\n"), + binding + "journal-binding operation=install transactionId=not-canonical\n", + strings.Replace(binding, "nested-ready", "Nested-Ready", 1), + strings.Replace(binding, "recovery=fresh", "recovery=unknown", 1), + } { + if _, err := parseNativePackageInstallProof(base+malformed, 0); err == nil { + t.Fatalf("noncanonical journal binding was accepted: %q", malformed) + } + } + failure := "result=error operation=install changed=1 rebootRequired=0 rollback=succeeded exitCode=1\n" + if _, err := parseNativePackageInstallProof(failure+binding, 1); err == nil { + t.Fatal("failure outcome carried an unauthorized forward journal binding") + } +} + +func TestNativePackageBrokerSettlementDiscardReceiptIsCanonical(t *testing.T) { + t.Parallel() + line := "journal-discard operation=broker-settlement-discard" + + " brokerTransactionId=" + strings.Repeat("a", 32) + + " brokerDigest=" + strings.Repeat("b", 64) + + " driverTransactionId=" + strings.Repeat("c", 64) + + " driverDigest=" + strings.Repeat("d", 64) + + " settlementNonce=" + strings.Repeat("e", 64) + + " requestSha256=" + strings.Repeat("f", 64) + + " discarded=1 retained=1\n" + receipt, err := parseNativePackageBrokerSettlementDiscardReceipt(line, 0) + if err != nil { + t.Fatal(err) + } + if !receipt.Discarded || !receipt.Retained || + receipt.BrokerTransactionID != strings.Repeat("a", 32) || + receipt.BrokerDigest != strings.Repeat("b", 64) || + receipt.DriverTransactionID != strings.Repeat("c", 64) || + receipt.DriverDigest != strings.Repeat("d", 64) || + receipt.SettlementNonce != strings.Repeat("e", 64) || + receipt.RequestSHA256 != strings.Repeat("f", 64) { + t.Fatalf("discard receipt=%+v", receipt) + } + for _, malformed := range []string{ + strings.Replace(line, " retained=1", "", 1), + strings.Replace(line, "retained=1", "retained=true", 1), + strings.TrimSuffix(line, "\n"), + line + line, + } { + if _, err := parseNativePackageBrokerSettlementDiscardReceipt(malformed, 0); err == nil { + t.Fatalf("noncanonical discard receipt was accepted: %q", malformed) + } + } +} diff --git a/internal/cmd/native_package.go b/internal/cmd/native_package.go index ff8803ae..5f84f7a8 100644 --- a/internal/cmd/native_package.go +++ b/internal/cmd/native_package.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/hex" "errors" "fmt" "log/slog" @@ -18,6 +19,15 @@ var nativePackageSHA256 = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) var nativePackageInstallProofPattern = regexp.MustCompile( `(?m)^result=(success|error) operation=install changed=([01]) rebootRequired=([01]) rollback=(not-needed|succeeded|failed) exitCode=([0-9]+)(?: .*)?\r?$`, ) +var nativePackageInstallJournalBindingPattern = regexp.MustCompile( + `(?m)^journal-binding operation=install transactionId=([0-9a-f]{32}) outerTransactionId=([0-9a-f]{64}) candidateSha256=([0-9a-f]{64}) state=(nested-ready) digest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) recovery=(fresh|replayed)\r?$`, +) +var nativePackageBrokerSettlementReceiptPattern = regexp.MustCompile( + `(?m)^journal-settlement operation=broker-settlement-ack brokerTransactionId=([0-9a-f]{32}) brokerPendingDigest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverPendingDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) requestSha256=([0-9a-f]{64}) state=(outer-settled) digest=([0-9a-f]{64})\r?$`, +) +var nativePackageBrokerSettlementDiscardPattern = regexp.MustCompile( + `(?m)^journal-discard operation=broker-settlement-discard brokerTransactionId=([0-9a-f]{32}) brokerDigest=([0-9a-f]{64}) driverTransactionId=([0-9a-f]{64}) driverDigest=([0-9a-f]{64}) settlementNonce=([0-9a-f]{64}) requestSha256=([0-9a-f]{64}) discarded=([01]) retained=([01])\r?$`, +) const ( nativePackageTransactionTimeout = 4 * time.Minute @@ -29,6 +39,12 @@ type nativePackageRebootRequiredError struct { cause error } +type nativePackageRecoveryRetryError struct{} + +func (*nativePackageRecoveryRetryError) Error() string { + return "a prior native package transaction was recovered and settled; retry the requested package transaction" +} + func (e *nativePackageRebootRequiredError) Error() string { return "native package activation requires a restart after safe rollback: " + e.cause.Error() } @@ -69,6 +85,7 @@ type NativePackageBrokerCommit struct { ExpectedBrokerSHA256 string `help:"Installer-bound SHA-256 of the broker being committed." required:""` TargetUserSID string `help:"Interactive Windows user SID that owns legacy startup state." required:""` TransactionDeadlineUnixMS string `help:"Outer package transaction deadline as Unix milliseconds." required:""` + RecoveryOnly bool `help:"Replay or reconcile only the exact durable child transaction; never start a new one." hidden:""` } type nativePackageBrokerCommitResult struct { @@ -76,14 +93,50 @@ type nativePackageBrokerCommitResult struct { changed bool rollback string exitCode int + journal nativeBrokerJournalProof +} + +type nativeBrokerJournalProof struct { + TransactionID string + OuterTransactionID string + CandidateSHA256 string + State string + Digest string } type nativePackageInstallProof struct { - success bool - changed bool - rebootRequired bool - rollback string - exitCode int + success bool + changed bool + rebootRequired bool + rollback string + exitCode int + journal nativeBrokerJournalProof + driverTransactionID string + driverPendingDigest string + settlementNonce string + journalRecovery string +} + +type nativePackageBrokerSettlementReceipt struct { + BrokerTransactionID string `json:"brokerTransactionId"` + BrokerPendingDigest string `json:"brokerPendingDigest"` + DriverTransactionID string `json:"driverTransactionId"` + DriverPendingDigest string `json:"driverPendingDigest"` + SettlementNonce string `json:"settlementNonce"` + RequestSHA256 string `json:"requestSha256"` + State string `json:"state"` + Digest string `json:"digest"` +} + +type nativePackageBrokerSettlementDiscardReceipt struct { + BrokerTransactionID string + BrokerDigest string + DriverTransactionID string + DriverDigest string + SettlementNonce string + RequestSHA256 string + Discarded bool + Retained bool } func parseNativePackageInstallProof(output string, processExitCode int) (nativePackageInstallProof, error) { @@ -102,6 +155,44 @@ func parseNativePackageInstallProof(output string, processExitCode int) (nativeP rollback: matches[0][4], exitCode: proofExitCode, } + journalBindingSeen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-binding") { + journalBinding := nativePackageInstallJournalBindingPattern.FindStringSubmatch(line) + if !terminated || len(journalBinding) != 10 || journalBinding[0] != line { + return nativePackageInstallProof{}, errors.New( + "driver helper emitted a noncanonical broker journal binding", + ) + } + if journalBindingSeen { + return nativePackageInstallProof{}, errors.New("driver helper emitted multiple broker journal bindings") + } + journalBindingSeen = true + proof.journal = nativeBrokerJournalProof{ + TransactionID: journalBinding[1], + OuterTransactionID: journalBinding[2], + CandidateSHA256: journalBinding[3], + State: journalBinding[4], + Digest: journalBinding[5], + } + proof.driverTransactionID = journalBinding[6] + proof.driverPendingDigest = journalBinding[7] + proof.settlementNonce = journalBinding[8] + proof.journalRecovery = journalBinding[9] + } + if !terminated { + break + } + cursor = lineEnd + 1 + } if proof.exitCode != processExitCode { return nativePackageInstallProof{}, fmt.Errorf( "driver helper install process exit %d disagreed with structured exit %d", @@ -139,9 +230,128 @@ func parseNativePackageInstallProof(output string, processExitCode int) (nativeP "driver helper returned unsupported structured install exit %d", proof.exitCode, ) } + if journalBindingSeen && (!proof.success || !proof.changed || proof.exitCode != 0) { + return nativePackageInstallProof{}, errors.New( + "driver helper emitted a broker journal binding for a non-forward-success outcome", + ) + } return proof, nil } +func parseNativePackageBrokerSettlementReceipt( + output string, + processExitCode int, +) (nativePackageBrokerSettlementReceipt, error) { + if processExitCode != 0 { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "driver helper settlement acknowledgement exited with %d", processExitCode, + ) + } + var receipt nativePackageBrokerSettlementReceipt + seen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-settlement") { + match := nativePackageBrokerSettlementReceiptPattern.FindStringSubmatch(line) + if !terminated || len(match) != 9 || match[0] != line { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted a noncanonical broker settlement acknowledgement", + ) + } + if seen { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted multiple broker settlement acknowledgements", + ) + } + seen = true + receipt = nativePackageBrokerSettlementReceipt{ + BrokerTransactionID: match[1], + BrokerPendingDigest: match[2], + DriverTransactionID: match[3], + DriverPendingDigest: match[4], + SettlementNonce: match[5], + RequestSHA256: match[6], + State: match[7], + Digest: match[8], + } + } + if !terminated { + break + } + cursor = lineEnd + 1 + } + if !seen { + return nativePackageBrokerSettlementReceipt{}, errors.New( + "driver helper emitted no broker settlement acknowledgement", + ) + } + return receipt, nil +} + +func parseNativePackageBrokerSettlementDiscardReceipt( + output string, + processExitCode int, +) (nativePackageBrokerSettlementDiscardReceipt, error) { + if processExitCode != 0 { + return nativePackageBrokerSettlementDiscardReceipt{}, fmt.Errorf( + "driver helper settled-tombstone discard exited with %d", processExitCode, + ) + } + var receipt nativePackageBrokerSettlementDiscardReceipt + seen := false + for cursor := 0; cursor < len(output); { + lineEnd := strings.IndexByte(output[cursor:], '\n') + terminated := lineEnd >= 0 + if terminated { + lineEnd += cursor + } else { + lineEnd = len(output) + } + line := output[cursor:lineEnd] + if strings.HasPrefix(line, "journal-discard") { + match := nativePackageBrokerSettlementDiscardPattern.FindStringSubmatch(line) + if !terminated || len(match) != 9 || match[0] != line { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted a noncanonical settled-tombstone discard receipt", + ) + } + if seen { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted multiple settled-tombstone discard receipts", + ) + } + seen = true + receipt = nativePackageBrokerSettlementDiscardReceipt{ + BrokerTransactionID: match[1], + BrokerDigest: match[2], + DriverTransactionID: match[3], + DriverDigest: match[4], + SettlementNonce: match[5], + RequestSHA256: match[6], + Discarded: match[7] == "1", + Retained: match[8] == "1", + } + } + if !terminated { + break + } + cursor = lineEnd + 1 + } + if !seen { + return nativePackageBrokerSettlementDiscardReceipt{}, errors.New( + "driver helper emitted no settled-tombstone discard receipt", + ) + } + return receipt, nil +} + func (r nativePackageBrokerCommitResult) proofLine() string { status := "error" if r.success { @@ -157,6 +367,29 @@ func (r nativePackageBrokerCommitResult) proofLine() string { ) } +func (r nativePackageBrokerCommitResult) journalProofLine() string { + if len(r.journal.TransactionID) != 32 || + !nativePackageSHA256.MatchString(r.journal.OuterTransactionID) || + !nativePackageSHA256.MatchString(r.journal.CandidateSHA256) || + !nativePackageSHA256.MatchString(r.journal.Digest) || + r.journal.TransactionID != strings.ToLower(r.journal.TransactionID) || + r.journal.OuterTransactionID != strings.ToLower(r.journal.OuterTransactionID) || + r.journal.CandidateSHA256 != strings.ToLower(r.journal.CandidateSHA256) || + r.journal.Digest != strings.ToLower(r.journal.Digest) || + (r.journal.State != "nested-ready" && r.journal.State != "rollback-settled" && + r.journal.State != "manual") { + return "" + } + if _, err := hex.DecodeString(r.journal.TransactionID); err != nil { + return "" + } + return fmt.Sprintf( + "journal-proof operation=native-package-broker-commit transactionId=%s outerTransactionId=%s candidateSha256=%s state=%s digest=%s\n", + r.journal.TransactionID, r.journal.OuterTransactionID, r.journal.CandidateSHA256, + r.journal.State, r.journal.Digest, + ) +} + type nativePackageBrokerCommitError struct { cause error exitCode int @@ -182,9 +415,9 @@ func (c *NativePackageBrokerCommit) Run(logger *slog.Logger) error { result, err = commitNativePackageBroker(logger, strings.TrimSpace(c.TokenFile), strings.ToLower(strings.TrimSpace(c.ExpectedTokenSHA256)), strings.ToLower(strings.TrimSpace(c.ExpectedBrokerSHA256)), strings.TrimSpace(c.TargetUserSID), - strings.TrimSpace(c.TransactionDeadlineUnixMS)) + strings.TrimSpace(c.TransactionDeadlineUnixMS), c.RecoveryOnly) } - fmt.Fprint(os.Stdout, result.proofLine()) + fmt.Fprint(os.Stdout, result.proofLine()+result.journalProofLine()) if err != nil { return &nativePackageBrokerCommitError{cause: err, exitCode: result.exitCode} } diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index 9c6a4ca0..c9327e5a 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -31,6 +31,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { filepath.Join(root, "internal", "cmd", "native_package_uninstall.go")) serviceSource := readNativePackageContractFile(t, filepath.Join(root, "internal", "cmd", "native_service_install_windows.go")) + brokerJournalSource := readNativePackageContractFile(t, + filepath.Join(root, "internal", "cmd", "native_broker_journal_windows.go")) requiredWindows := []string{ "expectedManifestSHA256", @@ -100,7 +102,7 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "lockNativePackageUninstallLiveLog(", "promoteNativePackageUninstallLiveLog(ctx)", "stopNativeService(ctx, t.service", "--transaction-deadline-unix-ms", "exec.Command(t.request.driverHelper", "parseNativePackageRemoveProof(", - "serviceRestoreVerified", "t.service.Delete()", + "return result, proofErr", "serviceRestoreVerified", "t.service.Delete()", "waitForNativePackageServiceDeletion", "deleteNativePackageUninstallFileHandle(", "errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE)", "nativePackageUninstallIsCurrentExecutable(file)", @@ -201,7 +203,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "PreparePreinstalledDriverOnDevice(", "CommitPreparedDriverBinding(", "requirePristineRuntime", "RequiresDriverMutation(", "RequiresPristineRuntimeProof(", "RuntimeStatsArePristine(", - "AbiCompatibilityProfile", "{12, 29, 152, true}", + "AbiCompatibilityProfile", "{13, 29, 152, true}", + "{12, 29, 152, true}", "{11, 29, 144, false}", "{10, 13, 144, false}", "AbiCompatibilityProfilesAreValid()", "IsAbiRetryEligible(", "AbiHealthPurpose::PristineUpgrade", "AbiHealthPurpose::PristineRecheck", @@ -217,6 +220,20 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "self-test-pristine-runtime-decision", "self-test-pristine-runtime-stats", "--broker-quiesce-request-handle", "--broker-quiesce-ready-handle", "--broker-quiesce-abort-handle", "--broker-handoff-handle", + "--recovery-only", "journal-binding operation=install", + "OuterPackageMutexWitness", "VerifyHeldByOuterOwner(", + "AcknowledgeBrokerOuterSettlement(", "DiscardBrokerSettlementTombstone(", + "ReconcileSettledBrokerOuterSettlement(", + "ParseBrokerSettlementRequest(", "ParseBrokerSettlementFinal(", + "ReadProtectedBrokerSettlementFinal(", + "ValidateBrokerSettlementFinalBinding(", + "ValidateBrokerSettlementFinalJournal(", + "IsBrokerOuterSettlementContinuationPhase(", + "BrokerOuterSettlementPendingDriverDigest(", + "LockProtectedBrokerImage(", + "kBrokerSettlementRequestFile", "kInstallRecoverySettledPrefix", + "kBrokerSettlementFinalFile", "kInstallRecoveryDiscardPrefix", + `<< " retained=" << (retained ? 1 : 0)`, } for _, fragment := range requiredHelper { if !strings.Contains(helperSource, fragment) { @@ -304,6 +321,7 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "DriverValidated", "BrokerHandoffEntered", "BrokerHandoffReturned", "BrokerChildEntered", "BrokerChildSettled", + "BrokerOuterSettlementPending", "BrokerOuterSettled", "RollbackBindingEntered", "PartialRootRemovalEntered", "PartialRootRemovalReturned", "PartialRootRemovalRebootPending", "RollbackBindingReturned", @@ -377,6 +395,63 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Error("driver helper can continue after an indeterminate journal append") } + brokerRunSource := sourceRegion("broker proof publication", + "bool RunBrokerInstall(", "Outcome Install(", false) + assertOrdered("durable broker authority publication", brokerRunSource, + "ParseBrokerCommitProof(", "RecordBrokerProof(proof", + "*driverRollbackAuthorized = proof.driverRollbackAuthorized;", + "*brokerChanged = proof.changed;") + + brokerAckSource := sourceRegion("broker settlement acknowledgement", + "bool AcknowledgeBrokerOuterSettlement(", + "bool DiscardBrokerSettlementTombstone(", false) + assertOrdered("outer settlement lock and journal order", brokerAckSource, + "outerMutex.VerifyHeldByOuterOwner(", + "transactionMutex.Acquire(", + "ReadProtectedBrokerSettlementRequest(", + "ParseBrokerSettlementRequest(", + "LoadSettlementInstallJournal(", + "ValidateBrokerSettlementJournalBinding(", + "AppendBrokerOuterSettled(", + "RetireInstallRecoveryActiveDirectory(") + for _, fragment := range []string{ + "error, true, &retiredPath", "BrokerOuterSettlementPending", + "BrokerOuterSettled", "requestSha256", + } { + if !strings.Contains(brokerAckSource, fragment) { + t.Errorf("driver helper broker settlement acknowledgement lost %q", fragment) + } + } + brokerDiscardSource := sourceRegion("broker settlement discard", + "bool DiscardBrokerSettlementTombstone(", + "void EmitBrokerSettlementAck(", false) + assertOrdered("authenticated atomic settlement discard", brokerDiscardSource, + "outerMutex.VerifyHeldByOuterOwner(", + "transactionMutex.Acquire(", + "ReadProtectedBrokerSettlementFinal(", + "ParseBrokerSettlementFinal(", + "ReadProtectedBrokerSettlementRequest(", + "ParseBrokerSettlementRequest(", + "ValidateBrokerSettlementFinalBinding(", + "OpenSettledInstallJournalDirectory(", + "OpenDiscardingInstallJournalDirectory(", + "ValidateBrokerSettlementFinalJournal(", + "RecoveryStateMatchesForward(", + "MoveFileExW(settled.c_str(), discarding.c_str()", + "MOVEFILE_WRITE_THROUGH", + "OpenStableDirectory(") + if strings.Contains(brokerDiscardSource, + "std::filesystem::remove_all(settled") { + t.Error("driver settlement discard deletes its authoritative tombstone in place") + } + for _, fragment := range []string{ + "driverPendingDigest", "brokerPendingDigest", "settlementNonce", + } { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper broker settlement binding lost %q", fragment) + } + } + watchdogSource := sourceRegion("authoritative mutation watchdog", "class SynchronousMutationWatchdog final", "class DeviceInfoSet final", false) for _, fragment := range []string{ @@ -413,11 +488,178 @@ func TestNativePackageProductionSourceContract(t *testing.T) { removeEntrySource := sourceRegion("remove entry", "Outcome Remove(const RemoveOptions& options)", "Outcome Recover(", false) assertOrdered("remove pre-mutation reconciliation", removeEntrySource, - "mutex.Acquire(", "ReconcileInstallJournal(", "CaptureSnapshot(", - "BackupPackages(") + "mutex.Acquire(", "ReconcileRemoveJournal(", "ReconcileInstallJournal(", + "CaptureSnapshot(", "PrepareRemoveJournal(", "ReconcileRemoveJournal(") + + removeRequired := []string{ + `kRemoveRecoveryRootDirectory[]`, `L"VIIPER-UdeCx-RemoveTransactions"`, + `kRemoveRecoveryActiveDirectory[] = L"active-v2"`, + "WriteRemoveJournalRecord(", `\"previousSha256\"`, `\"payloadSha256\"`, + "ValidateRemoveJournalTransition(", "loaded->poisoned = true", + "PrepareRemoveJournal(", "BackupPackagesIntoDirectory(", + "DeviceRemovalEntered", "DeviceRemovalReturned", "DeviceRemovalCommitted", + "PackageRemovalEntered", "PackageRemovalReturned", "PackageRemovalCommitted", + "RollbackAdmitted", "RollbackPackageEntered", "RollbackPackageReturned", + "RollbackPackageCommitted", "RollbackBindingEntered", "RollbackBindingReturned", + "ForwardRebootPending", "RestoreRebootPending", + "ManualReconciliationRequired", "pendingRebootBootIdentifier", + "ObserveRemoveRootShape(", "ObserveRemovePackagePrefix(", + "ObserveRemovePackageSubset(", "InvokeRemovePackageMutation(", + "InvokeRestorePackageMutation(", "CurrentRemoveStateIsUninstalled(", + "CurrentRemoveStateMatchesPrior(", "RetireRemoveRecoveryActiveDirectory(", + "RetireLoadedRemoveJournal(", "RunRemoveJournalModelSelfTest(", + "RunRemoveJournalRetirementSelfTest(", "RemoveExactCapturedDevice(", + "FreshRemoveRollbackDeadline(", + "CrossedRemoveRebootStillPendingRequiresManual(", + `warning=\"remove-settled-cleanup-retained\"`, + "ReconcileRemoveJournal(", + } + for _, fragment := range removeRequired { + if !strings.Contains(helperSource, fragment) { + t.Errorf("driver helper remove journal lost %q", fragment) + } + } + removePrepareSource := sourceRegion("remove journal preparation", + "bool PrepareRemoveJournal(", "enum class RemoveRootShape", false) + assertOrdered("remove protected evidence before Prepared", removePrepareSource, + "OpenChain(true", "PublishRemoveRecoveryEvidence(", + "BackupPackagesIntoDirectory(", "ValidateRemoveJournalTransition(nullptr", + "WriteRemoveJournalRecord(") + removeRecordSource := sourceRegion("remove atomic journal record", + "bool AppendRemoveJournalRecord(", "bool PrepareRemoveJournal(", true) + assertOrdered("remove atomic journal state publication", removeRecordSource, + "ValidateRemoveJournalTransition(", "WriteRemoveJournalRecord(", + "loaded->state = std::move(next);", "PublishRemoveRecoveryEvidence(") + if !strings.Contains(removeRecordSource, "loaded->poisoned = true") { + t.Error("driver helper can continue after an indeterminate remove append") + } + removeRetireSource := sourceRegion("loaded remove journal retirement", + "bool RetireLoadedRemoveJournal(", "bool AppendRemoveJournalRecord(", false) + assertOrdered("remove descendant evidence release immediately before rename", + removeRetireSource, + "RemoveJournalPhase::ForwardValidated", + "RemoveJournalPhase::ExactPriorRestored", + "const std::string transactionId", + "loaded->priorBackups.clear();", "loaded->evidenceLocks.clear();", + "return RetireRemoveRecoveryActiveDirectory(") + removePriorRetireSource := sourceRegion("remove prior terminal retirement", + "bool RetireRemoveJournalAsPrior(", + "bool RetireRemoveJournalAsUninstalled(", false) + assertOrdered("remove prior terminal double validation", removePriorRetireSource, + "CurrentRemoveStateMatchesPrior(", + "RemoveJournalPhase::ExactPriorRestored", "RecordRemoveJournalPhase(", + "CurrentRemoveStateMatchesPrior(", "RetireLoadedRemoveJournal(") + if !strings.Contains(removePriorRetireSource, + "if (outcome->error.recoveryBackup.empty())") { + t.Error("prior retirement overwrites exact tombstone failure evidence with absent active-v2") + } + removeForwardRetireSource := sourceRegion("remove forward terminal retirement", + "bool RetireRemoveJournalAsUninstalled(", "bool FailRemoveJournalManual(", false) + assertOrdered("remove forward terminal double validation", removeForwardRetireSource, + "CurrentRemoveStateIsUninstalled(", + "RemoveJournalPhase::ForwardValidated", "RecordRemoveJournalPhase(", + "CurrentRemoveStateIsUninstalled(", "RetireLoadedRemoveJournal(") + if !strings.Contains(removeForwardRetireSource, + "if (outcome->error.recoveryBackup.empty())") { + t.Error("forward retirement overwrites exact tombstone failure evidence with absent active-v2") + } + removeManualSource := sourceRegion("remove manual evidence retention", + "bool FailRemoveJournalManual(", + "bool ReturnRemoveJournalRebootPending(", false) + if !strings.Contains(removeManualSource, "!cause->recoveryBackup.empty()") || + strings.Contains(removeManualSource, "RetireLoadedRemoveJournal(") { + t.Error("manual recovery does not preserve callee tombstone evidence or releases terminal locks") + } + + removeDeviceSource := sourceRegion("single captured device removal", + "bool RemoveExactCapturedDevice(", "bool RegisterRootDevice(", false) + assertOrdered("single captured device immutable revalidation", removeDeviceSource, + "FindExactDevices(", "LoadOwnedPackage(", + "IsExactCapturedRemoveTarget(", "return RemoveDevice(") + if strings.Contains(helperSource, "RemoveAllExactDevices(") { + t.Error("driver helper retained forbidden broad all-device remove plumbing") + } + + removeRollbackSource := sourceRegion("remove rollback recovery", + "bool RunRemoveRollbackRecovery(", "bool AdmitRemoveRollback(", false) + assertOrdered("interrupted binding admission reuse", removeRollbackSource, + "ReusesInterruptedRemoveBindingAdmission(", + "!reusingInterruptedBindingAdmission", + "RemoveJournalPhase::RollbackBindingEntered", "ObserveRemoveRootShape(", + "VerifyPackageInventory(", "RestorePriorBinding(") + assertOrdered("remove rollback uses exact-absence binding authority", + removeRollbackSource, "ObserveRemoveRootShape(", + "root != RemoveRootShape::Absent", "VerifyPackageInventory(", + "RestorePriorBinding(restorable,", + "RestorePriorBindingPolicy::RemoveJournalExactAbsence") + restoreBindingSource := sourceRegion("prior binding restore policy", + "bool RestorePriorBinding(", "bool RollbackInstall(", false) + assertOrdered("remove exact-absence race fails before mutation", + restoreBindingSource, "CaptureSnapshot(", + "RestorePriorBindingTopologyAdmitsMutation(", + "RestorePriorBindingPolicy::InstallRollbackReconcile &&", + "RemoveDevice(", "RegisterRootDeviceExact(", + "InstallPreinstalledDriverOnDevice(") + if !strings.Contains(restoreBindingSource, + "policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence") || + !strings.Contains(helperSource, + `L"self-test-remove-journal-binding-exact-absence-race"`) { + t.Error("remove rollback lost its explicit zero-mutation exact-absence race policy/model") + } + removeAdmissionSource := sourceRegion("remove rollback admission", + "bool AdmitRemoveRollback(", "bool RunRemoveForwardRecovery(", false) + assertOrdered("durable rollback admission and fresh deadline", removeAdmissionSource, + "RemoveJournalPhase::RestoreRebootPending", "RecordRemoveJournalPhase(", + "FreshRemoveRollbackDeadline();", "RunRemoveRollbackRecovery(") + if strings.Contains(removeAdmissionSource, "deadlineUnixMs") { + t.Error("forward-to-rollback admission accepts or reuses the exhausted forward deadline") + } + removeForwardSource := sourceRegion("remove forward recovery", + "bool RunRemoveForwardRecovery(", "bool ReconcileRemoveJournal(", false) + assertOrdered("crossed reboot loop fails closed", removeForwardSource, + "CrossedRemoveRebootStillPendingRequiresManual(", + "FailRemoveJournalManual(", "ReturnRemoveJournalRebootPending(") + crossedRebootSource := sourceRegion("crossed remove reboot decision", + "bool CrossedRemoveRebootStillPendingRequiresManual(", + "bool ReusesInterruptedRemoveBindingAdmission(", false) + for _, fragment := range []string{ + "RemoveJournalPhase::DeviceRemovalReturned", "callSucceeded", + "freshRebootRequired", "!samePendingBoot", + } { + if !strings.Contains(crossedRebootSource, fragment) { + t.Errorf("returned-to-pending crossed reboot decision lost %q", fragment) + } + } + if !strings.Contains(helperSource, + `L"self-test-remove-journal-device-returned-pending-cut"`) { + t.Error("driver helper lost the compiled DeviceRemovalReturned-to-pending crash cut test") + } + if !strings.Contains(removeForwardSource, "RemoveExactCapturedDevice(") || + strings.Contains(removeForwardSource, "RemoveAllExactDevices(") { + t.Error("protected forward removal is not confined to one immutable captured root") + } + + removeRawRetireSource := sourceRegion("remove raw terminal retirement", + "bool RetireRemoveRecoveryActiveDirectory(", + "struct RemoveJournalStateData {", false) + assertOrdered("remove tombstone retirement and warning evidence", removeRawRetireSource, + "MoveFileExW(", "error->recoveryBackup = tombstone.wstring();", + "ClearActiveRecoveryEvidence();", "std::filesystem::remove_all(", + "gRetainedRemoveTombstoneError", "OutputDebugStringW(") + removeReconcileSource := sourceRegion("remove startup reconciliation", + "bool ReconcileRemoveJournal(", "struct RemoveOptions {", true) + for _, fragment := range []string{ + "LoadRemoveJournal(", "InstallRecoveryDirectory installDirectory", + "installExists", "ManualReconciliationRequired", "GetBootIdentifier(", + "RunRemoveRollbackRecovery(", "RunRemoveForwardRecovery(", + } { + if !strings.Contains(removeReconcileSource, fragment) { + t.Errorf("driver helper remove reconciliation lost %q", fragment) + } + } reconcileSource := sourceRegion("startup journal reconciliation", - "bool ReconcileInstallJournal(", "bool RollbackRemove(", true) + "bool ReconcileInstallJournal(", "const char* RemoveJournalPhaseName(", true) for _, fragment := range []string{ "ForwardRebootPending && sameBoot", "RestoreRebootPending && sameBoot", "return rebootPending(", @@ -794,11 +1036,20 @@ func TestNativePackageProductionSourceContract(t *testing.T) { if strings.Contains(windowsSource, `strings.Contains(text, "result=success operation=install")`) { t.Error("native package install must parse one exact helper outcome instead of accepting a success substring") } - backupMove := strings.Index(windowsSource, - "moveNativePackageFile(t.destination, backupPath, false)") - backupPublish := strings.Index(windowsSource, "t.backupPath = backupPath") - if backupMove < 0 || backupPublish < 0 || backupPublish < backupMove { - t.Error("native package rollback path is published before the prior broker rename succeeds") + imageIntent := strings.Index(windowsSource, "nativeBrokerPhaseImageSwitchIntent") + atomicReplace := strings.Index(windowsSource, + "replaceNativePackageFileAtomically(t.temporaryPath, t.destination, priorExists)") + imageSettled := strings.Index(windowsSource, "nativeBrokerPhaseImageSwitched") + if imageIntent < 0 || atomicReplace <= imageIntent || imageSettled <= atomicReplace { + t.Error("native package image replacement lost intent -> atomic replace -> settled ordering") + } + for _, fragment := range []string{ + "copyNativeBrokerJournalImage(", "nativeBrokerJournalPriorImageName", + "appendPhase(nativeBrokerPhasePrepared", "REPLACEFILE_WRITE_THROUGH", + } { + if !strings.Contains(brokerJournalSource+windowsSource, fragment) { + t.Errorf("native broker durable image transaction lost %q", fragment) + } } requiredTransaction := []string{ "transaction.Preflight(ctx)", "transaction.InspectService(ctx)", @@ -825,6 +1076,34 @@ func TestNativePackageProductionSourceContract(t *testing.T) { t.Errorf("package uninstall transaction lost %q", fragment) } } + for _, fragment := range []string{ + "parseNativePackageRemoveProofFields(", + "parseOptionalNativePackageRemoveWarning(", + "nativePackageRemoveRetainedTombstoneMaximumRunes", + "validateNativePackageRemoveRetainedTombstone(", + "retainedTombstoneWin32Error", + `logger.Warn("Native remove journal retired with a retained settled tombstone"`, + } { + if !strings.Contains(uninstallTransactionSource, fragment) { + t.Errorf("native remove warning proof channel lost %q", fragment) + } + } + warningParserStart := strings.Index(uninstallTransactionSource, + "func parseOptionalNativePackageRemoveWarning(") + warningParserEnd := strings.Index(uninstallTransactionSource, + "func parseNativePackageRemoveProof(") + if warningParserStart < 0 || warningParserEnd <= warningParserStart { + t.Fatal("native remove warning parser source region is missing or malformed") + } + warningParserSource := uninstallTransactionSource[warningParserStart:warningParserEnd] + assertOrdered("native remove warning tuple", + warningParserSource, `"warning"`, + `"warningWin32Error"`, `"retainedTombstone"`, + "validateNativePackageRemoveRetainedTombstone(", + "*position != len(fields)") + if strings.Contains(uninstallTransactionSource, `(?: .*)?`) { + t.Error("native remove proof parser still accepts an arbitrary trailing field wildcard") + } if !strings.Contains(serviceSource, "func acquireNativeInstallMutex(") { t.Error("native broker service mutex wrapper was removed") } diff --git a/internal/cmd/native_package_other.go b/internal/cmd/native_package_other.go index 05e7d216..4ba23256 100644 --- a/internal/cmd/native_package_other.go +++ b/internal/cmd/native_package_other.go @@ -13,7 +13,7 @@ func installNativePackage(context.Context, *slog.Logger, nativePackageRequest) e } func commitNativePackageBroker( - *slog.Logger, string, string, string, string, string, + *slog.Logger, string, string, string, string, string, bool, ) (nativePackageBrokerCommitResult, error) { return nativePackageBrokerPreflightFailure( errors.New("native UDE package installation is supported only on Windows"), diff --git a/internal/cmd/native_package_uninstall.go b/internal/cmd/native_package_uninstall.go index 8b1c8623..3c4d9386 100644 --- a/internal/cmd/native_package_uninstall.go +++ b/internal/cmd/native_package_uninstall.go @@ -6,16 +6,20 @@ import ( "fmt" "log/slog" "path/filepath" - "regexp" "strconv" "strings" "time" + "unicode/utf8" ) const nativePackageUninstallCleanupTimeout = 2 * time.Minute -var nativePackageRemoveProofPattern = regexp.MustCompile( - `(?m)^result=(success|error) operation=remove changed=([01]) rebootRequired=([01]) rollback=(not-needed|succeeded|failed) exitCode=([0-9]+)(?: .*)?\r?$`, +const ( + nativePackageRemoveProofMaximumLineBytes = 64 * 1024 + nativePackageRemoveRetainedTombstoneWarning = "remove-settled-cleanup-retained" + nativePackageRemoveRetainedTombstoneMaximumRunes = 259 + nativePackageRemoveRecoveryDirectory = "VIIPER-UdeCx-RemoveTransactions" + nativePackageRemoveSettledPrefix = "settled-v2-" ) type nativePackageUninstallRequest struct { @@ -47,33 +51,370 @@ func (r nativePackageUninstallRequest) validate() error { } type nativePackageRemoveResult struct { - rebootRequired bool - serviceRestoreVerified bool + rebootRequired bool + serviceRestoreVerified bool + retainedTombstone string + retainedTombstoneWin32Error uint32 } type nativePackageRemoveProof struct { - success bool - changed bool - rebootRequired bool - rollback string - exitCode int + success bool + changed bool + rebootRequired bool + rollback string + exitCode int + retainedTombstone string + retainedTombstoneWin32Error uint32 +} + +type nativePackageRemoveProofField struct { + name string + value string + quoted bool +} + +func parseNativePackageRemoveProofFields(line string) ([]nativePackageRemoveProofField, error) { + if line == "" || len(line) > nativePackageRemoveProofMaximumLineBytes || !utf8.ValidString(line) || + strings.ContainsAny(line, "\r\n") { + return nil, errors.New("driver helper emitted a malformed structured remove outcome line") + } + fields := make([]nativePackageRemoveProofField, 0, 16) + seen := make(map[string]struct{}, 16) + for position := 0; position < len(line); { + if position != 0 { + if line[position] != ' ' { + return nil, errors.New("driver helper structured remove fields are not space-delimited") + } + position++ + if position == len(line) || line[position] == ' ' { + return nil, errors.New("driver helper structured remove outcome has empty or trailing fields") + } + } + nameStart := position + for position < len(line) && line[position] != '=' { + character := line[position] + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9')) { + return nil, errors.New("driver helper structured remove outcome has an invalid field name") + } + position++ + } + if position == nameStart || position == len(line) { + return nil, errors.New("driver helper structured remove outcome has a field without a value") + } + name := line[nameStart:position] + if _, duplicate := seen[name]; duplicate { + return nil, fmt.Errorf("driver helper structured remove outcome duplicated field %q", name) + } + seen[name] = struct{}{} + position++ + quoted := position < len(line) && line[position] == '"' + var value strings.Builder + if quoted { + position++ + closed := false + for position < len(line) { + character := line[position] + position++ + if character == '"' { + closed = true + break + } + if character == '\\' { + if position == len(line) || (line[position] != '\\' && line[position] != '"') { + return nil, errors.New("driver helper structured remove outcome has an invalid quoted escape") + } + character = line[position] + position++ + } + if character < 0x20 || character == 0x7f { + return nil, errors.New("driver helper structured remove outcome has a control character") + } + value.WriteByte(character) + } + if !closed { + return nil, errors.New("driver helper structured remove outcome has an unterminated quoted value") + } + if position < len(line) && line[position] != ' ' { + return nil, errors.New("driver helper structured remove outcome has trailing quoted data") + } + } else { + valueStart := position + for position < len(line) && line[position] != ' ' { + character := line[position] + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '-') { + return nil, errors.New("driver helper structured remove outcome has an invalid unquoted value") + } + position++ + } + if position == valueStart { + return nil, errors.New("driver helper structured remove outcome has an empty unquoted value") + } + value.WriteString(line[valueStart:position]) + } + fields = append(fields, nativePackageRemoveProofField{ + name: name, value: value.String(), quoted: quoted, + }) + } + return fields, nil +} + +func requireNativePackageRemoveProofField( + fields []nativePackageRemoveProofField, + position *int, + name string, + quoted bool, +) (string, error) { + if *position >= len(fields) || fields[*position].name != name || fields[*position].quoted != quoted { + return "", fmt.Errorf("driver helper structured remove outcome requires ordered field %q", name) + } + value := fields[*position].value + (*position)++ + return value, nil +} + +func parseNativePackageRemoveUint32(fieldName, value string) (uint32, error) { + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("parse driver helper remove %s: %w", fieldName, err) + } + return uint32(parsed), nil +} + +func validateNativePackageRemoveErrorEvidence( + fields []nativePackageRemoveProofField, + position *int, +) error { + phase, err := requireNativePackageRemoveProofField(fields, position, "phase", true) + if err != nil { + return err + } + if phase == "" || utf8.RuneCountInString(phase) > 256 { + return errors.New("driver helper structured remove outcome has an invalid error phase") + } + win32Error, err := requireNativePackageRemoveProofField(fields, position, "win32Error", false) + if err != nil { + return err + } + if _, err := parseNativePackageRemoveUint32("Win32 error", win32Error); err != nil { + return err + } + if *position < len(fields) && fields[*position].name == "nestedExitCode" { + nestedExitCode, err := requireNativePackageRemoveProofField(fields, position, "nestedExitCode", false) + if err != nil { + return err + } + if _, err := strconv.ParseInt(nestedExitCode, 10, 32); err != nil { + return fmt.Errorf("parse driver helper remove nested exit code: %w", err) + } + } + message, err := requireNativePackageRemoveProofField(fields, position, "message", true) + if err != nil { + return err + } + if utf8.RuneCountInString(message) > 4096 { + return errors.New("driver helper structured remove outcome error message is unbounded") + } + if *position < len(fields) && fields[*position].name == "recoveryRecord" { + recoveryRecord, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecord", true) + if err != nil { + return err + } + if recoveryRecord == "" || utf8.RuneCountInString(recoveryRecord) > 32767 { + return errors.New("driver helper structured remove outcome has an invalid recovery record path") + } + recordWritten, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordWritten", false) + if err != nil { + return err + } + if recordWritten != "0" && recordWritten != "1" { + return errors.New("driver helper structured remove outcome has an invalid recovery record state") + } + if *position < len(fields) && fields[*position].name == "recoveryRecordPhase" { + if recordWritten != "0" { + return errors.New("driver helper structured remove outcome attached a write failure to a published recovery record") + } + if _, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordPhase", true); err != nil { + return err + } + recordError, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordWin32Error", false) + if err != nil { + return err + } + if _, err := parseNativePackageRemoveUint32("recovery record Win32 error", recordError); err != nil { + return err + } + if _, err := requireNativePackageRemoveProofField(fields, position, "recoveryRecordMessage", true); err != nil { + return err + } + } + } + if *position < len(fields) && fields[*position].name == "recoveryBackup" { + recoveryBackup, err := requireNativePackageRemoveProofField(fields, position, "recoveryBackup", true) + if err != nil { + return err + } + if recoveryBackup == "" || utf8.RuneCountInString(recoveryBackup) > 32767 { + return errors.New("driver helper structured remove outcome has an invalid recovery backup path") + } + backupRetained, err := requireNativePackageRemoveProofField(fields, position, "recoveryBackupRetained", false) + if err != nil { + return err + } + if backupRetained != "0" && backupRetained != "1" { + return errors.New("driver helper structured remove outcome has an invalid recovery backup state") + } + } + return nil +} + +func validateNativePackageRemoveRetainedTombstone(path string) error { + if path == "" || utf8.RuneCountInString(path) > nativePackageRemoveRetainedTombstoneMaximumRunes || + len(path) < 4 || !((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z')) || + path[1] != ':' || path[2] != '\\' || strings.Contains(path, "/") { + return errors.New("driver helper retained tombstone is not a bounded absolute Windows path") + } + components := strings.Split(path[3:], `\`) + if len(components) < 3 || + !strings.EqualFold(components[len(components)-2], nativePackageRemoveRecoveryDirectory) { + return errors.New("driver helper retained tombstone is outside the remove recovery directory") + } + for _, component := range components { + if component == "" || component == "." || component == ".." || + strings.ContainsAny(component, `:*?"<>|`) || strings.HasSuffix(component, " ") || + strings.HasSuffix(component, ".") { + return errors.New("driver helper retained tombstone has an invalid path component") + } + for _, character := range component { + if character < 0x20 || character == 0x7f { + return errors.New("driver helper retained tombstone has a control character") + } + } + } + settledName := components[len(components)-1] + if !strings.HasPrefix(settledName, nativePackageRemoveSettledPrefix) { + return errors.New("driver helper retained tombstone has an invalid settled identity") + } + transactionID := strings.TrimPrefix(settledName, nativePackageRemoveSettledPrefix) + if len(transactionID) != 64 { + return errors.New("driver helper retained tombstone has an invalid transaction identity length") + } + for _, character := range transactionID { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return errors.New("driver helper retained tombstone has a non-canonical transaction identity") + } + } + return nil +} + +func parseOptionalNativePackageRemoveWarning( + fields []nativePackageRemoveProofField, + position *int, + proof *nativePackageRemoveProof, +) error { + if *position == len(fields) { + return nil + } + warning, err := requireNativePackageRemoveProofField(fields, position, "warning", true) + if err != nil { + return err + } + if warning != nativePackageRemoveRetainedTombstoneWarning { + return fmt.Errorf("driver helper emitted unsupported remove warning %q", warning) + } + warningError, err := requireNativePackageRemoveProofField(fields, position, "warningWin32Error", false) + if err != nil { + return err + } + parsedWarningError, err := parseNativePackageRemoveUint32("warning Win32 error", warningError) + if err != nil { + return err + } + if parsedWarningError == 0 { + return errors.New("driver helper retained tombstone warning has no cleanup error") + } + retainedTombstone, err := requireNativePackageRemoveProofField(fields, position, "retainedTombstone", true) + if err != nil { + return err + } + if err := validateNativePackageRemoveRetainedTombstone(retainedTombstone); err != nil { + return err + } + if *position != len(fields) { + return errors.New("driver helper structured remove outcome has trailing fields after its warning evidence") + } + proof.retainedTombstone = retainedTombstone + proof.retainedTombstoneWin32Error = parsedWarningError + return nil } func parseNativePackageRemoveProof(output string, processExitCode int) (nativePackageRemoveResult, error) { - matches := nativePackageRemoveProofPattern.FindAllStringSubmatch(output, -1) - if len(matches) != 1 { + var proofLines []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "result=") { + proofLines = append(proofLines, line) + } + } + if len(proofLines) != 1 { return nativePackageRemoveResult{}, errors.New("driver helper did not emit exactly one structured remove outcome") } - proofExitCode, err := strconv.Atoi(matches[0][5]) + fields, err := parseNativePackageRemoveProofFields(proofLines[0]) + if err != nil { + return nativePackageRemoveResult{}, err + } + position := 0 + resultValue, err := requireNativePackageRemoveProofField(fields, &position, "result", false) + if err != nil { + return nativePackageRemoveResult{}, err + } + operation, err := requireNativePackageRemoveProofField(fields, &position, "operation", false) + if err != nil || operation != "remove" { + return nativePackageRemoveResult{}, errors.New("driver helper structured outcome is not an exact remove operation") + } + changed, err := requireNativePackageRemoveProofField(fields, &position, "changed", false) + if err != nil || (changed != "0" && changed != "1") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid changed state") + } + rebootRequired, err := requireNativePackageRemoveProofField(fields, &position, "rebootRequired", false) + if err != nil || (rebootRequired != "0" && rebootRequired != "1") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid reboot state") + } + rollback, err := requireNativePackageRemoveProofField(fields, &position, "rollback", false) + if err != nil || (rollback != "not-needed" && rollback != "succeeded" && rollback != "failed") { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid rollback state") + } + exitCodeValue, err := requireNativePackageRemoveProofField(fields, &position, "exitCode", false) + if err != nil { + return nativePackageRemoveResult{}, err + } + proofExitCode, err := strconv.ParseUint(exitCodeValue, 10, 31) if err != nil { return nativePackageRemoveResult{}, fmt.Errorf("parse driver helper remove exit code: %w", err) } proof := nativePackageRemoveProof{ - success: matches[0][1] == "success", - changed: matches[0][2] == "1", - rebootRequired: matches[0][3] == "1", - rollback: matches[0][4], - exitCode: proofExitCode, + success: resultValue == "success", + changed: changed == "1", + rebootRequired: rebootRequired == "1", + rollback: rollback, + exitCode: int(proofExitCode), + } + if resultValue != "success" && resultValue != "error" { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has an invalid result state") + } + if !proof.success { + if err := validateNativePackageRemoveErrorEvidence(fields, &position); err != nil { + return nativePackageRemoveResult{}, err + } + } + if err := parseOptionalNativePackageRemoveWarning(fields, &position, &proof); err != nil { + return nativePackageRemoveResult{}, err + } + if position != len(fields) { + return nativePackageRemoveResult{}, errors.New("driver helper structured remove outcome has unknown or trailing fields") } if proof.exitCode != processExitCode { return nativePackageRemoveResult{}, fmt.Errorf( @@ -81,27 +422,34 @@ func parseNativePackageRemoveProof(output string, processExitCode int) (nativePa processExitCode, proof.exitCode, ) } + result := nativePackageRemoveResult{ + retainedTombstone: proof.retainedTombstone, + retainedTombstoneWin32Error: proof.retainedTombstoneWin32Error, + } switch proof.exitCode { case 0: if !proof.success || proof.rebootRequired || proof.rollback != "not-needed" { return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid success remove outcome") } - return nativePackageRemoveResult{}, nil + return result, nil case nativePackageRebootRequiredCode: if !proof.success || !proof.changed || !proof.rebootRequired || proof.rollback != "not-needed" { return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid reboot-success remove outcome") } - return nativePackageRemoveResult{rebootRequired: true}, nil + result.rebootRequired = true + return result, nil case 4: if proof.success || proof.changed || proof.rebootRequired || proof.rollback != "not-needed" { return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid preflight-rejection outcome") } - return nativePackageRemoveResult{serviceRestoreVerified: true}, fmt.Errorf("driver helper rejected package removal before mutation: %s", strings.TrimSpace(output)) + result.serviceRestoreVerified = true + return result, fmt.Errorf("driver helper rejected package removal before mutation: %s", strings.TrimSpace(output)) case 1: if proof.success || !proof.changed || proof.rollback != "succeeded" { return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rolled-back failure outcome") } - return nativePackageRemoveResult{serviceRestoreVerified: !proof.rebootRequired}, fmt.Errorf("driver helper package removal failed and rolled back: %s", strings.TrimSpace(output)) + result.serviceRestoreVerified = !proof.rebootRequired + return result, fmt.Errorf("driver helper package removal failed and rolled back: %s", strings.TrimSpace(output)) case 3: if proof.success || !proof.changed || proof.rollback != "failed" { return nativePackageRemoveResult{}, errors.New("driver helper emitted an invalid rollback-failure outcome") @@ -225,6 +573,12 @@ func runNativePackageUninstallTransaction( return fmt.Errorf("native package uninstall canceled before driver removal: %w", err) } removeResult, err := transaction.RemoveDriver(ctx) + if removeResult.retainedTombstone != "" && logger != nil { + logger.Warn("Native remove journal retired with a retained settled tombstone", + "warning", nativePackageRemoveRetainedTombstoneWarning, + "win32Error", removeResult.retainedTombstoneWin32Error, + "retainedTombstone", removeResult.retainedTombstone) + } if err != nil { serviceRestoreVerified = removeResult.serviceRestoreVerified return fmt.Errorf("remove exact native driver package: %w", err) diff --git a/internal/cmd/native_package_uninstall_test.go b/internal/cmd/native_package_uninstall_test.go index 64895327..0c26c7ec 100644 --- a/internal/cmd/native_package_uninstall_test.go +++ b/internal/cmd/native_package_uninstall_test.go @@ -1,10 +1,13 @@ package cmd import ( + "bytes" "context" "errors" "fmt" + "log/slog" "reflect" + "strconv" "strings" "testing" ) @@ -344,13 +347,15 @@ func TestNativePackageRemoveStructuredExitSemantics(t *testing.T) { {name: "success", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, {name: "idempotent success", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0}, {name: "reboot success", line: "result=success operation=remove changed=1 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, reboot: true}, - {name: "preflight", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology"`, exit: 4, wantErr: true, errContains: "before mutation"}, - {name: "rolled back", line: `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver"`, exit: 1, wantErr: true, errContains: "rolled back"}, - {name: "rolled back pending reboot", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=succeeded exitCode=1 phase="remove-driver"`, exit: 1, wantErr: true, errContains: "rolled back"}, - {name: "rollback failed", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=failed exitCode=3 phase="remove-rollback"`, exit: 3, wantErr: true, errContains: "rollback failed"}, + {name: "preflight", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology" win32Error=13 message="rejected"`, exit: 4, wantErr: true, errContains: "before mutation"}, + {name: "rolled back", line: `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="failed"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rolled back pending reboot", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="failed"`, exit: 1, wantErr: true, errContains: "rolled back"}, + {name: "rollback failed", line: `result=error operation=remove changed=1 rebootRequired=1 rollback=failed exitCode=3 phase="remove-rollback" win32Error=5 nestedExitCode=1 message="failed" recoveryRecord="C:\\ProgramData\\active-v2" recoveryRecordWritten=0 recoveryRecordPhase="journal-write" recoveryRecordWin32Error=112 recoveryRecordMessage="full" recoveryBackup="C:\\ProgramData\\backup" recoveryBackupRetained=1`, exit: 3, wantErr: true, errContains: "rollback failed"}, {name: "exit mismatch", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0", exit: 1, wantErr: true, errContains: "disagreed"}, {name: "invalid 3010", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, {name: "unchanged 3010", line: "result=success operation=remove changed=0 rebootRequired=1 rollback=not-needed exitCode=3010", exit: 3010, wantErr: true, errContains: "invalid reboot-success"}, + {name: "success trailing field", line: "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0 phase=spoof", exit: 0, wantErr: true, errContains: "warning"}, + {name: "error missing evidence", line: `result=error operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="remove-topology"`, exit: 4, wantErr: true, errContains: "win32Error"}, {name: "unstructured", line: "removed", exit: 0, wantErr: true, errContains: "exactly one"}, {name: "duplicate proof", line: "result=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0\nresult=success operation=remove changed=0 rebootRequired=0 rollback=not-needed exitCode=0", exit: 0, wantErr: true, errContains: "exactly one"}, } @@ -381,6 +386,77 @@ func TestNativePackageRemoveStructuredExitSemantics(t *testing.T) { } } +func TestNativePackageRemoveRetainedTombstoneProofIsExactAndBounded(t *testing.T) { + t.Parallel() + tombstone := `C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\settled-v2-` + strings.Repeat("a", 64) + base := "result=success operation=remove changed=1 rebootRequired=0 rollback=not-needed exitCode=0" + warning := " warning=\"remove-settled-cleanup-retained\" warningWin32Error=5 retainedTombstone=" + strconv.Quote(tombstone) + result, err := parseNativePackageRemoveProof(base+warning, 0) + if err != nil { + t.Fatalf("parse exact retained tombstone proof: %v", err) + } + if result.retainedTombstone != tombstone || result.retainedTombstoneWin32Error != 5 { + t.Fatalf("retained tombstone result=%+v", result) + } + rolledBack := `result=error operation=remove changed=1 rebootRequired=0 rollback=succeeded exitCode=1 phase="remove-driver" win32Error=5 message="rolled back"` + result, err = parseNativePackageRemoveProof(rolledBack+warning, 1) + if err == nil || !strings.Contains(err.Error(), "rolled back") || + result.retainedTombstone != tombstone || result.retainedTombstoneWin32Error != 5 || + !result.serviceRestoreVerified { + t.Fatalf("rolled-back retained tombstone result=%+v error=%v", result, err) + } + + cases := map[string]string{ + "missing code": base + ` warning="remove-settled-cleanup-retained" retainedTombstone=` + strconv.Quote(tombstone), + "zero code": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=0 retainedTombstone=` + strconv.Quote(tombstone), + "overflow code": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=4294967296 retainedTombstone=` + strconv.Quote(tombstone), + "wrong warning": base + ` warning="unknown" warningWin32Error=5 retainedTombstone=` + strconv.Quote(tombstone), + "relative path": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone="settled-v2-` + strings.Repeat("a", 64) + `"`, + "wrong directory": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone=` + strconv.Quote(`C:\Other\settled-v2-`+strings.Repeat("a", 64)), + "bad identity": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone=` + strconv.Quote(strings.TrimSuffix(tombstone, "a")+"g"), + "duplicate warning": base + warning + warning, + "trailing field": base + warning + ` ignored=1`, + "reordered tuple": base + ` warning="remove-settled-cleanup-retained" retainedTombstone=` + strconv.Quote(tombstone) + ` warningWin32Error=5`, + "unescaped path": base + ` warning="remove-settled-cleanup-retained" warningWin32Error=5 retainedTombstone="C:\ProgramData"`, + "duplicate base key": base + ` changed=1`, + } + for name, line := range cases { + name, line := name, line + t.Run(name, func(t *testing.T) { + t.Parallel() + if result, err := parseNativePackageRemoveProof(line, 0); err == nil { + t.Fatalf("malformed warning proof accepted: %+v", result) + } + }) + } +} + +func TestNativePackageUninstallSurfacesRetainedTombstoneWarning(t *testing.T) { + t.Parallel() + tombstone := `C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\settled-v2-` + strings.Repeat("b", 64) + fake := &fakeNativePackageUninstallTransaction{ + removeResult: nativePackageRemoveResult{ + retainedTombstone: tombstone, + retainedTombstoneWin32Error: 5, + }, + } + var records bytes.Buffer + logger := slog.New(slog.NewTextHandler(&records, nil)) + if err := runNativePackageUninstallTransaction(context.Background(), logger, fake); err != nil { + t.Fatalf("run uninstall: %v", err) + } + for _, evidence := range []string{ + "Native remove journal retired with a retained settled tombstone", + "warning=remove-settled-cleanup-retained", + "win32Error=5", + "retainedTombstone=" + tombstone, + } { + if !strings.Contains(records.String(), evidence) { + t.Fatalf("warning log %q missing %q", records.String(), evidence) + } + } +} + func slicesContainString(values []string, target string) bool { for _, value := range values { if value == target { diff --git a/internal/cmd/native_package_uninstall_windows.go b/internal/cmd/native_package_uninstall_windows.go index da639eee..3bf8b5c8 100644 --- a/internal/cmd/native_package_uninstall_windows.go +++ b/internal/cmd/native_package_uninstall_windows.go @@ -153,6 +153,9 @@ func (t *windowsNativePackageUninstallTransaction) Preflight(ctx context.Context return fmt.Errorf("resolve exact native broker credential owner: %w", err) } t.userSID = userSID + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, t.logger, t.userSID); err != nil { + return fmt.Errorf("reconcile interrupted native broker transaction before uninstall: %w", err) + } directoryHandles, err := lockNativePackageDirectoryChain(filepath.Dir(t.request.driverHelper)) if err != nil { @@ -768,9 +771,9 @@ func (t *windowsNativePackageUninstallTransaction) RemoveDriver( result, proofErr := parseNativePackageRemoveProof(output.String(), exitCode) if proofErr != nil { if waitErr != nil { - return nativePackageRemoveResult{}, fmt.Errorf("%w (process: %v)", proofErr, waitErr) + return result, fmt.Errorf("%w (process: %v)", proofErr, waitErr) } - return nativePackageRemoveResult{}, proofErr + return result, proofErr } return result, nil } diff --git a/internal/cmd/native_package_windows.go b/internal/cmd/native_package_windows.go index 5a5c96e6..c297f5c2 100644 --- a/internal/cmd/native_package_windows.go +++ b/internal/cmd/native_package_windows.go @@ -52,6 +52,8 @@ type windowsNativePackageTransaction struct { driverBrokerHandoff bool driverHelperSettled bool driverCoordinationErr error + pendingBrokerOuterSettlement bool + replayedBrokerRecovery bool programFiles string destination string @@ -74,15 +76,19 @@ type windowsNativePackageTransaction struct { weakServiceMutation bool weakServiceRemoved bool - temporaryPath string - backupPath string - destinationPublished bool - destinationRelease func() - tokenPath string - tokenSHA256 string - tokenHandle windows.Handle - installProof bool - closed bool + temporaryPath string + backupPath string + destinationPublished bool + destinationRelease func() + tokenPath string + tokenSHA256 string + tokenHandle windows.Handle + boundOuterTokenPath string + installProof bool + brokerJournal *nativeBrokerJournal + brokerJournalProof nativeBrokerJournalProof + brokerJournalCutpoint func(string) error + closed bool } func installNativePackage( @@ -91,12 +97,19 @@ func installNativePackage( request nativePackageRequest, ) error { transaction := &windowsNativePackageTransaction{logger: logger, request: request} - return runNativePackageTransaction(ctx, logger, transaction) + if err := runNativePackageTransaction(ctx, logger, transaction); err != nil { + return err + } + if transaction.replayedBrokerRecovery { + return &nativePackageRecoveryRetryError{} + } + return nil } func commitNativePackageBroker( logger *slog.Logger, tokenPath, expectedTokenSHA256, expectedBrokerSHA256, targetUserSID, deadlineUnixMS string, + recoveryOnly bool, ) (nativePackageBrokerCommitResult, error) { preflightFailure := func(err error) (nativePackageBrokerCommitResult, error) { return nativePackageBrokerPreflightFailure(err) @@ -151,6 +164,53 @@ func commitNativePackageBroker( if !held { return preflightFailure(errors.New("outer native package transaction mutex is not held")) } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + replayBudget := time.Until(deadline) + releaseReplayMutex, err := acquireNativeInstallMutex(replayBudget) + if err != nil { + _, active, pathErr := nativeBrokerJournalPaths(targetUserSID) + if pathErr == nil { + if _, attributeErr := nativePathAttributes(active); attributeErr == nil || + (!errors.Is(attributeErr, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(attributeErr, windows.ERROR_PATH_NOT_FOUND)) { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, + }, fmt.Errorf("acquire service transaction mutex with an active broker journal: %w", err) + } + } + return preflightFailure(fmt.Errorf("acquire service transaction mutex for broker proof replay: %w", err)) + } + replayProof, replayed, activeJournal, replayErr := replayNativeBrokerNestedReadyProof( + ctx, logger, targetUserSID, tokenPath, expectedTokenSHA256, expectedBrokerSHA256, + ) + releaseReplayMutex() + if replayErr != nil { + if activeJournal { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "failed", exitCode: 3, journal: replayProof, + }, replayErr + } + return preflightFailure(replayErr) + } + if replayed { + logger.Info("Replayed exact durable nested broker readiness", + "transactionId", replayProof.TransactionID, "journalDigest", replayProof.Digest) + return nativePackageBrokerCommitResult{ + success: true, changed: true, rollback: "not-needed", exitCode: 0, + journal: replayProof, + }, nil + } + if activeJournal { + return nativePackageBrokerCommitResult{ + changed: true, rollback: "succeeded", exitCode: 1, journal: replayProof, + }, errors.New("reconciled an interrupted nested broker transaction to its exact prior state") + } + if recoveryOnly { + return preflightFailure(errors.New( + "broker recovery query found no exact active child transaction and will not start a new one", + )) + } executable, err := currentExecutable() if err != nil { return preflightFailure(fmt.Errorf("resolve nested broker executable: %w", err)) @@ -161,15 +221,15 @@ func commitNativePackageBroker( brokerSource: executable, expectedBrokerSHA256: expectedBrokerSHA256, targetUserSID: targetUserSID, }, - nestedBrokerCommit: true, + nestedBrokerCommit: true, + tokenSHA256: expectedTokenSHA256, + boundOuterTokenPath: tokenPath, } - ctx, cancel := context.WithDeadline(context.Background(), deadline) - defer cancel() err = runNativePackageTransaction(ctx, logger, transaction) if err == nil { return nativePackageBrokerCommitResult{ success: true, changed: transaction.nestedMutationStarted, - rollback: "not-needed", exitCode: 0, + rollback: "not-needed", exitCode: 0, journal: transaction.brokerJournalProof, }, nil } if !transaction.nestedMutationStarted { @@ -178,10 +238,12 @@ func commitNativePackageBroker( if transaction.nestedRollbackSucceeded { return nativePackageBrokerCommitResult{ changed: true, rollback: "succeeded", exitCode: 1, + journal: transaction.brokerJournalProof, }, err } return nativePackageBrokerCommitResult{ changed: true, rollback: "failed", exitCode: 3, + journal: transaction.brokerJournalProof, }, err } @@ -363,6 +425,21 @@ func (t *windowsNativePackageTransaction) InspectService( return nativePackageServiceSnapshot{}, fmt.Errorf("lock native broker service transaction: %w", err) } t.releaseServiceMutex = release + if t.nestedBrokerCommit { + if err := reconcileNativeBrokerJournalBeforeAdmission( + ctx, t.logger, t.request.targetUserSID, + ); err != nil { + return nativePackageServiceSnapshot{}, err + } + } else { + pending, err := reconcileNativeBrokerJournalBeforeOuterPackage( + ctx, t.logger, t.request.targetUserSID, + ) + if err != nil { + return nativePackageServiceSnapshot{}, err + } + t.pendingBrokerOuterSettlement = pending + } manager, err := mgr.Connect() if err != nil { return nativePackageServiceSnapshot{}, fmt.Errorf("connect to SCM: %w", err) @@ -568,6 +645,12 @@ func (t *windowsNativePackageTransaction) Prepare( if t.nestedBrokerHealthy { return nil } + journal, err := beginNativeBrokerJournal(ctx, t) + if err != nil { + return fmt.Errorf("arm durable native broker recovery: %w", err) + } + t.brokerJournal = journal + t.brokerJournalProof = journal.proof() // From this point onward the nested callback may stop/delete SCM state or // publish the canonical broker image. Any failure must prove rollback before // the still-running helper may touch its captured driver snapshot again. @@ -583,10 +666,16 @@ func (t *windowsNativePackageTransaction) Prepare( // STOP is itself the mutation. Arm reconciliation before sending it so // a timeout while StopPending cannot strand a formerly-running service. t.stoppedTrustedService = true + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseServiceStopIntent, ""); err != nil { + return fmt.Errorf("journal prior broker stop intent: %w", err) + } if err := stopNativeService(ctx, t.service, waitContext); err != nil { return fmt.Errorf("quiesce trusted %s for atomic image replacement: %w", NativeBrokerServiceName, err) } + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseServiceStopped, ""); err != nil { + return fmt.Errorf("journal prior broker stopped state: %w", err) + } } // The read-only preflight lock deliberately denies rename/delete. Once // the exact trusted service is quiescent, release that lock so the @@ -611,7 +700,9 @@ func (t *windowsNativePackageTransaction) InstallDriverAndBroker(ctx context.Con var evidence nativeBrokerInstallEvidence if err := installNativeBrokerTransactionWithEvidence( ctx, t.logger, t.destination, - productionNativeInstallDependencies(t.request.targetUserSID), + productionNativeInstallDependenciesWithJournal( + t.request.targetUserSID, t.brokerJournal, + ), &evidence, ); err != nil { t.nestedServiceRollbackSettled = @@ -663,6 +754,12 @@ func (t *windowsNativePackageTransaction) VerifyAuthenticatedHealth(ctx context. } func (t *windowsNativePackageTransaction) Commit(context.Context) error { + if t.nestedBrokerCommit && t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseNestedReady, ""); err != nil { + return fmt.Errorf("persist nested broker readiness: %w", err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } if t.destinationRelease != nil { t.destinationRelease() t.destinationRelease = nil @@ -696,11 +793,33 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE } }() var rollbackErrors []error + if t.brokerJournal != nil && t.brokerJournal.lastPhase() != nativeBrokerPhaseRollbackSettled && + t.brokerJournal.lastPhase() != nativeBrokerPhaseOuterSettled && + t.brokerJournal.lastPhase() != nativeBrokerPhaseManual && + nativeBrokerJournalPhaseIndex( + nativeBrokerForwardPhaseOrder, t.brokerJournal.lastPhase(), + ) >= 0 { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackIntent, ""); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("persist broker rollback intent: %w", err)) + } + } if t.destinationRelease != nil { t.destinationRelease() t.destinationRelease = nil } - if err := t.releaseCoordinationToken(); err != nil { + retainTokenForRecovery := !t.nestedBrokerCommit && t.driverBrokerHandoff && + !t.driverHelperSettled + if retainTokenForRecovery { + if t.tokenHandle != 0 { + if err := windows.CloseHandle(t.tokenHandle); err != nil { + rollbackErrors = append(rollbackErrors, + fmt.Errorf("close retained package transaction token: %w", err)) + } + t.tokenHandle = 0 + } + t.logger.Warn("Retaining protected package transaction token for authoritative broker replay", + "path", t.tokenPath) + } else if err := t.releaseCoordinationToken(); err != nil { rollbackErrors = append(rollbackErrors, fmt.Errorf("remove package transaction token: %w", err)) } @@ -712,6 +831,12 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE // both protected images for explicit external reconciliation. rollbackErrors = append(rollbackErrors, errors.New( "nested native broker service rollback is unsettled; retaining staged and prior broker images and leaving the service stopped for external reconciliation")) + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseManual, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } return errors.Join(rollbackErrors...) } if !t.nestedBrokerCommit && t.stoppedTrustedService && !t.driverHelperSettled { @@ -730,6 +855,21 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE restored = false rollbackErrors = append(rollbackErrors, err) } + if t.brokerJournal != nil && restored { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackImage, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + resultErr = errors.Join(rollbackErrors...) + return resultErr + } + _, priorLegacy, err := t.brokerJournal.loadProtectedArtifacts() + if err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := restoreNativeBrokerJournalLegacy(ctx, t.brokerJournal, priorLegacy); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackLegacy, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + } if t.nestedBrokerCommit && t.stoppedTrustedService && t.service != nil && t.serviceSnapshot.wasRunning { if !restored { @@ -749,6 +889,24 @@ func (t *windowsNativePackageTransaction) Rollback(ctx context.Context) (resultE fmt.Errorf("restore prior trusted %s run state: %w", NativeBrokerServiceName, err)) } } + if t.brokerJournal != nil { + if len(rollbackErrors) != 0 { + if err := t.brokerJournal.appendPhase(nativeBrokerPhaseManual, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + t.brokerJournalProof = t.brokerJournal.proof() + } else if err := t.brokerJournal.appendPhase(nativeBrokerPhaseRollbackSettled, ""); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else { + t.brokerJournalProof = t.brokerJournal.proof() + if err := retireNativeBrokerJournal(t.brokerJournal); err != nil { + t.logger.Warn("Retaining settled native broker rollback journal for later cleanup", + "transactionId", t.brokerJournalProof.TransactionID, "error", err) + } else { + t.brokerJournal = nil + } + } + } return errors.Join(rollbackErrors...) } @@ -853,8 +1011,111 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e if proofErr != nil { return fmt.Errorf("validate native driver helper proof: %w: %s", proofErr, text) } + if proof.journalRecovery == "replayed" && !t.pendingBrokerOuterSettlement { + return errors.New("driver helper replayed a broker journal without a preexisting pending outer settlement") + } + if proof.journalRecovery != "replayed" && t.pendingBrokerOuterSettlement { + return errors.New("driver helper did not replay the preexisting pending broker settlement") + } + settleCtx, cancelSettle := context.WithTimeout( + context.WithoutCancel(ctx), nativePackageRollbackTimeout, + ) + defer cancelSettle() + if proof.success && proof.journal.TransactionID != "" { + var activeJournal *nativeBrokerJournal + var prepared nativeBrokerOuterSettlementPrepared + var receipt nativePackageBrokerSettlementReceipt + var settledJournal *nativeBrokerJournal + var finalReceipt nativeBrokerOuterSettlementFinalPrepared + journalErr := executeNativeBrokerOuterSettlement(nativeBrokerOuterSettlementOperations{ + recordPending: func() error { + var err error + activeJournal, prepared, err = armNativeBrokerOuterSettlement( + settleCtx, t.request.targetUserSID, t.tokenSHA256, + t.request.expectedBrokerSHA256, proof, + ) + if activeJournal != nil { + t.brokerJournalProof = activeJournal.proof() + } + return err + }, + publishRequest: func() error { + return publishNativeBrokerOuterSettlementRequest(activeJournal, prepared) + }, + acknowledgeDriver: func() error { + if activeJournal != nil && + activeJournal.lastPhase() == nativeBrokerPhaseOuterSettled { + existingFinal, err := loadNativeBrokerOuterSettlementFinalForReconciliation( + activeJournal, + ) + if err == nil { + finalReceipt = existingFinal + receipt = nativeBrokerDriverReceiptFromFinal(existingFinal.Receipt) + return validateNativeBrokerOuterSettlementReceipt(prepared, receipt) + } + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && + !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return err + } + } + var err error + receipt, err = t.executeDriverBrokerSettlementAck(settleCtx, prepared) + return err + }, + recordBrokerSettled: func() error { + var err error + settledJournal, finalReceipt, err = recordNativeBrokerOuterSettlement( + settleCtx, t.request.targetUserSID, receipt, + ) + if settledJournal != nil { + t.brokerJournalProof = settledJournal.proof() + } + return err + }, + retireBrokerJournal: func() error { + return retireNativeBrokerJournal(settledJournal) + }, + discardInertState: func() error { + return t.discardDriverBrokerSettlementTombstone( + settleCtx, prepared, finalReceipt, receipt, t.brokerJournalProof, + ) + }, + observeDiscardError: func(err error) { + t.logger.Warn("Retaining inert settled transaction artifacts for later cleanup", + "brokerTransactionId", proof.journal.TransactionID, "error", err) + }, + }) + if journalErr != nil { + t.driverHelperSettled = false + return fmt.Errorf("complete durable two-phase broker settlement: %w", journalErr) + } + if proof.journalRecovery == "replayed" { + if err := discardSettledNativeBrokerOuterToken(settledJournal); err != nil { + t.logger.Warn("Retaining inert settled outer token after cleanup error", + "brokerTransactionId", proof.journal.TransactionID, "error", err) + } + } + t.logger.Info("Native broker and outer package journals reached exact settlement", + "brokerTransactionId", t.brokerJournalProof.TransactionID, + "brokerJournalDigest", t.brokerJournalProof.Digest, + "driverTransactionId", receipt.DriverTransactionID, + "driverJournalDigest", receipt.Digest) + } else { + journalProof, journalErr := reconcileNativeBrokerJournalAfterOuterFailure( + settleCtx, t.request.targetUserSID, t.tokenSHA256, + t.request.expectedBrokerSHA256, proof, + ) + t.brokerJournalProof = journalProof + if journalErr != nil { + t.driverHelperSettled = false + return fmt.Errorf("reconcile durable native broker ownership after driver proof: %w", journalErr) + } + } + if proof.journalRecovery == "replayed" { + t.replayedBrokerRecovery = true + } t.driverHelperSettled = proof.exitCode != 3 - if proof.success && !t.driverBrokerHandoff { + if proof.success && !t.driverBrokerHandoff && proof.journalRecovery != "replayed" { t.driverHelperSettled = false return errors.New("native driver helper reported success without the broker service handoff") } @@ -885,6 +1146,116 @@ func (t *windowsNativePackageTransaction) runDriverHelper(ctx context.Context) e return nil } +func (t *windowsNativePackageTransaction) executePinnedDriverHelper( + arguments []string, +) (string, int, error) { + if t.releaseMutex == nil || t.helperHandle == 0 { + return "", 0, errors.New("driver helper replay requires the held package mutex and pinned helper") + } + helperHash, err := hashNativePackageHandle(t.helperHandle) + if err != nil { + return "", 0, fmt.Errorf("rehash pinned driver helper: %w", err) + } + if !strings.EqualFold(helperHash, t.request.expectedHelperSHA256) { + return "", 0, errors.New("pinned driver helper identity changed before journal replay") + } + // The helper owns its write-through journal transition. Its propagated + // deadline is cooperative; killing it between FlushFileBuffers and readback + // would manufacture the very indeterminate boundary this handshake closes. + command := exec.Command(t.request.driverHelper, arguments...) + command.Dir = filepath.Dir(t.request.driverHelper) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + err = command.Run() + processExitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return output.String(), 0, err + } + processExitCode = exitError.ExitCode() + } + return output.String(), processExitCode, err +} + +func (t *windowsNativePackageTransaction) executeDriverBrokerSettlementAck( + ctx context.Context, + prepared nativeBrokerOuterSettlementPrepared, +) (nativePackageBrokerSettlementReceipt, error) { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return nativePackageBrokerSettlementReceipt{}, context.DeadlineExceeded + } + output, processExitCode, processErr := t.executePinnedDriverHelper([]string{ + "broker-settlement-ack", + "--request", prepared.RequestPath, + "--request-sha256", prepared.RequestSHA256, + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + }) + if processErr != nil && processExitCode == 0 { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "run driver settlement acknowledgement: %w", processErr, + ) + } + receipt, err := parseNativePackageBrokerSettlementReceipt(output, processExitCode) + if err != nil { + return nativePackageBrokerSettlementReceipt{}, fmt.Errorf( + "validate driver settlement acknowledgement: %w: %s", err, output, + ) + } + if err := validateNativeBrokerOuterSettlementReceipt(prepared, receipt); err != nil { + return nativePackageBrokerSettlementReceipt{}, err + } + return receipt, nil +} + +func (t *windowsNativePackageTransaction) discardDriverBrokerSettlementTombstone( + ctx context.Context, + prepared nativeBrokerOuterSettlementPrepared, + finalReceipt nativeBrokerOuterSettlementFinalPrepared, + receipt nativePackageBrokerSettlementReceipt, + brokerProof nativeBrokerJournalProof, +) error { + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) { + return context.DeadlineExceeded + } + output, processExitCode, processErr := t.executePinnedDriverHelper([]string{ + "broker-settlement-discard", + "--broker-transaction-id", brokerProof.TransactionID, + "--broker-settled-digest", brokerProof.Digest, + "--driver-transaction-id", receipt.DriverTransactionID, + "--driver-settled-digest", receipt.Digest, + "--settlement-nonce", receipt.SettlementNonce, + "--request-sha256", receipt.RequestSHA256, + "--broker-final-receipt", finalReceipt.ReceiptPath, + "--broker-final-receipt-sha256", finalReceipt.ReceiptSHA256, + "--transaction-deadline-unix-ms", strconv.FormatInt(deadline.UnixMilli(), 10), + }) + if processErr != nil && processExitCode == 0 { + return fmt.Errorf("discard settled driver journal tombstone: %w", processErr) + } + discard, err := parseNativePackageBrokerSettlementDiscardReceipt(output, processExitCode) + if err != nil { + return fmt.Errorf("validate settled driver journal discard receipt: %w: %s", err, output) + } + if discard.BrokerTransactionID != brokerProof.TransactionID || + discard.BrokerDigest != brokerProof.Digest || + discard.DriverTransactionID != receipt.DriverTransactionID || + discard.DriverDigest != receipt.Digest || + discard.SettlementNonce != receipt.SettlementNonce || + discard.RequestSHA256 != receipt.RequestSHA256 { + return errors.New("settled driver journal discard receipt mismatched the exact two-phase transaction") + } + if discard.Retained { + t.logger.Warn("Retaining inert driver settlement cleanup tombstone", + "driverTransactionId", discard.DriverTransactionID, + "brokerTransactionId", discard.BrokerTransactionID) + } + return nil +} + type nativePackageDriverCoordination struct { quiesceRequest windows.Handle quiesceReady windows.Handle @@ -1281,8 +1652,10 @@ func (t *windowsNativePackageTransaction) executeDriverHelper(ctx context.Contex err = waitNativePackageHelperCoordinated(command, func(process windows.Handle) error { return t.coordinateDriverHelper(ctx, process, coordination) }) - text := strings.TrimSpace(output.String()) - return text, err + // Preserve exact record framing. The authenticated journal binding is valid + // only as one canonical newline-terminated line; trimming helper output here + // would erase that boundary before the strict parser can verify it. + return output.String(), err } func reconcileNativePackageServiceRunning(ctx context.Context, service nativeManagedService) error { @@ -1453,6 +1826,7 @@ func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { return err } var err error + priorExists := false if existing, openErr := openNativePathWithoutReparse( t.destination, windows.GENERIC_READ|windows.READ_CONTROL, false, ); openErr == nil { @@ -1477,36 +1851,46 @@ func (t *windowsNativePackageTransaction) stageBrokerExecutable() error { t.destinationRelease = release return nil } - backupPath, err := t.uniqueManagedPath("rollback") - if err != nil { - return err - } - if err := moveNativePackageFile(t.destination, backupPath, false); err != nil { - return fmt.Errorf("retain prior broker for rollback: %w", err) - } - // Publish rollback ownership only after the atomic rename succeeds. On a - // failed rename the canonical prior image is still in place and may be - // safely revalidated/restarted by Rollback. - t.backupPath = backupPath + priorExists = true } else if !errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) { return fmt.Errorf("inspect existing broker: %w", openErr) } - t.temporaryPath, err = t.uniqueManagedPath("staging") - if err != nil { - return err + if t.brokerJournal != nil { + t.temporaryPath = filepath.Join( + t.parent, ".viiper.staging."+t.brokerJournal.snapshot.TransactionID+".tmp", + ) + } else { + t.temporaryPath, err = t.uniqueManagedPath("staging") + if err != nil { + return err + } } if err := copyNativePackageHandleAtomically( t.sourceHandle, t.temporaryPath, t.request.expectedBrokerSHA256, ); err != nil { return err } - if err := moveNativePackageFile(t.temporaryPath, t.destination, false); err != nil { + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase( + nativeBrokerPhaseImageSwitchIntent, t.request.expectedBrokerSHA256, + ); err != nil { + return fmt.Errorf("journal broker image switch intent: %w", err) + } + } + if err := replaceNativePackageFileAtomically(t.temporaryPath, t.destination, priorExists); err != nil { return fmt.Errorf("publish staged broker: %w", err) } t.temporaryPath = "" t.destinationPublished = true + if t.brokerJournal != nil { + if err := t.brokerJournal.appendPhase( + nativeBrokerPhaseImageSwitched, t.request.expectedBrokerSHA256, + ); err != nil { + return fmt.Errorf("journal published broker image: %w", err) + } + } release, err := lockNativeServiceExecutableReadOnly(t.destination) if err != nil { return fmt.Errorf("verify published protected broker: %w", err) @@ -1524,7 +1908,13 @@ func (t *windowsNativePackageTransaction) restoreBrokerExecutable() error { } t.temporaryPath = "" } - if t.destinationPublished { + if t.destinationPublished && t.brokerJournal != nil { + if err := restoreNativeBrokerJournalImage(t.brokerJournal); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore durable prior broker image: %w", err)) + } else { + t.destinationPublished = false + } + } else if t.destinationPublished { handle, err := openNativePathWithoutReparse(t.destination, windows.GENERIC_READ, false) if err != nil { restoreErrors = append(restoreErrors, fmt.Errorf("lock rejected broker for rollback: %w", err)) @@ -1789,6 +2179,37 @@ func moveNativePackageFile(source, destination string, replace bool) error { return windows.MoveFileEx(from, to, flags) } +var replaceNativePackageFileW = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReplaceFileW") + +func replaceNativePackageFileAtomically(source, destination string, destinationExists bool) error { + if !destinationExists { + return moveNativePackageFile(source, destination, false) + } + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := replaceNativePackageFileW.Call( + uintptr(unsafe.Pointer(destinationPointer)), + uintptr(unsafe.Pointer(sourcePointer)), + 0, + 1, // REPLACEFILE_WRITE_THROUGH + 0, + 0, + ) + if result == 0 { + if callErr == nil || errors.Is(callErr, windows.ERROR_SUCCESS) { + callErr = errors.New("ReplaceFileW returned false") + } + return callErr + } + return nil +} + func deleteNativePackageFile(path string) error { pointer, err := windows.UTF16PtrFromString(path) if err != nil { diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 6f64e550..1f4116ac 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -390,9 +390,17 @@ type nativeInstallDependencies struct { restartLegacy func(context.Context, nativeLegacyState) error verifyBroker func(context.Context, string) error wait func(context.Context, time.Duration) error + brokerJournal *nativeBrokerJournal } func productionNativeInstallDependencies(userSID string) nativeInstallDependencies { + return productionNativeInstallDependenciesWithJournal(userSID, nil) +} + +func productionNativeInstallDependenciesWithJournal( + userSID string, + journal *nativeBrokerJournal, +) nativeInstallDependencies { return nativeInstallDependencies{ connectSCM: func() (nativeSCM, error) { manager, err := mgr.Connect() @@ -404,7 +412,7 @@ func productionNativeInstallDependencies(userSID string) nativeInstallDependenci lockExecutable: lockNativeServiceExecutable, lockPriorExecutable: lockNativePriorServiceExecutable, provisionCredential: func() (nativeCredential, error) { - return provisionNativeServiceCredential(userSID) + return provisionNativeServiceCredentialWithJournal(userSID, journal) }, rollbackCredential: rollbackNativeServiceCredential, preflightDriver: requireNativeUDEBroker, @@ -428,6 +436,7 @@ func productionNativeInstallDependencies(userSID string) nativeInstallDependenci return nil } }, + brokerJournal: journal, } } @@ -441,12 +450,15 @@ func installNativeBroker(logger *slog.Logger, explicitUserSID string) error { if err != nil { return err } + ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) + defer cancel() + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, logger, userSID); err != nil { + return err + } executable, err := currentExecutable() if err != nil { return err } - ctx, cancel := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) - defer cancel() return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) } @@ -472,12 +484,15 @@ func installNativeBrokerUntil( if err != nil { return err } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + if err := reconcileNativeBrokerJournalBeforeAdmission(ctx, logger, userSID); err != nil { + return err + } executable, err := currentExecutable() if err != nil { return err } - ctx, cancel := context.WithDeadline(context.Background(), deadline) - defer cancel() return installNativeBrokerTransaction(ctx, logger, executable, productionNativeInstallDependencies(userSID)) } @@ -515,6 +530,9 @@ func uninstallNativeBrokerTransaction( if legacy.release != nil { defer legacy.release() } + if err := dependencies.brokerJournal.validatePriorOwnership(before, legacy); err != nil { + return fmt.Errorf("revalidate durable prior broker ownership: %w", err) + } serviceChanged := false legacyStopped := false @@ -540,13 +558,13 @@ func uninstallNativeBrokerTransaction( // trigger or StartWhenAvailable. Do not make any legacy registration live // until the rejected service has been stopped/deleted or the prior service // has been restored completely. - if registrationsMayHaveChanged && safeToRestartLegacy { + if dependencies.brokerJournal == nil && registrationsMayHaveChanged && safeToRestartLegacy { if rollbackErr := dependencies.restoreLegacy(rollbackCtx, legacy); rollbackErr != nil { safeToRestartLegacy = false rollbackErrors = append(rollbackErrors, rollbackErr) } } - if legacyStopped && safeToRestartLegacy { + if dependencies.brokerJournal == nil && legacyStopped && safeToRestartLegacy { if rollbackErr := dependencies.restartLegacy(rollbackCtx, legacy); rollbackErr != nil { rollbackErrors = append(rollbackErrors, fmt.Errorf("restart legacy VIIPER after uninstall rollback: %w", rollbackErr)) } @@ -693,6 +711,9 @@ func installNativeBrokerTransactionWithEvidence( if legacy.release != nil { defer legacy.release() } + if err := dependencies.brokerJournal.validatePriorOwnership(before, legacy); err != nil { + return fmt.Errorf("revalidate durable prior broker ownership: %w", err) + } serviceChanged := false legacyStopped := false @@ -707,11 +728,24 @@ func installNativeBrokerTransactionWithEvidence( rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), nativeServiceInstallTimeout) defer cancelRollback() var rollbackErrors []error + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackIntent, "", + ); err != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, fmt.Errorf("persist broker rollback intent: %w", err)) + } safeToRestartLegacy := true if serviceChanged { var rollbackErr error + rollbackBefore := before + if dependencies.brokerJournal != nil { + // The package layer restores the prior image atomically after this + // inner SCM/key rollback. Starting here could execute the candidate + // bytes under the restored prior credential. + rollbackBefore.status.State = svc.Stopped + } safeToRestartLegacy, rollbackErr = rollbackNativeService( - rollbackCtx, manager, service, before, dependencies.wait, rollbackCredential, + rollbackCtx, manager, service, rollbackBefore, dependencies.wait, rollbackCredential, ) if rollbackErr != nil { rollbackFailed = true @@ -720,6 +754,15 @@ func installNativeBrokerTransactionWithEvidence( if !safeToRestartLegacy { rollbackFailed = true } + if rollbackErr == nil && safeToRestartLegacy { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackService, "", + ); journalErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, journalErr) + } + } if !safeToRestartLegacy && credentialProvisioned && !credentialFinalized { // The replacement could still own the key path. Retain the new // credential rather than invalidating a service we failed to stop @@ -735,6 +778,15 @@ func installNativeBrokerTransactionWithEvidence( rollbackErrors = append(rollbackErrors, fmt.Errorf("restore native broker credential before legacy restart: %w", rollbackErr)) } + if safeToRestartLegacy && credentialFinalized { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackCredential, "", + ); journalErr != nil { + rollbackFailed = true + safeToRestartLegacy = false + rollbackErrors = append(rollbackErrors, journalErr) + } + } // Restoring task XML can itself launch the legacy process. Keep legacy // ownership absent until the service and credential rollback has made it // safe for that process to exist again. @@ -751,6 +803,22 @@ func installNativeBrokerTransactionWithEvidence( rollbackErrors = append(rollbackErrors, fmt.Errorf("restart prior legacy VIIPER process: %w", rollbackErr)) } } + if dependencies.brokerJournal == nil && safeToRestartLegacy { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseRollbackLegacy, "", + ); journalErr != nil { + rollbackFailed = true + rollbackErrors = append(rollbackErrors, journalErr) + } + } + if rollbackFailed && dependencies.brokerJournal != nil && + dependencies.brokerJournal.lastPhase() != nativeBrokerPhaseManual { + if journalErr := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseManual, "", + ); journalErr != nil { + rollbackErrors = append(rollbackErrors, journalErr) + } + } if len(rollbackErrors) != 0 { resultErr = errors.Join(resultErr, errors.Join(rollbackErrors...)) } @@ -761,18 +829,38 @@ func installNativeBrokerTransactionWithEvidence( // status query fails, rollback must reconcile the snapshotted state. serviceChanged = true markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStopIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service stop intent: %w", err) + } if err := stopNativeService(ctx, service, dependencies.wait); err != nil { return fmt.Errorf("stop previous %s service: %w", NativeBrokerServiceName, err) } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStopped, "", + ); err != nil { + return fmt.Errorf("journal broker service stopped state: %w", err) + } } legacyStopped = true markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyStopIntent, "", + ); err != nil { + return fmt.Errorf("journal legacy stop intent: %w", err) + } stopLegacyErr := dependencies.stopLegacy(ctx, &legacy, logger) registrationsMayHaveChanged = legacy.scheduledDisabled if stopLegacyErr != nil { return fmt.Errorf("stop legacy VIIPER process: %w", stopLegacyErr) } legacyStopped = hasRunningLegacyCommand(legacy) + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyStopped, "", + ); err != nil { + return fmt.Errorf("journal legacy stopped state: %w", err) + } if err := dependencies.preflightDriver(); err != nil { return err @@ -801,6 +889,11 @@ func installNativeBrokerTransactionWithEvidence( // configuration failure can occur after the base configuration changed. serviceChanged = true markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service configuration intent: %w", err) + } if err := service.UpdateConfig(config); err != nil { return fmt.Errorf("update %s service: %w", NativeBrokerServiceName, err) } @@ -814,6 +907,11 @@ func installNativeBrokerTransactionWithEvidence( baseConfig.SidType = windows.SERVICE_SID_TYPE_NONE baseConfig.DelayedAutoStart = false markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service creation intent: %w", err) + } service, err = manager.CreateService(NativeBrokerServiceName, executable, baseConfig, arguments...) if err != nil { return fmt.Errorf("create %s service: %w", NativeBrokerServiceName, err) @@ -832,12 +930,27 @@ func installNativeBrokerTransactionWithEvidence( if err := verifyConfiguredNativeService(service, config); err != nil { return err } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceConfigured, "", + ); err != nil { + return fmt.Errorf("journal configured broker service: %w", err) + } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStartIntent, "", + ); err != nil { + return fmt.Errorf("journal broker service start intent: %w", err) + } if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { return fmt.Errorf("start %s service: %w", NativeBrokerServiceName, err) } if err := waitForNativeServiceState(ctx, service, svc.Running, dependencies.wait); err != nil { return fmt.Errorf("wait for %s service readiness: %w", NativeBrokerServiceName, err) } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseServiceStarted, "", + ); err != nil { + return fmt.Errorf("journal broker service running state: %w", err) + } servicePID, err := requireNativeServiceProcess(service, 0) if err != nil { return err @@ -848,15 +961,30 @@ func installNativeBrokerTransactionWithEvidence( if _, err := requireNativeServiceProcess(service, servicePID); err != nil { return fmt.Errorf("revalidate %s after authenticated ping: %w", NativeBrokerServiceName, err) } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseAuthenticated, "", + ); err != nil { + return fmt.Errorf("journal authenticated broker state: %w", err) + } // Legacy registrations remain intact through authenticated readiness. They // are removed last so a failed native migration can still restart the exact // legacy command without reconstructing startup ownership. registrationsMayHaveChanged = true markMutation() + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyRemoveIntent, "", + ); err != nil { + return fmt.Errorf("journal legacy ownership removal intent: %w", err) + } if err := dependencies.removeLegacy(ctx, legacy); err != nil { return fmt.Errorf("remove legacy VIIPER startup after native verification: %w", err) } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseLegacyRemoved, "", + ); err != nil { + return fmt.Errorf("journal removed legacy ownership: %w", err) + } // Re-authenticate after removing the legacy owner. A task trigger or restart // policy can race the earlier stop; the migration is committed only while the // verified native service still owns the exact endpoint contract. @@ -866,6 +994,11 @@ func installNativeBrokerTransactionWithEvidence( if _, err := requireNativeServiceProcess(service, servicePID); err != nil { return fmt.Errorf("revalidate %s after legacy removal: %w", NativeBrokerServiceName, err) } + if err := dependencies.brokerJournal.appendPhase( + nativeBrokerPhaseReauthenticated, "", + ); err != nil { + return fmt.Errorf("journal final authenticated broker state: %w", err) + } credentialFinalized = true logger.Info("VIIPER native broker service installed and authenticated", "service", NativeBrokerServiceName, "exe", executable, "credential", credential.path) @@ -1666,6 +1799,13 @@ func serviceWasOperational(state svc.State) bool { } func provisionNativeServiceCredential(userSID string) (nativeCredential, error) { + return provisionNativeServiceCredentialWithJournal(userSID, nil) +} + +func provisionNativeServiceCredentialWithJournal( + userSID string, + journal *nativeBrokerJournal, +) (nativeCredential, error) { path, err := nativeServiceKeyFilePath() if err != nil { return nativeCredential{}, err @@ -1684,13 +1824,23 @@ func provisionNativeServiceCredential(userSID string) (nativeCredential, error) if err != nil { return nativeCredential{}, fmt.Errorf("read credential: %w", err) } + if err := journal.validatePriorCredential(existed, prior); err != nil { + return nativeCredential{}, err + } password, err := rotatedNativeServiceKey(prior, auth.GenerateKey) if err != nil { return nativeCredential{}, fmt.Errorf("generate credential: %w", err) } + candidateDigest := nativeBrokerJournalHash([]byte(password)) + if err := journal.appendPhase(nativeBrokerPhaseCredentialWriteIntent, candidateDigest); err != nil { + return nativeCredential{}, err + } if err := writeNativeCredentialAtomically(path, []byte(password), userSID); err != nil { return nativeCredential{}, err } + if err := journal.appendPhase(nativeBrokerPhaseCredentialWritten, candidateDigest); err != nil { + return nativeCredential{}, err + } return nativeCredential{ path: path, password: password, userSID: userSID, created: !existed, replaced: existed, priorBytes: append([]byte(nil), prior...), diff --git a/internal/cmd/service_windows.go b/internal/cmd/service_windows.go index 456237ba..64f00754 100644 --- a/internal/cmd/service_windows.go +++ b/internal/cmd/service_windows.go @@ -44,6 +44,13 @@ func (c *ServiceCommand) Run(logger *slog.Logger, rawLogger log.RawLogger) error } c.KeyFile = path } + executable, err := currentExecutable() + if err != nil { + return fmt.Errorf("resolve native broker service image: %w", err) + } + if err := admitNativeBrokerServiceStartup(executable, c.KeyFile); err != nil { + return fmt.Errorf("native broker startup admission rejected: %w", err) + } c.serviceMode = true handler := &nativeBrokerService{logger: logger, run: func(ctx context.Context, ready func()) error { c.ready = ready diff --git a/internal/server/usb/native.go b/internal/server/usb/native.go index ae88e9bd..ba3b5f64 100644 --- a/internal/server/usb/native.go +++ b/internal/server/usb/native.go @@ -12,12 +12,13 @@ import ( ) type nativeLaneKey struct { - deviceID uint64 - generation uint32 - endpoint uint8 - attributes uint8 - interval uint8 - maxPacket uint16 + deviceID uint64 + generation uint32 + endpointGeneration uint32 + endpoint uint8 + attributes uint8 + interval uint8 + maxPacket uint16 } type nativeSessionKey struct { @@ -26,10 +27,11 @@ type nativeSessionKey struct { } type nativeEndpointSignature struct { - address uint8 - attributes uint8 - interval uint8 - maxPacket uint16 + endpointGeneration uint32 + address uint8 + attributes uint8 + interval uint8 + maxPacket uint16 } type nativeSessionState struct { @@ -180,14 +182,15 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o return err } key := nativeLaneKey{ - deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, + deviceID: op.DeviceID, generation: op.Generation, + endpointGeneration: op.EndpointGeneration, endpoint: op.EndpointAddress, attributes: op.EndpointAttributes, interval: op.EndpointInterval, maxPacket: op.EndpointMaxPacketSize, } switch op.Kind { case udecx.OperationEndpointStart: - p.clearEndpointLanes(key) + p.clearEndpointAddressLanes(key) p.invalidateInterruptInput(dev, op.EndpointAddress) p.activateEndpointLocked(dev, op, session) case udecx.OperationEndpointPurge: @@ -232,7 +235,8 @@ func (p *NativeProcessor) Lifecycle(ctx context.Context, dev usbdevice.Device, o func signatureFromOperation(op udecx.Operation) nativeEndpointSignature { return nativeEndpointSignature{ - address: op.EndpointAddress, attributes: op.EndpointAttributes, + endpointGeneration: op.EndpointGeneration, + address: op.EndpointAddress, attributes: op.EndpointAttributes, interval: op.EndpointInterval, maxPacket: op.EndpointMaxPacketSize, } } @@ -244,6 +248,11 @@ func signatureFromDescriptor(endpoint usbdevice.EndpointDescriptor) nativeEndpoi } } +func sameNativeEndpointShape(left, right nativeEndpointSignature) bool { + return left.address == right.address && left.attributes == right.attributes && + left.interval == right.interval && left.maxPacket == right.maxPacket +} + func nativeSignatureFromDescriptor(speed uint32, endpoint usbdevice.EndpointDescriptor) (nativeEndpointSignature, bool) { projected, err := udecx.EndpointDescriptorForNativeUdeCx( @@ -267,7 +276,7 @@ func descriptorInterfaceAltForEndpoint(desc *usbdevice.Descriptor, } for _, endpoint := range iface.Endpoints { projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) - if !valid || projected != signature { + if !valid || !sameNativeEndpointShape(projected, signature) { continue } candidateInterface := iface.Descriptor.BInterfaceNumber @@ -307,8 +316,10 @@ func descriptorInterfaceAltIsActive(desc *usbdevice.Descriptor, interfaceNumber, for _, endpoint := range iface.Endpoints { projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) if valid { - if _, ok := active[projected]; ok { - return true + for signature := range active { + if sameNativeEndpointShape(projected, signature) { + return true + } } } } @@ -331,6 +342,12 @@ func (p *NativeProcessor) activateEndpointLocked(dev usbdevice.Device, op udecx. if !ok { return } + for active := range session.active { + if active.address == signature.address && + active.endpointGeneration != signature.endpointGeneration { + delete(session.active, active) + } + } session.active[signature] = struct{}{} if p.server.getInterfaceAlt(dev, interfaceNumber) != alternateSetting { p.server.setInterfaceAlt(dev, interfaceNumber, alternateSetting) @@ -386,6 +403,26 @@ func (p *NativeProcessor) clearDeviceLanes(identity udecx.DeviceIdentity) { } func (p *NativeProcessor) clearEndpointLanes(endpoint nativeLaneKey) { + p.mu.Lock() + for key := range p.next { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint && + key.endpointGeneration == endpoint.endpointGeneration { + delete(p.next, key) + delete(p.lastIn, key) + } + } + for key := range p.lastIn { + if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && + key.endpoint == endpoint.endpoint && + key.endpointGeneration == endpoint.endpointGeneration { + delete(p.lastIn, key) + } + } + p.mu.Unlock() +} + +func (p *NativeProcessor) clearEndpointAddressLanes(endpoint nativeLaneKey) { p.mu.Lock() for key := range p.next { if key.deviceID == endpoint.deviceID && key.generation == endpoint.generation && @@ -405,7 +442,8 @@ func (p *NativeProcessor) clearEndpointLanes(endpoint nativeLaneKey) { func nativeLaneKeyFromOperation(op udecx.Operation) nativeLaneKey { return nativeLaneKey{ - deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress, + deviceID: op.DeviceID, generation: op.Generation, + endpointGeneration: op.EndpointGeneration, endpoint: op.EndpointAddress, attributes: op.EndpointAttributes, interval: op.EndpointInterval, maxPacket: op.EndpointMaxPacketSize, } @@ -419,7 +457,7 @@ func logicalEndpointForNativeSignature(desc *usbdevice.Descriptor, for _, iface := range desc.Interfaces { for _, endpoint := range iface.Endpoints { projected, valid := nativeSignatureFromDescriptor(desc.Device.Speed, endpoint) - if valid && projected == signature { + if valid && sameNativeEndpointShape(projected, signature) { return endpoint, true } } @@ -757,7 +795,8 @@ func successCompletion(op udecx.Operation, transferLength uint32, payload []byte packets []udecx.IsoPacket) udecx.Completion { return udecx.Completion{ Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, - Status: 0, USBDStatus: 0, IsoPackets: packets, Payload: payload, + EndpointGeneration: op.EndpointGeneration, + Status: 0, USBDStatus: 0, IsoPackets: packets, Payload: payload, TransferLength: transferLength, } } diff --git a/internal/server/usb/native_live_teardown_gate_test.go b/internal/server/usb/native_live_teardown_gate_test.go index 341240ad..756c2d62 100644 --- a/internal/server/usb/native_live_teardown_gate_test.go +++ b/internal/server/usb/native_live_teardown_gate_test.go @@ -55,6 +55,14 @@ func nativeLiveTraceEventName(event uint16) string { return "endpoint-cleanup-end" case udecx.TraceDeviceCleanupEnd: return "device-cleanup-end" + case udecx.TraceEndpointQuiescenceWatchdog: + return "endpoint-quiescence-watchdog" + case udecx.TraceCompletionRundownWatchdog: + return "completion-rundown-watchdog" + case udecx.TraceControllerRundownWatchdog: + return "controller-rundown-watchdog" + case udecx.TraceOwnerRundownWatchdog: + return "owner-rundown-watchdog" default: return fmt.Sprintf("event-%d", event) } @@ -102,6 +110,16 @@ func auditNativeLiveTeardown( trace udecx.LifecycleTrace, stats udecx.Stats, ) (nativeLiveTeardownAudit, error) { + if trace.StatusFlags&udecx.LifecycleTraceStatusWatchdogFired != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle watchdog status is sticky even if its record rolled out: flags=%#x latest-sequence=%d", + trace.StatusFlags, trace.LatestSequence) + } + if trace.StatusFlags&udecx.LifecycleTraceStatusDroppedRecord != 0 { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle recorder dropped a contended record: flags=%#x latest-sequence=%d", + trace.StatusFlags, trace.LatestSequence) + } if trace.LatestSequence == 0 { return nativeLiveTeardownAudit{diagnostic: "no lifecycle records are published yet"}, nil } @@ -151,6 +169,15 @@ func auditNativeLiveTeardown( } for _, record := range trace.Records { + if record.Event >= udecx.TraceEndpointQuiescenceWatchdog && + record.Event <= udecx.TraceOwnerRundownWatchdog { + return nativeLiveTeardownAudit{}, fmt.Errorf( + "lifecycle watchdog %s fired at sequence %d: device=%#x generation=%d endpoint=%#02x object=%#x line=%d queue-state=%#x active=%d pending=%d", + nativeLiveTraceEventName(record.Event), + record.PublishedSequence, record.DeviceID, record.Generation, + record.EndpointAddress, record.EndpointObject, record.Line, + record.QueueState, record.ActiveOperations, record.PendingOperations) + } deviceKey := nativeLiveTeardownDevice(record) lastDevice[deviceKey] = record if record.EndpointObject != 0 { @@ -313,7 +340,8 @@ func nativeLiveTrace(events ...uint16) udecx.LifecycleTrace { Event: event, Line: uint32(100 + index), } - if event >= udecx.TraceEndpointPurgeBegin && event <= udecx.TraceEndpointCleanupEnd { + if (event >= udecx.TraceEndpointPurgeBegin && event <= udecx.TraceEndpointCleanupEnd) || + event == udecx.TraceEndpointQuiescenceWatchdog { record.EndpointObject = endpointObject record.EndpointAddress = 0x81 } @@ -361,6 +389,39 @@ func TestNativeLiveTeardownAuditAcceptsReadyQueuePurge(t *testing.T) { } } +func TestNativeLiveTeardownAuditRejectsAnyQuiescenceWatchdog(t *testing.T) { + trace := nativeLiveTrace( + udecx.TraceEndpointPurgeBegin, + udecx.TraceEndpointQuiescenceWatchdog, + ) + trace.Records[1].Status = -1 + trace.Records[1].ActiveOperations = 1 + trace.Records[1].QueueState = 0x0f + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "endpoint-quiescence-watchdog") || + !strings.Contains(err.Error(), "active=1") { + t.Fatalf("watchdog audit error=%v want explicit active rundown snapshot", err) + } +} + +func TestNativeLiveTeardownAuditRejectsStickyWatchdogAfterRecordRollover(t *testing.T) { + trace := nativeLiveTrace(udecx.TraceCreateBegin) + trace.StatusFlags = udecx.LifecycleTraceStatusWatchdogFired + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "watchdog status is sticky") { + t.Fatalf("sticky watchdog audit error=%v want permanent release failure", err) + } +} + +func TestNativeLiveTeardownAuditRejectsRecorderContentionDrop(t *testing.T) { + trace := nativeLiveTrace(udecx.TraceCreateBegin) + trace.StatusFlags = udecx.LifecycleTraceStatusDroppedRecord + _, err := auditNativeLiveTeardown(trace, udecx.Stats{}) + if err == nil || !strings.Contains(err.Error(), "dropped a contended record") { + t.Fatalf("recorder drop audit error=%v want fail-closed release result", err) + } +} + func TestNativeLiveTeardownAuditTracksRepeatedPurgesFIFO(t *testing.T) { trace := nativeLiveTrace( udecx.TraceEndpointPurgeBegin, diff --git a/internal/server/usb/native_playstation_transport_soak_test.go b/internal/server/usb/native_playstation_transport_soak_test.go index dc9bbd97..647ed4d2 100644 --- a/internal/server/usb/native_playstation_transport_soak_test.go +++ b/internal/server/usb/native_playstation_transport_soak_test.go @@ -45,6 +45,17 @@ type nativePlayStationSoakDriver struct { failures []error } +func nativePlayStationOperationUsesEndpointGeneration(kind udecx.OperationKind) bool { + switch kind { + case udecx.OperationControl, udecx.OperationTransfer, + udecx.OperationEndpointStart, udecx.OperationEndpointPurge, + udecx.OperationEndpointReset, udecx.OperationCancel: + return true + default: + return false + } +} + func newNativePlayStationSoakDriver() *nativePlayStationSoakDriver { return &nativePlayStationSoakDriver{ operations: make(chan udecx.Operation, 4096), @@ -137,6 +148,9 @@ func (d *nativePlayStationSoakDriver) submit( ) (uint64, <-chan udecx.Completion) { d.mu.Lock() op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + if nativePlayStationOperationUsesEndpointGeneration(op.Kind) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } if op.Kind != udecx.OperationCancel { key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} d.endpointSequences[key]++ @@ -166,6 +180,9 @@ func (d *nativePlayStationSoakDriver) submitCancellable( ) uint64 { d.mu.Lock() op.DeviceID, op.Generation = identity.DeviceID, identity.Generation + if nativePlayStationOperationUsesEndpointGeneration(op.Kind) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } key := nativePlayStationEndpointKey{deviceID: identity.DeviceID, address: op.EndpointAddress} d.endpointSequences[key]++ d.deviceSequences[identity.DeviceID]++ @@ -184,7 +201,7 @@ func (d *nativePlayStationSoakDriver) cancel( d.operations <- udecx.Operation{ Kind: udecx.OperationCancel, Token: token, DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: endpoint, + EndpointAddress: endpoint, EndpointGeneration: 1, } } diff --git a/internal/server/usb/native_test.go b/internal/server/usb/native_test.go index 68774cd0..1a3757a2 100644 --- a/internal/server/usb/native_test.go +++ b/internal/server/usb/native_test.go @@ -737,6 +737,89 @@ func TestNativeEndpointResetClearsAlternateReuseClocks(t *testing.T) { } } +func TestNativeEndpointIncarnationDoesNotReuseWorkerOrActiveState(t *testing.T) { + desc := &usbdevice.Descriptor{ + Device: usbdevice.DeviceDescriptor{Speed: uint32(udecx.DeviceSpeedHigh)}, + Interfaces: []usbdevice.InterfaceConfig{ + {Descriptor: usbdevice.InterfaceDescriptor{BInterfaceNumber: 2}}, + {Descriptor: usbdevice.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, + }, Endpoints: []usbdevice.EndpointDescriptor{{ + BEndpointAddress: 0x82, BMAttributes: 0x03, + WMaxPacketSize: 64, BInterval: 4, + }}}, + }, + } + dev := &altSettingTestDevice{desc: desc} + processor := nativeProcessorForTest(t) + first := udecx.Operation{ + DeviceID: 8, Generation: 3, EndpointGeneration: 1, + EndpointAddress: 0x82, EndpointAttributes: 0x03, + EndpointInterval: 4, EndpointMaxPacketSize: 64, + } + second := first + second.EndpointGeneration = 2 + firstKey, secondKey := nativeLaneKeyFromOperation(first), nativeLaneKeyFromOperation(second) + if firstKey == secondKey { + t.Fatal("same-address endpoint incarnations share a worker/cache key") + } + processor.next[firstKey] = time.Now() + processor.next[secondKey] = time.Now().Add(time.Second) + processor.lastIn[firstKey] = []byte{1} + processor.lastIn[secondKey] = []byte{2} + processor.clearEndpointLanes(firstKey) + if _, ok := processor.next[secondKey]; !ok { + t.Fatal("retired endpoint generation cleared the successor service clock") + } + if got := processor.lastIn[secondKey]; !bytes.Equal(got, []byte{2}) { + t.Fatalf("successor input cache=%v want [2]", got) + } + + processor.next[firstKey] = time.Now() + processor.lastIn[firstKey] = []byte{1} + second.Kind = udecx.OperationEndpointStart + if err := processor.Lifecycle(context.Background(), dev, second); err != nil { + t.Fatal(err) + } + if _, ok := processor.next[firstKey]; ok { + t.Fatal("successor endpoint start retained the retired incarnation clock") + } + if _, ok := processor.lastIn[firstKey]; ok { + t.Fatal("successor endpoint start retained the retired incarnation cache") + } + + sessionKey := nativeSessionKey{deviceID: second.DeviceID, generation: second.Generation} + session := processor.lockSession(sessionKey) + if len(session.active) != 1 { + t.Fatalf("active endpoint incarnations=%d want 1", len(session.active)) + } + if _, ok := session.active[signatureFromOperation(second)]; !ok { + t.Fatal("successor endpoint incarnation was not made authoritative") + } + session.mu.Unlock() + + first.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, first); err != nil { + t.Fatal(err) + } + session = processor.lockSession(sessionKey) + _, successorActive := session.active[signatureFromOperation(second)] + session.mu.Unlock() + if !successorActive { + t.Fatal("retired endpoint purge removed the successor active state") + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("retired endpoint generation changed active alt to %d", got) + } + second.Kind = udecx.OperationEndpointPurge + if err := processor.Lifecycle(context.Background(), dev, second); err != nil { + t.Fatal(err) + } + if got := processor.server.getInterfaceAlt(dev, 2); got != 0 { + t.Fatalf("authoritative endpoint purge left alt %d active", got) + } +} + type concurrentNativeTestDevice struct { desc *usbdevice.Descriptor mu sync.Mutex diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index 6c846129..e0ae5883 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -135,10 +135,13 @@ type Client struct { // removes a scheduler/channel round trip from direct input without changing // cancellation or lifecycle I/O. skipCompletionPortOnSuccess bool - driverNonce uint64 - buildIdentity [BuildIdentitySize]byte - capabilities Capabilities - limits NegotiateResponse + // driverNonce is the nonzero negotiated tag for this exact exclusive file + // session. The Client and its Host are one-shot, so it cannot be inherited + // by a successor handle or reused by a later worker/publication graph. + driverNonce uint64 + buildIdentity [BuildIdentitySize]byte + capabilities Capabilities + limits NegotiateResponse // pendingObserver is a package-private synchronization seam for the // Windows IOCP stress harness. Production clients leave it nil. It runs // only after the overlapped issuer has returned ERROR_IO_PENDING, so tests can diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index d7b37fbd..a4474efb 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -98,6 +98,20 @@ func TestNegotiationRejectsMissingCapabilitiesAndImpossibleLimits(t *testing.T) } } +func TestNegotiationNoncesFenceExactFileSession(t *testing.T) { + valid := validTestNegotiation() + if err := validateNegotiation(valid, valid.ClientNonce+1, valid.BuildIdentity); err == nil { + t.Fatal("negotiation accepted a response from a different client-nonce session") + } + zeroDriverNonce := valid + zeroDriverNonce.DriverNonce = 0 + if err := validateNegotiation( + zeroDriverNonce, valid.ClientNonce, valid.BuildIdentity, + ); err == nil { + t.Fatal("negotiation accepted a session without a kernel nonce tag") + } +} + func TestNegotiationRejectsStaleLoadedKernelDespiteMatchingOnDiskPackageContract(t *testing.T) { // acceptedPackageIdentity represents the exact source-bound identity from // the already validated signed on-disk package and protected manifest. The diff --git a/internal/transport/udecx/driver_dispatch_contract_test.go b/internal/transport/udecx/driver_dispatch_contract_test.go index e9bf6164..4c424326 100644 --- a/internal/transport/udecx/driver_dispatch_contract_test.go +++ b/internal/transport/udecx/driver_dispatch_contract_test.go @@ -363,9 +363,10 @@ func TestNativeCachedInputReadyUsesCompletionDPCWithoutWorkerHop(t *testing.T) { } complete := normalizedContract(nativeCFunction(t, device, "ViiperCompleteRetrievedInputUrb")) - if !strings.Contains(complete, "ViiperQueueUrbCompletion(") { - t.Fatal("cached input no longer transfers terminal completion to the shared DPC") - } + requireContractOrder(t, complete, + "requestContext->DeviceGeneration = deviceContext->Generation;", + "requestContext->EndpointGeneration = endpointContext->Generation;", + "ViiperQueueUrbCompletion(") } func TestNativeDirectInputStatsCommitAfterTerminalUdeCxCompletion(t *testing.T) { @@ -394,7 +395,11 @@ func TestNativeDirectInputStatsCommitAfterTerminalUdeCxCompletion(t *testing.T) requireContractOrder(t, dpc, "directInputBytes = requestContext->DirectInputBytes;", "directInputSequence = requestContext->DirectInputSequence;", + "deviceGeneration = requestContext->DeviceGeneration;", + "endpointGeneration = requestContext->EndpointGeneration;", "WdfSpinLockRelease(controllerContext->BrokerLock);", + "ViiperGetEndpointContext(endpoint)->Generation != endpointGeneration", + "ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation != deviceGeneration", "UdecxUrbCompleteWithNtStatus(request, completionStatus);", "UdecxUrbComplete(request, usbdStatus);", "directInputSequence != 0", @@ -410,6 +415,9 @@ func TestNativeFastInputQueuesTransitionsButCoalescesIdleCadence(t *testing.T) { device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") submit := normalizedContract(nativeCFunction(t, device, "ViiperSubmitInputReport")) requireContractOrder(t, submit, + "input->EndpointGeneration == 0", + "endpointContext->Generation != input->EndpointGeneration", + "deviceContext->EndpointGenerations[input->EndpointAddress] != input->EndpointGeneration", "if ((input->Flags & VIIPER_UDE_INPUT_REPORT_TRANSITION) != 0 &&", "return STATUS_DEVICE_BUSY;", "RtlCopyMemory(endpointContext->InputReport", @@ -440,17 +448,22 @@ func TestNativeCompletionValidatesImmutableIdentityBeforeClaim(t *testing.T) { broker := nativeContractSource(t, "native", "udecx", "driver", "Broker.c") complete := normalizedContract(nativeCFunction(t, broker, "ViiperCompleteOperation")) requireContractOrder(t, complete, + "((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 && completion->EndpointGeneration == 0", "controllerContext->PendingSlots[slot].Token == completion->Token", "controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight", "controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId", "controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation", + "controllerContext->PendingSlots[slot].EndpointGeneration == completion->EndpointGeneration", "controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting;", "WdfObjectReference(urbRequest);", "identityMismatch = TRUE;", "WdfSpinLockRelease(controllerContext->BrokerLock);", "if (identityMismatch)", "return STATUS_INVALID_PARAMETER;", - "WdfRequestUnmarkCancelable(urbRequest);") + "WdfRequestUnmarkCancelable(urbRequest);", + "completion->Generation != requestContext->DeviceGeneration", + "completion->EndpointGeneration != requestContext->EndpointGeneration", + "completion->EndpointGeneration != ViiperGetEndpointContext(requestContext->Endpoint)->Generation") } func TestNativeBrokerFaultFencesAdmissionAndPublication(t *testing.T) { diff --git a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go index f8982c2a..8ce91c14 100644 --- a/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go +++ b/internal/transport/udecx/driver_endpoint_quiescence_contract_test.go @@ -526,6 +526,7 @@ func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *te "RetiredToken = token;", "RetiredDeviceId =", "RetiredDeviceGeneration =", + "RetiredEndpointGeneration =", "RetiredNotificationPending =", "ViiperUdePendingQueued;", "State = ViiperUdePendingCompleting;", @@ -545,6 +546,7 @@ func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *te "RetiredToken != event.Token", "RetiredDeviceId != event.DeviceId", "RetiredDeviceGeneration != event.Generation", + "RetiredEndpointGeneration != event.EndpointGeneration", "RetiredNotificationPending = FALSE;", "RetiredToken = 0;", "event.Kind = ViiperUdeOperationCancel;", @@ -556,9 +558,11 @@ func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *te "RetiredToken == Completion->Token", "RetiredDeviceId == Completion->DeviceId", "RetiredDeviceGeneration == Completion->Generation", + "RetiredEndpointGeneration == Completion->EndpointGeneration", "RetiredToken = 0;", "RetiredDeviceId = 0;", "RetiredDeviceGeneration = 0;", + "RetiredEndpointGeneration = 0;", "RetiredOwnerFile = WDF_NO_HANDLE;", "retiredCompletion = TRUE;", "return retiredCompletion ? STATUS_SUCCESS : STATUS_NOT_FOUND;") @@ -581,6 +585,7 @@ func TestNativeDeviceDestroyAbortsPinnedManagementBeforeConsumingUdeHandle(t *te "pending->RetiredToken = 0;", "pending->RetiredDeviceId = 0;", "pending->RetiredDeviceGeneration = 0;", + "pending->RetiredEndpointGeneration = 0;", "pending->RetiredOwnerFile = WDF_NO_HANDLE;", "pending->RetiredNotificationPending = FALSE;", "WdfSpinLockRelease(controllerContext->BrokerLock);") @@ -1037,3 +1042,42 @@ func TestNativeResetPublicationRejectsConcurrentRemoval(t *testing.T) { t.Fatal("live exact reset identity did not publish after quiescence") } } + +func TestNativeDuplicateEndpointAddCannotRetireLiveIncarnation(t *testing.T) { + add := normalizedContract(nativeCFunction(t, + nativeContractSource(t, "native", "udecx", "driver", "Device.c"), + "ViiperEvtEndpointAdd")) + + requireContractOrder(t, add, + "deviceContext->Endpoints[descriptor.bEndpointAddress] != WDF_NO_HANDLE", + "status = STATUS_OBJECT_NAME_COLLISION;", + "deviceContext->EndpointGenerations[ descriptor.bEndpointAddress] == MAXULONG", + "deviceContext->EndpointGenerations[descriptor.bEndpointAddress] = generation;") + if !strings.Contains(add, + "descriptor.bEndpointAddress == 0 && deviceContext->DefaultEndpoint != WDF_NO_HANDLE") { + t.Fatal("endpoint-add can advance endpoint zero while a default endpoint is live") + } + + type endpointSlot struct { + generation uint32 + live bool + } + allocate := func(slot *endpointSlot) (uint32, bool) { + if slot.live || slot.generation == ^uint32(0) { + return 0, false + } + slot.generation++ + return slot.generation, true + } + slot := endpointSlot{generation: 1, live: true} + if generation, admitted := allocate(&slot); admitted || generation != 0 || slot.generation != 1 { + t.Fatalf("duplicate add retired live generation: admitted=%t result=%d slot=%+v", + admitted, generation, slot) + } + // Once cleanup retires the exact live object, the successor gets a fresh + // incarnation; the failed duplicate did not create an unobservable hole. + slot.live = false + if generation, admitted := allocate(&slot); !admitted || generation != 2 { + t.Fatalf("successor allocation=(%d,%t) want generation 2", generation, admitted) + } +} diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index 51a93dfa..ba29c3fa 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -113,10 +113,17 @@ func TestKernelOwnerCleanupJoinsFiniteMutationRundown(t *testing.T) { finish := normalizedContract(nativeCFunction(t, controller, "ViiperFinishOwnerCleanup")) requireContractOrder(t, finish, "WdfWaitLockRelease(context->OwnerLock);", - "KeWaitForSingleObject( &context->OwnerAdmissionsDrained", + "ViiperWaitForControllerRundown( Device, &context->OwnerAdmissionsDrained", "ViiperDestroyOwnedDevices(Device, OwnerFile)", "context->OwnerFile = WDF_NO_HANDLE;", "WdfObjectDereference(OwnerFile);") + wait := normalizedContract(controller) + requireContractOrder(t, wait, + "VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS", + "KeWaitForSingleObject( Event", + "if (waitStatus != STATUS_TIMEOUT)", + "STATUS_IO_TIMEOUT", + "InterlockedCompareExchange(ActiveCounter, 0, 0)") for _, name := range []string{"ViiperCreateVirtualDevice", "ViiperDestroyVirtualDevice"} { mutation := normalizedContract(nativeCFunction(t, device, name)) diff --git a/internal/transport/udecx/driver_trace_recorder_contract_test.go b/internal/transport/udecx/driver_trace_recorder_contract_test.go new file mode 100644 index 00000000..a9ea2b48 --- /dev/null +++ b/internal/transport/udecx/driver_trace_recorder_contract_test.go @@ -0,0 +1,193 @@ +package udecx + +import ( + "strings" + "testing" +) + +func TestNativeLifecycleTraceUsesBoundedPerProcessorRecorder(t *testing.T) { + header := nativeContractSource(t, "native", "udecx", "driver", "ViiperUde.h") + controller := nativeContractSource(t, "native", "udecx", "driver", "Controller.c") + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + trace := nativeContractSource(t, "native", "udecx", "driver", "Trace.c") + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + + for _, fragment := range []string{ + "#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64", + "typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD", + "DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence;", + "volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];", + "VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];", + "WDFMEMORY LifecycleTraceStorage;", + "VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards;", + "ULONG LifecycleTraceShardCount;", + } { + if !strings.Contains(header, fragment) { + t.Fatalf("native lifecycle recorder is missing %q", fragment) + } + } + if strings.Contains(header, + "LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]") { + t.Fatal("controller context retained the contended global lifecycle record array") + } + + initialize := normalizedContract(nativeCFunction(t, trace, + "ViiperInitializeLifecycleTrace")) + requireContractOrder(t, initialize, + "KeQueryMaximumProcessorCountEx(ALL_PROCESSOR_GROUPS)", + "VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS", + "WdfMemoryCreate(", + "NonPagedPoolNx", + "RtlZeroMemory(rawStorage, storageSize);", + "controllerContext->LifecycleTraceShards =", + "controllerContext->LifecycleTraceShardCount = shardCount;") + if !strings.Contains(controller, "status = ViiperInitializeLifecycleTrace(device);") { + t.Fatal("controller publishes emulation without constructing the nonpaged recorder") + } + + hot := normalizedContract(nativeCFunction(t, trace, "ViiperTraceLifecycle")) + requireContractOrder(t, hot, + "KeGetCurrentProcessorNumberEx(&processorNumber);", + "KeGetProcessorIndexFromNumber(&processorNumber);", + "shardIndex = processorIndex % controllerContext->LifecycleTraceShardCount;", + "InterlockedIncrement64(&shard->WriteSequence);", + "slotState = &shard->SlotStates[slotIndex];", + "claimedSlotState = (LONG64)((localSequence << 1) | 1ULL);", + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD", + "InterlockedCompareExchange64( slotState, claimedSlotState, observedSlotState)", + "InterlockedIncrement64( &controllerContext->LifecycleTraceSequence);", + "record = &shard->Records[", + "InterlockedExchange64((volatile LONG64 *)&record->PublishedSequence, 0);", + "KeMemoryBarrier();", + "InterlockedExchange64( (volatile LONG64 *)&record->PublishedSequence, (LONG64)sequence);", + "InterlockedExchange64(slotState, (LONG64)(localSequence << 1));") + for _, forbidden := range []string{ + "WdfMemoryCreate", "WdfSpinLockAcquire", "WdfWaitLockAcquire", + "ExAcquirePushLock", "KeWaitForSingleObject", + } { + if strings.Contains(hot, forbidden) { + t.Fatalf("lifecycle recorder hot path contains %q", forbidden) + } + } + + query := normalizedContract(nativeCFunction(t, ioctl, + "ViiperHandleQueryLifecycleTrace")) + requireContractOrder(t, query, + "latestSequence = (ULONGLONG)ViiperReadCounter( &context->LifecycleTraceSequence);", + "shardIndex < context->LifecycleTraceShardCount", + "recordIndex < VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY", + "slotStateBefore = InterlockedCompareExchange64(", + "publishedBefore < firstSequence", + "RtlCopyMemory(&candidate, source, sizeof(candidate));", + "slotStateAfter = InterlockedCompareExchange64(", + "slotStateAfter != slotStateBefore", + "publishedAfter != publishedBefore", + "output->Records[insertIndex] = candidate;", + "++output->RecordCount;", + "output->StatusFlags = (VIIPER_UDE_UINT32)InterlockedCompareExchange(", + "WdfRequestSetInformation(Request, sizeof(*output));") + + for _, name := range []string{ + "ViiperWaitForEndpointQuiescence", + "ViiperWaitForEndpointPurgeQuiescence", + } { + wait := normalizedContract(nativeCFunction(t, device, name)) + requireContractOrder(t, wait, + "watchdogWait.QuadPart = -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS;", + "KeWaitForSingleObject(", + "&watchdogWait);", + "if (quiescent)", + "return;", + "VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG", + "STATUS_IO_TIMEOUT", + "KeDelayExecutionThread(") + if strings.Contains(wait, "UdecxUsbEndpointPurgeComplete") { + t.Fatalf("%s abandons rundown and completes UdeCx from its watchdog path", name) + } + } +} + +func TestLifecycleRecorderSlotClaimRejectsPreemptedStaleWriter(t *testing.T) { + const capacity = LifecycleTraceCapacity + type slot struct { + state uint64 + sequence uint64 + } + var slots [capacity]slot + claim := func(local uint64) bool { + index := (local - 1) % capacity + observed := slots[index].state + if observed&1 != 0 || observed>>1 >= local { + return false + } + slots[index].state = local<<1 | 1 + return true + } + publish := func(local, sequence uint64) { + index := (local - 1) % capacity + slots[index].sequence = sequence + slots[index].state = local << 1 + } + + // Writer 1 is preempted after reserving its local sequence but before its + // atomic slot claim. A complete ring of successors reaches the same slot. + for local := uint64(2); local <= capacity+1; local++ { + if !claim(local) { + t.Fatalf("successor local sequence %d could not claim its slot", local) + } + publish(local, local) + } + if claim(1) { + t.Fatal("preempted writer reclaimed a slot already published by a newer wrap") + } + if got := slots[0].sequence; got != capacity+1 { + t.Fatalf("slot 0 sequence=%d want newest sequence %d", got, capacity+1) + } + + // A writer which already owns the slot cannot be overwritten either. The + // colliding successor is dropped and made sticky by the production path. + slots[0] = slot{state: 1<<1 | 1} + if claim(capacity + 1) { + t.Fatal("colliding successor overwrote an active slot writer") + } + publish(1, 1) + if !claim(capacity + 1) { + t.Fatal("settled old slot did not admit its newer wrap") + } +} + +func TestPerProcessorLifecycleRecorderRetainsGlobalPublicWindow(t *testing.T) { + const ( + shardCount = 7 + capacity = LifecycleTraceCapacity + writes = 10000 + ) + type record struct{ sequence uint64 } + shards := make([][capacity]record, shardCount) + local := make([]uint64, shardCount) + for sequence := uint64(1); sequence <= writes; sequence++ { + // Exercise uneven load and processor-to-shard collisions rather than a + // round-robin distribution. + processor := int((sequence*sequence + sequence*17 + 3) % 97) + shard := processor % shardCount + local[shard]++ + shards[shard][(local[shard]-1)%capacity] = record{sequence: sequence} + } + first := uint64(writes-capacity) + 1 + seen := make(map[uint64]struct{}, capacity) + for shard := range shards { + for _, record := range shards[shard] { + if record.sequence >= first && record.sequence <= writes { + seen[record.sequence] = struct{}{} + } + } + } + if len(seen) != capacity { + t.Fatalf("retained records=%d want complete global suffix=%d", len(seen), capacity) + } + for sequence := first; sequence <= writes; sequence++ { + if _, ok := seen[sequence]; !ok { + t.Fatalf("global retained suffix is missing sequence %d", sequence) + } + } +} diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 36e2c39a..00f62bc4 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -67,31 +67,38 @@ type OperationProcessor interface { } type registeredDevice struct { - identity DeviceIdentity - device usb.Device - sequence *deviceSequenceBarrier - ctx context.Context - cancel context.CancelFunc - stopping bool - publisherStopping bool - fastInput map[uint8]fastInputEndpoint - publishers map[uint8]*inputPublisher - activeInput map[uint8]bool - resettingInput map[uint8]bool - inputSequences map[uint8]*atomic.Uint64 - inD0 bool - resetting bool - powerSequence uint64 + identity DeviceIdentity + device usb.Device + sequence *deviceSequenceBarrier + ctx context.Context + cancel context.CancelFunc + stopping bool + publisherStopping bool + fastInput map[uint8]fastInputEndpoint + publishers map[uint8]*inputPublisher + activeInput map[uint8]uint32 + resettingInput map[uint8]uint32 + endpointGeneration map[uint8]uint32 + inputSequences map[endpointIdentity]*atomic.Uint64 + inD0 bool + resetting bool + powerSequence uint64 } type inputPublisher struct { - endpoint uint8 - reportSize int - interval time.Duration - sequence *atomic.Uint64 - submitCtx context.Context - cancel context.CancelFunc - done chan struct{} + endpoint uint8 + endpointGeneration uint32 + reportSize int + interval time.Duration + sequence *atomic.Uint64 + submitCtx context.Context + cancel context.CancelFunc + done chan struct{} +} + +type endpointIdentity struct { + address uint8 + generation uint32 } type fastInputEndpoint struct { @@ -100,9 +107,10 @@ type fastInputEndpoint struct { } type laneKey struct { - deviceID uint64 - generation uint32 - endpoint uint8 + deviceID uint64 + generation uint32 + endpoint uint8 + endpointGeneration uint32 } type operationLane struct { @@ -116,13 +124,15 @@ type operationLane struct { } type operationState struct { - deviceID uint64 - generation uint32 - cancel context.CancelFunc - cancelled bool - received bool - processing bool - done bool + deviceID uint64 + generation uint32 + endpoint uint8 + endpointGeneration uint32 + cancel context.CancelFunc + cancelled bool + received bool + processing bool + done bool } type deviceLifecycleGate struct { @@ -130,6 +140,15 @@ type deviceLifecycleGate struct { references int } +// InputPathDiagnostics makes every slower compatibility path observable. +// These counters are publisher-lifetime events rather than per-report events, +// so collecting them adds no atomic operation to the interrupt-input hot path. +type InputPathDiagnostics struct { + PublisherStarts uint64 + LegacyTransferFallbackStarts uint64 + DeadlineContextFallbackStarts uint64 +} + // Host owns one exclusive driver session and routes operations concurrently // across endpoints while preserving strict FIFO within each endpoint. type Host struct { @@ -155,11 +174,29 @@ type Host struct { operations map[uint64]*operationState completed []uint64 + inputPublisherStarts atomic.Uint64 + legacyTransferFallbackStarts atomic.Uint64 + deadlineContextFallbackStarts atomic.Uint64 + // inputAttemptContext is a deterministic deadline seam for host tests. // Production hosts leave it nil and use context.WithTimeout. inputAttemptContext func(context.Context, time.Duration) (context.Context, context.CancelFunc) } +// InputDiagnostics returns a lock-free snapshot of input-publisher path +// selection. A nonzero fallback count is deliberately visible to release +// telemetry instead of silently trading latency for compatibility. +func (h *Host) InputDiagnostics() InputPathDiagnostics { + if h == nil { + return InputPathDiagnostics{} + } + return InputPathDiagnostics{ + PublisherStarts: h.inputPublisherStarts.Load(), + LegacyTransferFallbackStarts: h.legacyTransferFallbackStarts.Load(), + DeadlineContextFallbackStarts: h.deadlineContextFallbackStarts.Load(), + } +} + func NewHost(driver Driver, processor OperationProcessor, workers int) (*Host, error) { if driver == nil || processor == nil { return nil, errors.New("native UDE host requires a driver and operation processor") @@ -279,18 +316,21 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D h.mu.Unlock() return DeviceIdentity{}, fmt.Errorf("native UDE device %d is already registered", deviceID) } - generation := h.generations[deviceID] + 1 - if generation == 0 { - generation = 1 + if h.generations[deviceID] == math.MaxUint32 { + h.mu.Unlock() + return DeviceIdentity{}, fmt.Errorf( + "native UDE device %d exhausted its generation space", deviceID) } + generation := h.generations[deviceID] + 1 identity := DeviceIdentity{DeviceID: deviceID, Generation: generation} deviceCtx, cancel := context.WithCancel(context.Background()) entry := ®isteredDevice{ identity: identity, device: dev, sequence: newDeviceSequenceBarrier(), ctx: deviceCtx, cancel: cancel, fastInput: fastInputEndpoints(dev), publishers: make(map[uint8]*inputPublisher), - activeInput: make(map[uint8]bool), resettingInput: make(map[uint8]bool), - inputSequences: make(map[uint8]*atomic.Uint64), inD0: true, + activeInput: make(map[uint8]uint32), resettingInput: make(map[uint8]uint32), + endpointGeneration: make(map[uint8]uint32), + inputSequences: make(map[endpointIdentity]*atomic.Uint64), inD0: true, } h.devices[deviceID] = entry h.generations[deviceID] = generation @@ -375,7 +415,7 @@ func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { entry.publisherStopping = false h.mu.Unlock() for _, endpoint := range activePublishers { - h.startInputPublisher(entry, endpoint) + h.startInputPublisher(entry, endpoint.address, endpoint.generation) } return err } @@ -433,6 +473,7 @@ func (h *Host) observeLifecycleRemoval(identity DeviceIdentity) { return } seen := make(map[uint64]struct{}, LifecycleTraceCapacity) + statusReported := false for _, delay := range []time.Duration{0, 100 * time.Millisecond, 500 * time.Millisecond, 2 * time.Second, 5 * time.Second} { if delay != 0 { timer := time.NewTimer(delay) @@ -447,6 +488,13 @@ func (h *Host) observeLifecycleRemoval(identity DeviceIdentity) { "error", err) return } + if trace.StatusFlags != 0 && !statusReported { + statusReported = true + slog.Error("native UDE lifecycle recorder reported sticky failure state", + "device_id", identity.DeviceID, "generation", identity.Generation, + "status_flags", fmt.Sprintf("%#08x", uint32(trace.StatusFlags)), + "latest_sequence", trace.LatestSequence) + } for _, record := range trace.Records { if record.DeviceID != identity.DeviceID || record.Generation != identity.Generation { continue @@ -502,6 +550,9 @@ func lifecycleTraceEventName(event uint16) string { "endpoint-purge-complete-begin", "endpoint-purge-complete-end", "endpoint-cleanup-begin", "endpoint-cleanup-end", "device-cleanup-begin", "device-cleanup-end", "controller-shutdown-begin", "controller-shutdown-end", + "endpoint-quiescence-watchdog", + "completion-rundown-watchdog", "controller-rundown-watchdog", + "owner-rundown-watchdog", } if int(event) < len(names) && names[event] != "" { return names[event] @@ -509,13 +560,17 @@ func lifecycleTraceEventName(event uint16) string { return fmt.Sprintf("event-%d", event) } -func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { - if h.input == nil { +func (h *Host) startInputPublisher( + entry *registeredDevice, endpoint uint8, endpointGeneration uint32, +) { + if h.input == nil || endpointGeneration == 0 { return } h.mu.Lock() if !h.running || entry.stopping || entry.publisherStopping || !entry.inD0 || entry.resetting || - entry.resettingInput[endpoint] || + entry.activeInput[endpoint] != endpointGeneration || + entry.endpointGeneration[endpoint] != endpointGeneration || + entry.resettingInput[endpoint] == endpointGeneration || h.devices[entry.identity.DeviceID] != entry { h.mu.Unlock() return @@ -525,15 +580,17 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { h.mu.Unlock() return } - sequence := entry.inputSequences[endpoint] + identity := endpointIdentity{address: endpoint, generation: endpointGeneration} + sequence := entry.inputSequences[identity] if sequence == nil { sequence = &atomic.Uint64{} - entry.inputSequences[endpoint] = sequence + entry.inputSequences[identity] = sequence } ctx, cancel := context.WithCancel(entry.ctx) publisher := &inputPublisher{ - endpoint: endpoint, reportSize: endpointContract.reportSize, - interval: endpointContract.interval, sequence: sequence, + endpoint: endpoint, endpointGeneration: endpointGeneration, + reportSize: endpointContract.reportSize, + interval: endpointContract.interval, sequence: sequence, submitCtx: h.runCtx, cancel: cancel, done: make(chan struct{}), } @@ -543,12 +600,16 @@ func (h *Host) startInputPublisher(entry *registeredDevice, endpoint uint8) { go h.runInputPublisher(ctx, entry, publisher) } -func (h *Host) stopInputPublisher(entry *registeredDevice, endpoint uint8) bool { +func (h *Host) stopInputPublisher( + entry *registeredDevice, endpoint uint8, endpointGeneration uint32, +) bool { h.mu.Lock() publisher := entry.publishers[endpoint] - if publisher != nil { + if publisher != nil && publisher.endpointGeneration == endpointGeneration { delete(entry.publishers, endpoint) publisher.cancel() + } else { + publisher = nil } h.mu.Unlock() if publisher == nil { @@ -558,26 +619,30 @@ func (h *Host) stopInputPublisher(entry *registeredDevice, endpoint uint8) bool return true } -func (h *Host) stopAllInputPublishers(entry *registeredDevice) []uint8 { +func (h *Host) stopAllInputPublishers(entry *registeredDevice) []endpointIdentity { h.mu.RLock() - endpoints := make([]uint8, 0, len(entry.publishers)) - for endpoint := range entry.publishers { - endpoints = append(endpoints, endpoint) + endpoints := make([]endpointIdentity, 0, len(entry.publishers)) + for endpoint, publisher := range entry.publishers { + endpoints = append(endpoints, endpointIdentity{ + address: endpoint, generation: publisher.endpointGeneration, + }) } h.mu.RUnlock() for _, endpoint := range endpoints { - h.stopInputPublisher(entry, endpoint) + h.stopInputPublisher(entry, endpoint.address, endpoint.generation) } return endpoints } -func (h *Host) activeInputEndpoints(entry *registeredDevice) []uint8 { +func (h *Host) activeInputEndpoints(entry *registeredDevice) []endpointIdentity { h.mu.RLock() defer h.mu.RUnlock() - endpoints := make([]uint8, 0, len(entry.activeInput)) - for endpoint, active := range entry.activeInput { - if active { - endpoints = append(endpoints, endpoint) + endpoints := make([]endpointIdentity, 0, len(entry.activeInput)) + for endpoint, generation := range entry.activeInput { + if generation != 0 { + endpoints = append(endpoints, endpointIdentity{ + address: endpoint, generation: generation, + }) } } return endpoints @@ -621,6 +686,26 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p reader, direct := entry.device.(usb.InterruptInputDevice) scheduledReader, scheduled := entry.device.(usb.ScheduledInterruptInputDevice) classifiedReader, classified := entry.device.(usb.ClassifiedScheduledInterruptInputDevice) + h.inputPublisherStarts.Add(1) + if !direct { + h.legacyTransferFallbackStarts.Add(1) + slog.Warn("native UDE interrupt input compatibility fallback activated", + "device_id", entry.identity.DeviceID, + "generation", entry.identity.Generation, + "endpoint", fmt.Sprintf("%#02x", publisher.endpoint), + "endpoint_generation", publisher.endpointGeneration, + "fallback", "legacy-handle-transfer", + "reason", "device does not implement InterruptInputDevice") + } else if publisher.interval > 0 && !scheduled { + h.deadlineContextFallbackStarts.Add(1) + slog.Warn("native UDE interrupt input compatibility fallback activated", + "device_id", entry.identity.DeviceID, + "generation", entry.identity.Generation, + "endpoint", fmt.Sprintf("%#02x", publisher.endpoint), + "endpoint_generation", publisher.endpointGeneration, + "fallback", "per-report-deadline-context", + "reason", "device does not implement ScheduledInterruptInputDevice") + } var reportBuffer []byte var deadlineTimer *time.Timer var retryTimer *time.Timer @@ -727,7 +812,8 @@ func (h *Host) runInputPublisher(ctx context.Context, entry *registeredDevice, p } report := InputReport{ DeviceID: entry.identity.DeviceID, Generation: entry.identity.Generation, - EndpointAddress: publisher.endpoint, Transition: transition, + EndpointGeneration: publisher.endpointGeneration, + EndpointAddress: publisher.endpoint, Transition: transition, Sequence: sequence, Payload: payload, } for { @@ -798,7 +884,7 @@ func (h *Host) Serve(ctx context.Context) error { h.mu.Unlock() for _, entry := range entries { for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) + h.startInputPublisher(entry, endpoint.address, endpoint.generation) } } defer func() { @@ -891,6 +977,13 @@ func (h *Host) Serve(ctx context.Context) error { continue } if result.op.Kind == OperationCancel { + // A management-token cancel is a teardown tombstone for a held + // lifecycle request which the kernel already retired. It has no + // future ordinary operation to match, so accepting it must not + // retain an unbounded cancellation entry in the session map. + if isManagementToken(result.op.Token) { + continue + } h.cancelOperation(result.op) continue } @@ -941,7 +1034,10 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { if err := ctx.Err(); err != nil { return err } - key := laneKey{deviceID: op.DeviceID, generation: op.Generation, endpoint: op.EndpointAddress} + key := laneKey{ + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + } h.mu.Lock() entry := h.devices[op.DeviceID] @@ -1007,8 +1103,8 @@ func (h *Host) dispatch(ctx context.Context, op Operation) error { return nil default: err := fmt.Errorf( - "native UDE device %d generation %d endpoint 0x%02x lane is saturated at the %d-operation pending contract", - key.deviceID, key.generation, key.endpoint, laneQueueDepth) + "native UDE device %d generation %d endpoint 0x%02x generation %d lane is saturated at the %d-operation pending contract", + key.deviceID, key.generation, key.endpoint, key.endpointGeneration, laneQueueDepth) lane.terminalErr = err lane.cancel() lane.stateMu.Unlock() @@ -1133,6 +1229,40 @@ func isLifecycleOperation(kind OperationKind) bool { } } +// admitEndpointGeneration establishes the endpoint incarnation before any +// controller callback or direct-input publisher can observe it. Device-wide +// lifecycle operations carry generation zero and deliberately bypass this +// endpoint fence. A higher generation permanently retires the prior address +// incarnation; a lower generation is stale even if its endpoint sequence is +// otherwise locally valid. +func (h *Host) admitEndpointGeneration(entry *registeredDevice, op Operation) bool { + if op.EndpointGeneration == 0 { + return false + } + h.mu.Lock() + current := entry.endpointGeneration[op.EndpointAddress] + if current > op.EndpointGeneration { + h.mu.Unlock() + return true + } + retired := uint32(0) + if current < op.EndpointGeneration { + retired = current + entry.endpointGeneration[op.EndpointAddress] = op.EndpointGeneration + if entry.activeInput[op.EndpointAddress] != op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] != op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + } + h.mu.Unlock() + if retired != 0 { + h.stopInputPublisher(entry, op.EndpointAddress, retired) + } + return false +} + func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op Operation) error { gateCtx, lease, superseded, err := entry.sequence.enter(ctx, op) if err != nil { @@ -1141,6 +1271,13 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op } return err } + if h.admitEndpointGeneration(entry, op) { + defer lease.finish() + if op.Token == 0 { + return nil + } + return h.completeLifecycle(ctx, op, statusUnsuccessful) + } if superseded { defer lease.finish() // Endpoint lifecycle notifications describe durable UdeCx state even @@ -1150,16 +1287,24 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op switch op.Kind { case OperationEndpointStart: h.mu.Lock() - entry.activeInput[op.EndpointAddress] = true + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.activeInput[op.EndpointAddress] = op.EndpointGeneration + } h.mu.Unlock() case OperationEndpointPurge: h.mu.Lock() - entry.activeInput[op.EndpointAddress] = false - delete(entry.resettingInput, op.EndpointAddress) + if entry.activeInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } h.mu.Unlock() case OperationEndpointReset: h.mu.Lock() - delete(entry.resettingInput, op.EndpointAddress) + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } h.mu.Unlock() } return h.completeSupersededLifecycle(ctx, entry, op) @@ -1171,15 +1316,21 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op switch op.Kind { case OperationEndpointPurge: h.mu.Lock() - entry.activeInput[op.EndpointAddress] = false - delete(entry.resettingInput, op.EndpointAddress) + if entry.activeInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.activeInput, op.EndpointAddress) + } + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } h.mu.Unlock() - h.stopInputPublisher(entry, op.EndpointAddress) + h.stopInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) case OperationEndpointReset: h.mu.Lock() - entry.resettingInput[op.EndpointAddress] = true + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.resettingInput[op.EndpointAddress] = op.EndpointGeneration + } h.mu.Unlock() - h.stopInputPublisher(entry, op.EndpointAddress) + h.stopInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) case OperationDeviceD0Exit: h.mu.Lock() if op.DeviceSequence > entry.powerSequence { @@ -1195,7 +1346,7 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op h.mu.Lock() if !entry.resetting { entry.resetting = true - entry.resettingInput = make(map[uint8]bool) + entry.resettingInput = make(map[uint8]uint32) applyDeviceReset = true } h.mu.Unlock() @@ -1231,16 +1382,21 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op switch op.Kind { case OperationEndpointStart: h.mu.Lock() - entry.activeInput[op.EndpointAddress] = true + if entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration { + entry.activeInput[op.EndpointAddress] = op.EndpointGeneration + } h.mu.Unlock() - h.startInputPublisher(entry, op.EndpointAddress) + h.startInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) case OperationEndpointReset: h.mu.Lock() - delete(entry.resettingInput, op.EndpointAddress) - restart := entry.activeInput[op.EndpointAddress] + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } + restart := entry.activeInput[op.EndpointAddress] == op.EndpointGeneration && + entry.endpointGeneration[op.EndpointAddress] == op.EndpointGeneration h.mu.Unlock() if restart { - h.startInputPublisher(entry, op.EndpointAddress) + h.startInputPublisher(entry, op.EndpointAddress, op.EndpointGeneration) } case OperationDeviceD0Entry: h.mu.Lock() @@ -1252,7 +1408,7 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op h.mu.Unlock() if applyPowerTransition { for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) + h.startInputPublisher(entry, endpoint.address, endpoint.generation) } } case OperationDeviceReset: @@ -1261,7 +1417,7 @@ func (h *Host) processLifecycle(ctx context.Context, entry *registeredDevice, op entry.resetting = false h.mu.Unlock() for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) + h.startInputPublisher(entry, endpoint.address, endpoint.generation) } } } @@ -1273,7 +1429,9 @@ func (h *Host) discardSupersededLifecycle(entry *registeredDevice, op Operation) return } h.mu.Lock() - delete(entry.resettingInput, op.EndpointAddress) + if entry.resettingInput[op.EndpointAddress] == op.EndpointGeneration { + delete(entry.resettingInput, op.EndpointAddress) + } h.mu.Unlock() } @@ -1300,7 +1458,8 @@ func (h *Host) completeLifecycle(ctx context.Context, op Operation, status int32 completionCtx, cancel := context.WithTimeout(ctx, completionTimeout) defer cancel() return h.driver.Complete(completionCtx, Completion{ - Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, Status: status, + Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, + EndpointGeneration: op.EndpointGeneration, Status: status, }) } @@ -1315,6 +1474,10 @@ func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operatio h.finishOperation(op.Token) return err } + if h.admitEndpointGeneration(entry, op) { + defer lease.finish() + return h.completeFailure(ctx, op) + } if superseded { defer lease.finish() h.cancelOperation(op) @@ -1341,7 +1504,7 @@ func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operatio entry.resetting = false h.mu.Unlock() for _, endpoint := range h.activeInputEndpoints(entry) { - h.startInputPublisher(entry, endpoint) + h.startInputPublisher(entry, endpoint.address, endpoint.generation) } }() } @@ -1370,6 +1533,7 @@ func (h *Host) process(ctx context.Context, entry *registeredDevice, op Operatio completion.Token = op.Token completion.DeviceID = op.DeviceID completion.Generation = op.Generation + completion.EndpointGeneration = op.EndpointGeneration // Keep the completion inside the same cancellable device-sequence lease as // the controller callback. A reset announced after Process returns must be // able to cancel a blocked driver completion and join it before the reset is @@ -1408,11 +1572,15 @@ func (h *Host) trackOperation(op Operation) error { state := h.operations[op.Token] if state == nil { h.operations[op.Token] = &operationState{ - deviceID: op.DeviceID, generation: op.Generation, received: true, + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + received: true, } return nil } - if state.done || state.received || state.deviceID != op.DeviceID || state.generation != op.Generation { + if state.done || state.received || state.deviceID != op.DeviceID || + state.generation != op.Generation || state.endpoint != op.EndpointAddress || + state.endpointGeneration != op.EndpointGeneration { return errors.New("native UDE operation reuses a completed or mismatched token") } state.received = true @@ -1455,7 +1623,9 @@ func (h *Host) beginOperation(parent context.Context, op Operation) (context.Con h.operationMu.Lock() defer h.operationMu.Unlock() state := h.operations[op.Token] - if state == nil || state.done || state.cancelled { + if state == nil || state.done || state.cancelled || state.deviceID != op.DeviceID || + state.generation != op.Generation || state.endpoint != op.EndpointAddress || + state.endpointGeneration != op.EndpointGeneration { return parent, func() {}, false } opCtx, cancel := context.WithCancel(parent) @@ -1479,11 +1649,18 @@ func (h *Host) cancelOperation(op Operation) { state := h.operations[op.Token] if state == nil { state = &operationState{ - deviceID: op.DeviceID, generation: op.Generation, cancelled: true, + deviceID: op.DeviceID, generation: op.Generation, + endpoint: op.EndpointAddress, endpointGeneration: op.EndpointGeneration, + cancelled: true, } h.operations[op.Token] = state - } else if !state.done && state.deviceID == op.DeviceID && state.generation == op.Generation { + } else if !state.done && state.deviceID == op.DeviceID && state.generation == op.Generation && + state.endpoint == op.EndpointAddress && + state.endpointGeneration == op.EndpointGeneration { state.cancelled = true + } else { + h.operationMu.Unlock() + return } cancel := state.cancel h.operationMu.Unlock() @@ -1542,7 +1719,8 @@ func (h *Host) finishOperation(token uint64) { func failureCompletion(op Operation) Completion { return Completion{ Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, - Status: statusUnsuccessful, + EndpointGeneration: op.EndpointGeneration, + Status: statusUnsuccessful, } } @@ -1567,7 +1745,8 @@ func processorErrorCompletion(op Operation, err error) Completion { } return Completion{ Token: op.Token, DeviceID: op.DeviceID, Generation: op.Generation, - USBDStatus: status, IsoPackets: packets, + EndpointGeneration: op.EndpointGeneration, + USBDStatus: status, IsoPackets: packets, } } } diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index c6d91b25..f999275f 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -152,6 +152,12 @@ func (d *fakeHostDriver) DestroyDevice(_ context.Context, identity DeviceIdentit func (d *fakeHostDriver) Dequeue(ctx context.Context, _ []byte) (Operation, error) { select { case op := <-d.operations: + // Most host tests construct semantic operations directly instead of + // round-tripping the versioned wire parser. Supply the current endpoint + // incarnation those fixtures would carry on the ABI. + if operationRequiresEndpointGeneration(op) && op.EndpointGeneration == 0 { + op.EndpointGeneration = 1 + } return op, nil case <-ctx.Done(): return Operation{}, ctx.Err() @@ -1012,7 +1018,7 @@ func (*inputPublisherTestDevice) GetDeviceSpecificArgs() map[string]any { func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { driver := &fastInputDriver{fakeHostDriver: newFakeHostDriver(), reports: make(chan InputReport, 4)} processor := &recordingProcessor{ - processed: make(chan uint64, 1), lifecycle: make(chan uint64, 2), + processed: make(chan uint64, 1), lifecycle: make(chan uint64, 4), resets: make(chan DeviceIdentity, 1), } host, err := NewHost(driver, processor, 2) @@ -1029,7 +1035,8 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { go func() { done <- host.Serve(ctx) }() driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, DeviceSequence: 1, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, DeviceSequence: 1, Kind: OperationEndpointStart, } select { @@ -1041,23 +1048,65 @@ func TestHostPublishesInterruptInputDirectlyAfterEndpointStart(t *testing.T) { select { case report := <-driver.reports: if report.DeviceID != identity.DeviceID || report.Generation != identity.Generation || - report.EndpointAddress != 0x81 || report.Sequence != 1 || + report.EndpointGeneration != 1 || report.EndpointAddress != 0x81 || + report.Sequence != 1 || string(report.Payload) != string([]byte{1, 2, 3, 4}) { t.Fatalf("unexpected direct input report: %+v", report) } case <-time.After(time.Second): t.Fatal("interrupt-IN report did not use the direct publisher") } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 1 || + diagnostics.DeadlineContextFallbackStarts != 0 { + t.Fatalf("legacy input fallback diagnostics=%+v want starts=1 legacy=1 deadline=0", + diagnostics) + } driver.operations <- Operation{ DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 2, Kind: OperationEndpointPurge, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 2, DeviceSequence: 2, Kind: OperationEndpointPurge, } select { case <-processor.lifecycle: case <-time.After(time.Second): t.Fatal("endpoint purge was not processed") } + + // Recreating the same endpoint address establishes an independent lane and + // direct-input sequence. A delayed callback for generation 1 must not invoke + // controller state or replace the generation 2 publisher. + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 2, + EndpointSequence: 1, DeviceSequence: 3, Kind: OperationEndpointStart, + } + select { + case <-processor.lifecycle: + case <-time.After(time.Second): + t.Fatal("replacement endpoint start was not processed") + } + device.reports <- []byte{5, 6, 7, 8} + select { + case report := <-driver.reports: + if report.EndpointGeneration != 2 || report.Sequence != 1 || + string(report.Payload) != string([]byte{5, 6, 7, 8}) { + t.Fatalf("replacement direct input report: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("replacement endpoint did not publish direct input") + } + driver.operations <- Operation{ + DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 3, DeviceSequence: 4, Kind: OperationEndpointStart, + } + select { + case sequence := <-processor.lifecycle: + t.Fatalf("stale endpoint generation reached lifecycle callback at sequence %d", sequence) + case <-time.After(75 * time.Millisecond): + } cancel() select { case err = <-done: @@ -1176,6 +1225,12 @@ func TestHostReusesOneDescriptorSizedDirectInputBuffer(t *testing.T) { if first.Sequence != 1 || second.Sequence != 2 { t.Fatalf("direct input sequences first=%d second=%d", first.Sequence, second.Sequence) } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 0 || + diagnostics.DeadlineContextFallbackStarts != 1 { + t.Fatalf("deadline input fallback diagnostics=%+v want starts=1 legacy=0 deadline=1", + diagnostics) + } cancel() select { @@ -1247,6 +1302,12 @@ func TestHostReusesOneDeadlineTimerForScheduledInterruptInput(t *testing.T) { if calls := device.fallbackRead.Load(); calls != 0 { t.Fatalf("scheduled input used fallback ReadInterruptInput %d time(s)", calls) } + if diagnostics := host.InputDiagnostics(); diagnostics.PublisherStarts != 1 || + diagnostics.LegacyTransferFallbackStarts != 0 || + diagnostics.DeadlineContextFallbackStarts != 0 { + t.Fatalf("scheduled input diagnostics=%+v want no compatibility fallback", + diagnostics) + } // Endpoint reset must synchronously cancel the blocked scheduled read, // dispose its timer, and start a fresh publisher only after lifecycle ACK. @@ -3366,11 +3427,12 @@ func TestHostCancelBeforeOperationSkipsProcessingAndCompletion(t *testing.T) { driver.operations <- Operation{ Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, Kind: OperationCancel, + EndpointAddress: 0x81, EndpointGeneration: 1, Kind: OperationCancel, } driver.operations <- Operation{ Token: 44, DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, Kind: OperationTransfer, } deadline := time.Now().Add(time.Second) @@ -3401,6 +3463,53 @@ func TestHostCancelBeforeOperationSkipsProcessingAndCompletion(t *testing.T) { <-done } +func TestHostAcceptsDeviceScopedManagementCancelTombstone(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1), + } + host, _ := NewHost(driver, processor, 1) + identity, err := host.Register(context.Background(), 61, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- host.Serve(ctx) }() + + managementToken := uint64(2)<<32 | uint64(ManagementSlotFlag) | 1 + driver.operations <- Operation{ + Token: managementToken, DeviceID: identity.DeviceID, + Generation: identity.Generation, Kind: OperationCancel, + } + driver.operations <- Operation{ + Token: 62, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 1, + EndpointSequence: 1, DeviceSequence: 1, Kind: OperationTransfer, + } + select { + case completion := <-driver.completions: + if completion.Token != 62 || completion.EndpointGeneration != 1 { + t.Fatalf("post-tombstone completion=%+v", completion) + } + case serveErr := <-done: + t.Fatalf("device-scoped management tombstone faulted host: %v", serveErr) + case <-time.After(time.Second): + t.Fatal("host did not continue after device-scoped management tombstone") + } + host.operationMu.Lock() + _, retained := host.operations[managementToken] + host.operationMu.Unlock() + if retained { + t.Fatal("device-scoped management tombstone retained an unmatched cancellation owner") + } + + cancel() + if err = <-done; err != nil { + t.Fatal(err) + } +} + func TestHostCancelInterruptsActiveProcessor(t *testing.T) { driver := newFakeHostDriver() processor := &cancellableProcessor{started: make(chan struct{}), cancelled: make(chan struct{})} @@ -3414,7 +3523,8 @@ func TestHostCancelInterruptsActiveProcessor(t *testing.T) { go func() { done <- host.Serve(ctx) }() driver.operations <- Operation{ Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, EndpointSequence: 1, Kind: OperationTransfer, + EndpointAddress: 0x81, EndpointGeneration: 2, + EndpointSequence: 1, Kind: OperationTransfer, } select { case <-processor.started: @@ -3423,7 +3533,16 @@ func TestHostCancelInterruptsActiveProcessor(t *testing.T) { } driver.operations <- Operation{ Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, - EndpointAddress: 0x81, Kind: OperationCancel, + EndpointAddress: 0x81, EndpointGeneration: 1, Kind: OperationCancel, + } + select { + case <-processor.cancelled: + t.Fatal("stale endpoint generation cancelled the active request") + case <-time.After(50 * time.Millisecond): + } + driver.operations <- Operation{ + Token: 55, DeviceID: identity.DeviceID, Generation: identity.Generation, + EndpointAddress: 0x81, EndpointGeneration: 2, Kind: OperationCancel, } select { case <-processor.cancelled: diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index 96349272..c065c4bf 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -16,12 +16,12 @@ import ( const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 12 + ABIMinor uint16 = 13 // DriverPackageVersion is the native driver package version built and // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.36" + DriverPackageVersion = "0.1.0.37" BuildIdentitySize = sha256.Size HeaderSize = 16 @@ -31,21 +31,22 @@ const ( CreateDeviceSize = 56 DeviceIdentitySize = 32 IsoPacketSize = 16 - OperationSize = 104 + OperationSize = 108 CompletionSize = 72 - InputReportSize = 48 + InputReportSize = 52 StatsSize = 152 LifecycleTraceRecordSize = 80 LifecycleTraceSize = 41008 LifecycleTraceCapacity = 512 - MaxDevices = 32 - MaxDescriptorBytes = 256 * 1024 - MaxTransferBytes = 1024 * 1024 - MaxIsoPackets = 1024 - MaxInputReportBytes = 4096 - MaxPendingOperations = 4096 - InputReportTransition uint8 = 0x01 + MaxDevices = 32 + MaxDescriptorBytes = 256 * 1024 + MaxTransferBytes = 1024 * 1024 + MaxIsoPackets = 1024 + MaxInputReportBytes = 4096 + MaxPendingOperations = 4096 + ManagementSlotFlag uint32 = 0x80000000 + InputReportTransition uint8 = 0x01 // TransferFlagDirectionIn is the wire value of // USBD_TRANSFER_DIRECTION_IN from usb.h. TransferFlagDirectionIn uint32 = 0x00000001 @@ -118,6 +119,19 @@ const ( TraceDeviceCleanupEnd TraceControllerShutdownBegin TraceControllerShutdownEnd + TraceEndpointQuiescenceWatchdog + TraceCompletionRundownWatchdog + TraceControllerRundownWatchdog + TraceOwnerRundownWatchdog +) + +type LifecycleTraceStatus uint32 + +const ( + LifecycleTraceStatusDroppedRecord LifecycleTraceStatus = 1 << iota + LifecycleTraceStatusWatchdogFired + lifecycleTraceStatusValidMask = LifecycleTraceStatusDroppedRecord | + LifecycleTraceStatusWatchdogFired ) // nativeSourceRevision must be injected by the production build. Native @@ -425,6 +439,55 @@ type Operation struct { Payload []byte EndpointSequence uint64 DeviceSequence uint64 + EndpointGeneration uint32 +} + +func operationUsesEndpointGeneration(kind OperationKind) bool { + switch kind { + case OperationControl, OperationTransfer, OperationEndpointStart, + OperationEndpointPurge, OperationEndpointReset, OperationCancel: + return true + default: + return false + } +} + +func isManagementToken(token uint64) bool { + return uint32(token)&ManagementSlotFlag != 0 +} + +func operationRequiresEndpointGeneration(op Operation) bool { + if op.Kind == OperationCancel { + return !isManagementToken(op.Token) + } + return operationUsesEndpointGeneration(op.Kind) +} + +func validateOperationIdentity(op Operation) error { + if op.Kind < OperationControl || op.Kind > OperationBrokerFault { + return fmt.Errorf("%w: unknown operation kind %d", ErrInvalidRange, op.Kind) + } + if op.Kind == OperationBrokerFault { + if op.Token != 0 || op.DeviceID != 0 || op.Generation != 0 || + op.EndpointGeneration != 0 { + return fmt.Errorf("%w: broker-fault operation carries an identity", ErrInvalidRange) + } + return nil + } + if op.DeviceID == 0 || op.Generation == 0 { + return fmt.Errorf("%w: zero operation device identity", ErrInvalidRange) + } + if op.Kind == OperationCancel && op.Token == 0 { + return fmt.Errorf("%w: zero cancellation token", ErrInvalidRange) + } + if operationRequiresEndpointGeneration(op) { + if op.EndpointGeneration == 0 { + return fmt.Errorf("%w: zero operation endpoint generation", ErrInvalidRange) + } + } else if op.Kind != OperationCancel && op.EndpointGeneration != 0 { + return fmt.Errorf("%w: device-scoped operation carries an endpoint generation", ErrInvalidRange) + } + return nil } func ParseOperation(src []byte) (Operation, error) { @@ -479,6 +542,7 @@ func ParseOperation(src []byte) (Operation, error) { TransferLength: transferLength, EndpointSequence: binary.LittleEndian.Uint64(src[88:96]), DeviceSequence: binary.LittleEndian.Uint64(src[96:104]), + EndpointGeneration: binary.LittleEndian.Uint32(src[104:108]), IsoPackets: make([]IsoPacket, int(packetCount)), Payload: append([]byte(nil), src[payloadOffset:payloadOffset+payloadLength]...), } @@ -496,6 +560,9 @@ func ParseOperation(src []byte) (Operation, error) { } op.IsoPackets[i] = packet } + if err := validateOperationIdentity(op); err != nil { + return Operation{}, err + } return op, nil } @@ -514,11 +581,12 @@ func parseDequeuedOperation(buffer []byte, bytesReturned uint32) (Operation, err } type Completion struct { - Token uint64 - DeviceID uint64 - Generation uint32 - Status int32 - USBDStatus uint32 + Token uint64 + DeviceID uint64 + Generation uint32 + EndpointGeneration uint32 + Status int32 + USBDStatus uint32 // TransferLength is the number of bytes completed. For ISO-IN transfers, // Payload may span the original gapped transfer buffer and therefore be // larger than this sum of packet actual lengths. @@ -532,16 +600,18 @@ type Completion struct { // only a fresh, already encoded report. Audio, control, output, and lifecycle // traffic deliberately remain on the ordered operation broker. type InputReport struct { - DeviceID uint64 - Generation uint32 - EndpointAddress uint8 - Transition bool - Sequence uint64 - Payload []byte + DeviceID uint64 + Generation uint32 + EndpointGeneration uint32 + EndpointAddress uint8 + Transition bool + Sequence uint64 + Payload []byte } func (m InputReport) marshalMetadata(dst []byte) error { - if m.DeviceID == 0 || m.Generation == 0 || m.EndpointAddress&0x80 == 0 || + if m.DeviceID == 0 || m.Generation == 0 || m.EndpointGeneration == 0 || + m.EndpointAddress&0x80 == 0 || m.Sequence == 0 || m.Sequence > math.MaxInt64 { return fmt.Errorf("%w: invalid input-report identity", ErrInvalidRange) } @@ -567,6 +637,7 @@ func (m InputReport) marshalMetadata(dst []byte) error { binary.LittleEndian.PutUint32(dst[32:36], InputReportSize) binary.LittleEndian.PutUint32(dst[36:40], uint32(len(m.Payload))) binary.LittleEndian.PutUint64(dst[40:48], m.Sequence) + binary.LittleEndian.PutUint32(dst[48:52], m.EndpointGeneration) return nil } @@ -661,6 +732,7 @@ type LifecycleTraceRecord struct { type LifecycleTrace struct { LatestSequence uint64 PerformanceFrequency uint64 + StatusFlags LifecycleTraceStatus Records []LifecycleTraceRecord } @@ -671,10 +743,13 @@ func ParseLifecycleTrace(src []byte) (LifecycleTrace, error) { } if h.Size != LifecycleTraceSize || len(src) != LifecycleTraceSize || binary.LittleEndian.Uint32(src[36:40]) != LifecycleTraceRecordSize || - binary.LittleEndian.Uint32(src[40:44]) != LifecycleTraceCapacity || - binary.LittleEndian.Uint32(src[44:48]) != 0 { + binary.LittleEndian.Uint32(src[40:44]) != LifecycleTraceCapacity { return LifecycleTrace{}, ErrInvalidSize } + statusFlags := LifecycleTraceStatus(binary.LittleEndian.Uint32(src[44:48])) + if statusFlags&^lifecycleTraceStatusValidMask != 0 { + return LifecycleTrace{}, ErrInvalidRange + } recordCount := binary.LittleEndian.Uint32(src[32:36]) if recordCount > LifecycleTraceCapacity { return LifecycleTrace{}, ErrInvalidRange @@ -682,6 +757,7 @@ func ParseLifecycleTrace(src []byte) (LifecycleTrace, error) { trace := LifecycleTrace{ LatestSequence: binary.LittleEndian.Uint64(src[16:24]), PerformanceFrequency: binary.LittleEndian.Uint64(src[24:32]), + StatusFlags: statusFlags, Records: make([]LifecycleTraceRecord, 0, recordCount), } for index := uint32(0); index < recordCount; index++ { @@ -714,6 +790,9 @@ func (m Completion) wireLayout() (transferLength uint32, isoBytes int, total int if m.Token == 0 || m.DeviceID == 0 || m.Generation == 0 { return 0, 0, 0, fmt.Errorf("%w: zero completion identity", ErrInvalidRange) } + if !isManagementToken(m.Token) && m.EndpointGeneration == 0 { + return 0, 0, 0, fmt.Errorf("%w: zero completion endpoint generation", ErrInvalidRange) + } if len(m.Payload) > MaxTransferBytes || len(m.IsoPackets) > MaxIsoPackets { return 0, 0, 0, ErrLimitExceeded } @@ -761,10 +840,10 @@ func (m Completion) marshalBinaryInto(dst []byte) error { binary.LittleEndian.PutUint32(dst[52:56], uint32(CompletionSize+isoBytes)) binary.LittleEndian.PutUint32(dst[56:60], uint32(len(m.Payload))) binary.LittleEndian.PutUint32(dst[60:64], CompletionSize) - // CompletionSize includes the C ABI's two explicit reserved words. Fresh - // allocations make both zero implicitly; caller-owned and pooled buffers - // must make that wire invariant explicit. - clear(dst[64:CompletionSize]) + binary.LittleEndian.PutUint32(dst[64:68], m.EndpointGeneration) + // CompletionSize retains one explicit reserved word. Caller-owned and + // pooled buffers must restore its zero wire invariant on every use. + clear(dst[68:CompletionSize]) for i, packet := range m.IsoPackets { off := CompletionSize + i*IsoPacketSize binary.LittleEndian.PutUint32(dst[off:off+4], packet.Offset) diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index e9b4f650..01fdfb21 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -107,33 +107,36 @@ type contractOperation struct { EndpointMaxPacketSize uint16 EndpointSequence uint64 DeviceSequence uint64 + EndpointGeneration uint32 } type contractCompletion struct { - Header contractHeader - Token uint64 - DeviceId uint64 - Generation uint32 - Status int32 - UsbdStatus uint32 - TransferLength uint32 - IsoPacketCount uint32 - PayloadOffset uint32 - PayloadLength uint32 - IsoPacketsOffset uint32 - Reserved [2]uint32 + Header contractHeader + Token uint64 + DeviceId uint64 + Generation uint32 + Status int32 + UsbdStatus uint32 + TransferLength uint32 + IsoPacketCount uint32 + PayloadOffset uint32 + PayloadLength uint32 + IsoPacketsOffset uint32 + EndpointGeneration uint32 + Reserved uint32 } type contractInputReport struct { - Header contractHeader - DeviceId uint64 - Generation uint32 - EndpointAddress uint8 - Flags uint8 - Reserved1 [2]uint8 - PayloadOffset uint32 - PayloadLength uint32 - Sequence uint64 + Header contractHeader + DeviceId uint64 + Generation uint32 + EndpointAddress uint8 + Flags uint8 + Reserved1 [2]uint8 + PayloadOffset uint32 + PayloadLength uint32 + Sequence uint64 + EndpointGeneration uint32 } type contractStats struct { @@ -188,10 +191,30 @@ type contractLifecycleTrace struct { RecordCount uint32 RecordSize uint32 Capacity uint32 - Reserved uint32 + StatusFlags uint32 Records [LifecycleTraceCapacity]contractLifecycleTraceRecord } +func packedContractSize(contract reflect.Type) uintptr { + var size uintptr + for index := 0; index < contract.NumField(); index++ { + size += contract.Field(index).Type.Size() + } + return size +} + +func packedContractFieldOffset(contract reflect.Type, name string) (uintptr, bool) { + var offset uintptr + for index := 0; index < contract.NumField(); index++ { + field := contract.Field(index) + if field.Name == name { + return offset, true + } + offset += field.Type.Size() + } + return 0, false +} + func nativeContractSource(t *testing.T, name ...string) string { t.Helper() parts := append([]string{"..", "..", ".."}, name...) @@ -235,26 +258,35 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { header := nativeContractSource(t, "native", "udecx", "include", "ViiperUdeProtocol.h") numbers := map[string]uint64{ - "VIIPER_UDE_MAGIC": uint64(Magic), - "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), - "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), - "VIIPER_UDE_BUILD_IDENTITY_BYTES": BuildIdentitySize, - "VIIPER_UDE_MAX_DEVICES": MaxDevices, - "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, - "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, - "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, - "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, - "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, - "VIIPER_UDE_INPUT_REPORT_TRANSITION": uint64(InputReportTransition), - "VIIPER_UDE_MS_OS_10_STRING_INDEX": uint64(MicrosoftOS10StringIndex), - "VIIPER_UDE_MS_OS_10_STRING_LENGTH": MicrosoftOS10StringLength, - "VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET": MicrosoftOS10VendorCodeOffset, - "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), - "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), - "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), - "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), - "VIIPER_UDE_CAP_LIFECYCLE_TRACE": uint64(CapabilityLifecycleTrace), - "VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY": LifecycleTraceCapacity, + "VIIPER_UDE_MAGIC": uint64(Magic), + "VIIPER_UDE_ABI_MAJOR": uint64(ABIMajor), + "VIIPER_UDE_ABI_MINOR": uint64(ABIMinor), + "VIIPER_UDE_BUILD_IDENTITY_BYTES": BuildIdentitySize, + "VIIPER_UDE_MAX_DEVICES": MaxDevices, + "VIIPER_UDE_MAX_DESCRIPTOR_BYTES": MaxDescriptorBytes, + "VIIPER_UDE_MAX_TRANSFER_BYTES": MaxTransferBytes, + "VIIPER_UDE_MAX_ISO_PACKETS": MaxIsoPackets, + "VIIPER_UDE_MAX_INPUT_REPORT_BYTES": MaxInputReportBytes, + "VIIPER_UDE_MAX_PENDING_OPERATIONS": MaxPendingOperations, + "VIIPER_UDE_MANAGEMENT_SLOT_FLAG": uint64(ManagementSlotFlag), + "VIIPER_UDE_INPUT_REPORT_TRANSITION": uint64(InputReportTransition), + "VIIPER_UDE_MS_OS_10_STRING_INDEX": uint64(MicrosoftOS10StringIndex), + "VIIPER_UDE_MS_OS_10_STRING_LENGTH": MicrosoftOS10StringLength, + "VIIPER_UDE_MS_OS_10_VENDOR_CODE_OFFSET": MicrosoftOS10VendorCodeOffset, + "VIIPER_UDE_CAP_ISOCHRONOUS": uint64(CapabilityIsochronous), + "VIIPER_UDE_CAP_STREAMS": uint64(CapabilityStreams), + "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), + "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), + "VIIPER_UDE_CAP_LIFECYCLE_TRACE": uint64(CapabilityLifecycleTrace), + "VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY": LifecycleTraceCapacity, + "VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG": uint64(TraceEndpointQuiescenceWatchdog), + "VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG": uint64(TraceCompletionRundownWatchdog), + "VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG": uint64(TraceControllerRundownWatchdog), + "VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG": uint64(TraceOwnerRundownWatchdog), + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD": uint64( + LifecycleTraceStatusDroppedRecord), + "VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED": uint64( + LifecycleTraceStatusWatchdogFired), } for name, want := range numbers { if got := cDefineNumber(t, header, name); got != want { @@ -295,7 +327,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { t.Fatalf("C contract added unmodeled type VIIPER_UDE_%s", name) } declared, _ := strconv.ParseUint(match[2], 10, 64) - if got := wireType.Size(); uint64(got) != declared || got != wantSizes[name] { + if got := packedContractSize(wireType); uint64(got) != declared || got != wantSizes[name] { t.Errorf("VIIPER_UDE_%s size: C=%d Go=%d contract=%d", name, declared, got, wantSizes[name]) } seenSizes[name] = true @@ -311,13 +343,13 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { if !ok { t.Fatalf("C contract added offsets for unmodeled type VIIPER_UDE_%s", match[1]) } - field, ok := wireType.FieldByName(match[2]) + fieldOffset, ok := packedContractFieldOffset(wireType, match[2]) if !ok { t.Fatalf("Go contract type %s has no field %s", match[1], match[2]) } want, _ := strconv.ParseUint(match[3], 10, 64) - if uint64(field.Offset) != want { - t.Errorf("VIIPER_UDE_%s.%s offset: C=%d Go=%d", match[1], match[2], want, field.Offset) + if uint64(fieldOffset) != want { + t.Errorf("VIIPER_UDE_%s.%s offset: C=%d Go=%d", match[1], match[2], want, fieldOffset) } seenOffsets++ } diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 6096c571..6eb9118d 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "6796b0cf22a80984b283662a50a3b364c46218e37766a2e1880b38851b65d9ad" + const wantHex = "b6bdcfe32dec8eb48bfde2f70b72542695588d2483ab71218636ce0b733aa067" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -89,8 +89,8 @@ func TestABISizes(t *testing.T) { "completion": CompletionSize, "input report": InputReportSize, "stats": StatsSize, } { - if got%8 != 0 { - t.Fatalf("%s ABI size %d is not 8-byte aligned", name, got) + if got%4 != 0 { + t.Fatalf("%s ABI size %d is not 32-bit aligned", name, got) } } } @@ -176,6 +176,7 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { binary.LittleEndian.PutUint32(raw[72:76], OperationSize) binary.LittleEndian.PutUint64(raw[88:96], 17) binary.LittleEndian.PutUint64(raw[96:104], 23) + binary.LittleEndian.PutUint32(raw[104:108], 29) binary.LittleEndian.PutUint32(raw[OperationSize:OperationSize+4], 0) binary.LittleEndian.PutUint32(raw[OperationSize+4:OperationSize+8], uint32(len(payload))) copy(raw[OperationSize+IsoPacketSize:], payload) @@ -185,7 +186,8 @@ func TestParseOperationCopiesPayloadAndPackets(t *testing.T) { t.Fatal(err) } if op.Token != 99 || op.DeviceID != 4 || op.Generation != 8 || - op.EndpointSequence != 17 || op.DeviceSequence != 23 || op.InterfaceNumber != 2 || + op.EndpointSequence != 17 || op.DeviceSequence != 23 || op.EndpointGeneration != 29 || + op.InterfaceNumber != 2 || op.InterfaceSetting != 1 || op.EndpointAttributes != 0x05 || op.EndpointInterval != 4 || op.EndpointMaxPacketSize != 196 || len(op.IsoPackets) != 1 { @@ -318,6 +320,7 @@ func TestParseOperationAcceptsCanonicalEmptyTail(t *testing.T) { binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationEndpointStart)) binary.LittleEndian.PutUint32(raw[64:68], OperationSize) binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + binary.LittleEndian.PutUint32(raw[104:108], 3) op, err := ParseOperation(raw) if err != nil { @@ -334,6 +337,50 @@ func TestParseOperationAcceptsCanonicalEmptyTail(t *testing.T) { } } +func TestParseOperationRequiresKindScopedEndpointGeneration(t *testing.T) { + raw := make([]byte, OperationSize) + header, err := NewHeader(OperationSize) + if err != nil { + t.Fatal(err) + } + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 7) + binary.LittleEndian.PutUint64(raw[24:32], 9) + binary.LittleEndian.PutUint32(raw[32:36], 2) + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationEndpointStart)) + binary.LittleEndian.PutUint32(raw[64:68], OperationSize) + binary.LittleEndian.PutUint32(raw[72:76], OperationSize) + + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("zero endpoint generation error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 3) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("endpoint-scoped identity: %v", err) + } + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationDeviceD0Exit)) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("device-scoped endpoint generation error=%v want ErrInvalidRange", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 0) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("device-scoped zero endpoint generation: %v", err) + } + binary.LittleEndian.PutUint32(raw[36:40], uint32(OperationCancel)) + if _, err = ParseOperation(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ordinary cancellation zero endpoint generation error=%v want ErrInvalidRange", err) + } + managementToken := uint64(2)<<32 | uint64(ManagementSlotFlag) | 1 + binary.LittleEndian.PutUint64(raw[16:24], managementToken) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("device-scoped management cancellation: %v", err) + } + binary.LittleEndian.PutUint32(raw[104:108], 3) + if _, err = ParseOperation(raw); err != nil { + t.Fatalf("endpoint-scoped management cancellation: %v", err) + } +} + func TestParseDequeuedOperationRequiresExactBytesReturned(t *testing.T) { valid := dualSenseIsoOperationFixture(1, 4) if _, err := parseDequeuedOperation(valid, uint32(len(valid))); err != nil { @@ -367,7 +414,8 @@ func TestParseDequeuedOperationRequiresExactBytesReturned(t *testing.T) { func TestCompletionMarshalling(t *testing.T) { raw, err := (Completion{ - Token: 3, DeviceID: 9, Generation: 4, Status: -1, USBDStatus: 0xc0000001, + Token: 3, DeviceID: 9, Generation: 4, EndpointGeneration: 5, + Status: -1, USBDStatus: 0xc0000001, IsoPackets: []IsoPacket{{Offset: 0, Length: 3}}, Payload: []byte{7, 8, 9}, }).MarshalBinary() if err != nil { @@ -379,17 +427,21 @@ func TestCompletionMarshalling(t *testing.T) { if got := binary.LittleEndian.Uint32(raw[52:56]); got != CompletionSize+IsoPacketSize { t.Fatalf("payload offset=%d", got) } + if got := binary.LittleEndian.Uint32(raw[64:68]); got != 5 { + t.Fatalf("endpoint generation=%d want=5", got) + } } func TestCompletionMarshallingPreservesZeroLengthSparseISO(t *testing.T) { payload := make([]byte, 64) raw, err := (Completion{ - Token: 3, - DeviceID: 9, - Generation: 4, - TransferLength: 0, - IsoPackets: []IsoPacket{{Offset: 0, Length: 0}}, - Payload: payload, + Token: 3, + DeviceID: 9, + Generation: 4, + EndpointGeneration: 1, + TransferLength: 0, + IsoPackets: []IsoPacket{{Offset: 0, Length: 0}}, + Payload: payload, }).MarshalBinary() if err != nil { t.Fatal(err) @@ -404,7 +456,8 @@ func TestCompletionMarshallingPreservesZeroLengthSparseISO(t *testing.T) { func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { completion := Completion{ - Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, IsoPackets: []IsoPacket{ {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, @@ -427,9 +480,12 @@ func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { if allocations != 0 { t.Fatalf("caller-buffer completion encoding allocated %.2f objects", allocations) } - for index, value := range dst[64:CompletionSize] { + if got := binary.LittleEndian.Uint32(dst[64:68]); got != completion.EndpointGeneration { + t.Fatalf("endpoint generation=%d want=%d", got, completion.EndpointGeneration) + } + for index, value := range dst[68:CompletionSize] { if value != 0 { - t.Fatalf("completion reserved byte %d retained %#x", 64+index, value) + t.Fatalf("completion reserved byte %d retained %#x", 68+index, value) } } for packet := range completion.IsoPackets { @@ -440,9 +496,31 @@ func TestCompletionEncodingIntoCallerBufferDoesNotAllocate(t *testing.T) { } } +func TestCompletionEndpointGenerationScopedByToken(t *testing.T) { + ordinary := Completion{Token: 1, DeviceID: 2, Generation: 3} + if _, err := ordinary.MarshalBinary(); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("ordinary zero endpoint generation error=%v want ErrInvalidRange", err) + } + deviceManagement := Completion{ + Token: uint64(2)<<32 | uint64(ManagementSlotFlag) | 1, + DeviceID: 2, Generation: 3, + } + raw, err := deviceManagement.MarshalBinary() + if err != nil { + t.Fatalf("device-scoped management completion: %v", err) + } + if got := binary.LittleEndian.Uint32(raw[64:68]); got != 0 { + t.Fatalf("device-scoped endpoint generation=%d want=0", got) + } + deviceManagement.EndpointGeneration = 7 + if _, err = deviceManagement.MarshalBinary(); err != nil { + t.Fatalf("endpoint-scoped management completion: %v", err) + } +} + func TestInputReportMarshalling(t *testing.T) { raw, err := (InputReport{ - DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, Transition: true, Sequence: 11, Payload: []byte{1, 2, 3}, }).MarshalBinary() if err != nil { @@ -453,6 +531,7 @@ func TestInputReportMarshalling(t *testing.T) { binary.LittleEndian.Uint32(raw[32:36]) != InputReportSize || binary.LittleEndian.Uint32(raw[36:40]) != 3 || binary.LittleEndian.Uint64(raw[40:48]) != 11 || + binary.LittleEndian.Uint32(raw[48:52]) != 9 || string(raw[InputReportSize:]) != string([]byte{1, 2, 3}) { t.Fatalf("invalid input-report wire layout: %x", raw) } @@ -460,7 +539,7 @@ func TestInputReportMarshalling(t *testing.T) { func TestInputReportMetadataEncodingDoesNotAllocate(t *testing.T) { report := InputReport{ - DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, Sequence: 11, Payload: []byte{1, 2, 3}, } var metadata [InputReportSize]byte @@ -476,7 +555,7 @@ func TestInputReportMetadataEncodingDoesNotAllocate(t *testing.T) { func TestInputReportMetadataClearsReusedTransitionFlag(t *testing.T) { report := InputReport{ - DeviceID: 5, Generation: 7, EndpointAddress: 0x81, + DeviceID: 5, Generation: 7, EndpointGeneration: 9, EndpointAddress: 0x81, Transition: true, Sequence: 11, Payload: []byte{1}, } var metadata [InputReportSize]byte @@ -544,6 +623,8 @@ func TestParseLifecycleTracePreservesDebugState(t *testing.T) { binary.LittleEndian.PutUint32(raw[32:36], 1) binary.LittleEndian.PutUint32(raw[36:40], LifecycleTraceRecordSize) binary.LittleEndian.PutUint32(raw[40:44], LifecycleTraceCapacity) + binary.LittleEndian.PutUint32(raw[44:48], uint32( + LifecycleTraceStatusDroppedRecord|LifecycleTraceStatusWatchdogFired)) record := raw[48 : 48+LifecycleTraceRecordSize] binary.LittleEndian.PutUint64(record[0:8], 23) @@ -566,7 +647,9 @@ func TestParseLifecycleTracePreservesDebugState(t *testing.T) { if err != nil { t.Fatal(err) } - if trace.LatestSequence != 23 || trace.PerformanceFrequency != 10_000_000 || len(trace.Records) != 1 { + if trace.LatestSequence != 23 || trace.PerformanceFrequency != 10_000_000 || + trace.StatusFlags != LifecycleTraceStatusDroppedRecord|LifecycleTraceStatusWatchdogFired || + len(trace.Records) != 1 { t.Fatalf("unexpected lifecycle trace header: %+v", trace) } got := trace.Records[0] @@ -578,6 +661,10 @@ func TestParseLifecycleTracePreservesDebugState(t *testing.T) { got.Source != TraceSourceDevice || got.IRQL != 0 || got.EndpointAddress != 0x84 { t.Fatalf("unexpected lifecycle trace record: %+v", got) } + binary.LittleEndian.PutUint32(raw[44:48], 0x80000000) + if _, err = ParseLifecycleTrace(raw); !errors.Is(err, ErrInvalidRange) { + t.Fatalf("unknown lifecycle trace status error=%v want ErrInvalidRange", err) + } } func FuzzParseOperation(f *testing.F) { @@ -665,6 +752,7 @@ func dualSenseIsoOperationFixture(packetCount, packetLength int) []byte { binary.LittleEndian.PutUint32(raw[68:72], uint32(packetCount*packetLength)) binary.LittleEndian.PutUint32(raw[72:76], OperationSize) binary.LittleEndian.PutUint64(raw[88:96], 1) + binary.LittleEndian.PutUint32(raw[104:108], 1) for index := 0; index < packetCount; index++ { offset := OperationSize + index*IsoPacketSize binary.LittleEndian.PutUint32(raw[offset:offset+4], uint32(index*packetLength)) @@ -688,7 +776,8 @@ func BenchmarkParseDualSenseIsoOperation(b *testing.B) { func BenchmarkMarshalDualSenseIsoCompletion(b *testing.B) { completion := Completion{ - Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, IsoPackets: []IsoPacket{ {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, @@ -719,7 +808,8 @@ func TestDualSenseIsoProtocolAllocationBudget(t *testing.T) { } completion := Completion{ - Token: 1, DeviceID: 2, Generation: 3, TransferLength: 4 * 196, + Token: 1, DeviceID: 2, Generation: 3, EndpointGeneration: 1, + TransferLength: 4 * 196, IsoPackets: []IsoPacket{ {Offset: 0, Length: 196}, {Offset: 196, Length: 196}, {Offset: 392, Length: 196}, {Offset: 588, Length: 196}, diff --git a/native/udecx/README.md b/native/udecx/README.md index a0bbf8e8..9bd940b1 100644 --- a/native/udecx/README.md +++ b/native/udecx/README.md @@ -21,12 +21,16 @@ never accepted by a Release recipe or production workflow. controller as a driver-store transaction. Installation requires the source-revision submission manifest, verifies the catalog signature and four-part `DriverVer`, rejects same-version replacement and implicit - downgrade, records the prior published INF, and negotiates the ABI plus the + downgrade, add-only stages and verifies a missing candidate before + quiescence, records the prior published INF, and negotiates the ABI plus the source-bound identity embedded in the currently loaded kernel image after - start. A stale same-ABI driver cannot satisfy health. The helper restores the - prior binding on failure. Removal backs up every - exact signed VIIPER package before deleting only exact owned devnodes and - packages; unrelated driver-store entries are never force-deleted. + start. A stale same-ABI driver cannot satisfy health. Fixed protected, + write-through install and remove journals record exact identities, backups, + mutation receipts, reboot epochs, and hash-chained cut points before each + boundary, so restart reconciliation can finish forward or restore narrowly + without adopting concurrent state. Removal backs up every exact signed + VIIPER package before deleting only the captured owned devnode and packages; + unrelated driver-store entries are never force-deleted. - `tools/Test-ViiperUdeCtlTransaction.ps1` deterministically guards the transaction, rollback, ownership, downgrade, and structured-reboot source contracts. Passing a compiled tool through `-BinaryPath` also runs its pure @@ -111,14 +115,37 @@ capacity never causes an adaptive long packet to be consumed and truncated. can coalesce into one latest state before Windows polls, but one publication can never manufacture multiple completions. +Input path selection is observable at publisher activation. `Host.InputDiagnostics` +counts publisher starts, legacy transfer fallbacks, and per-report deadline-context +fallbacks without adding per-report atomics. Either compatibility path also emits +a structured warning containing the device, device generation, endpoint, endpoint +generation, fallback name, and reason, so a production run cannot silently claim +the scheduled direct-input path. + +The kernel lifecycle recorder uses bounded nonpaged, cache-isolated shards and +retains the globally latest 512 stable records whenever its sticky status is +clean, without locks, allocation, or waits on the trace hot path. Monotonic +slot claims prevent a preempted writer from +overwriting a newer wrap; any active-slot collision is dropped and made sticky. +Two-second endpoint, completion, controller, and owner rundown watchdog records +preserve the active count and queue state while the driver continues the +safety-required join. Sticky drop/watchdog status survives record-window rollover, +and the signed live teardown audit treats either status as a failure. + The design and release gates are in `docs/architecture/native-udecx.md`. The Microsoft signing boundary is in `docs/architecture/native-udecx-signing.md`. Production installation is intentionally available only through the signed package orchestrator, which binds the broker/helper/manifest hashes and keeps -the driver rollback snapshot alive through authenticated broker health. An -operator can run the same read-only production preflight without mutation: +the driver rollback snapshot alive through authenticated broker health. Driver +and broker recovery are separately journaled under protected fixed ProgramData +roots. A two-phase receipt binds both transaction IDs, both pending and final +journal digests, the package token, candidate identity, settlement nonce, and +request hash before either side retires authoritative evidence. An interrupted +`nested-ready`, pending acknowledgement, or final settlement is replayed +idempotently before any new package child may start. An operator can run the +same read-only production preflight without mutation: ```powershell $manifest = 'C:\ViiperUde\ViiperUde.cab.sha256.json' diff --git a/native/udecx/driver/Broker.c b/native/udecx/driver/Broker.c index bce4d397..b084eaef 100644 --- a/native/udecx/driver/Broker.c +++ b/native/udecx/driver/Broker.c @@ -101,6 +101,7 @@ ViiperQueueCancelEventLocked( event->EndpointSequence = 0; event->DeviceSequence = 0; event->Generation = Pending->DeviceGeneration; + event->EndpointGeneration = Pending->EndpointGeneration; event->Kind = ViiperUdeOperationCancel; event->EndpointAddress = Pending->EndpointAddress; event->InterfaceNumber = 0; @@ -157,13 +158,21 @@ ViiperDispatchNotificationEvents( if (managementSlot >= VIIPER_UDE_MAX_PENDING_MANAGEMENT || (controllerContext->ManagementSlots[managementSlot].Token != event.Token || controllerContext->ManagementSlots[managementSlot].State != - ViiperUdePendingQueued) && + ViiperUdePendingQueued || + controllerContext->ManagementSlots[managementSlot].DeviceId != + event.DeviceId || + controllerContext->ManagementSlots[managementSlot].DeviceGeneration != + event.Generation || + controllerContext->ManagementSlots[managementSlot].EndpointGeneration != + event.EndpointGeneration) && (!controllerContext->ManagementSlots[managementSlot].RetiredNotificationPending || controllerContext->ManagementSlots[managementSlot].RetiredToken != event.Token || controllerContext->ManagementSlots[managementSlot].RetiredDeviceId != event.DeviceId || controllerContext->ManagementSlots[managementSlot].RetiredDeviceGeneration != - event.Generation)) { + event.Generation || + controllerContext->ManagementSlots[managementSlot].RetiredEndpointGeneration != + event.EndpointGeneration)) { status = STATUS_INVALID_DEVICE_STATE; } else if (controllerContext->ManagementSlots[ managementSlot].RetiredNotificationPending) { @@ -177,6 +186,8 @@ ViiperDispatchNotificationEvents( controllerContext->ManagementSlots[managementSlot].RetiredDeviceId = 0; controllerContext->ManagementSlots[ managementSlot].RetiredDeviceGeneration = 0; + controllerContext->ManagementSlots[ + managementSlot].RetiredEndpointGeneration = 0; controllerContext->ManagementSlots[ managementSlot].RetiredOwnerFile = WDF_NO_HANDLE; event.Kind = ViiperUdeOperationCancel; @@ -210,6 +221,7 @@ ViiperDispatchNotificationEvents( operation->EndpointMaxPacketSize = event.EndpointMaxPacketSize; operation->EndpointSequence = event.EndpointSequence; operation->DeviceSequence = event.DeviceSequence; + operation->EndpointGeneration = event.EndpointGeneration; // Lifecycle and cancel notifications have an empty canonical tail. // Keep both offsets at the first byte after the fixed header so the // same strict parser contract applies to notifications and URBs. @@ -270,6 +282,7 @@ ViiperClearManagementSlotLocked( pending->DeviceId = 0; pending->ResetEpoch = 0; pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->Kind = 0; pending->EndpointAddress = 0; @@ -321,6 +334,10 @@ ViiperAdmissionCanPublishLocked( return FALSE; } endpointContext = ViiperGetEndpointContext(Pending->Endpoint); + if (Pending->EndpointGeneration == 0 || + Pending->EndpointGeneration != endpointContext->Generation) { + return FALSE; + } return endpointContext->AdmissionQueue.Flink == &Pending->AdmissionEntry; } @@ -360,6 +377,7 @@ ViiperClearSlotLocked( pending->DeviceId = 0; pending->AdmissionSequence = 0; pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->AbortPending = FALSE; pending->PublishedToOwner = FALSE; @@ -568,6 +586,9 @@ ViiperQueueUrbCompletion( NT_ASSERT(FALSE); return FALSE; } + NT_ASSERT(requestContext->DeviceGeneration != 0); + NT_ASSERT(requestContext->EndpointGeneration != 0); + NT_ASSERT(requestContext->Endpoint == Endpoint); WdfObjectReference(Request); requestContext->CompletionRequest = Request; @@ -626,6 +647,8 @@ ViiperEvtCompletionDpc( BOOLEAN completeWithNtStatus = FALSE; ULONG directInputBytes = 0; ULONGLONG directInputSequence = 0; + ULONG deviceGeneration = 0; + ULONG endpointGeneration = 0; BOOLEAN ownershipReleased = FALSE; PLIST_ENTRY entry; VIIPER_UDE_REQUEST_CONTEXT *requestContext; @@ -649,6 +672,8 @@ ViiperEvtCompletionDpc( completeWithNtStatus = requestContext->CompleteWithNtStatus; directInputBytes = requestContext->DirectInputBytes; directInputSequence = requestContext->DirectInputSequence; + deviceGeneration = requestContext->DeviceGeneration; + endpointGeneration = requestContext->EndpointGeneration; requestContext->CompletionRequest = WDF_NO_HANDLE; requestContext->CompletionQueued = FALSE; if (slot < VIIPER_UDE_MAX_PENDING_OPERATIONS) { @@ -665,6 +690,17 @@ ViiperEvtCompletionDpc( } WdfSpinLockRelease(controllerContext->BrokerLock); + if (endpoint == WDF_NO_HANDLE || deviceGeneration == 0 || endpointGeneration == 0 || + ViiperGetEndpointContext(endpoint)->Generation != endpointGeneration || + ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation != + deviceGeneration) { + NT_ASSERT(FALSE); + completionStatus = STATUS_DEVICE_NOT_READY; + completeWithNtStatus = TRUE; + directInputBytes = 0; + directInputSequence = 0; + } + if (completeWithNtStatus) { UdecxUrbCompleteWithNtStatus(request, completionStatus); } else { @@ -687,7 +723,8 @@ ViiperEvtCompletionDpc( controllerContext->PendingSlots[slot].State == ViiperUdePendingCompleting) { ViiperClearSlotLocked(controllerContext, slot); ownershipReleased = TRUE; - } else if (slot >= VIIPER_UDE_MAX_PENDING_OPERATIONS) { + } else if (slot >= VIIPER_UDE_MAX_PENDING_OPERATIONS && + endpoint != WDF_NO_HANDLE) { ViiperEndpointOperationCompletedLocked(endpoint); ownershipReleased = TRUE; } @@ -717,17 +754,46 @@ ViiperDrainUrbCompletions( ) { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(Controller); + LARGE_INTEGER watchdogWait; NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; for (;;) { BOOLEAN drained; + NTSTATUS waitStatus; - (VOID)KeWaitForSingleObject( + waitStatus = KeWaitForSingleObject( &controllerContext->CompletionOperationsDrained, Executive, KernelMode, FALSE, - NULL); + &watchdogWait); + if (waitStatus == STATUS_TIMEOUT) { + LONG pendingCompletions; + ULONG completionDpcActive; + + WdfSpinLockAcquire(controllerContext->BrokerLock); + pendingCompletions = InterlockedCompareExchange( + &controllerContext->PendingCompletions, 0, 0); + completionDpcActive = + controllerContext->CompletionDpcActive ? 1U : 0U; + WdfSpinLockRelease(controllerContext->BrokerLock); + VIIPER_TRACE_LIFECYCLE( + Controller, + VIIPER_UDE_TRACE_SOURCE_BROKER, + VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG, + 0, + 0, + WDF_NO_HANDLE, + WDF_NO_HANDLE, + 0, + STATUS_IO_TIMEOUT, + pendingCompletions, + completionDpcActive); + } else { + NT_ASSERT(waitStatus == STATUS_SUCCESS); + } (VOID)WdfDpcCancel(controllerContext->CompletionDpc, TRUE); // Closing the device's I/O queues precedes this join. If cancellation @@ -788,6 +854,7 @@ ViiperQueueLifecycleEventLocked( _In_ VIIPER_UDE_CONTROLLER_CONTEXT *ControllerContext, _In_ VIIPER_UDE_DEVICE_CONTEXT *DeviceContext, _In_opt_ const USB_ENDPOINT_DESCRIPTOR *EndpointDescriptor, + _In_ ULONG EndpointGeneration, _In_ VIIPER_UDE_OPERATION_KIND Kind, _In_ UCHAR InterfaceNumber, _In_ UCHAR InterfaceSetting, @@ -813,6 +880,7 @@ ViiperQueueLifecycleEventLocked( event->Token = Token; event->DeviceId = DeviceContext->DeviceId; event->Generation = DeviceContext->Generation; + event->EndpointGeneration = EndpointGeneration; event->Kind = Kind; if (EndpointDescriptor != NULL) { event->EndpointAddress = EndpointDescriptor->bEndpointAddress; @@ -822,8 +890,13 @@ ViiperQueueLifecycleEventLocked( } event->InterfaceNumber = InterfaceNumber; event->InterfaceSetting = InterfaceSetting; - event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( - &DeviceContext->EndpointSequences[event->EndpointAddress]); + if (EndpointGeneration != 0) { + event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->EndpointSequences[event->EndpointAddress]); + } else { + event->EndpointSequence = (ULONGLONG)InterlockedIncrement64( + &DeviceContext->DeviceLifecycleSequence); + } event->DeviceSequence = (ULONGLONG)InterlockedIncrement64( &DeviceContext->DeviceSequence); ControllerContext->NotificationTail = (ControllerContext->NotificationTail + 1) % @@ -857,6 +930,7 @@ ViiperQueueEndpointLifecycleEvent( controllerContext, deviceContext, &endpointContext->Descriptor, + endpointContext->Generation, Kind, 0, 0, @@ -904,7 +978,7 @@ ViiperQueueDeviceLifecycleEvent( &controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE; queued = active && ViiperQueueLifecycleEventLocked( - controllerContext, deviceContext, NULL, Kind, 0, 0, 0); + controllerContext, deviceContext, NULL, 0, Kind, 0, 0, 0); faulted = ownerActive && InterlockedCompareExchange( &controllerContext->BrokerFaulted, FALSE, FALSE) != FALSE; WdfSpinLockRelease(controllerContext->BrokerLock); @@ -945,6 +1019,7 @@ ViiperQueueInterfaceLifecycleEvent( controllerContext, deviceContext, NULL, + 0, ViiperUdeOperationSetInterface, InterfaceNumber, InterfaceSetting, @@ -1036,13 +1111,11 @@ ViiperQueueAcknowledgedLifecycleEvent( VIIPER_UDE_MANAGEMENT_SLOT *pending = &controllerContext->ManagementSlots[index]; ULONGLONG token; - if (pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0) { + if (pending->State != ViiperUdePendingEmpty || pending->RetiredToken != 0 || + pending->Generation == MAXULONG) { continue; } ++pending->Generation; - if (pending->Generation == 0) { - ++pending->Generation; - } token = ((ULONGLONG)pending->Generation << 32) | VIIPER_UDE_MANAGEMENT_SLOT_FLAG | (index + 1); pending->Request = Request; @@ -1057,6 +1130,9 @@ ViiperQueueAcknowledgedLifecycleEvent( : (ULONGLONG)InterlockedCompareExchange64( &deviceContext->ResetEpoch, 0, 0); pending->DeviceGeneration = deviceContext->Generation; + pending->EndpointGeneration = endpointContext != NULL + ? endpointContext->Generation + : 0; pending->State = ViiperUdePendingQueued; pending->Kind = Kind; pending->EndpointAddress = descriptor != NULL ? descriptor->bEndpointAddress : 0; @@ -1064,6 +1140,7 @@ ViiperQueueAcknowledgedLifecycleEvent( controllerContext, deviceContext, descriptor, + pending->EndpointGeneration, Kind, InterfaceNumber, InterfaceSetting, @@ -1076,6 +1153,7 @@ ViiperQueueAcknowledgedLifecycleEvent( pending->DeviceId = 0; pending->ResetEpoch = 0; pending->DeviceGeneration = 0; + pending->EndpointGeneration = 0; pending->State = ViiperUdePendingEmpty; pending->Kind = 0; pending->EndpointAddress = 0; @@ -1178,14 +1256,11 @@ ViiperAllocatePendingSlot( ULONG index = (ControllerContext->NextPendingSlot + offset) % VIIPER_UDE_MAX_PENDING_OPERATIONS; VIIPER_UDE_PENDING_SLOT *pending = &ControllerContext->PendingSlots[index]; - if (pending->State != ViiperUdePendingEmpty) { + if (pending->State != ViiperUdePendingEmpty || pending->Generation == MAXULONG) { continue; } NT_ASSERT(!pending->AdmissionLinked); ++pending->Generation; - if (pending->Generation == 0) { - ++pending->Generation; - } pending->Request = Request; pending->Endpoint = Endpoint; pending->Token = ((ULONGLONG)pending->Generation << 32) | (index + 1); @@ -1196,6 +1271,7 @@ ViiperAllocatePendingSlot( } pending->AdmissionSequence = endpointContext->NextAdmissionSequence; pending->DeviceGeneration = deviceContext->Generation; + pending->EndpointGeneration = endpointContext->Generation; pending->State = ViiperUdePendingPreparing; pending->AbortPending = FALSE; pending->PublishedToOwner = FALSE; @@ -1239,6 +1315,9 @@ ViiperEvtUrbCanceledOnQueue( requestContext->Controller = controller; requestContext->Endpoint = endpoint; requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = + ViiperGetDeviceContext(ViiperGetEndpointContext(endpoint)->Device)->Generation; + requestContext->EndpointGeneration = ViiperGetEndpointContext(endpoint)->Generation; WdfSpinLockAcquire(controllerContext->BrokerLock); ViiperEndpointOperationStarted(endpoint); WdfSpinLockRelease(controllerContext->BrokerLock); @@ -1712,6 +1791,7 @@ ViiperSerializeOperation( operation->EndpointAttributes = endpointContext->Descriptor.bmAttributes; operation->EndpointInterval = endpointContext->Descriptor.bInterval; operation->EndpointMaxPacketSize = endpointContext->Descriptor.wMaxPacketSize; + operation->EndpointGeneration = endpointContext->Generation; operation->Direction = directionIn ? 1 : 0; operation->UrbFunction = urb->UrbHeader.Function; operation->TransferFlags = transferFlags; @@ -1983,6 +2063,14 @@ ViiperDispatchAvailable( VIIPER_UDE_PENDING_SLOT *pending = &controllerContext->PendingSlots[slot]; abortPending = pending->AbortPending; abortStatus = pending->AbortStatus; + if (pending->DeviceGeneration != + ViiperGetRequestContext(urbRequest)->DeviceGeneration || + pending->EndpointGeneration != + ViiperGetRequestContext(urbRequest)->EndpointGeneration || + pending->EndpointGeneration != + ViiperGetEndpointContext(endpoint)->Generation) { + status = STATUS_DEVICE_NOT_READY; + } } else { status = STATUS_CANCELLED; } @@ -2019,6 +2107,15 @@ ViiperDispatchAvailable( } else { abortPending = pending->AbortPending; abortStatus = pending->AbortStatus; + if (pending->DeviceGeneration != + ViiperGetRequestContext(urbRequest)->DeviceGeneration || + pending->EndpointGeneration != + ViiperGetRequestContext(urbRequest)->EndpointGeneration || + pending->EndpointGeneration != + ViiperGetEndpointContext(endpoint)->Generation) { + abortPending = TRUE; + abortStatus = STATUS_DEVICE_NOT_READY; + } pending->State = abortPending ? ViiperUdePendingCompleting : ViiperUdePendingInFlight; @@ -2136,6 +2233,8 @@ ViiperQueueUrb( requestContext->Controller = deviceContext->Controller; requestContext->Endpoint = endpoint; requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = deviceContext->Generation; + requestContext->EndpointGeneration = endpointContext->Generation; // KMDF has already delivered this UdeCx request to the driver. Enter // endpoint rundown and decide whether it may reach the broker in the same // BrokerLock transaction. A request delivered immediately before PURGE, @@ -2286,7 +2385,9 @@ ViiperCompleteManagementOperation( if (ControllerContext->ManagementSlots[slot].Token == Completion->Token && ControllerContext->ManagementSlots[slot].State == ViiperUdePendingInFlight && ControllerContext->ManagementSlots[slot].DeviceId == Completion->DeviceId && - ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation) { + ControllerContext->ManagementSlots[slot].DeviceGeneration == Completion->Generation && + ControllerContext->ManagementSlots[slot].EndpointGeneration == + Completion->EndpointGeneration) { request = ControllerContext->ManagementSlots[slot].Request; device = ControllerContext->ManagementSlots[slot].Device; endpoint = ControllerContext->ManagementSlots[slot].Endpoint; @@ -2301,13 +2402,16 @@ ViiperCompleteManagementOperation( ControllerContext->ManagementSlots[slot].RetiredDeviceId == Completion->DeviceId && ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration == - Completion->Generation) { + Completion->Generation && + ControllerContext->ManagementSlots[slot].RetiredEndpointGeneration == + Completion->EndpointGeneration) { // The corresponding request and WDF-object pins were synchronously // retired by child teardown after this token crossed to user mode. // Consume the tombstone as a harmless expected-late ACK. ControllerContext->ManagementSlots[slot].RetiredToken = 0; ControllerContext->ManagementSlots[slot].RetiredDeviceId = 0; ControllerContext->ManagementSlots[slot].RetiredDeviceGeneration = 0; + ControllerContext->ManagementSlots[slot].RetiredEndpointGeneration = 0; ControllerContext->ManagementSlots[slot].RetiredOwnerFile = WDF_NO_HANDLE; retiredCompletion = TRUE; } @@ -2330,6 +2434,7 @@ ViiperCompleteManagementOperation( Completion->Generation, device, WDF_NO_HANDLE, + 0, resetEpoch, 0, TRUE, @@ -2346,6 +2451,7 @@ ViiperCompleteManagementOperation( Completion->Generation, device, endpoint, + Completion->EndpointGeneration, resetEpoch, endpointAddress, FALSE, @@ -2432,7 +2538,9 @@ ViiperCompleteOperation( completion->TransferLength > VIIPER_UDE_MAX_TRANSFER_BYTES || completion->PayloadLength > VIIPER_UDE_MAX_TRANSFER_BYTES || completion->IsoPacketCount > VIIPER_UDE_MAX_ISO_PACKETS || - completion->Reserved[0] != 0 || completion->Reserved[1] != 0) { + (((ULONG)completion->Token & VIIPER_UDE_MANAGEMENT_SLOT_FLAG) == 0 && + completion->EndpointGeneration == 0) || + completion->Reserved != 0) { InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } @@ -2488,7 +2596,9 @@ ViiperCompleteOperation( if (controllerContext->PendingSlots[slot].Token == completion->Token && controllerContext->PendingSlots[slot].State == ViiperUdePendingInFlight) { if (controllerContext->PendingSlots[slot].DeviceId == completion->DeviceId && - controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation) { + controllerContext->PendingSlots[slot].DeviceGeneration == completion->Generation && + controllerContext->PendingSlots[slot].EndpointGeneration == + completion->EndpointGeneration) { urbRequest = controllerContext->PendingSlots[slot].Request; controllerContext->PendingSlots[slot].State = ViiperUdePendingCompleting; WdfObjectReference(urbRequest); @@ -2525,6 +2635,10 @@ ViiperCompleteOperation( ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->DeviceId || completion->Generation != ViiperGetDeviceContext(ViiperGetEndpointContext(requestContext->Endpoint)->Device)->Generation || + completion->Generation != requestContext->DeviceGeneration || + completion->EndpointGeneration != requestContext->EndpointGeneration || + completion->EndpointGeneration != + ViiperGetEndpointContext(requestContext->Endpoint)->Generation || completion->TransferLength > requestContext->TransferLength) { status = STATUS_INVALID_PARAMETER; InterlockedIncrement64(&controllerContext->InvalidMessages); @@ -2751,6 +2865,8 @@ ViiperAbortManagementOperationsMatching( controllerContext->ManagementSlots[index].DeviceId; controllerContext->ManagementSlots[index].RetiredDeviceGeneration = controllerContext->ManagementSlots[index].DeviceGeneration; + controllerContext->ManagementSlots[index].RetiredEndpointGeneration = + controllerContext->ManagementSlots[index].EndpointGeneration; controllerContext->ManagementSlots[index].RetiredOwnerFile = controllerContext->ManagementSlots[index].OwnerFile; controllerContext->ManagementSlots[index].RetiredNotificationPending = @@ -2857,6 +2973,7 @@ ViiperRetireManagementTombstonesForOwner( pending->RetiredToken = 0; pending->RetiredDeviceId = 0; pending->RetiredDeviceGeneration = 0; + pending->RetiredEndpointGeneration = 0; pending->RetiredOwnerFile = WDF_NO_HANDLE; pending->RetiredNotificationPending = FALSE; } diff --git a/native/udecx/driver/Controller.c b/native/udecx/driver/Controller.c index 35686d2b..72670801 100644 --- a/native/udecx/driver/Controller.c +++ b/native/udecx/driver/Controller.c @@ -22,9 +22,19 @@ ViiperFinishOwnerCleanup( _In_ WDFFILEOBJECT OwnerFile ); +static +VOID +ViiperWaitForControllerRundown( + _In_ WDFDEVICE Device, + _Inout_ PKEVENT Event, + _In_ USHORT WatchdogEvent, + _Inout_ volatile LONG *ActiveCounter + ); + #ifdef ALLOC_PRAGMA #pragma alloc_text(PAGE, ViiperEvtDeviceAdd) #pragma alloc_text(PAGE, ViiperEvtDeviceSelfManagedIoInit) +#pragma alloc_text(PAGE, ViiperWaitForControllerRundown) #pragma alloc_text(PAGE, ViiperFinishOwnerCleanup) #pragma alloc_text(PAGE, ViiperEvtFileCreate) #pragma alloc_text(PAGE, ViiperEvtFileClose) @@ -57,12 +67,11 @@ ViiperFinishOwnerCleanup( // only those finite UdeCx API calls here. Child EvtCleanup is deliberately // not part of this rundown because PlugOutAndDelete consumes its handle // before KMDF necessarily destroys the object. - (VOID)KeWaitForSingleObject( + ViiperWaitForControllerRundown( + Device, &context->OwnerAdmissionsDrained, - Executive, - KernelMode, - FALSE, - NULL); + VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG, + &context->ActiveOwnerAdmissions); if (InterlockedCompareExchange(&context->ShuttingDown, 0, 0) != 0) { return FALSE; } @@ -84,6 +93,49 @@ ViiperFinishOwnerCleanup( return TRUE; } +static +VOID +ViiperWaitForControllerRundown( + _In_ WDFDEVICE Device, + _Inout_ PKEVENT Event, + _In_ USHORT WatchdogEvent, + _Inout_ volatile LONG *ActiveCounter + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *context = + ViiperGetControllerContext(Device); + LARGE_INTEGER watchdogWait; + + PAGED_CODE(); + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + for (;;) { + NTSTATUS waitStatus = KeWaitForSingleObject( + Event, + Executive, + KernelMode, + FALSE, + &watchdogWait); + if (waitStatus != STATUS_TIMEOUT) { + NT_ASSERT(waitStatus == STATUS_SUCCESS); + return; + } + VIIPER_TRACE_LIFECYCLE( + Device, + VIIPER_UDE_TRACE_SOURCE_CONTROLLER, + WatchdogEvent, + 0, + 0, + WDF_NO_HANDLE, + WDF_NO_HANDLE, + 0, + STATUS_IO_TIMEOUT, + InterlockedCompareExchange(ActiveCounter, 0, 0), + (ULONG)InterlockedCompareExchange( + &context->PendingOperations, 0, 0)); + } +} + NTSTATUS ViiperEvtQueryUsbCapability( _In_ WDFDEVICE UdecxWdfDevice, @@ -185,6 +237,11 @@ ViiperEvtDeviceAdd( KeInitializeEvent(&context->OwnerAdmissionsDrained, NotificationEvent, TRUE); KeInitializeEvent(&context->FileCleanupsDrained, NotificationEvent, TRUE); + status = ViiperInitializeLifecycleTrace(device); + if (!NT_SUCCESS(status)) { + return status; + } + // UdeCx owns the controller's USB root-hub power policy. Establish the // proven non-wakeable S0 idle contract before publishing emulation so a // port connect cannot race an implicit hub-suspend transition. This is a @@ -302,12 +359,11 @@ ViiperEvtDeviceSelfManagedIoCleanup( // using the controller's queue and lock children. The gate prevents any // successor, so this event is a finite rundown join before those objects // are purged. A cleanup that reaches OwnerLock after the gate never enters. - (VOID)KeWaitForSingleObject( + ViiperWaitForControllerRundown( + Device, &context->FileCleanupsDrained, - Executive, - KernelMode, - FALSE, - NULL); + VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG, + &context->ActiveFileCleanups); // These queues are non-power-managed. KMDF purges them before this // callback on normal removal, but an explicit idempotent purge also covers @@ -337,12 +393,11 @@ ViiperEvtDeviceSelfManagedIoCleanup( BOOLEAN stable; if (InterlockedCompareExchange(&context->PendingOperations, 0, 0) != 0) { - (VOID)KeWaitForSingleObject( + ViiperWaitForControllerRundown( + Device, &context->BrokerOperationsDrained, - Executive, - KernelMode, - FALSE, - NULL); + VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG, + &context->PendingOperations); } // BrokerOperationsDrained covers tracked slots. The second join // also covers rejected and fast-input URBs, then cancels/joins the diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index 125dcf4f..d3a9938e 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -1398,15 +1398,14 @@ ViiperEvtEndpointAdd( WDF_OBJECT_ATTRIBUTES attributes; UDECXUSBENDPOINT endpoint; VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext; + VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = + ViiperGetControllerContext(deviceContext->Controller); WDF_IO_QUEUE_DISPATCH_TYPE dispatchType; NTSTATUS status; PAGED_CODE(); - if (InterlockedCompareExchange( - &ViiperGetControllerContext( - ViiperGetDeviceContext(Device)->Controller)->ShuttingDown, - 0, - 0) != 0) { + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0) { return STATUS_DEVICE_REMOVED; } RtlZeroMemory(&descriptor, sizeof(descriptor)); @@ -1438,6 +1437,34 @@ ViiperEvtEndpointAdd( endpointContext->Descriptor = descriptor; InitializeListHead(&endpointContext->AdmissionQueue); KeInitializeEvent(&endpointContext->OperationsDrained, NotificationEvent, TRUE); + // Allocate an address-scoped incarnation before any queue or work-item can + // publish ownership. Failed creations deliberately consume a generation; + // no future endpoint may reuse an identity observed by a delayed callback. + ViiperAcquireDeviceLockExclusive(controllerContext); + if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) != 0 || + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0) { + status = STATUS_DEVICE_REMOVED; + } else if (deviceContext->Endpoints[descriptor.bEndpointAddress] != WDF_NO_HANDLE || + (descriptor.bEndpointAddress == 0 && + deviceContext->DefaultEndpoint != WDF_NO_HANDLE)) { + // A duplicate add must not advance the address generation while the + // published incarnation is still live. Direct-input validation treats + // EndpointGenerations[address] as the live endpoint's exact identity. + status = STATUS_OBJECT_NAME_COLLISION; + } else if (deviceContext->EndpointGenerations[ + descriptor.bEndpointAddress] == MAXULONG) { + status = STATUS_INTEGER_OVERFLOW; + } else { + ULONG generation = deviceContext->EndpointGenerations[ + descriptor.bEndpointAddress] + 1; + deviceContext->EndpointGenerations[descriptor.bEndpointAddress] = generation; + endpointContext->Generation = generation; + status = STATUS_SUCCESS; + } + ViiperReleaseDeviceLockExclusive(controllerContext); + if (!NT_SUCCESS(status)) { + return status; + } WDF_WORKITEM_CONFIG_INIT(&workItemConfig, ViiperEvtEndpointPurgeWorkItem); workItemConfig.AutomaticSerialization = WdfFalse; WDF_OBJECT_ATTRIBUTES_INIT(&attributes); @@ -1521,17 +1548,19 @@ ViiperEvtEndpointAdd( } { - VIIPER_UDE_DEVICE_CONTEXT *deviceContext = ViiperGetDeviceContext(Device); - VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = - ViiperGetControllerContext(deviceContext->Controller); + UCHAR address = descriptor.bEndpointAddress; ViiperAcquireDeviceLockExclusive(controllerContext); if (InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && - InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0) { + InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && + endpointContext->Generation != 0 && + deviceContext->EndpointGenerations[address] == endpointContext->Generation && + deviceContext->Endpoints[address] == WDF_NO_HANDLE) { if (descriptor.bEndpointAddress == 0) { deviceContext->DefaultEndpoint = endpoint; } - deviceContext->Endpoints[descriptor.bEndpointAddress] = endpoint; - deviceContext->RetiredEndpoints[descriptor.bEndpointAddress] = FALSE; + deviceContext->Endpoints[address] = endpoint; + deviceContext->RetiredEndpoints[address] = FALSE; + InterlockedExchange64(&deviceContext->EndpointSequences[address], 0); status = STATUS_SUCCESS; } else { // UdeCx owns the just-created child and will reclaim it when this @@ -1567,12 +1596,20 @@ ViiperCompleteRetrievedInputUrb( _In_ ULONGLONG DirectInputSequence ) { + VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(Endpoint); VIIPER_UDE_DEVICE_CONTEXT *deviceContext = - ViiperGetDeviceContext(ViiperGetEndpointContext(Endpoint)->Device); + ViiperGetDeviceContext(endpointContext->Device); + VIIPER_UDE_REQUEST_CONTEXT *requestContext = ViiperGetRequestContext(Request); BOOLEAN queued; // The passive caller owns buffer validation/copying. Terminal completion // and the endpoint rundown release are transferred together to the DPC. + RtlZeroMemory(requestContext, sizeof(*requestContext)); + requestContext->Controller = deviceContext->Controller; + requestContext->Endpoint = Endpoint; + requestContext->PendingSlot = VIIPER_UDE_MAX_PENDING_OPERATIONS; + requestContext->DeviceGeneration = deviceContext->Generation; + requestContext->EndpointGeneration = endpointContext->Generation; queued = ViiperQueueUrbCompletion( deviceContext->Controller, Endpoint, @@ -1824,7 +1861,8 @@ ViiperSubmitInputReport( input->Header.Minor != VIIPER_UDE_ABI_MINOR || input->Header.Flags != 0 || input->Header.Size != sizeof(*input) + input->PayloadLength || - input->DeviceId == 0 || input->Generation == 0 || input->Sequence == 0 || + input->DeviceId == 0 || input->Generation == 0 || + input->EndpointGeneration == 0 || input->Sequence == 0 || input->Sequence > MAXLONGLONG || (input->EndpointAddress & USB_ENDPOINT_DIRECTION_MASK) == 0 || input->PayloadOffset != sizeof(*input) || input->PayloadLength == 0 || @@ -1851,13 +1889,23 @@ ViiperSubmitInputReport( } else { endpoint = deviceContext->Endpoints[input->EndpointAddress]; if (endpoint == WDF_NO_HANDLE) { - if (deviceContext->RetiredEndpoints[input->EndpointAddress]) { + if (deviceContext->RetiredEndpoints[input->EndpointAddress] || + (deviceContext->EndpointGenerations[input->EndpointAddress] != 0 && + input->EndpointGeneration <= + deviceContext->EndpointGenerations[input->EndpointAddress])) { lifecycleDrop = TRUE; status = STATUS_SUCCESS; } } else { endpointContext = ViiperGetEndpointContext(endpoint); - if (!endpointContext->FastInput || + if (endpointContext->Generation != input->EndpointGeneration || + deviceContext->EndpointGenerations[input->EndpointAddress] != + input->EndpointGeneration) { + if (input->EndpointGeneration < endpointContext->Generation) { + lifecycleDrop = TRUE; + status = STATUS_SUCCESS; + } + } else if (!endpointContext->FastInput || endpointContext->InputLock == WDF_NO_HANDLE) { status = STATUS_INVALID_DEVICE_STATE; } else { @@ -1909,6 +1957,7 @@ ViiperSubmitInputReport( InterlockedCompareExchange(&deviceContext->InD0, 0, 0) == 0 || InterlockedCompareExchange(&deviceContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) != 0 || + endpointContext->Generation != input->EndpointGeneration || InterlockedCompareExchange(&endpointContext->Purging, 0, 0) != 0 || InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0) { WdfSpinLockRelease(controllerContext->BrokerLock); @@ -2026,21 +2075,29 @@ ViiperWaitForEndpointQuiescence( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); LARGE_INTEGER retryInterval; + LARGE_INTEGER watchdogWait; + ULONGLONG nextWatchdog; NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); // One millisecond is used only on the cold purge/reset path. The ordinary // case returns after one event wait and one read-only queue-state sample. retryInterval.QuadPart = -10 * 1000; + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + nextWatchdog = KeQueryInterruptTime() + + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; for (;;) { WDF_IO_QUEUE_STATE queueState; BOOLEAN quiescent; + LONG activeOperations; + ULONGLONG now; (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, Executive, KernelMode, FALSE, - NULL); + &watchdogWait); // WdfIoQueueDriverNoRequests closes the interval in which a callback // was delivered and then preempted before its first BrokerLock @@ -2051,16 +2108,35 @@ ViiperWaitForEndpointQuiescence( WdfSpinLockAcquire(controllerContext->BrokerLock); queueState = WdfIoQueueGetState(endpointContext->Queue, NULL, NULL); quiescent = (queueState & WdfIoQueueDriverNoRequests) != 0 && - InterlockedCompareExchange(&endpointContext->ActiveOperations, 0, 0) == 0; + InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0) == 0; + activeOperations = InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0); WdfSpinLockRelease(controllerContext->BrokerLock); if (quiescent) { return; } + now = KeQueryInterruptTime(); + if (now >= nextWatchdog) { + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, + VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + Endpoint, + endpointContext->Descriptor.bEndpointAddress, + STATUS_IO_TIMEOUT, + activeOperations, + (ULONG)queueState); + nextWatchdog = now + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + } // A callback can be between KMDF delivery and its first BrokerLock // acquisition. It will either enter rundown and re-arm the event or - // finish its terminal DPC and make the queue idle. Avoid spinning while - // that passive callback is scheduled. + // finish its terminal DPC and return the request to framework + // ownership. Avoid spinning while that passive callback is scheduled. (VOID)KeDelayExecutionThread(KernelMode, FALSE, &retryInterval); } } @@ -2080,21 +2156,29 @@ ViiperWaitForEndpointPurgeQuiescence( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(deviceContext->Controller); LARGE_INTEGER retryInterval; + LARGE_INTEGER watchdogWait; + ULONGLONG nextWatchdog; NT_ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); retryInterval.QuadPart = -10 * 1000; + watchdogWait.QuadPart = + -(LONGLONG)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + nextWatchdog = KeQueryInterruptTime() + + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; for (;;) { WDF_IO_QUEUE_STATE queueState; ULONG queuedRequests; ULONG driverRequests; BOOLEAN quiescent; + LONG activeOperations; + ULONGLONG now; (VOID)KeWaitForSingleObject( &endpointContext->OperationsDrained, Executive, KernelMode, FALSE, - NULL); + &watchdogWait); // UdeCx exclusively owns the associated queue's START/PURGE state. // The PURGE callback is the upstream stop/cancel boundary even when @@ -2114,6 +2198,8 @@ ViiperWaitForEndpointPurgeQuiescence( driverRequests == 0 && InterlockedCompareExchange( &endpointContext->ActiveOperations, 0, 0) == 0; + activeOperations = InterlockedCompareExchange( + &endpointContext->ActiveOperations, 0, 0); if (quiescent) { *FinalQueueState = queueState; *FinalQueuedRequests = queuedRequests; @@ -2123,6 +2209,22 @@ ViiperWaitForEndpointPurgeQuiescence( if (quiescent) { return; } + now = KeQueryInterruptTime(); + if (now >= nextWatchdog) { + VIIPER_TRACE_LIFECYCLE( + deviceContext->Controller, + VIIPER_UDE_TRACE_SOURCE_DEVICE, + VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG, + deviceContext->DeviceId, + deviceContext->Generation, + endpointContext->Device, + Endpoint, + endpointContext->Descriptor.bEndpointAddress, + STATUS_IO_TIMEOUT, + activeOperations, + (ULONG)queueState); + nextWatchdog = now + VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS; + } // This runs only during an endpoint lifecycle transition. A short // passive wait lets any callback already dispatched by KMDF reach its @@ -2176,6 +2278,7 @@ ViiperQuiesceResetByIdentity( _In_ ULONG Generation, _In_ UDECXUSBDEVICE ExpectedDevice, _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONG ExpectedEndpointGeneration, _In_ ULONGLONG ExpectedResetEpoch, _In_ UCHAR EndpointAddress, _In_ BOOLEAN WholeDevice, @@ -2210,6 +2313,7 @@ ViiperQuiesceResetByIdentity( ULONGLONG currentResetEpoch; WdfSpinLockAcquire(controllerContext->BrokerLock); + NT_ASSERT(ExpectedEndpointGeneration == 0); currentResetEpoch = (ULONGLONG)InterlockedCompareExchange64( &deviceContext->ResetEpoch, 0, 0); found = InterlockedCompareExchange(&controllerContext->ShuttingDown, 0, 0) == 0 && @@ -2253,7 +2357,10 @@ ViiperQuiesceResetByIdentity( } else { UDECXUSBENDPOINT endpoint = deviceContext->Endpoints[EndpointAddress]; - if (endpoint != WDF_NO_HANDLE && endpoint == ExpectedEndpoint) { + if (endpoint != WDF_NO_HANDLE && endpoint == ExpectedEndpoint && + ExpectedEndpointGeneration != 0 && + deviceContext->EndpointGenerations[EndpointAddress] == + ExpectedEndpointGeneration) { VIIPER_UDE_ENDPOINT_CONTEXT *endpointContext = ViiperGetEndpointContext(endpoint); @@ -2262,6 +2369,7 @@ ViiperQuiesceResetByIdentity( InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && (ULONGLONG)InterlockedCompareExchange64( &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + endpointContext->Generation == ExpectedEndpointGeneration && InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && @@ -2279,6 +2387,9 @@ ViiperQuiesceResetByIdentity( InterlockedCompareExchange(&controllerContext->BrokerFaulted, FALSE, FALSE) == FALSE && (ULONGLONG)InterlockedCompareExchange64( &deviceContext->ResetEpoch, 0, 0) == ExpectedResetEpoch && + endpointContext->Generation == ExpectedEndpointGeneration && + deviceContext->EndpointGenerations[EndpointAddress] == + ExpectedEndpointGeneration && InterlockedCompareExchange(&deviceContext->Purging, 0, 0) == 0 && InterlockedCompareExchange(&deviceContext->Resetting, 0, 0) == 0 && InterlockedCompareExchange(&endpointContext->Resetting, 0, 0) != 0 && @@ -2358,6 +2469,7 @@ ViiperEvtEndpointResetWorkItem( deviceContext->Generation, endpointContext->Device, endpoint, + endpointContext->Generation, (ULONGLONG)InterlockedCompareExchange64( &endpointContext->ResetDeviceEpoch, 0, 0), endpointContext->Descriptor.bEndpointAddress, diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index a98f165b..ac16dc5d 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -187,7 +187,8 @@ ViiperHandleQueryLifecycleTrace( LARGE_INTEGER frequency; ULONGLONG latestSequence; ULONGLONG firstSequence; - ULONGLONG sequence; + ULONG shardIndex; + ULONG recordIndex; if (fileObject == WDF_NO_HANDLE) { return STATUS_INVALID_HANDLE; @@ -220,29 +221,78 @@ ViiperHandleQueryLifecycleTrace( firstSequence = latestSequence > VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY ? latestSequence - VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY + 1 : 1; - for (sequence = firstSequence; sequence <= latestSequence; ++sequence) { - VIIPER_UDE_LIFECYCLE_TRACE_RECORD *source = - &context->LifecycleTrace[ - (sequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; - ULONGLONG publishedBefore = (ULONGLONG)InterlockedCompareExchange64( - (volatile LONG64 *)&source->PublishedSequence, 0, 0); - ULONGLONG publishedAfter; + for (shardIndex = 0; + shardIndex < context->LifecycleTraceShardCount; + ++shardIndex) { + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *shard = + &context->LifecycleTraceShards[shardIndex]; + for (recordIndex = 0; + recordIndex < VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY; + ++recordIndex) { + VIIPER_UDE_LIFECYCLE_TRACE_RECORD *source = + &shard->Records[recordIndex]; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD candidate; + LONG64 slotStateBefore = InterlockedCompareExchange64( + &shard->SlotStates[recordIndex], 0, 0); + ULONGLONG publishedBefore = + (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + ULONGLONG publishedAfter; + LONG64 slotStateAfter; + ULONG insertIndex; - if (publishedBefore != sequence) { - continue; - } - KeMemoryBarrier(); - RtlCopyMemory( - &output->Records[output->RecordCount], source, sizeof(*source)); - KeMemoryBarrier(); - publishedAfter = (ULONGLONG)InterlockedCompareExchange64( - (volatile LONG64 *)&source->PublishedSequence, 0, 0); - if (publishedAfter == sequence && - output->Records[output->RecordCount].PublishedSequence == sequence) { + if ((slotStateBefore & 1) != 0 || + publishedBefore < firstSequence || + publishedBefore > latestSequence) { + continue; + } + KeMemoryBarrier(); + RtlCopyMemory(&candidate, source, sizeof(candidate)); + KeMemoryBarrier(); + publishedAfter = (ULONGLONG)InterlockedCompareExchange64( + (volatile LONG64 *)&source->PublishedSequence, 0, 0); + slotStateAfter = InterlockedCompareExchange64( + &shard->SlotStates[recordIndex], 0, 0); + if (slotStateAfter != slotStateBefore || + (slotStateAfter & 1) != 0 || + publishedAfter != publishedBefore || + candidate.PublishedSequence != publishedBefore || + output->RecordCount >= VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY) { + continue; + } + + insertIndex = output->RecordCount; + while (insertIndex > 0 && + output->Records[insertIndex - 1].PublishedSequence > + candidate.PublishedSequence) { + --insertIndex; + } + if ((insertIndex > 0 && + output->Records[insertIndex - 1].PublishedSequence == + candidate.PublishedSequence) || + (insertIndex < output->RecordCount && + output->Records[insertIndex].PublishedSequence == + candidate.PublishedSequence)) { + continue; + } + if (insertIndex < output->RecordCount) { + RtlMoveMemory( + &output->Records[insertIndex + 1], + &output->Records[insertIndex], + (output->RecordCount - insertIndex) * + sizeof(output->Records[0])); + } + output->Records[insertIndex] = candidate; ++output->RecordCount; } } + // Status is monotonic. Sample it only after the complete record scan so a + // watchdog or contended writer observed during the scan cannot be omitted + // from an otherwise successful release-gate snapshot. + output->StatusFlags = (VIIPER_UDE_UINT32)InterlockedCompareExchange( + &context->LifecycleTraceStatus, 0, 0); + WdfRequestSetInformation(Request, sizeof(*output)); return STATUS_SUCCESS; } diff --git a/native/udecx/driver/Trace.c b/native/udecx/driver/Trace.c index 958e226a..68549ec4 100644 --- a/native/udecx/driver/Trace.c +++ b/native/udecx/driver/Trace.c @@ -4,6 +4,58 @@ #pragma intrinsic(_ReturnAddress) +#ifdef ALLOC_PRAGMA +#pragma alloc_text(PAGE, ViiperInitializeLifecycleTrace) +#endif + +NTSTATUS +ViiperInitializeLifecycleTrace( + _In_ WDFDEVICE Controller + ) +{ + VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + WDF_OBJECT_ATTRIBUTES attributes; + ULONG maximumProcessors; + ULONG shardCount; + SIZE_T storageSize; + PVOID rawStorage; + ULONG_PTR alignedStorage; + NTSTATUS status; + + PAGED_CODE(); + controllerContext = ViiperGetControllerContext(Controller); + maximumProcessors = KeQueryMaximumProcessorCountEx(ALL_PROCESSOR_GROUPS); + if (maximumProcessors == 0) { + return STATUS_DEVICE_CONFIGURATION_ERROR; + } + shardCount = maximumProcessors > VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS + ? VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS + : maximumProcessors; + storageSize = sizeof(VIIPER_UDE_LIFECYCLE_TRACE_SHARD) * shardCount + + SYSTEM_CACHE_ALIGNMENT_SIZE - 1U; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Controller; + status = WdfMemoryCreate( + &attributes, + NonPagedPoolNx, + 0x56495554, + storageSize, + &controllerContext->LifecycleTraceStorage, + &rawStorage); + if (!NT_SUCCESS(status)) { + controllerContext->LifecycleTraceStorage = WDF_NO_HANDLE; + return status; + } + RtlZeroMemory(rawStorage, storageSize); + alignedStorage = ((ULONG_PTR)rawStorage + SYSTEM_CACHE_ALIGNMENT_SIZE - 1U) & + ~((ULONG_PTR)SYSTEM_CACHE_ALIGNMENT_SIZE - 1U); + controllerContext->LifecycleTraceShards = + (VIIPER_UDE_LIFECYCLE_TRACE_SHARD *)alignedStorage; + controllerContext->LifecycleTraceShardCount = shardCount; + return STATUS_SUCCESS; +} + VOID ViiperTraceLifecycle( _In_ WDFDEVICE Controller, @@ -21,22 +73,66 @@ ViiperTraceLifecycle( ) { VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext; + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *shard; VIIPER_UDE_LIFECYCLE_TRACE_RECORD *record; PROCESSOR_NUMBER processorNumber; LARGE_INTEGER timestamp; + ULONGLONG localSequence; ULONGLONG sequence; + volatile LONG64 *slotState; + LONG64 observedSlotState; + LONG64 claimedSlotState; + ULONG processorIndex; + ULONG shardIndex; + ULONG slotIndex; controllerContext = ViiperGetControllerContext(Controller); + if (Event >= VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG && + Event <= VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG) { + (VOID)InterlockedOr( + &controllerContext->LifecycleTraceStatus, + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED); + } + if (controllerContext->LifecycleTraceShards == NULL || + controllerContext->LifecycleTraceShardCount == 0) { + return; + } + KeGetCurrentProcessorNumberEx(&processorNumber); + processorIndex = KeGetProcessorIndexFromNumber(&processorNumber); + if (processorIndex == INVALID_PROCESSOR_INDEX) { + processorIndex = processorNumber.Group * MAXIMUM_PROC_PER_GROUP + + processorNumber.Number; + } + shardIndex = processorIndex % controllerContext->LifecycleTraceShardCount; + shard = &controllerContext->LifecycleTraceShards[shardIndex]; + localSequence = (ULONGLONG)InterlockedIncrement64(&shard->WriteSequence); + slotIndex = (ULONG)( + (localSequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY); + slotState = &shard->SlotStates[slotIndex]; + claimedSlotState = (LONG64)((localSequence << 1) | 1ULL); + for (;;) { + observedSlotState = InterlockedCompareExchange64(slotState, 0, 0); + if ((observedSlotState & 1) != 0 || + ((ULONGLONG)observedSlotState >> 1) >= localSequence) { + (VOID)InterlockedOr( + &controllerContext->LifecycleTraceStatus, + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD); + return; + } + if (InterlockedCompareExchange64( + slotState, claimedSlotState, observedSlotState) == + observedSlotState) { + break; + } + } sequence = (ULONGLONG)InterlockedIncrement64( &controllerContext->LifecycleTraceSequence); - record = &controllerContext->LifecycleTrace[ - (sequence - 1) % VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + record = &shard->Records[slotIndex]; (VOID)InterlockedExchange64((volatile LONG64 *)&record->PublishedSequence, 0); KeMemoryBarrier(); timestamp = KeQueryPerformanceCounter(NULL); - KeGetCurrentProcessorNumberEx(&processorNumber); record->TimestampQpc = (ULONGLONG)timestamp.QuadPart; record->Caller = (ULONGLONG)(ULONG_PTR)_ReturnAddress(); record->DeviceId = DeviceId; @@ -60,4 +156,6 @@ ViiperTraceLifecycle( KeMemoryBarrier(); (VOID)InterlockedExchange64( (volatile LONG64 *)&record->PublishedSequence, (LONG64)sequence); + KeMemoryBarrier(); + (VOID)InterlockedExchange64(slotState, (LONG64)(localSequence << 1)); } diff --git a/native/udecx/driver/ViiperUde.h b/native/udecx/driver/ViiperUde.h index 0e60eb95..2115880d 100644 --- a/native/udecx/driver/ViiperUde.h +++ b/native/udecx/driver/ViiperUde.h @@ -19,7 +19,12 @@ EXTERN_C const GUID GUID_DEVINTERFACE_VIIPER_UDE; #define VIIPER_UDE_MAX_PENDING_MANAGEMENT 256 #define VIIPER_UDE_MAX_INPUT_TRANSITIONS 256 #define VIIPER_UDE_MAX_INPUT_TRANSITION_BYTES 65536 -#define VIIPER_UDE_MANAGEMENT_SLOT_FLAG 0x80000000UL +// Keep one cache-isolated recorder shard per logical processor on ordinary +// client systems. Very large systems hash processors into this fixed ceiling; +// every shard still retains the complete public trace window, so collisions +// cannot discard a record merely because another processor used the shard. +#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64 +#define VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS (2ULL * 1000ULL * 1000ULL * 10ULL) // UdeCx numbers USB 3 ports after every USB 2 port on the controller. Keep // the topology constants shared by controller creation and child plug-in so // fixed slot-to-port identity cannot drift between those two boundaries. @@ -45,6 +50,7 @@ typedef struct VIIPER_UDE_PENDING_SLOT { ULONGLONG AdmissionSequence; ULONG Generation; ULONG DeviceGeneration; + ULONG EndpointGeneration; VIIPER_UDE_PENDING_STATE State; BOOLEAN AbortPending; BOOLEAN PublishedToOwner; @@ -62,6 +68,7 @@ typedef struct VIIPER_UDE_NOTIFICATION { ULONGLONG EndpointSequence; ULONGLONG DeviceSequence; ULONG Generation; + ULONG EndpointGeneration; ULONG Kind; UCHAR EndpointAddress; UCHAR InterfaceNumber; @@ -92,7 +99,9 @@ typedef struct VIIPER_UDE_MANAGEMENT_SLOT { ULONGLONG ResetEpoch; ULONG Generation; ULONG DeviceGeneration; + ULONG EndpointGeneration; ULONG RetiredDeviceGeneration; + ULONG RetiredEndpointGeneration; VIIPER_UDE_PENDING_STATE State; BOOLEAN RetiredNotificationPending; ULONG Kind; @@ -104,6 +113,8 @@ typedef struct VIIPER_UDE_REQUEST_CONTEXT { UDECXUSBENDPOINT Endpoint; ULONG PendingSlot; ULONGLONG Token; + ULONG DeviceGeneration; + ULONG EndpointGeneration; ULONG TransferLength; ULONG IsoPacketCount; ULONG IsoStartFrame; @@ -126,6 +137,21 @@ typedef struct VIIPER_UDE_REQUEST_CONTEXT { WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_REQUEST_CONTEXT, ViiperGetRequestContext) +typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD { + DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence; + UCHAR SequencePadding[SYSTEM_CACHE_ALIGNMENT_SIZE - sizeof(LONG64)]; + // A slot state is (local sequence << 1) | writer-active. The monotonically + // increasing claim prevents a preempted old writer from overwriting a + // newer wrap of the same ring slot, while the low bit makes collisions + // fail closed without a lock or wait. + volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; +} VIIPER_UDE_LIFECYCLE_TRACE_SHARD; + +C_ASSERT((SYSTEM_CACHE_ALIGNMENT_SIZE & (SYSTEM_CACHE_ALIGNMENT_SIZE - 1)) == 0); +C_ASSERT(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_SHARD) % + SYSTEM_CACHE_ALIGNMENT_SIZE == 0); + typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { WDFWAITLOCK OwnerLock; // UdeCx endpoint/device cleanup can run while the framework is deleting @@ -188,8 +214,10 @@ typedef struct VIIPER_UDE_CONTROLLER_CONTEXT { volatile LONG64 BytesToDevice; volatile LONG64 BytesFromDevice; volatile LONG64 LifecycleTraceSequence; - DECLSPEC_ALIGN(8) VIIPER_UDE_LIFECYCLE_TRACE_RECORD - LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; + volatile LONG LifecycleTraceStatus; + WDFMEMORY LifecycleTraceStorage; + VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards; + ULONG LifecycleTraceShardCount; // Sorted by DeviceId and protected by DeviceLock. The input producer uses // a shared binary lookup while lifecycle mutations retain exclusive access // to the physical UDE port table below. @@ -253,6 +281,11 @@ ViiperReleaseDeviceLockShared( } typedef struct VIIPER_UDE_FILE_CONTEXT { + // The reference-pinned WDFFILEOBJECT containing this context is the + // kernel session incarnation: KMDF cannot recycle that object while any + // owner/request callback retains it, and Closing makes retirement a + // permanent one-way generation fence. DriverNonce is the corresponding + // nonzero user-visible session tag established by negotiation. volatile LONG Negotiated; volatile LONG Closing; volatile LONG BrokerOwner; @@ -284,7 +317,9 @@ typedef struct VIIPER_UDE_DEVICE_CONTEXT { UDECXUSBENDPOINT DefaultEndpoint; UDECXUSBENDPOINT Endpoints[256]; BOOLEAN RetiredEndpoints[256]; + ULONG EndpointGenerations[256]; volatile LONG64 EndpointSequences[256]; + volatile LONG64 DeviceLifecycleSequence; volatile LONG64 DeviceSequence; } VIIPER_UDE_DEVICE_CONTEXT; @@ -292,6 +327,7 @@ WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VIIPER_UDE_DEVICE_CONTEXT, ViiperGetDeviceCon typedef struct VIIPER_UDE_ENDPOINT_CONTEXT { UDECXUSBDEVICE Device; + ULONG Generation; WDFQUEUE Queue; WDFWAITLOCK InputLock; WDFWORKITEM PurgeWorkItem; @@ -376,6 +412,7 @@ BOOLEAN ViiperQuiesceResetByIdentity( _In_ ULONG Generation, _In_ UDECXUSBDEVICE ExpectedDevice, _In_opt_ UDECXUSBENDPOINT ExpectedEndpoint, + _In_ ULONG ExpectedEndpointGeneration, _In_ ULONGLONG ExpectedResetEpoch, _In_ UCHAR EndpointAddress, _In_ BOOLEAN WholeDevice, @@ -424,6 +461,7 @@ VOID ViiperEndpointOperationStarted(_In_ UDECXUSBENDPOINT Endpoint); _IRQL_requires_max_(DISPATCH_LEVEL) VOID ViiperEndpointOperationCompleted(_In_ UDECXUSBENDPOINT Endpoint); VOID ViiperPurgeOwnerOperations(_In_ WDFDEVICE Controller, _In_ NTSTATUS Status); +NTSTATUS ViiperInitializeLifecycleTrace(_In_ WDFDEVICE Controller); VOID ViiperTraceLifecycle( _In_ WDFDEVICE Controller, _In_ UCHAR Source, diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 19658d10..3afa1b62 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/15/2026 - 0.1.0.36 + 0.1.0.37 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -113,7 +113,7 @@ - + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index c8a0f92e..8f234c97 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,8 +35,8 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(12) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.36" +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(13) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.37" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ @@ -58,6 +58,7 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAX_ISO_PACKETS VIIPER_UDE_UINT32_C(1024) #define VIIPER_UDE_MAX_INPUT_REPORT_BYTES VIIPER_UDE_UINT32_C(4096) #define VIIPER_UDE_MAX_PENDING_OPERATIONS VIIPER_UDE_UINT32_C(4096) +#define VIIPER_UDE_MANAGEMENT_SLOT_FLAG VIIPER_UDE_UINT32_C(0x80000000) #define VIIPER_UDE_INPUT_REPORT_TRANSITION 0x01 /* Microsoft OS 1.0 defines this reserved string outside normal LANGID rules. */ @@ -104,6 +105,16 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_TRACE_DEVICE_CLEANUP_END 22 #define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_BEGIN 23 #define VIIPER_UDE_TRACE_CONTROLLER_SHUTDOWN_END 24 +#define VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG 25 +#define VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG 26 +#define VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG 27 +#define VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG 28 + +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD VIIPER_UDE_UINT32_C(0x00000001) +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED VIIPER_UDE_UINT32_C(0x00000002) +#define VIIPER_UDE_LIFECYCLE_TRACE_STATUS_VALID_MASK \ + (VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD | \ + VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED) #if defined(_WIN32) #define VIIPER_UDE_IOCTL_BASE 0x900 @@ -233,6 +244,8 @@ typedef struct VIIPER_UDE_OPERATION { VIIPER_UDE_UINT16 EndpointMaxPacketSize; VIIPER_UDE_UINT64 EndpointSequence; VIIPER_UDE_UINT64 DeviceSequence; + /* Immutable incarnation of EndpointAddress within this device generation. */ + VIIPER_UDE_UINT32 EndpointGeneration; } VIIPER_UDE_OPERATION; typedef struct VIIPER_UDE_COMPLETION { @@ -247,7 +260,8 @@ typedef struct VIIPER_UDE_COMPLETION { VIIPER_UDE_UINT32 PayloadOffset; VIIPER_UDE_UINT32 PayloadLength; VIIPER_UDE_UINT32 IsoPacketsOffset; - VIIPER_UDE_UINT32 Reserved[2]; + VIIPER_UDE_UINT32 EndpointGeneration; + VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_COMPLETION; typedef struct VIIPER_UDE_INPUT_REPORT { @@ -260,6 +274,8 @@ typedef struct VIIPER_UDE_INPUT_REPORT { VIIPER_UDE_UINT32 PayloadOffset; VIIPER_UDE_UINT32 PayloadLength; VIIPER_UDE_UINT64 Sequence; + /* Immutable incarnation of EndpointAddress within Generation. */ + VIIPER_UDE_UINT32 EndpointGeneration; } VIIPER_UDE_INPUT_REPORT; typedef struct VIIPER_UDE_STATS { @@ -314,7 +330,7 @@ typedef struct VIIPER_UDE_LIFECYCLE_TRACE { VIIPER_UDE_UINT32 RecordCount; VIIPER_UDE_UINT32 RecordSize; VIIPER_UDE_UINT32 Capacity; - VIIPER_UDE_UINT32 Reserved; + VIIPER_UDE_UINT32 StatusFlags; VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY]; } VIIPER_UDE_LIFECYCLE_TRACE; @@ -328,9 +344,9 @@ static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); +static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); +static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 52, "VIIPER_UDE_INPUT_REPORT ABI drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); @@ -342,9 +358,9 @@ _Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTO _Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 104, "VIIPER_UDE_OPERATION ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); _Static_assert(sizeof(VIIPER_UDE_COMPLETION) == 72, "VIIPER_UDE_COMPLETION ABI drift"); -_Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 48, "VIIPER_UDE_INPUT_REPORT ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_INPUT_REPORT) == 52, "VIIPER_UDE_INPUT_REPORT ABI drift"); _Static_assert(sizeof(VIIPER_UDE_STATS) == 152, "VIIPER_UDE_STATS ABI drift"); _Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80, "VIIPER_UDE_LIFECYCLE_TRACE_RECORD ABI drift"); _Static_assert(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008, "VIIPER_UDE_LIFECYCLE_TRACE ABI drift"); @@ -364,9 +380,9 @@ typedef char VIIPER_UDE_ABI_DESCRIPTOR_RECORD_SIZE[(sizeof(VIIPER_UDE_DESCRIPTOR typedef char VIIPER_UDE_ABI_CREATE_DEVICE_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56) ? 1 : -1]; typedef char VIIPER_UDE_ABI_DEVICE_IDENTITY_SIZE[(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32) ? 1 : -1]; typedef char VIIPER_UDE_ABI_ISO_PACKET_SIZE[(sizeof(VIIPER_UDE_ISO_PACKET) == 16) ? 1 : -1]; -typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 104) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 108) ? 1 : -1]; typedef char VIIPER_UDE_ABI_COMPLETION_SIZE[(sizeof(VIIPER_UDE_COMPLETION) == 72) ? 1 : -1]; -typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 48) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_INPUT_REPORT_SIZE[(sizeof(VIIPER_UDE_INPUT_REPORT) == 52) ? 1 : -1]; typedef char VIIPER_UDE_ABI_STATS_SIZE[(sizeof(VIIPER_UDE_STATS) == 152) ? 1 : -1]; typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_RECORD_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE_RECORD) == 80) ? 1 : -1]; typedef char VIIPER_UDE_ABI_LIFECYCLE_TRACE_SIZE[(sizeof(VIIPER_UDE_LIFECYCLE_TRACE) == 41008) ? 1 : -1]; @@ -448,6 +464,7 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointInterval, 85); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointMaxPacketSize, 86); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointSequence, 88); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, DeviceSequence, 96); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_OPERATION, EndpointGeneration, 104); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Token, 16); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, DeviceId, 24); @@ -459,7 +476,8 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketCount, 48); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadOffset, 52); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, PayloadLength, 56); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, IsoPacketsOffset, 60); -VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Reserved, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, EndpointGeneration, 64); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_COMPLETION, Reserved, 68); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, DeviceId, 16); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Generation, 24); @@ -469,6 +487,7 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Reserved1, 30); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadOffset, 32); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, PayloadLength, 36); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, Sequence, 40); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_INPUT_REPORT, EndpointGeneration, 48); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsDequeued, 16); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_STATS, OperationsCompleted, 24); @@ -515,7 +534,7 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, PerformanceFrequency, 24); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordCount, 32); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, RecordSize, 36); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Capacity, 40); -VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Reserved, 44); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, StatusFlags, 44); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_LIFECYCLE_TRACE, Records, 48); #undef VIIPER_UDE_ASSERT_OFFSET diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index b282af09..ff2b3913 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/15/2026,0.1.0.36 +DriverVer=08/15/2026,0.1.0.37 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index 40e5c108..ae09924f 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -81,7 +81,7 @@ if ($versionNodes.Count -ne 1) { } $driverPackageVersion = $versionNodes[0].InnerText.Trim() $driverABIMajor = 1 -$driverABIMinor = 12 +$driverABIMinor = 13 $driverCapabilities = [uint32]29 $driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $SourceRevision ` diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 4cc14f5e..098b5a3a 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -106,7 +106,7 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $source = $SourceRevision.ToLowerInvariant() $buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $source -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 12 -Capabilities 29 + -ABIMajor 1 -ABIMinor 13 -Capabilities 29 $manifest = [ordered]@{ schema = 2 @@ -117,7 +117,7 @@ $manifest = [ordered]@{ sourceRevision = $source driverPackageVersion = $driverVersion driverABIMajor = 1 - driverABIMinor = 12 + driverABIMinor = 13 driverCapabilities = '0x0000001d' driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index a1e205cd..f2bcd284 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -91,6 +91,7 @@ $requiredContracts = [ordered]@{ 'self-test-pristine-runtime-stats' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' 'exact negotiated capability identity' = 'response\.Capabilities == profile\.capabilities' + 'explicit ABI 1.13 profile' = '\{13, 29, 152, true\}' 'explicit ABI 1.12 profile' = '\{12, 29, 152, true\}' 'explicit ABI 1.11 profile' = '\{11, 29, 144, false\}' 'explicit ABI 1.10 profile' = '\{10, 13, 144, false\}' @@ -172,40 +173,51 @@ $requiredContracts = [ordered]@{ 'same-handle manifest binding' = 'Sha256Handle\(manifest\.get\(\)' 'final exact package enumeration' = 'ValidateExactPackageDirectory\(' 'reboot boundary rollback' = 'broker-reboot-boundary' - 'remove rollback backup' = 'BackupPackages\(' + 'fixed remove recovery root' = + 'kRemoveRecoveryRootDirectory\[\][\s\S]{0,60}L"VIIPER-UdeCx-RemoveTransactions"' + 'single remove active identity' = + 'kRemoveRecoveryActiveDirectory\[\] = L"active-v2"' + 'remove protected prior backup' = 'BackupPackagesIntoDirectory\(' 'protected rollback directory' = 'kRollbackDirectorySecurity' 'inherited rollback protection' = 'O:BAD:P\(A;OICI;FA;;;SY\)\(A;OICI;FA;;;BA\)' - 'unpredictable rollback directory' = 'CryptGenRandom\(' 'verified protected rollback ACLs' = 'VerifyProtectedFileSystemSecurity\(' 'protected exact rollback file copy' = 'CopyProtectedBackupFile\(' 'exact rollback package tree' = 'ValidateExactPackageDirectory\(destination' 'durable rollback package payloads' = 'rollback-backup-file-flush' 'immutable rollback package files' = 'LockPackageFiles\(destination, &locks' - 'pre-mutation rollback preservation' = 'ArmPreservation\(' 'protected recovery record' = 'kRecoveryRecordSecurity' - 'private recovery record staging name' = 'kRecoveryRecordTemporaryName' 'explicit recovery record flush' = 'FlushFileBuffers\(file\.get\(\)\)' 'atomic recovery record publish' = 'MoveFileExW\([\s\S]{0,180}MOVEFILE_WRITE_THROUGH' - 'recovery record read-back verification' = 'recovery-record-verify' - 'prepared write-ahead recovery state' = - '\\"state\\":\\"prepared-remove-transaction\\"' - 'manual-only recovery policy' = '\\"automaticRestore\\":false' - 'recovery signature and hash revalidation' = - '\\"requiredValidation\\":\[\\"inf-signature\\"[\s\S]{0,180}\\"cat-sha256\\"\]' + 'remove record read-back verification' = 'remove-journal-readback' + 'remove hash chain' = 'WriteRemoveJournalRecord\(' + 'remove automatic recovery' = 'ReconcileRemoveJournal\(' + 'remove legal transitions' = 'ValidateRemoveJournalTransition\(' + 'remove manual latch' = 'RemoveJournalPhase::ManualReconciliationRequired' + 'remove deterministic model' = 'RunRemoveJournalModelSelfTest\(' 'recovery record path emission' = 'recoveryRecordWritten=' 'retained backup path emission' = 'recoveryBackupRetained=' - 'pre-journal retained backup reporting' = 'recovery-record-not-published' 'recovery relative path confinement' = 'IsSafeRecoveryRelativePath\(' - 'unique devnode package recovery binding' = '\\"packageIndex\\":' - 'checked rollback backup cleanup' = 'if \(!backupRoot\.Cleanup\(&backups' + 'exact package cutpoint identity' = '\\"activePackageIndex\\":' + 'atomic remove terminal retirement' = 'RetireRemoveRecoveryActiveDirectory\(' + 'loaded remove terminal retirement' = 'RetireLoadedRemoveJournal\(' + 'remove descendant lock release regression' = + 'RunRemoveJournalRetirementSelfTest\(' + 'single captured remove target' = 'RemoveExactCapturedDevice\(' + 'remove rollback exact-absence restore policy' = + 'RestorePriorBindingPolicy::RemoveJournalExactAbsence' + 'fresh remove rollback deadline' = 'FreshRemoveRollbackDeadline\(' + 'crossed remove reboot manual latch' = + 'CrossedRemoveRebootStillPendingRequiresManual\(' + 'retained settled tombstone warning' = + 'warning=\\"remove-settled-cleanup-retained\\"' 'top-level exception boundary' = 'catch \(\.\.\.\)' 'exception-safe active recovery path' = 'gActiveRecoveryRecordWritten' 'exception-safe mutation classification' = 'gTransactionMutationStarted' 'remove deadline parser' = 'ParseRemoveOptions\(' - 'remove mutation deadline' = 'remove-deadline-before-device' + 'remove mutation deadline' = 'remove-journal-package-deadline' 'finite remove rollback ceiling' = 'kDriverRollbackCeilingMs' - 'remove rollback deadline' = 'remove-rollback-deadline-package' + 'remove rollback deadline' = 'remove-journal-restore-package-deadline' 'transaction mutex' = 'VIIPER_UDE_DRIVER_TRANSACTION_V1' 'protected private transaction namespace' = 'CreatePrivateNamespaceW\(' 'protected transaction object DACL' = 'D:P\(A;;GA;;;SY\)\(A;;GA;;;BA\)' @@ -473,13 +485,225 @@ $removeEntrySource = Get-SourceContractRegion -Text $source ` Assert-OrderedSourceFragments -Text $removeEntrySource -Name 'remove pre-mutation reconciliation' ` -Fragments @( 'mutex.Acquire(', + 'ReconcileRemoveJournal(', 'ReconcileInstallJournal(', 'CaptureSnapshot(', - 'BackupPackages(' + 'PrepareRemoveJournal(', + 'ReconcileRemoveJournal(' ) +$removePrepareSource = Get-SourceContractRegion -Text $source ` + -Start 'bool PrepareRemoveJournal(' -End 'enum class RemoveRootShape' ` + -Name 'remove journal preparation' +Assert-OrderedSourceFragments -Text $removePrepareSource ` + -Name 'remove evidence before Prepared' -Fragments @( + 'OpenChain(true', + 'PublishRemoveRecoveryEvidence(', + 'BackupPackagesIntoDirectory(', + 'ValidateRemoveJournalTransition(nullptr', + 'WriteRemoveJournalRecord(' + ) + +$removeRecordSource = Get-SourceContractRegion -Text $source ` + -Start 'bool AppendRemoveJournalRecord(' -End 'bool PrepareRemoveJournal(' ` + -Name 'remove append-only record' -LastStart +Assert-OrderedSourceFragments -Text $removeRecordSource ` + -Name 'remove atomic state publication' -Fragments @( + 'ValidateRemoveJournalTransition(', + 'WriteRemoveJournalRecord(', + 'loaded->state = std::move(next);', + 'PublishRemoveRecoveryEvidence(' + ) +if (-not $removeRecordSource.Contains('loaded->poisoned = true')) { + throw 'ViiperUdeCtl must poison remove recovery after an indeterminate append.' +} + +$removeRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireLoadedRemoveJournal(' -End 'bool AppendRemoveJournalRecord(' ` + -Name 'loaded remove journal retirement' +Assert-OrderedSourceFragments -Text $removeRetireSource ` + -Name 'remove descendant evidence release immediately before rename' -Fragments @( + 'RemoveJournalPhase::ForwardValidated', + 'RemoveJournalPhase::ExactPriorRestored', + 'const std::string transactionId', + 'loaded->priorBackups.clear();', + 'loaded->evidenceLocks.clear();', + 'return RetireRemoveRecoveryActiveDirectory(' + ) + +$removeRawRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveRecoveryActiveDirectory(' ` + -End 'struct RemoveJournalStateData {' ` + -Name 'raw remove terminal retirement' +Assert-OrderedSourceFragments -Text $removeRawRetireSource ` + -Name 'remove tombstone retirement evidence' -Fragments @( + 'MoveFileExW(', + 'error->recoveryBackup = tombstone.wstring();', + 'ClearActiveRecoveryEvidence();', + 'std::filesystem::remove_all(', + 'gRetainedRemoveTombstoneError', + 'OutputDebugStringW(' + ) + +$removePriorRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveJournalAsPrior(' ` + -End 'bool RetireRemoveJournalAsUninstalled(' ` + -Name 'remove prior terminal retirement' +Assert-OrderedSourceFragments -Text $removePriorRetireSource ` + -Name 'remove prior terminal double validation' -Fragments @( + 'CurrentRemoveStateMatchesPrior(', + 'RemoveJournalPhase::ExactPriorRestored', + 'RecordRemoveJournalPhase(', + 'CurrentRemoveStateMatchesPrior(', + 'RetireLoadedRemoveJournal(' + ) +if (-not $removePriorRetireSource.Contains( + 'if (outcome->error.recoveryBackup.empty())')) { + throw 'Prior retirement must preserve an exact tombstone failure path instead of overwriting it with absent active-v2.' +} + +$removeForwardRetireSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RetireRemoveJournalAsUninstalled(' ` + -End 'bool FailRemoveJournalManual(' ` + -Name 'remove forward terminal retirement' +Assert-OrderedSourceFragments -Text $removeForwardRetireSource ` + -Name 'remove forward terminal double validation' -Fragments @( + 'CurrentRemoveStateIsUninstalled(', + 'RemoveJournalPhase::ForwardValidated', + 'RecordRemoveJournalPhase(', + 'CurrentRemoveStateIsUninstalled(', + 'RetireLoadedRemoveJournal(' + ) +if (-not $removeForwardRetireSource.Contains( + 'if (outcome->error.recoveryBackup.empty())')) { + throw 'Forward retirement must preserve an exact tombstone failure path instead of overwriting it with absent active-v2.' +} + +$removeManualSource = Get-SourceContractRegion -Text $source ` + -Start 'bool FailRemoveJournalManual(' ` + -End 'bool ReturnRemoveJournalRebootPending(' ` + -Name 'remove manual evidence retention' +if (-not $removeManualSource.Contains('!cause->recoveryBackup.empty()') -or + $removeManualSource.Contains('RetireLoadedRemoveJournal(')) { + throw 'Manual recovery must preserve callee tombstone evidence and must never release terminal evidence locks.' +} + +$removeDeviceSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RemoveExactCapturedDevice(' -End 'bool RegisterRootDevice(' ` + -Name 'single captured device removal' +Assert-OrderedSourceFragments -Text $removeDeviceSource ` + -Name 'single captured device immutable revalidation' -Fragments @( + 'FindExactDevices(', + 'LoadOwnedPackage(', + 'IsExactCapturedRemoveTarget(', + 'return RemoveDevice(' + ) + +$removeRollbackSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunRemoveRollbackRecovery(' -End 'bool AdmitRemoveRollback(' ` + -Name 'remove rollback recovery' +Assert-OrderedSourceFragments -Text $removeRollbackSource ` + -Name 'interrupted binding admission reuse' -Fragments @( + 'ReusesInterruptedRemoveBindingAdmission(', + '!reusingInterruptedBindingAdmission', + 'RemoveJournalPhase::RollbackBindingEntered', + 'ObserveRemoveRootShape(', + 'VerifyPackageInventory(', + 'RestorePriorBinding(' + ) +Assert-OrderedSourceFragments -Text $removeRollbackSource ` + -Name 'remove rollback exact-absence binding authority' -Fragments @( + 'ObserveRemoveRootShape(', + 'root != RemoveRootShape::Absent', + 'VerifyPackageInventory(', + 'RestorePriorBinding(restorable,', + 'RestorePriorBindingPolicy::RemoveJournalExactAbsence' + ) + +$restoreBindingSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RestorePriorBinding(' -End 'bool RollbackInstall(' ` + -Name 'prior binding restore policy' +Assert-OrderedSourceFragments -Text $restoreBindingSource ` + -Name 'remove exact-absence race fails before mutation' -Fragments @( + 'CaptureSnapshot(', + 'RestorePriorBindingTopologyAdmitsMutation(', + 'RestorePriorBindingPolicy::InstallRollbackReconcile &&', + 'RemoveDevice(', + 'RegisterRootDeviceExact(', + 'InstallPreinstalledDriverOnDevice(' + ) +if (-not $restoreBindingSource.Contains( + 'policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence') -or + -not $source.Contains( + 'self-test-remove-journal-binding-exact-absence-race')) { + throw 'Remove rollback must reject a concurrently appeared root before any restore mutation.' +} + +$removeAdmissionSource = Get-SourceContractRegion -Text $source ` + -Start 'bool AdmitRemoveRollback(' -End 'bool RunRemoveForwardRecovery(' ` + -Name 'remove rollback admission' +Assert-OrderedSourceFragments -Text $removeAdmissionSource ` + -Name 'durable rollback admission and fresh deadline' -Fragments @( + 'RemoveJournalPhase::RestoreRebootPending', + 'RecordRemoveJournalPhase(', + 'FreshRemoveRollbackDeadline();', + 'RunRemoveRollbackRecovery(' + ) +if ($removeAdmissionSource.Contains('deadlineUnixMs')) { + throw 'Forward-to-rollback admission must not accept or reuse the exhausted forward deadline.' +} + +$removeForwardSource = Get-SourceContractRegion -Text $source ` + -Start 'bool RunRemoveForwardRecovery(' -End 'bool ReconcileRemoveJournal(' ` + -Name 'remove forward recovery' +Assert-OrderedSourceFragments -Text $removeForwardSource ` + -Name 'crossed reboot loop fails closed' -Fragments @( + 'CrossedRemoveRebootStillPendingRequiresManual(', + 'FailRemoveJournalManual(', + 'ReturnRemoveJournalRebootPending(' + ) +$crossedRebootSource = Get-SourceContractRegion -Text $source ` + -Start 'bool CrossedRemoveRebootStillPendingRequiresManual(' ` + -End 'bool ReusesInterruptedRemoveBindingAdmission(' ` + -Name 'crossed remove reboot decision' +foreach ($fragment in @( + 'RemoveJournalPhase::DeviceRemovalReturned', + 'callSucceeded', + 'freshRebootRequired', + '!samePendingBoot' +)) { + if (-not $crossedRebootSource.Contains($fragment)) { + throw "Returned-to-pending crossed reboot decision lost '$fragment'." + } +} +if (-not $source.Contains( + 'self-test-remove-journal-device-returned-pending-cut')) { + throw 'ViiperUdeCtl lost the compiled DeviceRemovalReturned-to-pending crash cut test.' +} +if ($removeForwardSource.Contains('RemoveAllExactDevices(') -or + -not $removeForwardSource.Contains('RemoveExactCapturedDevice(')) { + throw 'Protected forward removal must target one immutable captured root and never call broad all-device removal.' +} + +$removeReconcileSource = Get-SourceContractRegion -Text $source ` + -Start 'bool ReconcileRemoveJournal(' -End 'struct RemoveOptions {' ` + -Name 'remove startup reconciliation' -LastStart +foreach ($fragment in @( + 'LoadRemoveJournal(', + 'InstallRecoveryDirectory installDirectory', + 'installExists', + 'ManualReconciliationRequired', + 'GetBootIdentifier(', + 'RunRemoveRollbackRecovery(', + 'RunRemoveForwardRecovery(' +)) { + if (-not $removeReconcileSource.Contains($fragment)) { + throw "ViiperUdeCtl remove reconciliation lost '$fragment'." + } +} + $reconcileSource = Get-SourceContractRegion -Text $source ` - -Start 'bool ReconcileInstallJournal(' -End 'bool RollbackRemove(' ` + -Start 'bool ReconcileInstallJournal(' -End 'const char* RemoveJournalPhaseName(' ` -Name 'startup journal reconciliation' -LastStart foreach ($fragment in @( 'ForwardRebootPending && sameBoot', @@ -659,16 +883,16 @@ $orderedMutationContracts = [ordered]@{ 'AbiHealthPurpose::RollbackHealth[\s\S]{0,400}RollbackLifecycleStateMatches\([\s\S]{0,300}rollback-stopped-state-verification' 'broker handoff follows exact binding verification and precedes nested commit' = 'VerifyInstalledBinding\([\s\S]{0,12000}SignalBrokerHandoff\([\s\S]{0,800}RunBrokerInstall\(' - 'recovery journal is published and preservation armed before mutation' = - 'BuildRemoveRecoveryRecord\([\s\S]{0,300}WriteProtectedRecoveryRecord\([\s\S]{0,240}ArmPreservation\([\s\S]{0,700}RemoveAllExactDevices\(' - 'failed remove rollback preserves published evidence before return' = - 'AttachRecoveryRecord\(&rollbackError\);[\s\S]{0,180}outcome\.rollback = L"failed";[\s\S]{0,300}return outcome;' - 'verified rollback performs checked evidence cleanup' = - 'outcome\.rollback = L"succeeded";[\s\S]{0,300}backupRoot\.Cleanup\(&backups, &cleanupError\)[\s\S]{0,300}return outcome;' - 'committed removal performs checked evidence cleanup before success' = - 'if \(!backupRoot\.Cleanup\(&backups, &cleanupError\)\)[\s\S]{0,240}ExitCode::RollbackFailed;[\s\S]{0,180}return outcome;[\s\S]{0,100}outcome\.success = true;' - 'preservation disarms only after verified evidence absence' = - 'std::filesystem::exists\(path_, presenceError\)[\s\S]{0,260}if \(removalError \|\| presenceError \|\| remains\)[\s\S]{0,900}preserve_ = false;[\s\S]{0,100}ClearActiveRecoveryEvidence\(\);' + 'remove journal and exact backups precede first mutation' = + 'Outcome Remove\([\s\S]{0,6000}PrepareRemoveJournal\([\s\S]{0,1400}ReconcileRemoveJournal\(' + 'remove package admission is revalidated before exact mutation' = + 'PackageRemovalEntered[\s\S]{0,1200}ObserveRemovePackagePrefix\([\s\S]{0,1200}InvokeRemovePackageMutation\(' + 'rollback package admission is revalidated before exact restoration' = + 'RollbackPackageEntered[\s\S]{0,1200}ObserveRemovePackageSubset\([\s\S]{0,1800}InvokeRestorePackageMutation\(' + 'forward removal retires only after double exact validation' = + 'CurrentRemoveStateIsUninstalled\([\s\S]{0,800}ForwardValidated[\s\S]{0,800}CurrentRemoveStateIsUninstalled\([\s\S]{0,800}RetireLoadedRemoveJournal\(' + 'rollback retires only after double exact prior validation' = + 'CurrentRemoveStateMatchesPrior\([\s\S]{0,800}ExactPriorRestored[\s\S]{0,800}CurrentRemoveStateMatchesPrior\([\s\S]{0,800}RetireLoadedRemoveJournal\(' 'exception outcome distinguishes preflight from mutation' = 'const bool changed = gTransactionMutationStarted;[\s\S]{0,180}changed[\s\S]{0,100}ExitCode::RollbackFailed : ExitCode::PreflightRejected;' } @@ -745,8 +969,8 @@ if ($source -match 'SetupDiCreateDeviceInfoW\([\s\S]{0,120}className\.c_str\(\)' throw 'Forward root creation must use the VIIPER-owned device-name namespace, not the INF class name.' } -if ([regex]::Matches($source, '\bRemoveAllExactDevices\(').Count -ne 2) { - throw 'All-device removal is allowed only for explicit forward uninstall, never rollback.' +if ($source -match '\bRemoveAllExactDevices\(') { + throw 'Protected removal must never retain broad all-device mutation plumbing.' } if ([regex]::Matches($source, 'VerifyDriverCatalogMember\(catalogPath').Count -ne 4) { diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index 9ee51661..dd7ed631 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -156,11 +156,11 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 12 -Capabilities 29 + -ABIMajor 1 -ABIMinor 13 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or [string]$manifest.driverPackageVersion -cne $driverVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 12 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 13 -or [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or -not [bool]$manifest.releaseEligible -or diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index 12128faf..ba22659d 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -739,11 +739,11 @@ $driverPackageVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverPackageVersion ` - -ABIMajor 1 -ABIMinor 12 -Capabilities 29 + -ABIMajor 1 -ABIMinor 13 -Capabilities 29 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 12 -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 13 -or [string]$manifest.driverCapabilities -cne '0x0000001d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 79012ae7..308d2bd8 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -103,7 +103,8 @@ struct AbiCompatibilityProfile { bool hasReservedPortFields; }; -constexpr std::array kAbiCompatibilityProfiles{{ +constexpr std::array kAbiCompatibilityProfiles{{ + {13, 29, 152, true}, {12, 29, 152, true}, {11, 29, 144, false}, {10, 13, 144, false}, @@ -114,20 +115,25 @@ constexpr bool AbiCompatibilityProfilesAreValid() noexcept { kAbiCompatibilityProfiles[0].capabilities == VIIPER_UDE_ADVERTISED_CAPABILITIES && kAbiCompatibilityProfiles[0].statsSize == sizeof(VIIPER_UDE_STATS) && kAbiCompatibilityProfiles[0].hasReservedPortFields && - kAbiCompatibilityProfiles[1].minor == 11 && + kAbiCompatibilityProfiles[1].minor == 12 && kAbiCompatibilityProfiles[1].capabilities == 29 && - kAbiCompatibilityProfiles[1].statsSize == 144 && - !kAbiCompatibilityProfiles[1].hasReservedPortFields && - kAbiCompatibilityProfiles[2].minor == 10 && - kAbiCompatibilityProfiles[2].capabilities == 13 && + kAbiCompatibilityProfiles[1].statsSize == 152 && + kAbiCompatibilityProfiles[1].hasReservedPortFields && + kAbiCompatibilityProfiles[2].minor == 11 && + kAbiCompatibilityProfiles[2].capabilities == 29 && kAbiCompatibilityProfiles[2].statsSize == 144 && !kAbiCompatibilityProfiles[2].hasReservedPortFields && + kAbiCompatibilityProfiles[3].minor == 10 && + kAbiCompatibilityProfiles[3].capabilities == 13 && + kAbiCompatibilityProfiles[3].statsSize == 144 && + !kAbiCompatibilityProfiles[3].hasReservedPortFields && kAbiCompatibilityProfiles[0].minor == kAbiCompatibilityProfiles[1].minor + 1 && - kAbiCompatibilityProfiles[1].minor == kAbiCompatibilityProfiles[2].minor + 1; + kAbiCompatibilityProfiles[1].minor == kAbiCompatibilityProfiles[2].minor + 1 && + kAbiCompatibilityProfiles[2].minor == kAbiCompatibilityProfiles[3].minor + 1; } static_assert(VIIPER_UDE_ABI_MAJOR == 1, "ABI compatibility table major drift"); -static_assert(VIIPER_UDE_ABI_MINOR == 12, "ABI compatibility table current minor drift"); +static_assert(VIIPER_UDE_ABI_MINOR == 13, "ABI compatibility table current minor drift"); static_assert(VIIPER_UDE_ADVERTISED_CAPABILITIES == 29, "ABI compatibility table current capabilities drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 152, @@ -176,24 +182,43 @@ constexpr wchar_t kRollbackDirectorySecurity[] = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; constexpr wchar_t kRecoveryRecordSecurity[] = L"O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)"; -constexpr wchar_t kRecoveryRecordName[] = L"recovery-v1.json"; -constexpr wchar_t kRecoveryRecordTemporaryName[] = L"recovery-v1.json.tmp"; constexpr size_t kMaximumRecoveryRecordBytes = 256U * 1024U; constexpr wchar_t kInstallRecoveryProductDirectory[] = L"VIIPER"; constexpr wchar_t kInstallRecoveryComponentDirectory[] = L"UdeCx"; constexpr wchar_t kInstallRecoveryTransactionsDirectory[] = L"Transactions"; constexpr wchar_t kInstallRecoveryActiveDirectory[] = L"active-v2"; constexpr wchar_t kInstallRecoverySettledPrefix[] = L"settled-v2-"; +constexpr wchar_t kInstallRecoveryDiscardPrefix[] = L"discarding-v2-"; constexpr wchar_t kInstallRecoveryJournalPrefix[] = L"journal-"; constexpr wchar_t kInstallRecoveryJournalSuffix[] = L".json"; constexpr wchar_t kInstallRecoveryTemporarySuffix[] = L".tmp"; constexpr wchar_t kInstallRecoveryPriorDirectory[] = L"prior"; constexpr wchar_t kInstallRecoveryCandidateDirectory[] = L"candidate"; +constexpr wchar_t kInstallRecoveryBrokerDirectory[] = L"broker"; +constexpr wchar_t kInstallRecoveryBrokerExecutable[] = L"viiper.exe"; constexpr size_t kMaximumInstallRecoveryRecords = 96; constexpr std::string_view kInstallRecoveryKind = "VIIPER-UDE-install-switch-recovery"; +constexpr wchar_t kRemoveRecoveryRootDirectory[] = + L"VIIPER-UdeCx-RemoveTransactions"; +constexpr wchar_t kRemoveRecoveryActiveDirectory[] = L"active-v2"; +constexpr wchar_t kRemoveRecoverySettledPrefix[] = L"settled-v2-"; +constexpr wchar_t kRemoveRecoveryPriorDirectory[] = L"prior"; +constexpr size_t kMaximumRemoveRecoveryRecords = 256; +constexpr std::string_view kRemoveRecoveryKind = + "VIIPER-UDE-remove-transaction-recovery"; constexpr std::string_view kZeroSha256 = "0000000000000000000000000000000000000000000000000000000000000000"; +constexpr wchar_t kBrokerSettlementRequestFile[] = L"outer-settlement.json"; +constexpr wchar_t kBrokerSettlementFinalFile[] = L"outer-settled.json"; +constexpr wchar_t kBrokerTransactionDirectory[] = L"BrokerTransactions"; +constexpr wchar_t kBrokerTransactionActiveDirectory[] = L"active-v1"; +constexpr size_t kMaximumBrokerSettlementRequestBytes = 16U * 1024U; +constexpr wchar_t kNativeInstallMutexNamespace[] = + L"VIIPER_NATIVE_INSTALL_NAMESPACE_V1"; +constexpr wchar_t kNativeInstallMutexBoundary[] = + L"VIIPER_NATIVE_INSTALL_ADMIN_BOUNDARY_V1"; +constexpr wchar_t kNativePackageInstallMutex[] = L"VIIPER.NativePackage.Install.v1"; constexpr std::string_view kHardwareVerificationOid = "1.3.6.1.4.1.311.10.3.5"; constexpr std::string_view kAttestationVerificationOid = "1.3.6.1.4.1.311.10.3.5.1"; @@ -206,6 +231,8 @@ std::array gActiveRecoveryRecord{}; bool gActiveRecoveryRecordWritten = false; std::array gActiveBackupRoot{}; bool gActiveBackupRootRetained = false; +std::array gRetainedRemoveTombstone{}; +DWORD gRetainedRemoveTombstoneError = ERROR_SUCCESS; bool gTransactionMutationStarted = false; bool gLastSynchronousMutationTimedOut = false; @@ -220,6 +247,11 @@ void ClearActiveRecoveryEvidence() noexcept { gActiveBackupRootRetained = false; } +void ClearRemoveRetirementWarning() noexcept { + gRetainedRemoveTombstone.fill(L'\0'); + gRetainedRemoveTombstoneError = ERROR_SUCCESS; +} + constexpr GUID kViiperInterfaceGuid = { 0x32d03f48, 0x725b, 0x4baa, {0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}}; @@ -251,6 +283,19 @@ bool IsGeneratedRootInstanceIdForDeviceName( const std::wstring& instanceId, const wchar_t* deviceName); bool IsOwnedGeneratedRootInstanceId(const std::wstring& instanceId); +struct BrokerJournalBinding { + bool present = false; + std::string transactionId; + std::string outerTransactionId; + std::string candidateSha256; + std::string state; + std::string digest; + std::string driverTransactionId; + std::string driverDigest; + std::string settlementNonce; + std::string recovery; +}; + struct Outcome { bool success = false; bool changed = false; @@ -258,6 +303,7 @@ struct Outcome { ExitCode exitCode = ExitCode::Failure; Error error; std::wstring rollback = L"not-needed"; + BrokerJournalBinding brokerBinding; }; std::wstring FormatError(DWORD error) { @@ -346,7 +392,36 @@ void EmitOutcome(const wchar_t* operation, const Outcome& outcome) { : gActiveBackupRootRetained) ? 1 : 0); } } + if (gRetainedRemoveTombstone[0] != L'\0') { + stream << L" warning=\"remove-settled-cleanup-retained\"" + << L" warningWin32Error=" + << gRetainedRemoveTombstoneError + << L" retainedTombstone=" + << std::quoted(gRetainedRemoveTombstone.data()); + } stream << L"\n"; + if (outcome.success && outcome.brokerBinding.present) { + const auto wide = [](const std::string& value) { + return std::wstring(value.begin(), value.end()); + }; + stream << L"journal-binding operation=install transactionId=" + << wide(outcome.brokerBinding.transactionId) + << L" outerTransactionId=" + << wide(outcome.brokerBinding.outerTransactionId) + << L" candidateSha256=" + << wide(outcome.brokerBinding.candidateSha256) + << L" state=" << wide(outcome.brokerBinding.state) + << L" digest=" << wide(outcome.brokerBinding.digest) + << L" driverTransactionId=" + << wide(outcome.brokerBinding.driverTransactionId) + << L" driverDigest=" + << wide(outcome.brokerBinding.driverDigest) + << L" settlementNonce=" + << wide(outcome.brokerBinding.settlementNonce) + << L" recovery=" << wide(outcome.brokerBinding.recovery) + << L"\n"; + } + stream.flush(); } class WinHandle final { @@ -590,6 +665,71 @@ class TransactionMutex final { bool abandoned_ = false; }; +class OuterPackageMutexWitness final { +public: + ~OuterPackageMutexWitness() { + mutex_.reset(); + if (namespace_ != nullptr) { + ClosePrivateNamespace(namespace_, 0); + } + if (boundary_ != nullptr) { + DeleteBoundaryDescriptor(boundary_); + } + } + + bool VerifyHeldByOuterOwner(Error* error) { + BYTE administratorsBuffer[SECURITY_MAX_SID_SIZE]{}; + DWORD administratorsSize = sizeof(administratorsBuffer); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, + administratorsBuffer, &administratorsSize)) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-sid"); + } + boundary_ = CreateBoundaryDescriptorW( + kNativeInstallMutexBoundary, 0); + if (boundary_ == nullptr || + !AddSIDToBoundaryDescriptor( + &boundary_, administratorsBuffer)) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-boundary"); + } + namespace_ = OpenPrivateNamespaceW( + boundary_, kNativeInstallMutexNamespace); + if (namespace_ == nullptr) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-namespace", + L"the authenticated outer package mutex namespace is absent"); + } + const std::wstring name = + std::wstring(kNativeInstallMutexNamespace) + L"\\" + + kNativePackageInstallMutex; + mutex_.reset(OpenMutexW( + SYNCHRONIZE | MUTEX_MODIFY_STATE, FALSE, name.c_str())); + if (!mutex_) { + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-open"); + } + const DWORD wait = WaitForSingleObject(mutex_.get(), 0); + if (wait == WAIT_TIMEOUT) { + return true; + } + if (wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED) { + ReleaseMutex(mutex_.get()); + return SetError(error, + L"broker-settlement-outer-mutex-owner", + ERROR_ACCESS_DENIED, + L"outer settlement requires a different process to retain the package mutex"); + } + return SetLastErrorDetail(error, + L"broker-settlement-outer-mutex-wait"); + } + +private: + HANDLE namespace_ = nullptr; + HANDLE boundary_ = nullptr; + WinHandle mutex_; +}; + bool IsElevated() { WinHandle token; HANDLE raw = nullptr; @@ -2203,6 +2343,8 @@ enum class InstallJournalPhase { BrokerHandoffReturned, BrokerChildEntered, BrokerChildSettled, + BrokerOuterSettlementPending, + BrokerOuterSettled, RollbackBindingEntered, PartialRootRemovalEntered, PartialRootRemovalReturned, @@ -2222,6 +2364,32 @@ enum class InstallJournalDirection { Rollback, }; +enum class RemoveJournalPhase { + Prepared, + DeviceRemovalEntered, + DeviceRemovalReturned, + DeviceRemovalCommitted, + PackageRemovalEntered, + PackageRemovalReturned, + PackageRemovalCommitted, + RollbackAdmitted, + RollbackPackageEntered, + RollbackPackageReturned, + RollbackPackageCommitted, + RollbackBindingEntered, + RollbackBindingReturned, + ForwardValidated, + ExactPriorRestored, + ForwardRebootPending, + RestoreRebootPending, + ManualReconciliationRequired, +}; + +enum class RemoveJournalDirection { + Forward, + Rollback, +}; + bool RecordActiveInstallJournalCutpoint( InstallJournalPhase phase, bool callSucceeded, @@ -2645,46 +2813,64 @@ bool RemoveDevice( return true; } -bool RemoveAllExactDevices( +bool IsExactCapturedRemoveTarget( + const DeviceState& expected, + const std::vector& observed) noexcept { + return observed.size() == 1U && + IsOwnedGeneratedRootInstanceId(observed[0].instanceId) && + _wcsicmp(observed[0].service.c_str(), kServiceName) == 0 && + IsSafePublishedInfName(observed[0].publishedInf) && + SameRootBinding(expected, observed[0]); +} + +bool RemoveExactCapturedDevice( + const DeviceState& expected, uint64_t transactionDeadlineUnixMs, bool* mutationStarted, bool* rebootRequired, Error* error) { DeviceInfoSet set = OpenRootDevices(); if (!set) { - return SetLastErrorDetail(error, L"open-root-devices"); + return SetLastErrorDetail(error, + L"remove-journal-open-captured-root"); } std::vector> matches; - if (!FindExactDevices(set.get(), &matches, error)) { - return false; - } + if (!FindExactDevices(set.get(), &matches, error)) return false; + std::filesystem::path infDirectory; - if (!GetSystemInfDirectory(&infDirectory, error)) { - return false; - } + if (!GetSystemInfDirectory(&infDirectory, error)) return false; + std::vector observed; + observed.reserve(matches.size()); for (auto& match : matches) { DeviceState& device = match.second; - if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || - _wcsicmp(device.service.c_str(), kServiceName) != 0 || - !IsSafePublishedInfName(device.publishedInf)) { - return SetError(error, L"remove-ownership", ERROR_ACCESS_DENIED, - L"refusing to remove an exact hardware ID not owned by the signed VIIPER package"); - } PackageInfo package; bool owned = false; - if (!LoadOwnedPackage(infDirectory / device.publishedInf, true, false, - &package, &owned, error) || !owned) { + if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + !IsSafePublishedInfName(device.publishedInf) || + !LoadOwnedPackage(infDirectory / device.publishedInf, + true, false, &package, &owned, error) || !owned || + !(device.version == package.version)) { + if (error == nullptr || error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-captured-root-identity", + ERROR_REVISION_MISMATCH, + L"the admitted root no longer has its exact captured package identity"); + } return false; } + package.publishedName = device.publishedInf; + device.package = std::move(package); + observed.push_back(device); } - for (auto& match : matches) { - if (!RemoveDevice(set.get(), match.first, transactionDeadlineUnixMs, - L"remove-deadline-before-device-mutation", mutationStarted, - rebootRequired, error)) { - return false; - } + if (!IsExactCapturedRemoveTarget(expected, observed)) { + return SetError(error, L"remove-journal-captured-root-authority", + ERROR_REVISION_MISMATCH, + L"device removal requires exactly one root with the immutable captured instance, service, published INF, version, and package bytes"); } - return true; + return RemoveDevice(set.get(), matches[0].first, + transactionDeadlineUnixMs, + L"remove-deadline-before-device-mutation", mutationStarted, + rebootRequired, error); } bool RegisterRootDevice( @@ -3694,8 +3880,24 @@ bool RemoveStagedCandidateExact( return true; } +enum class RestorePriorBindingPolicy { + InstallRollbackReconcile, + RemoveJournalExactAbsence, +}; + +bool RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy policy, + size_t priorDeviceCount, + size_t currentDeviceCount) noexcept { + if (policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence) { + return priorDeviceCount == 1U && currentDeviceCount == 0U; + } + return priorDeviceCount <= 1U && currentDeviceCount <= 1U; +} + bool RestorePriorBinding( const Snapshot& prior, + RestorePriorBindingPolicy policy, uint64_t transactionDeadlineUnixMs, bool* rebootRequired, Error* error) { @@ -3712,13 +3914,27 @@ bool RestorePriorBinding( } return false; } + if (!RestorePriorBindingTopologyAdmitsMutation( + policy, prior.devices.size(), current.devices.size())) { + return SetError(error, + policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence + ? L"remove-rollback-exact-absence-raced" + : L"rollback-topology", + current.devices.empty() + ? ERROR_INVALID_DATA + : ERROR_REVISION_MISMATCH, + policy == RestorePriorBindingPolicy::RemoveJournalExactAbsence + ? L"remove rollback requires one captured prior root and a fresh exact-absence observation; a concurrent root forbids all binding mutation" + : L"rollback observed an unsupported native topology"); + } const auto sameIdentity = [](const std::wstring& left, const std::wstring& right) { return _wcsicmp(left.c_str(), right.c_str()) == 0; }; const bool keepCurrent = !prior.devices.empty() && !current.devices.empty() && sameIdentity(prior.devices[0].instanceId, current.devices[0].instanceId); - if (!current.devices.empty() && !keepCurrent) { + if (policy == RestorePriorBindingPolicy::InstallRollbackReconcile && + !current.devices.empty() && !keepCurrent) { DeviceInfoSet set = OpenRootDevices(); if (!set) { return SetLastErrorDetail(error, L"rollback-open-root-devices"); @@ -3814,8 +4030,9 @@ bool RollbackInstall( return false; } if (bindingMutationStarted) { - if (!RestorePriorBinding( - prior, rollbackDeadlineUnixMs, rebootRequired, error)) { + if (!RestorePriorBinding(prior, + RestorePriorBindingPolicy::InstallRollbackReconcile, + rollbackDeadlineUnixMs, rebootRequired, error)) { return false; } } else if (!CaptureAndVerifyRootUnchanged( @@ -4015,6 +4232,8 @@ class InstallJournal final { const std::wstring& publishedName, bool rebootRequired, uint64_t deadlineUnixMs, + BrokerJournalBinding* binding, + std::string_view recovery, Error* error); bool RetireAfterPriorValidation( bool rebootRequired, @@ -4067,6 +4286,17 @@ bool ReconcileInstallJournal( uint64_t deadlineUnixMs, Outcome* outcome); +bool ReconcileSettledBrokerOuterSettlement( + uint64_t deadlineUnixMs, + bool* handled, + Outcome* outcome, + Error* error); + +bool ReconcileRemoveJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome); + uint64_t CurrentUnixMilliseconds() { FILETIME now{}; GetSystemTimeAsFileTime(&now); @@ -4078,6 +4308,19 @@ uint64_t CurrentUnixMilliseconds() { ? 0 : (ticks.QuadPart - windowsToUnixEpochTicks) / 10000ULL; } +uint64_t SaturatingDeadlineAfter( + uint64_t nowUnixMs, + uint64_t durationMs) noexcept { + const uint64_t maximum = std::numeric_limits::max(); + return nowUnixMs > maximum - durationMs + ? maximum : nowUnixMs + durationMs; +} + +uint64_t FreshRemoveRollbackDeadline() { + return SaturatingDeadlineAfter( + CurrentUnixMilliseconds(), kDriverRollbackCeilingMs); +} + bool CheckTransactionDeadline(const InstallOptions& options, const wchar_t* phase, Error* error) { if (options.transactionDeadlineUnixMs == 0 || CurrentUnixMilliseconds() >= options.transactionDeadlineUnixMs) { @@ -4317,7 +4560,9 @@ std::wstring QuoteWindowsArgument(const std::wstring& value) { return quoted; } -std::wstring BuildBrokerCommitCommandLine(const InstallOptions& options) { +std::wstring BuildBrokerCommitCommandLine( + const InstallOptions& options, + bool recoveryOnly = false) { return QuoteWindowsArgument(options.brokerExecutable.wstring()) + L" native-package-broker-commit --token-file " + QuoteWindowsArgument(options.brokerToken.wstring()) + @@ -4330,7 +4575,8 @@ std::wstring BuildBrokerCommitCommandLine(const InstallOptions& options) { L" --target-user-sid " + QuoteWindowsArgument(options.targetUserSid) + L" --transaction-deadline-unix-ms " + - QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)); + QuoteWindowsArgument(std::to_wstring(options.transactionDeadlineUnixMs)) + + (recoveryOnly ? L" --recovery-only" : L""); } struct BrokerCommitProof { @@ -4340,8 +4586,66 @@ struct BrokerCommitProof { DWORD exitCode = ERROR_GEN_FAILURE; bool driverRollbackAuthorized = false; std::wstring diagnostic; + bool hasJournalProof = false; + std::string journalTransactionId; + std::string journalOuterTransactionId; + std::string journalCandidateSha256; + std::string journalState; + std::string journalDigest; }; +bool IsCanonicalLowerHex(std::string_view value, size_t length) noexcept { + return value.size() == length && + std::all_of(value.begin(), value.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'); + }); +} + +bool ParseBrokerJournalProofLine( + const std::string& line, + BrokerCommitProof* proof) { + std::istringstream stream(line); + std::array fields{}; + for (std::string& field : fields) { + if (!(stream >> field)) return false; + } + std::string extra; + if (stream >> extra || fields[0] != "journal-proof" || + fields[1] != "operation=native-package-broker-commit") { + return false; + } + const auto value = [&](size_t index, std::string_view prefix) { + return fields[index].starts_with(prefix) + ? fields[index].substr(prefix.size()) : std::string{}; + }; + BrokerCommitProof parsed = *proof; + parsed.journalTransactionId = value(2, "transactionId="); + parsed.journalOuterTransactionId = value(3, "outerTransactionId="); + parsed.journalCandidateSha256 = value(4, "candidateSha256="); + parsed.journalState = value(5, "state="); + parsed.journalDigest = value(6, "digest="); + std::string canonical = + "journal-proof operation=native-package-broker-commit transactionId=" + + parsed.journalTransactionId + " outerTransactionId=" + + parsed.journalOuterTransactionId + " candidateSha256=" + + parsed.journalCandidateSha256 + " state=" + parsed.journalState + + " digest=" + parsed.journalDigest; + if (canonical != line || + !IsCanonicalLowerHex(parsed.journalTransactionId, 32U) || + !IsCanonicalLowerHex(parsed.journalOuterTransactionId, 64U) || + !IsCanonicalLowerHex(parsed.journalCandidateSha256, 64U) || + !IsCanonicalLowerHex(parsed.journalDigest, 64U) || + (parsed.journalState != "nested-ready" && + parsed.journalState != "rollback-settled" && + parsed.journalState != "manual")) { + return false; + } + parsed.hasJournalProof = true; + *proof = std::move(parsed); + return true; +} + bool BrokerProofFieldsAreCanonical( bool success, bool changed, @@ -4425,6 +4729,7 @@ bool ParseBrokerCommitProof( false, true, "failed", 3, false}, }}; std::optional parsed; + std::optional journalProof; std::optional diagnostic; bool diagnosticSeen = false; bool diagnosticRejected = false; @@ -4459,6 +4764,15 @@ bool ParseBrokerCommitProof( match->driverRollbackAuthorized, }; } + if (line.starts_with("journal-proof")) { + BrokerCommitProof candidate; + if (!terminated || journalProof || + !ParseBrokerJournalProofLine(line, &candidate)) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker journal proof is not one canonical newline-terminated binding"); + } + journalProof = std::move(candidate); + } if (line.starts_with(kBrokerDiagnosticPrefix)) { if (!terminated || diagnosticSeen) { diagnosticRejected = true; @@ -4484,6 +4798,26 @@ bool ParseBrokerCommitProof( return SetError(error, L"broker-proof", ERROR_INVALID_DATA, L"nested broker process exit and structured outcome are missing or inconsistent"); } + const bool journalRequired = parsed->changed; + if (journalRequired != journalProof.has_value()) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker changed ownership without exactly one durable journal proof"); + } + if (journalProof) { + const std::string expectedState = parsed->success + ? "nested-ready" + : parsed->driverRollbackAuthorized ? "rollback-settled" : "manual"; + if (journalProof->journalState != expectedState) { + return SetError(error, L"broker-proof", ERROR_INVALID_DATA, + L"nested broker outcome and durable journal state disagree"); + } + parsed->hasJournalProof = true; + parsed->journalTransactionId = journalProof->journalTransactionId; + parsed->journalOuterTransactionId = journalProof->journalOuterTransactionId; + parsed->journalCandidateSha256 = journalProof->journalCandidateSha256; + parsed->journalState = journalProof->journalState; + parsed->journalDigest = journalProof->journalDigest; + } // Diagnostics are never transaction authority. Ambiguous, malformed, // unterminated, or success-adjacent text is discarded; only the exact // canonical result above controls changed/rollback classification. @@ -4552,11 +4886,16 @@ bool RunBrokerInstall( const InstallOptions& options, bool* driverRollbackAuthorized, bool* brokerChanged, + BrokerCommitProof* durableProof, + bool recoveryOnly, Error* error) { - // Until CreateProcess succeeds, no nested SCM/image mutation can have - // started, so the caller may safely restore its captured driver snapshot. - *driverRollbackAuthorized = true; + // Published ownership remains fail-closed until the exact child result and + // journal binding have both survived a write-through/readback append. + *driverRollbackAuthorized = false; *brokerChanged = false; + if (durableProof != nullptr) { + *durableProof = {}; + } if (options.brokerExecutable.empty() || !options.brokerExecutable.is_absolute() || options.brokerExecutable.filename().wstring() != L"viiper.exe" || options.brokerToken.empty() || !options.brokerToken.is_absolute() || @@ -4599,7 +4938,8 @@ bool RunBrokerInstall( L"staged native broker does not match the installer-bound SHA-256"); } - std::wstring commandLine = BuildBrokerCommitCommandLine(options); + std::wstring commandLine = + BuildBrokerCommitCommandLine(options, recoveryOnly); std::vector mutableCommand(commandLine.begin(), commandLine.end()); mutableCommand.push_back(L'\0'); SECURITY_ATTRIBUTES inheritedSecurity{}; @@ -4673,6 +5013,7 @@ bool RunBrokerInstall( *error = std::move(journalError); return false; } + *driverRollbackAuthorized = true; return SetError(error, L"broker-start", code); } MarkTransactionMutationStarted(); @@ -4766,12 +5107,15 @@ bool RunBrokerInstall( if (!ParseBrokerCommitProof(brokerOutput, exitCode, &proof, error)) { return false; } - *driverRollbackAuthorized = proof.driverRollbackAuthorized; - *brokerChanged = proof.changed; if (gActiveInstallJournal != nullptr && !gActiveInstallJournal->RecordBrokerProof(proof, error)) { return false; } + *driverRollbackAuthorized = proof.driverRollbackAuthorized; + *brokerChanged = proof.changed; + if (durableProof != nullptr) { + *durableProof = proof; + } if (!proof.success) { return SetBrokerCommitFailure(proof, error); } @@ -4791,7 +5135,9 @@ Outcome Install(const InstallOptions& options) { return outcome; } Outcome recoveryOutcome; - if (!ReconcileInstallJournal( + if (!ReconcileRemoveJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome) || + !ReconcileInstallJournal( false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { return recoveryOutcome; } @@ -5292,7 +5638,8 @@ Outcome Install(const InstallOptions& options) { options, L"transaction-deadline-before-broker", &brokerError) || !SignalBrokerHandoff(options, &brokerError) || !RunBrokerInstall( - options, &driverRollbackAuthorized, &brokerChanged, &brokerError)) { + options, &driverRollbackAuthorized, &brokerChanged, + nullptr, false, &brokerError)) { // The broker command includes authenticated health verification and // rolls back its own SCM/credential/legacy transaction. Keep the // driver snapshot alive in this process until that proof succeeds. @@ -5449,14 +5796,32 @@ Outcome Install(const InstallOptions& options) { if (!installJournal.RetireAfterForwardValidation( candidate, publishedCandidate.publishedName, outcome.rebootRequired, options.transactionDeadlineUnixMs, - &outcome.error)) { + &outcome.brokerBinding, "fresh", &outcome.error)) { outcome.rollback = L"failed"; outcome.exitCode = ExitCode::RollbackFailed; return outcome; } + if (outcome.rebootRequired) { + outcome.success = false; + if (outcome.changed) { + outcome.rollback = L"failed"; + SetError(&outcome.error, + L"install-forward-reboot-unsettled", + ERROR_INSTALL_SUSPEND, + L"forward mutation remains journaled across a required restart and is not yet a settled success"); + installJournal.AttachEvidence(&outcome.error); + outcome.exitCode = ExitCode::RollbackFailed; + } else { + outcome.rollback = L"not-needed"; + SetError(&outcome.error, L"install-reboot-boundary", + ERROR_SUCCESS_REBOOT_REQUIRED); + outcome.exitCode = ExitCode::RebootRequired; + } + return outcome; + } outcome.success = true; outcome.rollback = L"not-needed"; - outcome.exitCode = outcome.rebootRequired ? ExitCode::RebootRequired : ExitCode::Success; + outcome.exitCode = ExitCode::Success; return outcome; } @@ -5859,264 +6224,6 @@ bool CopyProtectedBackupFile( return true; } -class BackupDirectory final { -public: - ~BackupDirectory() noexcept { - try { - if (!path_.empty() && !preserve_) { - root_.reset(); - std::error_code removalError; - std::filesystem::remove_all(path_, removalError); - std::error_code presenceError; - const bool remains = std::filesystem::exists(path_, presenceError); - if (!removalError && !presenceError && !remains) { - path_.clear(); - ClearActiveRecoveryEvidence(); - } - } - } catch (...) { - // The top-level boundary must remain able to emit the fixed active - // evidence path after unwinding; a destructor must never terminate it. - } - } - - bool Create(Error* error) { - std::vector windowsDirectory(MAX_PATH); - const UINT length = GetWindowsDirectoryW( - windowsDirectory.data(), static_cast(windowsDirectory.size())); - if (length == 0 || static_cast(length) >= windowsDirectory.size()) { - return SetLastErrorDetail(error, L"rollback-backup-root"); - } - const std::filesystem::path parent = - std::filesystem::path(windowsDirectory.data()) / L"Temp"; - parent_.reset(CreateFileW( - parent.c_str(), FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | - FILE_FLAG_BACKUP_SEMANTICS, - nullptr)); - if (!parent_) { - return SetLastErrorDetail(error, L"rollback-backup-parent"); - } - FILE_ATTRIBUTE_TAG_INFO parentAttributes{}; - if (!GetFileInformationByHandleEx( - parent_.get(), FileAttributeTagInfo, &parentAttributes, - sizeof(parentAttributes)) || - (parentAttributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || - (parentAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { - return SetError(error, L"rollback-backup-parent", - ERROR_REPARSE_TAG_MISMATCH, - L"Windows temporary directory must be a regular non-reparse directory"); - } - - PSECURITY_DESCRIPTOR descriptor = nullptr; - if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( - kRollbackDirectorySecurity, SDDL_REVISION_1, &descriptor, nullptr)) { - return SetLastErrorDetail(error, L"rollback-backup-security"); - } - SECURITY_ATTRIBUTES security{}; - security.nLength = sizeof(security); - security.lpSecurityDescriptor = descriptor; - security.bInheritHandle = FALSE; - - HCRYPTPROV provider = 0; - if (!CryptAcquireContextW( - &provider, nullptr, nullptr, PROV_RSA_AES, - CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) { - const DWORD code = GetLastError(); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-random", code); - } - static constexpr wchar_t digits[] = L"0123456789abcdef"; - for (size_t attempt = 0; attempt < 32; ++attempt) { - std::array random{}; - if (!CryptGenRandom(provider, static_cast(random.size()), random.data())) { - const DWORD code = GetLastError(); - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-random", code); - } - std::wstring suffix; - suffix.reserve(random.size() * 2); - for (BYTE value : random) { - suffix.push_back(digits[value >> 4U]); - suffix.push_back(digits[value & 0x0fU]); - } - const std::filesystem::path candidate = - parent / (L"VIIPER-UDE-rollback-" + suffix); - if (!CreateDirectoryW(candidate.c_str(), &security)) { - if (GetLastError() == ERROR_ALREADY_EXISTS) { - continue; - } - const DWORD code = GetLastError(); - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-root", code); - } - try { - path_ = candidate; - } catch (...) { - RemoveDirectoryW(candidate.c_str()); - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - throw; - } - const std::wstring& rootValue = path_.native(); - constexpr size_t recordNameLength = std::size(kRecoveryRecordName) - 1; - const size_t recordLength = rootValue.size() + 1 + recordNameLength; - if (rootValue.empty() || rootValue.size() >= gActiveBackupRoot.size() || - recordLength >= gActiveRecoveryRecord.size()) { - if (RemoveDirectoryW(candidate.c_str())) { - path_.clear(); - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-root", - ERROR_FILENAME_EXCED_RANGE, - L"protected rollback paths exceed the exception-safe reporting bound"); - } - ClearActiveRecoveryEvidence(); - std::copy(rootValue.begin(), rootValue.end(), gActiveBackupRoot.begin()); - gActiveBackupRootRetained = true; - std::copy(rootValue.begin(), rootValue.end(), gActiveRecoveryRecord.begin()); - gActiveRecoveryRecord[rootValue.size()] = L'\\'; - std::copy_n(kRecoveryRecordName, recordNameLength, - gActiveRecoveryRecord.begin() + rootValue.size() + 1); - root_.reset(CreateFileW( - candidate.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | - FILE_FLAG_BACKUP_SEMANTICS, - nullptr)); - if (!root_) { - const DWORD code = GetLastError(); - if (RemoveDirectoryW(candidate.c_str())) { - path_.clear(); - ClearActiveRecoveryEvidence(); - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-root-lock", code); - } - FILE_ATTRIBUTE_TAG_INFO rootAttributes{}; - if (!GetFileInformationByHandleEx( - root_.get(), FileAttributeTagInfo, &rootAttributes, - sizeof(rootAttributes)) || - (rootAttributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || - (rootAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { - root_.reset(); - if (RemoveDirectoryW(candidate.c_str())) { - path_.clear(); - ClearActiveRecoveryEvidence(); - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-root-lock", - ERROR_REPARSE_TAG_MISMATCH); - } - if (!VerifyProtectedFileSystemSecurity( - root_.get(), true, L"rollback-backup-root-security", error)) { - root_.reset(); - if (RemoveDirectoryW(candidate.c_str())) { - path_.clear(); - ClearActiveRecoveryEvidence(); - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return false; - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return true; - } - CryptReleaseContext(provider, 0); - LocalFree(descriptor); - return SetError(error, L"rollback-backup-root", ERROR_ALREADY_EXISTS, - L"could not allocate a unique protected rollback directory"); - } - - const std::filesystem::path& path() const noexcept { return path_; } - - std::filesystem::path RecoveryRecordPath() const { - return path_ / kRecoveryRecordName; - } - - bool ArmPreservation(const std::filesystem::path& recoveryPath, Error* error) { - const std::wstring& value = recoveryPath.native(); - if (!gActiveBackupRootRetained || gActiveRecoveryRecord[0] == L'\0' || - value != gActiveRecoveryRecord.data()) { - return SetError(error, L"recovery-record-path", ERROR_INVALID_DATA, - L"published recovery record does not match the tracked protected backup root"); - } - gActiveRecoveryRecordWritten = true; - preserve_ = true; - return true; - } - - void AttachRecoveryRecord(Error* error) const { - if (error == nullptr) { - return; - } - if (gActiveBackupRootRetained && gActiveBackupRoot[0] != L'\0') { - error->recoveryBackup = gActiveBackupRoot.data(); - error->recoveryBackupRetained = true; - } else if (!path_.empty()) { - error->recoveryBackup = path_.wstring(); - error->recoveryBackupRetained = true; - } - if (gActiveRecoveryRecord[0] != L'\0') { - error->recoveryRecord = gActiveRecoveryRecord.data(); - error->recoveryRecordWritten = gActiveRecoveryRecordWritten; - } else if (!path_.empty()) { - error->recoveryRecord = RecoveryRecordPath().wstring(); - error->recoveryRecordWritten = false; - } - if (!error->recoveryRecord.empty() && !error->recoveryRecordWritten) { - error->recoveryRecordError = ERROR_FILE_NOT_FOUND; - error->recoveryRecordPhase = L"recovery-record-not-published"; - error->recoveryRecordMessage = - L"the retained backup predates a verified write-ahead recovery record"; - } - } - - bool Cleanup(std::vector* backups, Error* error) { - if (backups != nullptr) { - backups->clear(); - } - root_.reset(); - if (path_.empty()) { - preserve_ = false; - ClearActiveRecoveryEvidence(); - return true; - } - std::error_code removalError; - std::filesystem::remove_all(path_, removalError); - std::error_code presenceError; - const bool remains = std::filesystem::exists(path_, presenceError); - if (removalError || presenceError || remains) { - preserve_ = true; - const DWORD code = removalError - ? static_cast(removalError.value()) - : presenceError ? static_cast(presenceError.value()) - : ERROR_DIR_NOT_EMPTY; - SetError(error, L"rollback-backup-cleanup", code, - L"protected rollback backup cleanup could not be verified"); - AttachRecoveryRecord(error); - return false; - } - path_.clear(); - preserve_ = false; - ClearActiveRecoveryEvidence(); - return true; - } - -private: - std::filesystem::path path_; - WinHandle parent_; - WinHandle root_; - bool preserve_ = false; -}; - bool BackupPackagesIntoDirectory( const std::vector& packages, const std::filesystem::path& baseDirectory, @@ -6182,15 +6289,6 @@ bool BackupPackagesIntoDirectory( return true; } -bool BackupPackages( - const std::vector& packages, - BackupDirectory* root, - std::vector* backups, - Error* error) { - return root->Create(error) && - BackupPackagesIntoDirectory(packages, root->path(), backups, error); -} - bool IsSha256Digest(std::string_view value) { return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char character) { @@ -6259,289 +6357,6 @@ bool IsSafeRecoveryRelativePath(const std::filesystem::path& path) { return components != 0; } -bool RecoveryRelativePath( - const std::filesystem::path& root, - const std::filesystem::path& target, - std::wstring* relative, - Error* error) { - const std::filesystem::path candidate = target.lexically_relative(root); - if (!IsSafeRecoveryRelativePath(candidate) || - (root / candidate).lexically_normal() != target.lexically_normal()) { - return SetError(error, L"recovery-record-path", ERROR_INVALID_NAME, - L"rollback recovery paths must remain relative to the protected backup root"); - } - *relative = candidate.generic_wstring(); - return true; -} - -bool BuildRemoveRecoveryRecord( - const Snapshot& prior, - const std::vector& backups, - const std::filesystem::path& root, - std::string* record, - Error* error) { - if (backups.size() != prior.packages.size()) { - return SetError(error, L"recovery-record-binding", ERROR_INVALID_DATA, - L"rollback backup count does not match the captured package inventory"); - } - record->clear(); - record->append( - "{\"schema\":1,\"kind\":\"VIIPER-UDE-remove-rollback-recovery\"," - "\"state\":\"prepared-remove-transaction\"," - "\"hardwareId\":\"ROOT\\\\VIIPER\\\\UDE\",\"automaticRestore\":false," - "\"requiredValidation\":[\"inf-signature\",\"inf-catalog-membership\"," - "\"sys-catalog-membership\",\"inf-sha256\",\"sys-sha256\",\"cat-sha256\"]," - "\"devices\":["); - for (size_t index = 0; index < prior.devices.size(); ++index) { - const DeviceState& device = prior.devices[index]; - size_t packageIndex = prior.packages.size(); - size_t packageMatches = 0; - for (size_t candidate = 0; candidate < prior.packages.size(); ++candidate) { - const PackageInfo& package = prior.packages[candidate]; - if (_wcsicmp(package.publishedName.c_str(), device.publishedInf.c_str()) == 0 && - package.version == device.version && - SamePackageBytes(package, device.package)) { - packageIndex = candidate; - ++packageMatches; - } - } - if (!IsOwnedGeneratedRootInstanceId(device.instanceId) || - !IsSafePublishedInfName(device.publishedInf) || - _wcsicmp(device.service.c_str(), kServiceName) != 0 || - !(device.version == device.package.version) || - _wcsicmp(device.package.publishedName.c_str(), device.publishedInf.c_str()) != 0 || - packageMatches != 1 || packageIndex >= backups.size() || - _wcsicmp(backups[packageIndex].original.publishedName.c_str(), - device.publishedInf.c_str()) != 0 || - !(backups[packageIndex].original.version == device.version) || - !SamePackageBytes(backups[packageIndex].original, device.package) || - !IsSha256Digest(device.package.infSha256) || - !IsSha256Digest(device.package.sysSha256) || - !IsSha256Digest(device.package.catSha256)) { - return SetError(error, L"recovery-record-device", ERROR_INVALID_DATA, - L"captured devnode identity is not safe for a recovery record"); - } - if (index != 0) record->push_back(','); - record->append("{\"instanceId\":"); - AppendJsonString(record, device.instanceId); - record->append(",\"present\":"); - record->append(device.present ? "true" : "false"); - record->append(",\"started\":"); - record->append(device.started ? "true" : "false"); - record->append(",\"problem\":"); - record->append(std::to_string(device.problem)); - record->append(",\"service\":"); - AppendJsonString(record, device.service); - record->append(",\"publishedInf\":"); - AppendJsonString(record, device.publishedInf); - record->append(",\"packageIndex\":"); - record->append(std::to_string(packageIndex)); - record->append(",\"version\":"); - AppendJsonString(record, VersionToString(device.version)); - record->append(",\"infSha256\":"); - AppendJsonAsciiString(record, LowerAscii(device.package.infSha256)); - record->append(",\"sysSha256\":"); - AppendJsonAsciiString(record, LowerAscii(device.package.sysSha256)); - record->append(",\"catSha256\":"); - AppendJsonAsciiString(record, LowerAscii(device.package.catSha256)); - record->push_back('}'); - } - record->append("],\"packages\":["); - for (size_t index = 0; index < prior.packages.size(); ++index) { - const PackageInfo& package = prior.packages[index]; - const PackageBackup& backup = backups[index]; - const bool duplicatePublishedName = std::any_of( - prior.packages.begin(), prior.packages.end(), [&](const PackageInfo& candidate) { - return &candidate != &package && - _wcsicmp(candidate.publishedName.c_str(), package.publishedName.c_str()) == 0; - }); - if (!IsSafePublishedInfName(package.publishedName) || - duplicatePublishedName || - !(backup.original.version == package.version) || - _wcsicmp(backup.original.publishedName.c_str(), package.publishedName.c_str()) != 0 || - backup.infPath.parent_path() != backup.directory || - _wcsicmp(backup.infPath.filename().c_str(), L"ViiperUde.inf") != 0 || - !SamePackageBytes(backup.original, package) || - !IsSha256Digest(package.infSha256) || - !IsSha256Digest(package.sysSha256) || - !IsSha256Digest(package.catSha256)) { - return SetError(error, L"recovery-record-package", ERROR_INVALID_DATA, - L"protected rollback package does not match the captured inventory"); - } - std::wstring relativeDirectory; - std::wstring relativeInf; - std::wstring relativeSys; - std::wstring relativeCat; - if (!RecoveryRelativePath(root, backup.directory, &relativeDirectory, error) || - relativeDirectory != std::to_wstring(index) || - !RecoveryRelativePath(root, backup.infPath, &relativeInf, error) || - !RecoveryRelativePath(root, backup.directory / kDriverFileName, &relativeSys, error) || - !RecoveryRelativePath(root, backup.directory / kCatalogName, &relativeCat, error)) { - if (error->code == ERROR_SUCCESS) { - SetError(error, L"recovery-record-path", ERROR_INVALID_NAME); - } - return false; - } - if (index != 0) record->push_back(','); - record->append("{\"publishedInf\":"); - AppendJsonString(record, package.publishedName); - record->append(",\"version\":"); - AppendJsonString(record, VersionToString(package.version)); - record->append(",\"infSha256\":"); - AppendJsonAsciiString(record, LowerAscii(package.infSha256)); - record->append(",\"sysSha256\":"); - AppendJsonAsciiString(record, LowerAscii(package.sysSha256)); - record->append(",\"catSha256\":"); - AppendJsonAsciiString(record, LowerAscii(package.catSha256)); - record->append(",\"backupInf\":"); - AppendJsonString(record, relativeInf); - record->append(",\"backupSys\":"); - AppendJsonString(record, relativeSys); - record->append(",\"backupCat\":"); - AppendJsonString(record, relativeCat); - record->push_back('}'); - } - record->append("]}\n"); - if (record->size() > kMaximumRecoveryRecordBytes) { - return SetError(error, L"recovery-record-size", ERROR_FILE_TOO_LARGE); - } - return true; -} - -bool WriteProtectedRecoveryRecord( - const std::filesystem::path& path, - std::string_view record, - Error* error) { - if (path.filename() != kRecoveryRecordName || - record.empty() || record.size() > kMaximumRecoveryRecordBytes) { - return SetError(error, L"recovery-record-create", ERROR_INVALID_PARAMETER); - } - const std::filesystem::path temporaryPath = - path.parent_path() / kRecoveryRecordTemporaryName; - LocalSecurityDescriptor security; - if (!security.Initialize( - kRecoveryRecordSecurity, L"recovery-record-security", error)) { - return false; - } - WinHandle file(CreateFileW(temporaryPath.c_str(), - GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ, security.attributes(), CREATE_NEW, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | - FILE_FLAG_OPEN_REPARSE_POINT, - nullptr)); - const DWORD createError = GetLastError(); - if (!file) { - return SetError(error, L"recovery-record-create", createError); - } - const auto discardTemporary = [&]() noexcept { - file.reset(); - DeleteFileW(temporaryPath.c_str()); - }; - FILE_ATTRIBUTE_TAG_INFO attributes{}; - const BOOL queriedAttributes = GetFileInformationByHandleEx( - file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); - const DWORD attributeError = queriedAttributes ? ERROR_SUCCESS : GetLastError(); - if (!queriedAttributes || - (attributes.FileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { - const DWORD code = queriedAttributes - ? ERROR_REPARSE_TAG_MISMATCH : attributeError; - SetError(error, L"recovery-record-create", code, - L"recovery record must be a regular non-reparse file"); - discardTemporary(); - return false; - } - if (!VerifyProtectedFileSystemSecurity( - file.get(), false, L"recovery-record-security", error)) { - discardTemporary(); - return false; - } - size_t offset = 0; - while (offset < record.size()) { - const DWORD requested = static_cast(std::min( - record.size() - offset, MAXDWORD)); - DWORD written = 0; - if (!WriteFile(file.get(), record.data() + offset, requested, - &written, nullptr) || written == 0) { - const DWORD writeError = GetLastError(); - const DWORD code = writeError == ERROR_SUCCESS - ? ERROR_WRITE_FAULT : writeError; - SetError(error, L"recovery-record-write", code); - discardTemporary(); - return false; - } - offset += written; - } - if (!FlushFileBuffers(file.get())) { - SetLastErrorDetail(error, L"recovery-record-flush"); - discardTemporary(); - return false; - } - file.reset(); - if (!MoveFileExW( - temporaryPath.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH)) { - const DWORD code = GetLastError(); - DeleteFileW(temporaryPath.c_str()); - return SetError(error, L"recovery-record-publish", code); - } - - file.reset(CreateFileW(path.c_str(), - GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH | - FILE_FLAG_OPEN_REPARSE_POINT, - nullptr)); - if (!file) { - return SetLastErrorDetail(error, L"recovery-record-reopen"); - } - attributes = {}; - const BOOL queriedPublished = GetFileInformationByHandleEx( - file.get(), FileAttributeTagInfo, &attributes, sizeof(attributes)); - const DWORD publishedQueryError = queriedPublished - ? ERROR_SUCCESS : GetLastError(); - if (!queriedPublished || - (attributes.FileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { - const DWORD code = queriedPublished - ? ERROR_REPARSE_TAG_MISMATCH : publishedQueryError; - return SetError(error, L"recovery-record-reopen", code, - L"published recovery record must be a regular non-reparse file"); - } - if (!VerifyProtectedFileSystemSecurity( - file.get(), false, L"recovery-record-security", error)) { - return false; - } - offset = 0; - std::array verification{}; - while (offset < record.size()) { - const DWORD requested = static_cast(std::min( - verification.size(), record.size() - offset)); - DWORD read = 0; - if (!ReadFile(file.get(), verification.data(), requested, &read, nullptr)) { - return SetLastErrorDetail(error, L"recovery-record-verify"); - } - if (read != requested || - std::memcmp(verification.data(), record.data() + offset, read) != 0) { - return SetError(error, L"recovery-record-verify", ERROR_CRC, - L"published recovery record bytes do not match the flushed transaction journal"); - } - offset += read; - } - char trailing = 0; - DWORD trailingRead = 0; - if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr)) { - return SetLastErrorDetail(error, L"recovery-record-verify"); - } - if (trailingRead != 0) { - return SetError(error, L"recovery-record-verify", ERROR_FILE_INVALID, - L"published recovery record contains trailing bytes"); - } - if (!FlushFileBuffers(file.get())) { - return SetLastErrorDetail(error, L"recovery-record-published-flush"); - } - return true; -} - const char* InstallJournalPhaseName(InstallJournalPhase phase) noexcept { switch (phase) { case InstallJournalPhase::Prepared: return "Prepared"; @@ -6564,6 +6379,10 @@ const char* InstallJournalPhaseName(InstallJournalPhase phase) noexcept { case InstallJournalPhase::BrokerHandoffReturned: return "BrokerHandoffReturned"; case InstallJournalPhase::BrokerChildEntered: return "BrokerChildEntered"; case InstallJournalPhase::BrokerChildSettled: return "BrokerChildSettled"; + case InstallJournalPhase::BrokerOuterSettlementPending: + return "BrokerOuterSettlementPending"; + case InstallJournalPhase::BrokerOuterSettled: + return "BrokerOuterSettled"; case InstallJournalPhase::RollbackBindingEntered: return "RollbackBindingEntered"; case InstallJournalPhase::PartialRootRemovalEntered: return "PartialRootRemovalEntered"; @@ -6604,6 +6423,10 @@ std::optional ParseInstallJournalPhase( InstallJournalPhase::BrokerHandoffReturned, InstallJournalPhase::BrokerChildEntered, InstallJournalPhase::BrokerChildSettled, + InstallJournalPhase::BrokerOuterSettlementPending, + InstallJournalPhase::BrokerOuterSettled, + InstallJournalPhase::BrokerOuterSettlementPending, + InstallJournalPhase::BrokerOuterSettled, InstallJournalPhase::RollbackBindingEntered, InstallJournalPhase::PartialRootRemovalEntered, InstallJournalPhase::PartialRootRemovalReturned, @@ -6940,7 +6763,9 @@ struct InstallRecoveryDirectory { bool RetireInstallRecoveryActiveDirectory( InstallRecoveryDirectory* directory, std::string_view transactionId, - Error* error) { + Error* error, + bool retainTombstone = false, + std::filesystem::path* retiredPath = nullptr) { if (directory == nullptr || !IsSha256Digest(transactionId) || directory->active.filename() != kInstallRecoveryActiveDirectory) { return SetError(error, L"install-journal-retire-identity", @@ -6982,8 +6807,15 @@ bool RetireInstallRecoveryActiveDirectory( return false; } ClearActiveRecoveryEvidence(); + if (retiredPath != nullptr) { + *retiredPath = tombstone; + } tombstoneHandle.reset(); + if (retainTombstone) { + return true; + } + // Once active-v2 is atomically absent, cleanup is intentionally // best-effort. A power loss may leave a settled-v2-* tombstone, but it is // outside active admission and its transaction-bound name cannot be @@ -7089,6 +6921,9 @@ struct InstallJournalStateData { bool production = true; bool localTest = false; bool brokerRequired = false; + std::string brokerExecutableSha256; + std::filesystem::path brokerTokenPath; + std::wstring brokerTargetUserSid; bool brokerEntered = false; bool brokerSettled = false; bool hasBrokerProof = false; @@ -7097,6 +6932,15 @@ struct InstallJournalStateData { bool brokerDriverRollbackAuthorized = false; std::string brokerProofRollback; DWORD brokerProofExitCode = ERROR_SUCCESS; + std::string brokerJournalTransactionId; + std::string brokerJournalOuterTransactionId; + std::string brokerJournalCandidateSha256; + std::string brokerJournalState; + std::string brokerJournalDigest; + std::string brokerSettlementNonce; + std::string brokerDriverPendingDigest; + std::string brokerSettlementRequestSha256; + std::string brokerGoPendingDigest; bool hasPriorAbiProfile = false; AbiCompatibilityProfile priorAbiProfile{}; bool hasRootRegistrationIntent = false; @@ -7177,6 +7021,56 @@ bool BuildInstallJournalPayload( const bool rebootPendingPhase = state.phase == InstallJournalPhase::ForwardRebootPending || state.phase == InstallJournalPhase::RestoreRebootPending; + const bool brokerInvocationCanonical = state.brokerRequired + ? IsCanonicalLowerHex(state.brokerExecutableSha256, 64U) && + state.brokerTokenPath.is_absolute() && + state.brokerTokenPath.extension() == L".token" && + IsSafeTargetUserSid(state.brokerTargetUserSid) + : state.brokerExecutableSha256.empty() && + state.brokerTokenPath.empty() && + state.brokerTargetUserSid.empty(); + const bool brokerJournalCanonical = state.hasBrokerProof && + state.brokerProofChanged + ? IsCanonicalLowerHex(state.brokerJournalTransactionId, 32U) && + IsCanonicalLowerHex(state.brokerJournalOuterTransactionId, 64U) && + IsCanonicalLowerHex(state.brokerJournalCandidateSha256, 64U) && + IsCanonicalLowerHex(state.brokerJournalDigest, 64U) && + state.brokerJournalOuterTransactionId == state.transactionId && + state.brokerJournalCandidateSha256 == + state.brokerExecutableSha256 && + ((state.brokerProofSuccess && + state.brokerJournalState == "nested-ready") || + (!state.brokerProofSuccess && + state.brokerDriverRollbackAuthorized && + state.brokerJournalState == "rollback-settled") || + (!state.brokerProofSuccess && + !state.brokerDriverRollbackAuthorized && + state.brokerJournalState == "manual")) + : state.brokerJournalTransactionId.empty() && + state.brokerJournalOuterTransactionId.empty() && + state.brokerJournalCandidateSha256.empty() && + state.brokerJournalState.empty() && + state.brokerJournalDigest.empty(); + const bool settlementPending = + state.phase == InstallJournalPhase::BrokerOuterSettlementPending; + const bool settlementFinal = + state.phase == InstallJournalPhase::BrokerOuterSettled; + const bool brokerSettlementCanonical = settlementPending + ? IsCanonicalLowerHex(state.brokerSettlementNonce, 64U) && + state.brokerDriverPendingDigest.empty() && + state.brokerSettlementRequestSha256.empty() && + state.brokerGoPendingDigest.empty() + : settlementFinal + ? IsCanonicalLowerHex(state.brokerSettlementNonce, 64U) && + IsCanonicalLowerHex( + state.brokerDriverPendingDigest, 64U) && + IsCanonicalLowerHex( + state.brokerSettlementRequestSha256, 64U) && + IsCanonicalLowerHex(state.brokerGoPendingDigest, 64U) + : state.brokerSettlementNonce.empty() && + state.brokerDriverPendingDigest.empty() && + state.brokerSettlementRequestSha256.empty() && + state.brokerGoPendingDigest.empty(); if (!IsSha256Digest(state.previousDigest) || !IsCanonicalBootIdentifier(state.bootIdentifier) || (!state.pendingRebootBootIdentifier.empty() && @@ -7230,6 +7124,15 @@ bool BuildInstallJournalPayload( state.brokerProofRollback, state.brokerProofExitCode, state.brokerDriverRollbackAuthorized)) || + !brokerInvocationCanonical || !brokerJournalCanonical || + !brokerSettlementCanonical || + ((settlementPending || settlementFinal) && + (!state.brokerRequired || !state.hasBrokerProof || + !state.brokerProofSuccess || + !state.brokerProofChanged || + state.brokerDriverRollbackAuthorized || + state.direction != InstallJournalDirection::Forward || + state.rollbackAuthorized)) || (state.hasBrokerProof && state.brokerDriverRollbackAuthorized != state.rollbackAuthorized) || @@ -7275,6 +7178,18 @@ bool BuildInstallJournalPayload( payload->append(state.localTest ? "true" : "false"); payload->append(",\"brokerRequired\":"); payload->append(state.brokerRequired ? "true" : "false"); + payload->append(",\"brokerInvocation\":"); + if (state.brokerRequired) { + payload->append("{\"executableSha256\":"); + AppendJsonAsciiString(payload, state.brokerExecutableSha256); + payload->append(",\"tokenPath\":"); + AppendJsonString(payload, state.brokerTokenPath.wstring()); + payload->append(",\"targetUserSid\":"); + AppendJsonString(payload, state.brokerTargetUserSid); + payload->push_back('}'); + } else { + payload->append("null"); + } payload->append(",\"brokerEntered\":"); payload->append(state.brokerEntered ? "true" : "false"); payload->append(",\"brokerSettled\":"); @@ -7292,6 +7207,54 @@ bool BuildInstallJournalPayload( payload->append(",\"driverRollbackAuthorized\":"); payload->append(state.brokerDriverRollbackAuthorized ? "true" : "false"); + payload->append(",\"journal\":"); + if (state.brokerProofChanged) { + payload->append("{\"transactionId\":"); + AppendJsonAsciiString(payload, + state.brokerJournalTransactionId); + payload->append(",\"outerTransactionId\":"); + AppendJsonAsciiString(payload, + state.brokerJournalOuterTransactionId); + payload->append(",\"candidateSha256\":"); + AppendJsonAsciiString(payload, + state.brokerJournalCandidateSha256); + payload->append(",\"state\":"); + AppendJsonAsciiString(payload, state.brokerJournalState); + payload->append(",\"digest\":"); + AppendJsonAsciiString(payload, state.brokerJournalDigest); + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->push_back('}'); + } else { + payload->append("null"); + } + payload->append(",\"brokerSettlement\":"); + if (settlementPending || settlementFinal) { + payload->append("{\"nonce\":"); + AppendJsonAsciiString(payload, state.brokerSettlementNonce); + payload->append(",\"driverPendingDigest\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerDriverPendingDigest); + } else { + payload->append("null"); + } + payload->append(",\"requestSha256\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerSettlementRequestSha256); + } else { + payload->append("null"); + } + payload->append(",\"brokerPendingDigest\":"); + if (settlementFinal) { + AppendJsonAsciiString(payload, + state.brokerGoPendingDigest); + } else { + payload->append("null"); + } payload->push_back('}'); } else { payload->append("null"); @@ -7586,11 +7549,94 @@ bool CopyCandidateIntoInstallJournal( return LockPackageFiles(destinationDirectory, locks, error); } +bool LockProtectedBrokerImage( + const std::filesystem::path& image, + std::string_view expectedSha256, + WinHandle* lock, + Error* error) { + if (!IsCanonicalLowerHex(expectedSha256, 64U)) { + return SetError(error, L"install-journal-broker-image", + ERROR_INVALID_PARAMETER); + } + lock->reset(CreateFileW( + image.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!*lock) { + return SetLastErrorDetail(error, L"install-journal-broker-image-open"); + } + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + std::array header{}; + DWORD read = 0; + if (!GetFileInformationByHandleEx( + lock->get(), FileAttributeTagInfo, &attributes, + sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(lock->get(), &identity) || + identity.nNumberOfLinks != 1U || + !VerifyProtectedFileSystemSecurity( + lock->get(), false, + L"install-journal-broker-image-security", error) || + !ReadFile(lock->get(), header.data(), + static_cast(header.size()), &read, nullptr) || + read != header.size() || header[0] != 'M' || header[1] != 'Z') { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"install-journal-broker-image", + ERROR_BAD_EXE_FORMAT, + L"protected broker evidence must be one single-link non-reparse PE image"); + } + return false; + } + std::string observed; + if (!Sha256Handle(lock->get(), &observed, error)) { + error->phase = L"install-journal-broker-image-hash"; + return false; + } + if (observed != expectedSha256) { + return SetError(error, L"install-journal-broker-image-hash", + ERROR_CRC, + L"protected broker evidence differs from its immutable digest"); + } + return true; +} + +bool ValidateExactBrokerEvidenceDirectory( + const std::filesystem::path& directory, + Error* error) { + size_t entries = 0; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + directory, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + ++entries; + if (iterator->path().filename() != + kInstallRecoveryBrokerExecutable) { + return SetError(error, L"install-journal-broker-evidence", + ERROR_INVALID_DATA, + L"protected broker evidence directory contains an unexpected entry"); + } + } + if (enumerationError || entries != 1U) { + return SetError(error, L"install-journal-broker-evidence", + enumerationError + ? static_cast(enumerationError.value()) + : ERROR_FILE_NOT_FOUND, + L"protected broker evidence directory is incomplete"); + } + return true; +} + struct InstallJournal::Impl { InstallRecoveryDirectory directory; InstallJournalStateData state; std::vector priorBackups; std::vector candidateLocks; + WinHandle brokerLock; bool preparedRecord = false; bool retired = false; bool poisoned = false; @@ -7606,6 +7652,7 @@ struct InstallJournal::Impl { return; } candidateLocks.clear(); + brokerLock.reset(); priorBackups.clear(); directory.activeHandle.reset(); std::error_code ignored; @@ -7660,12 +7707,36 @@ bool InstallJournal::Prepare( return false; } + if (!options.brokerExecutable.empty()) { + WinHandle brokerDirectoryHandle; + const std::filesystem::path brokerDirectory = + impl_->directory.active / kInstallRecoveryBrokerDirectory; + const std::filesystem::path brokerImage = + brokerDirectory / kInstallRecoveryBrokerExecutable; + if (!CreateOrOpenInstallRecoveryDirectory( + brokerDirectory, false, true, &brokerDirectoryHandle, + &created, error) || + !CopyProtectedBackupFile( + options.brokerExecutable, brokerImage, error) || + !LockProtectedBrokerImage( + brokerImage, LowerAscii(options.brokerSha256), + &impl_->brokerLock, error)) { + return false; + } + } + impl_->state.prior = prior; impl_->state.candidate = candidate; impl_->state.expectedInventory = expectedInventory; impl_->state.production = options.production; impl_->state.localTest = options.localTest; impl_->state.brokerRequired = !options.brokerExecutable.empty(); + if (impl_->state.brokerRequired) { + impl_->state.brokerExecutableSha256 = + LowerAscii(options.brokerSha256); + impl_->state.brokerTokenPath = options.brokerToken; + impl_->state.brokerTargetUserSid = options.targetUserSid; + } impl_->state.sourceRevision = options.sourceRevision; if (!GetBootIdentifier(&impl_->state.bootIdentifier, error)) { return false; @@ -7937,7 +8008,15 @@ bool InstallJournal::RecordBrokerProof( Error* error) { if (!impl_ || !BrokerProofFieldsAreCanonical( proof.success, proof.changed, proof.rollback, - proof.exitCode, proof.driverRollbackAuthorized)) { + proof.exitCode, proof.driverRollbackAuthorized) || + (proof.changed != proof.hasJournalProof) || + (proof.changed && + (!IsCanonicalLowerHex(proof.journalTransactionId, 32U) || + !IsCanonicalLowerHex( + proof.journalOuterTransactionId, 64U) || + !IsCanonicalLowerHex( + proof.journalCandidateSha256, 64U) || + !IsCanonicalLowerHex(proof.journalDigest, 64U)))) { return SetError(error, L"install-journal-broker-proof", ERROR_INVALID_DATA); } @@ -7947,7 +8026,15 @@ bool InstallJournal::RecordBrokerProof( impl_->state.brokerProofRollback != proof.rollback || impl_->state.brokerProofExitCode != proof.exitCode || impl_->state.brokerDriverRollbackAuthorized != - proof.driverRollbackAuthorized)) { + proof.driverRollbackAuthorized || + impl_->state.brokerJournalTransactionId != + proof.journalTransactionId || + impl_->state.brokerJournalOuterTransactionId != + proof.journalOuterTransactionId || + impl_->state.brokerJournalCandidateSha256 != + proof.journalCandidateSha256 || + impl_->state.brokerJournalState != proof.journalState || + impl_->state.brokerJournalDigest != proof.journalDigest)) { return SetError(error, L"install-journal-broker-proof", ERROR_REVISION_MISMATCH, L"settled broker proof changed within one transaction"); @@ -7962,6 +8049,13 @@ bool InstallJournal::RecordBrokerProof( next.brokerProofExitCode = proof.exitCode; next.brokerDriverRollbackAuthorized = proof.driverRollbackAuthorized; + next.brokerJournalTransactionId = proof.journalTransactionId; + next.brokerJournalOuterTransactionId = + proof.journalOuterTransactionId; + next.brokerJournalCandidateSha256 = + proof.journalCandidateSha256; + next.brokerJournalState = proof.journalState; + next.brokerJournalDigest = proof.journalDigest; if (proof.driverRollbackAuthorized) { next.direction = InstallJournalDirection::Rollback; next.rollbackAuthorized = true; @@ -8058,11 +8152,19 @@ bool InstallJournal::RetireAfterForwardValidation( const std::wstring& publishedName, bool rebootRequired, uint64_t deadlineUnixMs, + BrokerJournalBinding* binding, + std::string_view recovery, Error* error) { if (!impl_ || !impl_->preparedRecord || impl_->retired || impl_->poisoned) { return SetError(error, L"install-journal-retire", ERROR_INVALID_STATE); } + if (binding == nullptr || + (recovery != "fresh" && recovery != "replayed")) { + return SetError(error, L"install-journal-retire", + ERROR_INVALID_PARAMETER); + } + *binding = {}; if (rebootRequired) { if (impl_->state.pendingRebootBootIdentifier.empty()) { return SetError(error, L"install-journal-forward-reboot-epoch", @@ -8097,10 +8199,43 @@ bool InstallJournal::RetireAfterForwardValidation( L"install-journal-retire-revalidation", error)) { return false; } + if (impl_->state.brokerRequired && + impl_->state.hasBrokerProof && + impl_->state.brokerProofSuccess && + impl_->state.brokerProofChanged) { + InstallJournalStateData pending = impl_->state; + if (!GenerateInstallTransactionId( + &pending.brokerSettlementNonce, error) || + !RecordNext(std::move(pending), + InstallJournalPhase::BrokerOuterSettlementPending, + &impl_->state.publishedCandidate, + impl_->state.packageStagedHere, + impl_->state.bindingMutationStarted, + false, true, ERROR_SUCCESS, false, false, error)) { + return false; + } + binding->present = true; + binding->transactionId = + impl_->state.brokerJournalTransactionId; + binding->outerTransactionId = + impl_->state.brokerJournalOuterTransactionId; + binding->candidateSha256 = + impl_->state.brokerJournalCandidateSha256; + binding->state = impl_->state.brokerJournalState; + binding->digest = impl_->state.brokerJournalDigest; + binding->driverTransactionId = impl_->state.transactionId; + binding->driverDigest = impl_->state.lastDigest; + binding->settlementNonce = + impl_->state.brokerSettlementNonce; + binding->recovery = std::string(recovery); + return true; + } impl_->candidateLocks.clear(); + impl_->brokerLock.reset(); impl_->priorBackups.clear(); if (!RetireInstallRecoveryActiveDirectory( - &impl_->directory, impl_->state.transactionId, error)) { + &impl_->directory, impl_->state.transactionId, + error, false, nullptr)) { return false; } impl_->retired = true; @@ -8170,7 +8305,8 @@ bool InstallJournal::RetireAfterPriorValidation( impl_->candidateLocks.clear(); impl_->priorBackups.clear(); if (!RetireInstallRecoveryActiveDirectory( - &impl_->directory, impl_->state.transactionId, error)) { + &impl_->directory, impl_->state.transactionId, + error, false, nullptr)) { return false; } impl_->retired = true; @@ -8378,6 +8514,48 @@ bool ParseInstallJournalPayload( } state->pendingRebootBootIdentifier = *pendingBoot; } + const JsonValue* brokerInvocationNode = + ObjectField(*object, "brokerInvocation"); + if (brokerInvocationNode == nullptr) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA); + } + if (state->brokerRequired) { + const JsonValue::Object* invocationObject = nullptr; + std::string tokenPath; + std::string targetUserSid; + std::wstring tokenPathWide; + if (!RequireJournalObject( + *brokerInvocationNode, &invocationObject, error) || + invocationObject->size() != 3U || + !RequireJournalString(*invocationObject, + "executableSha256", &state->brokerExecutableSha256, + error) || + !RequireJournalString(*invocationObject, + "tokenPath", &tokenPath, error) || + !RequireJournalString(*invocationObject, + "targetUserSid", &targetUserSid, error) || + !Utf8ToWide(tokenPath, &tokenPathWide, error) || + !Utf8ToWide(targetUserSid, &state->brokerTargetUserSid, + error)) { + return false; + } + state->brokerTokenPath = tokenPathWide; + if (!IsCanonicalLowerHex( + state->brokerExecutableSha256, 64U) || + !state->brokerTokenPath.is_absolute() || + state->brokerTokenPath.extension() != L".token" || + !IsSafeTargetUserSid(state->brokerTargetUserSid)) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA, + L"durable broker recovery invocation is not canonical"); + } + } else if (!std::holds_alternative( + brokerInvocationNode->value)) { + return SetError(error, L"install-journal-broker-invocation", + ERROR_INVALID_DATA, + L"non-broker transaction carried recovery invocation state"); + } const JsonValue* brokerProofNode = ObjectField(*object, "brokerProof"); if (brokerProofNode == nullptr) { return SetError(error, L"install-journal-broker-proof", @@ -8390,7 +8568,7 @@ bool ParseInstallJournalPayload( uint64_t exitCode = 0; if (!RequireJournalObject( *brokerProofNode, &brokerProofObject, error) || - brokerProofObject->size() != 5U || + brokerProofObject->size() != 6U || !RequireJournalBool(*brokerProofObject, "success", &state->brokerProofSuccess, error) || !RequireJournalBool(*brokerProofObject, "changed", @@ -8404,6 +8582,38 @@ bool ParseInstallJournalPayload( &state->brokerDriverRollbackAuthorized, error)) { return false; } + const JsonValue* journalNode = + ObjectField(*brokerProofObject, "journal"); + if (journalNode == nullptr) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA); + } + if (state->brokerProofChanged) { + const JsonValue::Object* journalObject = nullptr; + if (!RequireJournalObject( + *journalNode, &journalObject, error) || + journalObject->size() != 5U || + !RequireJournalString(*journalObject, + "transactionId", + &state->brokerJournalTransactionId, error) || + !RequireJournalString(*journalObject, + "outerTransactionId", + &state->brokerJournalOuterTransactionId, error) || + !RequireJournalString(*journalObject, + "candidateSha256", + &state->brokerJournalCandidateSha256, error) || + !RequireJournalString(*journalObject, "state", + &state->brokerJournalState, error) || + !RequireJournalString(*journalObject, "digest", + &state->brokerJournalDigest, error)) { + return false; + } + } else if (!std::holds_alternative( + journalNode->value)) { + return SetError(error, L"install-journal-broker-proof", + ERROR_INVALID_DATA, + L"unchanged child proof carried a journal identity"); + } state->brokerProofExitCode = static_cast(exitCode); if (!BrokerProofFieldsAreCanonical( state->brokerProofSuccess, @@ -8417,6 +8627,63 @@ bool ParseInstallJournalPayload( } state->hasBrokerProof = true; } + const JsonValue* brokerSettlementNode = + ObjectField(*object, "brokerSettlement"); + if (brokerSettlementNode == nullptr) { + return SetError(error, L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (!std::holds_alternative( + brokerSettlementNode->value)) { + const JsonValue::Object* settlementObject = nullptr; + const JsonValue* driverPendingNode = nullptr; + const JsonValue* requestNode = nullptr; + const JsonValue* pendingNode = nullptr; + if (!RequireJournalObject( + *brokerSettlementNode, &settlementObject, error) || + settlementObject->size() != 4U || + !RequireJournalString(*settlementObject, "nonce", + &state->brokerSettlementNonce, error) || + (driverPendingNode = ObjectField( + *settlementObject, "driverPendingDigest")) == nullptr || + (requestNode = ObjectField( + *settlementObject, "requestSha256")) == nullptr || + (pendingNode = ObjectField( + *settlementObject, "brokerPendingDigest")) == nullptr) { + return false; + } + const auto* requestDigest = + std::get_if(&requestNode->value); + const auto* pendingDigest = + std::get_if(&pendingNode->value); + const auto* driverPendingDigest = + std::get_if(&driverPendingNode->value); + if (driverPendingDigest != nullptr) { + state->brokerDriverPendingDigest = + *driverPendingDigest; + } else if (!std::holds_alternative( + driverPendingNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (requestDigest != nullptr) { + state->brokerSettlementRequestSha256 = *requestDigest; + } else if (!std::holds_alternative( + requestNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + if (pendingDigest != nullptr) { + state->brokerGoPendingDigest = *pendingDigest; + } else if (!std::holds_alternative( + pendingNode->value)) { + return SetError(error, + L"install-journal-broker-settlement", + ERROR_INVALID_DATA); + } + } const JsonValue* profileNode = ObjectField(*object, "priorAbiProfile"); if (profileNode == nullptr) { return SetError(error, L"install-journal-prior-abi-profile", @@ -8861,6 +9128,10 @@ bool SameInstallJournalImmutableState( left.production != right.production || left.localTest != right.localTest || left.brokerRequired != right.brokerRequired || + left.brokerExecutableSha256 != right.brokerExecutableSha256 || + left.brokerTokenPath != right.brokerTokenPath || + _wcsicmp(left.brokerTargetUserSid.c_str(), + right.brokerTargetUserSid.c_str()) != 0 || !SameJournalPackageIdentity(left.candidate, right.candidate) || !SamePackageInventory(left.prior.packages, right.prior.packages) || left.prior.devices.size() != right.prior.devices.size()) { @@ -8882,7 +9153,15 @@ bool SameDurableBrokerProof( left.brokerDriverRollbackAuthorized == right.brokerDriverRollbackAuthorized && left.brokerProofRollback == right.brokerProofRollback && - left.brokerProofExitCode == right.brokerProofExitCode)); + left.brokerProofExitCode == right.brokerProofExitCode && + left.brokerJournalTransactionId == + right.brokerJournalTransactionId && + left.brokerJournalOuterTransactionId == + right.brokerJournalOuterTransactionId && + left.brokerJournalCandidateSha256 == + right.brokerJournalCandidateSha256 && + left.brokerJournalState == right.brokerJournalState && + left.brokerJournalDigest == right.brokerJournalDigest)); } int ForwardInstallJournalPhaseRank( @@ -8905,6 +9184,8 @@ int ForwardInstallJournalPhaseRank( case InstallJournalPhase::BrokerHandoffReturned: return 71; case InstallJournalPhase::BrokerChildEntered: return 80; case InstallJournalPhase::BrokerChildSettled: return 81; + case InstallJournalPhase::BrokerOuterSettlementPending: return 91; + case InstallJournalPhase::BrokerOuterSettled: return 92; case InstallJournalPhase::PartialRootRemovalEntered: return 82; case InstallJournalPhase::PartialRootRemovalReturned: return 83; case InstallJournalPhase::PartialRootRemovalRebootPending: return 84; @@ -8918,7 +9199,7 @@ int ForwardInstallJournalPhaseRank( } bool IsInstallJournalTerminalPhase(InstallJournalPhase phase) noexcept { - return phase == InstallJournalPhase::ForwardValidated || + return phase == InstallJournalPhase::BrokerOuterSettled || phase == InstallJournalPhase::ExactPriorRestored || phase == InstallJournalPhase::ForwardRebootPending || phase == InstallJournalPhase::RestoreRebootPending || @@ -9002,6 +9283,11 @@ bool LegalForwardInstallJournalPhaseTransition( case InstallJournalPhase::ForwardRebootPending: return previous == InstallJournalPhase::DriverValidated || previous == InstallJournalPhase::BrokerChildSettled; + case InstallJournalPhase::BrokerOuterSettlementPending: + return previous == InstallJournalPhase::ForwardValidated; + case InstallJournalPhase::BrokerOuterSettled: + return previous == + InstallJournalPhase::BrokerOuterSettlementPending; default: return false; } @@ -9078,6 +9364,10 @@ bool ValidateInstallJournalTransition( next.direction != InstallJournalDirection::Forward || next.rollbackAuthorized || next.brokerEntered || next.brokerSettled || next.hasBrokerProof || + !next.brokerSettlementNonce.empty() || + !next.brokerDriverPendingDigest.empty() || + !next.brokerSettlementRequestSha256.empty() || + !next.brokerGoPendingDigest.empty() || next.hasPriorAbiProfile || next.hasRootRegistrationIntent || !next.rootRegistrationInstanceId.empty() || @@ -9117,7 +9407,19 @@ bool ValidateInstallJournalTransition( InstallJournalStateData::PartialRootRemovalBinding::None) || (!prior.partialRootRemovalBootIdentifier.empty() && next.partialRootRemovalBootIdentifier.empty()) || - (prior.hasBrokerProof && !next.hasBrokerProof)) { + (prior.hasBrokerProof && !next.hasBrokerProof) || + (!prior.brokerSettlementNonce.empty() && + prior.brokerSettlementNonce != + next.brokerSettlementNonce) || + (!prior.brokerDriverPendingDigest.empty() && + prior.brokerDriverPendingDigest != + next.brokerDriverPendingDigest) || + (!prior.brokerSettlementRequestSha256.empty() && + prior.brokerSettlementRequestSha256 != + next.brokerSettlementRequestSha256) || + (!prior.brokerGoPendingDigest.empty() && + prior.brokerGoPendingDigest != + next.brokerGoPendingDigest)) { return SetError(error, L"install-journal-monotonic-state", ERROR_INVALID_DATA, L"terminal, direction, ownership, or diagnostic state regressed"); @@ -9266,6 +9568,30 @@ bool ValidateInstallJournalTransition( ERROR_INVALID_DATA, L"durable broker proof first appeared outside child settlement"); } + const bool settlementNonceAppeared = + prior.brokerSettlementNonce.empty() && + !next.brokerSettlementNonce.empty(); + const bool settlementReceiptAppeared = + prior.brokerSettlementRequestSha256.empty() && + !next.brokerSettlementRequestSha256.empty(); + const bool brokerPendingDigestAppeared = + prior.brokerGoPendingDigest.empty() && + !next.brokerGoPendingDigest.empty(); + const bool driverPendingDigestAppeared = + prior.brokerDriverPendingDigest.empty() && + !next.brokerDriverPendingDigest.empty(); + if ((settlementNonceAppeared && + next.phase != + InstallJournalPhase::BrokerOuterSettlementPending) || + ((settlementReceiptAppeared || brokerPendingDigestAppeared || + driverPendingDigestAppeared) && + next.phase != InstallJournalPhase::BrokerOuterSettled) || + (settlementReceiptAppeared != brokerPendingDigestAppeared) || + (settlementReceiptAppeared != driverPendingDigestAppeared)) { + return SetError(error, L"install-journal-broker-settlement-chain", + ERROR_INVALID_DATA, + L"outer settlement identity appeared outside its exact durable phase"); + } if (!prior.brokerEntered && next.brokerEntered && next.phase != InstallJournalPhase::BrokerHandoffEntered) { return SetError(error, L"install-journal-broker-chain", @@ -9297,7 +9623,10 @@ bool ValidateInstallJournalTransition( L"prior terminal phase lacks durable broker-safe rollback authority"); } if ((next.phase == InstallJournalPhase::ForwardValidated || - next.phase == InstallJournalPhase::ForwardRebootPending) && + next.phase == InstallJournalPhase::ForwardRebootPending || + next.phase == + InstallJournalPhase::BrokerOuterSettlementPending || + next.phase == InstallJournalPhase::BrokerOuterSettled) && next.brokerRequired && (!next.brokerEntered || !next.brokerSettled || !next.hasBrokerProof || !next.brokerProofSuccess || @@ -9483,6 +9812,38 @@ bool ValidateLoadedInstallJournalEvidence( loaded->evidenceLocks.push_back(std::move(priorHandle)); loaded->evidenceLocks.push_back(std::move(candidateHandle)); + const std::filesystem::path brokerDirectory = + loaded->directory.active / kInstallRecoveryBrokerDirectory; + const DWORD brokerDirectoryAttributes = + GetFileAttributesW(brokerDirectory.c_str()); + if (loaded->state.brokerRequired) { + WinHandle brokerDirectoryHandle; + WinHandle brokerImage; + if (!OpenStableDirectory( + brokerDirectory, true, &brokerDirectoryHandle, error) || + !ValidateExactBrokerEvidenceDirectory( + brokerDirectory, error) || + !LockProtectedBrokerImage( + brokerDirectory / kInstallRecoveryBrokerExecutable, + loaded->state.brokerExecutableSha256, + &brokerImage, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(brokerDirectoryHandle)); + loaded->evidenceLocks.push_back(std::move(brokerImage)); + } else if (brokerDirectoryAttributes != INVALID_FILE_ATTRIBUTES) { + return SetError(error, L"install-journal-broker-evidence", + ERROR_INVALID_DATA, + L"non-broker transaction contains unexpected broker evidence"); + } else { + const DWORD absenceError = GetLastError(); + if (absenceError != ERROR_FILE_NOT_FOUND && + absenceError != ERROR_PATH_NOT_FOUND) { + return SetError(error, L"install-journal-broker-evidence", + absenceError); + } + } + const std::filesystem::path candidateDirectory = loaded->directory.active / kInstallRecoveryCandidateDirectory; PackageInfo candidateCopy; @@ -9579,7 +9940,8 @@ bool LoadInstallJournal( } if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (name == kInstallRecoveryPriorDirectory || - name == kInstallRecoveryCandidateDirectory)) { + name == kInstallRecoveryCandidateDirectory || + name == kInstallRecoveryBrokerDirectory)) { continue; } uint64_t sequence = 0; @@ -9705,7 +10067,8 @@ bool RetireLoadedInstallJournal( Error* error) { loaded->evidenceLocks.clear(); return RetireInstallRecoveryActiveDirectory( - &loaded->directory, loaded->state.transactionId, error); + &loaded->directory, loaded->state.transactionId, + error, false, nullptr); } bool CurrentStateMatchesPrior( @@ -10653,6 +11016,20 @@ ClassifyPartialRootRemovalJournalRecovery( return PartialRootRemovalRecoveryDisposition::ContinueRollback; } +bool IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase phase) noexcept { + return phase == InstallJournalPhase::BrokerChildSettled || + phase == InstallJournalPhase::ForwardValidated || + phase == InstallJournalPhase::BrokerOuterSettlementPending || + phase == InstallJournalPhase::BrokerOuterSettled; +} + +const std::string& BrokerOuterSettlementPendingDriverDigest( + const InstallJournalStateData& state) noexcept { + return state.phase == InstallJournalPhase::BrokerOuterSettled + ? state.brokerDriverPendingDigest : state.lastDigest; +} + bool ReconcileInstallJournal( bool explicitRecovery, uint64_t deadlineUnixMs, @@ -10671,6 +11048,17 @@ bool ReconcileInstallJournal( return false; } if (!exists) { + bool handled = false; + Error settledError; + if (!ReconcileSettledBrokerOuterSettlement( + deadlineUnixMs, &handled, outcome, &settledError)) { + outcome->error = std::move(settledError); + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + if (handled) { + return false; + } outcome->success = true; outcome->exitCode = ExitCode::Success; return true; @@ -10787,6 +11175,88 @@ bool ReconcileInstallJournal( InstallJournalStateData::PartialRootRemovalBinding::None, error); }; + const auto appendBrokerProof = + [&](const BrokerCommitProof& proof, Error* error) { + if (!BrokerProofFieldsAreCanonical( + proof.success, proof.changed, proof.rollback, + proof.exitCode, proof.driverRollbackAuthorized) || + proof.changed != proof.hasJournalProof || + (proof.changed && + (proof.journalOuterTransactionId != + loaded.state.transactionId || + proof.journalCandidateSha256 != + loaded.state.brokerExecutableSha256))) { + return SetError(error, + L"install-journal-broker-replay-proof", + ERROR_REVISION_MISMATCH, + L"replayed child proof is not bound to the retained outer token and protected image"); + } + InstallJournalStateData next = loaded.state; + next.phase = InstallJournalPhase::BrokerChildSettled; + next.brokerEntered = true; + next.brokerSettled = true; + next.hasBrokerProof = true; + next.brokerProofSuccess = proof.success; + next.brokerProofChanged = proof.changed; + next.brokerProofRollback = proof.rollback; + next.brokerProofExitCode = proof.exitCode; + next.brokerDriverRollbackAuthorized = + proof.driverRollbackAuthorized; + next.brokerJournalTransactionId = + proof.journalTransactionId; + next.brokerJournalOuterTransactionId = + proof.journalOuterTransactionId; + next.brokerJournalCandidateSha256 = + proof.journalCandidateSha256; + next.brokerJournalState = proof.journalState; + next.brokerJournalDigest = proof.journalDigest; + next.callSucceeded = proof.success; + next.callError = proof.exitCode; + if (proof.driverRollbackAuthorized) { + next.direction = InstallJournalDirection::Rollback; + next.rollbackAuthorized = true; + } + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, + loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; + const auto appendOuterSettlementPending = + [&](Error* error) { + InstallJournalStateData next = loaded.state; + if (!GenerateInstallTransactionId( + &next.brokerSettlementNonce, error)) { + return false; + } + next.phase = + InstallJournalPhase::BrokerOuterSettlementPending; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS; + if (!ValidateInstallJournalTransition( + &loaded.state, next, error) || + !WriteInstallJournalRecord( + loaded.directory.active, &next, error)) { + return false; + } + loaded.state = std::move(next); + if (!PublishInstallRecoveryEvidence( + loaded.directory.active, + loaded.state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; + }; const auto appendPartialRootRemovalEntered = [&](InstallJournalStateData::PartialRootRemovalBinding binding, Error* error) { @@ -10844,6 +11314,31 @@ bool ReconcileInstallJournal( ExitCode::RebootRequired, outcome); return false; }; + const auto returnPendingBinding = [&]() { + BrokerJournalBinding binding; + binding.present = true; + binding.transactionId = + loaded.state.brokerJournalTransactionId; + binding.outerTransactionId = + loaded.state.brokerJournalOuterTransactionId; + binding.candidateSha256 = + loaded.state.brokerJournalCandidateSha256; + binding.state = loaded.state.brokerJournalState; + binding.digest = loaded.state.brokerJournalDigest; + binding.driverTransactionId = loaded.state.transactionId; + binding.driverDigest = + BrokerOuterSettlementPendingDriverDigest(loaded.state); + binding.settlementNonce = + loaded.state.brokerSettlementNonce; + binding.recovery = "replayed"; + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + outcome->rollback = L"not-needed"; + outcome->brokerBinding = std::move(binding); + // Stop this invocation before it can admit a new package identity. + return false; + }; std::string currentBoot; Error bootError; @@ -10859,42 +11354,131 @@ bool ReconcileInstallJournal( if (loaded.state.phase == InstallJournalPhase::ManualReconciliationRequired) { return manual(L"a prior authoritative owner retained the transaction for manual reconciliation"); } - const bool partialRootRemovalPhase = - loaded.state.phase == - InstallJournalPhase::PartialRootRemovalEntered || - loaded.state.phase == - InstallJournalPhase::PartialRootRemovalReturned || - loaded.state.phase == InstallJournalPhase:: - PartialRootRemovalRebootPending; - if (partialRootRemovalPhase) { - InstallRecoveryRootObservation observedRoot; - Error observationError; - if (!ObservePriorEmptyInstallRecoveryRoot( - loaded, &observedRoot, &observationError)) { + if (loaded.state.phase == InstallJournalPhase::BrokerChildEntered && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized && + !loaded.state.hasBrokerProof) { + InstallOptions recoveryOptions; + recoveryOptions.brokerExecutable = + loaded.directory.active / + kInstallRecoveryBrokerDirectory / + kInstallRecoveryBrokerExecutable; + recoveryOptions.brokerSha256 = + loaded.state.brokerExecutableSha256; + recoveryOptions.brokerToken = loaded.state.brokerTokenPath; + recoveryOptions.brokerTokenSha256 = loaded.state.transactionId; + recoveryOptions.targetUserSid = + loaded.state.brokerTargetUserSid; + recoveryOptions.transactionDeadlineUnixMs = deadlineUnixMs; + bool rollbackAuthorized = false; + bool brokerChanged = false; + BrokerCommitProof replayedProof; + Error replayError; + const bool replaySucceeded = RunBrokerInstall( + recoveryOptions, &rollbackAuthorized, &brokerChanged, + &replayedProof, true, &replayError); + if (!replayedProof.hasJournalProof || !brokerChanged || + rollbackAuthorized != + replayedProof.driverRollbackAuthorized) { return manual( - L"partial root removal topology is not within the exact durable receipt authority", - &observationError); + L"the retained child recovery invocation did not return one authoritative journal-bound outcome", + &replayError); } - const PartialRootRemovalRecoveryDisposition disposition = - ClassifyPartialRootRemovalJournalRecovery( - loaded.state.phase, loaded.state.callSucceeded, - loaded.state.freshRebootRequired, - samePartialRootRemovalBoot, - loaded.state.partialRootRemovalBinding, - observedRoot.action); - if (disposition == - PartialRootRemovalRecoveryDisposition::Manual) { + Error appendError; + if (!appendBrokerProof(replayedProof, &appendError)) { return manual( - L"partial root removal outcome and current topology do not prove a safe automatic continuation"); + L"the recovered child proof could not be durably bound to the driver journal", + &appendError); } - if (disposition == - PartialRootRemovalRecoveryDisposition::RebootPending) { - if (loaded.state.phase != InstallJournalPhase:: - PartialRootRemovalRebootPending) { - Error pendingError; - if (!appendPhase(InstallJournalPhase:: - PartialRootRemovalRebootPending, - loaded.state.phase == InstallJournalPhase:: + if (!replaySucceeded && + !replayedProof.driverRollbackAuthorized) { + return manual( + L"the recovered child state is indeterminate and does not authorize driver rollback", + &replayError); + } + } + const bool exactChangedBrokerCommit = + loaded.state.brokerRequired && loaded.state.brokerEntered && + loaded.state.brokerSettled && loaded.state.hasBrokerProof && + loaded.state.brokerProofSuccess && + loaded.state.brokerProofChanged && + !loaded.state.brokerDriverRollbackAuthorized && + loaded.state.brokerJournalState == "nested-ready" && + loaded.state.direction == InstallJournalDirection::Forward && + !loaded.state.rollbackAuthorized; + const bool brokerSettlementContinuation = + IsBrokerOuterSettlementContinuationPhase(loaded.state.phase); + if (exactChangedBrokerCommit && brokerSettlementContinuation) { + Error validationError; + if (!RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &validationError)) { + return manual( + L"the journal-bound child committed but the exact forward driver state did not revalidate", + &validationError); + } + if (loaded.state.phase == + InstallJournalPhase::BrokerChildSettled) { + Error appendError; + if (!appendPhase(InstallJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, &appendError) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, &appendError)) { + return manual( + L"the replayed forward state could not be durably validated", + &appendError); + } + } + if (loaded.state.phase == InstallJournalPhase::ForwardValidated) { + Error appendError; + if (!appendOuterSettlementPending(&appendError)) { + return manual( + L"the replayed forward state could not enter durable outer settlement", + &appendError); + } + } + if (loaded.state.phase == + InstallJournalPhase::BrokerOuterSettlementPending || + loaded.state.phase == + InstallJournalPhase::BrokerOuterSettled) { + return returnPendingBinding(); + } + } + const bool partialRootRemovalPhase = + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalEntered || + loaded.state.phase == + InstallJournalPhase::PartialRootRemovalReturned || + loaded.state.phase == InstallJournalPhase:: + PartialRootRemovalRebootPending; + if (partialRootRemovalPhase) { + InstallRecoveryRootObservation observedRoot; + Error observationError; + if (!ObservePriorEmptyInstallRecoveryRoot( + loaded, &observedRoot, &observationError)) { + return manual( + L"partial root removal topology is not within the exact durable receipt authority", + &observationError); + } + const PartialRootRemovalRecoveryDisposition disposition = + ClassifyPartialRootRemovalJournalRecovery( + loaded.state.phase, loaded.state.callSucceeded, + loaded.state.freshRebootRequired, + samePartialRootRemovalBoot, + loaded.state.partialRootRemovalBinding, + observedRoot.action); + if (disposition == + PartialRootRemovalRecoveryDisposition::Manual) { + return manual( + L"partial root removal outcome and current topology do not prove a safe automatic continuation"); + } + if (disposition == + PartialRootRemovalRecoveryDisposition::RebootPending) { + if (loaded.state.phase != InstallJournalPhase:: + PartialRootRemovalRebootPending) { + Error pendingError; + if (!appendPhase(InstallJournalPhase:: + PartialRootRemovalRebootPending, + loaded.state.phase == InstallJournalPhase:: PartialRootRemovalReturned && loaded.state.callSucceeded, ERROR_SUCCESS_REBOOT_REQUIRED, true, @@ -11030,14 +11614,6 @@ bool ReconcileInstallJournal( &authorizationError); } } - if (loaded.state.phase == InstallJournalPhase::BrokerChildEntered && - loaded.state.direction == InstallJournalDirection::Forward && - !loaded.state.rollbackAuthorized && - !loaded.state.hasBrokerProof) { - return manual( - L"broker child creation was admitted without a durable canonical settlement proof; driver evidence was retained and no mutation was attempted"); - } - const bool priorRequiresAbiProfile = loaded.state.bindingMutationStarted && loaded.state.prior.devices.size() == 1U && @@ -11448,260 +12024,3747 @@ bool ReconcileInstallJournal( return finishSuccess(true); } -bool RollbackRemove( - const Snapshot& prior, - const std::vector& backups, - uint64_t rollbackDeadlineUnixMs, - bool* rebootRequired, +struct BrokerSettlementBindingData { + std::string brokerTransactionId; + std::string brokerOuterTransactionId; + std::string brokerCandidateSha256; + std::string brokerNestedDigest; + std::string driverTransactionId; + std::string driverPendingDigest; + std::string settlementNonce; +}; + +struct BrokerSettlementRequestData { + std::string payloadSha256; + std::string bindingSha256; + std::string brokerPendingDigest; + BrokerSettlementBindingData binding; + std::string requestSha256; +}; + +struct BrokerSettlementFinalData { + std::string payloadSha256; + std::string brokerTransactionId; + std::string brokerPendingDigest; + std::string brokerSettledDigest; + std::string driverTransactionId; + std::string driverPendingDigest; + std::string driverSettledDigest; + std::string settlementNonce; + std::string requestSha256; + std::string state; + std::string receiptSha256; +}; + +struct BrokerSettlementAckOptions { + std::filesystem::path requestPath; + std::string requestSha256; + uint64_t transactionDeadlineUnixMs = 0; +}; + +struct BrokerSettlementDiscardOptions { + std::string brokerTransactionId; + std::string brokerDigest; + std::string driverTransactionId; + std::string driverDigest; + std::string settlementNonce; + std::string requestSha256; + std::filesystem::path brokerFinalReceiptPath; + std::string brokerFinalReceiptSha256; + uint64_t transactionDeadlineUnixMs = 0; +}; + +void AppendBrokerSettlementBindingJson( + std::string* output, + const BrokerSettlementBindingData& binding) { + output->append("{\"schema\":1,\"brokerTransactionId\":"); + AppendJsonAsciiString(output, binding.brokerTransactionId); + output->append(",\"brokerOuterTransactionId\":"); + AppendJsonAsciiString(output, binding.brokerOuterTransactionId); + output->append(",\"brokerCandidateSha256\":"); + AppendJsonAsciiString(output, binding.brokerCandidateSha256); + output->append(",\"brokerNestedDigest\":"); + AppendJsonAsciiString(output, binding.brokerNestedDigest); + output->append(",\"driverTransactionId\":"); + AppendJsonAsciiString(output, binding.driverTransactionId); + output->append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(output, binding.driverPendingDigest); + output->append(",\"settlementNonce\":"); + AppendJsonAsciiString(output, binding.settlementNonce); + output->push_back('}'); +} + +bool BuildBrokerSettlementRequestJson( + const BrokerSettlementRequestData& request, + std::string* payload, + std::string* envelope, Error* error) { - for (const PackageBackup& backup : backups) { - if (!CheckTransactionDeadline( - rollbackDeadlineUnixMs, L"remove-rollback-deadline-package", error)) { - return false; - } - BOOL reboot = FALSE; - MarkTransactionMutationStarted(); - if (!DiInstallDriverW(nullptr, backup.infPath.c_str(), 0, &reboot)) { - return SetLastErrorDetail(error, L"remove-rollback-package"); + std::string binding; + AppendBrokerSettlementBindingJson(&binding, request.binding); + std::string observedBindingDigest; + if (!Sha256Data(binding, &observedBindingDigest, error) || + observedBindingDigest != request.bindingSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-binding-digest", + ERROR_CRC); } - *rebootRequired = *rebootRequired || reboot != FALSE; + return false; } - if (!CheckTransactionDeadline( - rollbackDeadlineUnixMs, L"remove-rollback-deadline-binding", error)) { + payload->assign("{\"schema\":1,\"bindingSha256\":"); + AppendJsonAsciiString(payload, request.bindingSha256); + payload->append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(payload, request.brokerPendingDigest); + payload->append(",\"binding\":"); + payload->append(binding); + payload->push_back('}'); + std::string observedPayloadDigest; + if (!Sha256Data(*payload, &observedPayloadDigest, error) || + observedPayloadDigest != request.payloadSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-payload-digest", + ERROR_CRC); + } return false; } - Snapshot restorablePrior = prior; - std::vector reinstalledPackages; - if (!EnumerateOwnedPackages(&reinstalledPackages, error)) { + envelope->assign("{\"schema\":1,\"payloadSha256\":"); + AppendJsonAsciiString(envelope, request.payloadSha256); + envelope->append(",\"payload\":"); + envelope->append(*payload); + envelope->push_back('}'); + return true; +} + +bool ParseBrokerSettlementRequest( + std::string_view contents, + BrokerSettlementRequestData* request, + Error* error) { + if (contents.empty() || + contents.size() > kMaximumBrokerSettlementRequestBytes || + contents.find('\n') != std::string_view::npos || + contents.find('\r') != std::string_view::npos) { + return SetError(error, L"broker-settlement-request-framing", + ERROR_INVALID_DATA); + } + JsonValue root; + std::string parseMessage; + if (!JsonParser(contents).Parse(&root, &parseMessage)) { + return SetError(error, L"broker-settlement-request-parse", + ERROR_INVALID_DATA); + } + const JsonValue::Object* envelopeObject = nullptr; + const JsonValue::Object* payloadObject = nullptr; + const JsonValue::Object* bindingObject = nullptr; + const JsonValue* payloadNode = nullptr; + const JsonValue* bindingNode = nullptr; + uint64_t envelopeSchema = 0; + uint64_t payloadSchema = 0; + uint64_t bindingSchema = 0; + if (!RequireJournalObject(root, &envelopeObject, error) || + envelopeObject->size() != 3U || + !RequireJournalUnsigned(*envelopeObject, "schema", 1U, + &envelopeSchema, error) || envelopeSchema != 1U || + !RequireJournalString(*envelopeObject, "payloadSha256", + &request->payloadSha256, error) || + (payloadNode = ObjectField(*envelopeObject, "payload")) == nullptr || + !RequireJournalObject(*payloadNode, &payloadObject, error) || + payloadObject->size() != 4U || + !RequireJournalUnsigned(*payloadObject, "schema", 1U, + &payloadSchema, error) || payloadSchema != 1U || + !RequireJournalString(*payloadObject, "bindingSha256", + &request->bindingSha256, error) || + !RequireJournalString(*payloadObject, "brokerPendingDigest", + &request->brokerPendingDigest, error) || + (bindingNode = ObjectField(*payloadObject, "binding")) == nullptr || + !RequireJournalObject(*bindingNode, &bindingObject, error) || + bindingObject->size() != 8U || + !RequireJournalUnsigned(*bindingObject, "schema", 1U, + &bindingSchema, error) || bindingSchema != 1U || + !RequireJournalString(*bindingObject, "brokerTransactionId", + &request->binding.brokerTransactionId, error) || + !RequireJournalString(*bindingObject, "brokerOuterTransactionId", + &request->binding.brokerOuterTransactionId, error) || + !RequireJournalString(*bindingObject, "brokerCandidateSha256", + &request->binding.brokerCandidateSha256, error) || + !RequireJournalString(*bindingObject, "brokerNestedDigest", + &request->binding.brokerNestedDigest, error) || + !RequireJournalString(*bindingObject, "driverTransactionId", + &request->binding.driverTransactionId, error) || + !RequireJournalString(*bindingObject, "driverPendingDigest", + &request->binding.driverPendingDigest, error) || + !RequireJournalString(*bindingObject, "settlementNonce", + &request->binding.settlementNonce, error)) { return false; } - for (DeviceState& device : restorablePrior.devices) { - const auto package = std::find_if(reinstalledPackages.begin(), reinstalledPackages.end(), - [&](const PackageInfo& candidate) { - return SamePackageBytes(candidate, device.package) && - candidate.version == device.package.version; - }); - if (package == reinstalledPackages.end()) { - return SetError(error, L"remove-rollback-package-identity", ERROR_NOT_FOUND, - L"the exact captured package was not republished for devnode restoration"); - } - device.package = *package; - device.publishedInf = package->publishedName; + if (!IsCanonicalLowerHex( + request->binding.brokerTransactionId, 32U) || + !IsCanonicalLowerHex( + request->binding.brokerOuterTransactionId, 64U) || + !IsCanonicalLowerHex( + request->binding.brokerCandidateSha256, 64U) || + !IsCanonicalLowerHex( + request->binding.brokerNestedDigest, 64U) || + !IsCanonicalLowerHex( + request->binding.driverTransactionId, 64U) || + !IsCanonicalLowerHex( + request->binding.driverPendingDigest, 64U) || + !IsCanonicalLowerHex( + request->binding.settlementNonce, 64U) || + !IsCanonicalLowerHex(request->payloadSha256, 64U) || + !IsCanonicalLowerHex(request->bindingSha256, 64U) || + !IsCanonicalLowerHex(request->brokerPendingDigest, 64U)) { + return SetError(error, L"broker-settlement-request-identity", + ERROR_INVALID_DATA); } - if (!RestorePriorBinding( - restorablePrior, rollbackDeadlineUnixMs, rebootRequired, error)) { + std::string canonicalPayload; + std::string canonicalEnvelope; + if (!BuildBrokerSettlementRequestJson( + *request, &canonicalPayload, &canonicalEnvelope, error) || + canonicalEnvelope != contents || + !Sha256Data(canonicalEnvelope, &request->requestSha256, + error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-canonical", + ERROR_INVALID_DATA); + } return false; } + return true; +} - if (!CheckTransactionDeadline( - rollbackDeadlineUnixMs, L"remove-rollback-deadline-verify", error)) { +bool BuildBrokerSettlementFinalJson( + const BrokerSettlementFinalData& receipt, + std::string* payload, + std::string* envelope, + Error* error) { + payload->assign("{\"schema\":1,\"brokerTransactionId\":"); + AppendJsonAsciiString(payload, receipt.brokerTransactionId); + payload->append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(payload, receipt.brokerPendingDigest); + payload->append(",\"brokerSettledDigest\":"); + AppendJsonAsciiString(payload, receipt.brokerSettledDigest); + payload->append(",\"driverTransactionId\":"); + AppendJsonAsciiString(payload, receipt.driverTransactionId); + payload->append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(payload, receipt.driverPendingDigest); + payload->append(",\"driverSettledDigest\":"); + AppendJsonAsciiString(payload, receipt.driverSettledDigest); + payload->append(",\"settlementNonce\":"); + AppendJsonAsciiString(payload, receipt.settlementNonce); + payload->append(",\"requestSha256\":"); + AppendJsonAsciiString(payload, receipt.requestSha256); + payload->append(",\"state\":"); + AppendJsonAsciiString(payload, receipt.state); + payload->push_back('}'); + std::string observedPayloadDigest; + if (!Sha256Data(*payload, &observedPayloadDigest, error) || + observedPayloadDigest != receipt.payloadSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-final-payload-digest", + ERROR_CRC); + } return false; } - Snapshot restored; - if (!CaptureSnapshot(&restored, error)) { - return false; + envelope->assign("{\"schema\":1,\"payloadSha256\":"); + AppendJsonAsciiString(envelope, receipt.payloadSha256); + envelope->append(",\"payload\":"); + envelope->append(*payload); + envelope->push_back('}'); + return true; +} + +bool ParseBrokerSettlementFinal( + std::string_view contents, + BrokerSettlementFinalData* receipt, + Error* error) { + if (contents.empty() || + contents.size() > kMaximumBrokerSettlementRequestBytes || + contents.find('\n') != std::string_view::npos || + contents.find('\r') != std::string_view::npos) { + return SetError(error, L"broker-settlement-final-framing", + ERROR_INVALID_DATA); } - std::multiset> expectedPackages; - std::multiset> actualPackages; - for (const PackageInfo& package : prior.packages) { - expectedPackages.emplace(package.version, PackageBytesKey(package)); + JsonValue root; + std::string parseMessage; + if (!JsonParser(contents).Parse(&root, &parseMessage)) { + return SetError(error, L"broker-settlement-final-parse", + ERROR_INVALID_DATA); } - for (const PackageInfo& package : restored.packages) { - actualPackages.emplace(package.version, PackageBytesKey(package)); + const JsonValue::Object* envelopeObject = nullptr; + const JsonValue::Object* payloadObject = nullptr; + const JsonValue* payloadNode = nullptr; + uint64_t envelopeSchema = 0; + uint64_t payloadSchema = 0; + if (!RequireJournalObject(root, &envelopeObject, error) || + envelopeObject->size() != 3U || + !RequireJournalUnsigned(*envelopeObject, "schema", 1U, + &envelopeSchema, error) || envelopeSchema != 1U || + !RequireJournalString(*envelopeObject, "payloadSha256", + &receipt->payloadSha256, error) || + (payloadNode = ObjectField(*envelopeObject, "payload")) == nullptr || + !RequireJournalObject(*payloadNode, &payloadObject, error) || + payloadObject->size() != 10U || + !RequireJournalUnsigned(*payloadObject, "schema", 1U, + &payloadSchema, error) || payloadSchema != 1U || + !RequireJournalString(*payloadObject, "brokerTransactionId", + &receipt->brokerTransactionId, error) || + !RequireJournalString(*payloadObject, "brokerPendingDigest", + &receipt->brokerPendingDigest, error) || + !RequireJournalString(*payloadObject, "brokerSettledDigest", + &receipt->brokerSettledDigest, error) || + !RequireJournalString(*payloadObject, "driverTransactionId", + &receipt->driverTransactionId, error) || + !RequireJournalString(*payloadObject, "driverPendingDigest", + &receipt->driverPendingDigest, error) || + !RequireJournalString(*payloadObject, "driverSettledDigest", + &receipt->driverSettledDigest, error) || + !RequireJournalString(*payloadObject, "settlementNonce", + &receipt->settlementNonce, error) || + !RequireJournalString(*payloadObject, "requestSha256", + &receipt->requestSha256, error) || + !RequireJournalString(*payloadObject, "state", + &receipt->state, error)) { + return false; } - if (expectedPackages != actualPackages || restored.devices.size() != prior.devices.size()) { - return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, - L"rollback did not restore the exact prior package and devnode topology"); + if (!IsCanonicalLowerHex(receipt->payloadSha256, 64U) || + !IsCanonicalLowerHex(receipt->brokerTransactionId, 32U) || + !IsCanonicalLowerHex(receipt->brokerPendingDigest, 64U) || + !IsCanonicalLowerHex(receipt->brokerSettledDigest, 64U) || + !IsCanonicalLowerHex(receipt->driverTransactionId, 64U) || + !IsCanonicalLowerHex(receipt->driverPendingDigest, 64U) || + !IsCanonicalLowerHex(receipt->driverSettledDigest, 64U) || + !IsCanonicalLowerHex(receipt->settlementNonce, 64U) || + !IsCanonicalLowerHex(receipt->requestSha256, 64U) || + receipt->state != "outer-settled") { + return SetError(error, L"broker-settlement-final-identity", + ERROR_INVALID_DATA); } - if (!prior.devices.empty()) { - if (_wcsicmp(restored.devices[0].instanceId.c_str(), prior.devices[0].instanceId.c_str()) != 0 || - !SamePackageBytes(restored.devices[0].package, prior.devices[0].package)) { - return SetError(error, L"remove-rollback-verification", ERROR_REVISION_MISMATCH, - L"rollback restored a different devnode identity or active package"); - } - if (!*rebootRequired && prior.devices[0].started) { - if (!CheckTransactionDeadline( - rollbackDeadlineUnixMs, L"remove-rollback-deadline-health", error)) { - return false; - } - const uint64_t healthDeadline = std::min( - rollbackDeadlineUnixMs, CurrentUnixMilliseconds() + 15000); - if (!VerifyAbiHealth(healthDeadline, nullptr, error)) { - return false; - } + std::string canonicalPayload; + std::string canonicalEnvelope; + if (!BuildBrokerSettlementFinalJson( + *receipt, &canonicalPayload, &canonicalEnvelope, error) || + canonicalEnvelope != contents || + !Sha256Data(canonicalEnvelope, &receipt->receiptSha256, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-final-canonical", + ERROR_INVALID_DATA); } + return false; } return true; } -struct RemoveOptions { - uint64_t transactionDeadlineUnixMs = 0; -}; +bool ResolveBrokerSettlementRequestPath( + std::filesystem::path* product, + std::filesystem::path* journalRoot, + std::filesystem::path* active, + std::filesystem::path* request, + Error* error) { + std::filesystem::path programData; + std::filesystem::path ignoredComponent; + std::filesystem::path ignoredTransactions; + std::filesystem::path ignoredActive; + if (!ResolveInstallRecoveryPaths(&programData, product, + &ignoredComponent, &ignoredTransactions, &ignoredActive, + error)) { + return false; + } + *journalRoot = *product / kBrokerTransactionDirectory; + *active = *journalRoot / kBrokerTransactionActiveDirectory; + *request = *active / kBrokerSettlementRequestFile; + if (!request->is_absolute() || + request->lexically_relative(programData).empty()) { + return SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_NAME); + } + return true; +} -Outcome Remove(const RemoveOptions& options) { - Outcome outcome; - if (!ValidateTransactionDeadlineBudget(options.transactionDeadlineUnixMs, &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; +bool ReadProtectedBrokerSettlementArtifact( + const std::filesystem::path& suppliedPath, + const wchar_t* expectedLeaf, + std::string* contents, + Error* error) { + std::filesystem::path product; + std::filesystem::path journalRoot; + std::filesystem::path active; + std::filesystem::path request; + if (!ResolveBrokerSettlementRequestPath( + &product, &journalRoot, &active, &request, error)) { + return false; } - if (!IsElevated()) { - SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + if (expectedLeaf == nullptr) { + return SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_PARAMETER); } - TransactionMutex mutex; - if (!mutex.Acquire(&outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + const std::filesystem::path expected = + std::wcscmp(expectedLeaf, kBrokerSettlementRequestFile) == 0 + ? request : active / expectedLeaf; + if (expected.filename() != expectedLeaf || + _wcsicmp(suppliedPath.lexically_normal().c_str(), + expected.lexically_normal().c_str()) != 0) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-path", + ERROR_INVALID_NAME, + L"settlement request is not the fixed protected active-journal path"); + } + return false; } - Outcome recoveryOutcome; - if (!ReconcileInstallJournal( - false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { - return recoveryOutcome; + WinHandle productHandle; + WinHandle rootHandle; + WinHandle activeHandle; + if (!OpenStableDirectory(product, false, &productHandle, error) || + !VerifyProtectedProductDirectorySecurity( + productHandle.get(), nullptr, error) || + !OpenStableDirectory(journalRoot, true, &rootHandle, error) || + !OpenStableDirectory(active, true, &activeHandle, error)) { + return false; } - if (!CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-before-snapshot", &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + WinHandle file(CreateFileW( + expected.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file) { + return SetLastErrorDetail(error, + L"broker-settlement-request-open"); } - Snapshot prior; - if (!CaptureSnapshot(&prior, &outcome.error)) { - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + BY_HANDLE_FILE_INFORMATION identity{}; + LARGE_INTEGER size{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !GetFileInformationByHandle(file.get(), &identity) || + identity.nNumberOfLinks != 1U || + !GetFileSizeEx(file.get(), &size) || size.QuadPart <= 0 || + static_cast(size.QuadPart) > + kMaximumBrokerSettlementRequestBytes || + !VerifyProtectedFileSystemSecurity(file.get(), false, + L"broker-settlement-request-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-identity", + ERROR_INVALID_DATA, + L"settlement request must be one bounded protected single-link regular file"); + } + return false; } - if (prior.devices.size() > 1 || - (!prior.devices.empty() && !prior.devices[0].present)) { - SetError(&outcome.error, L"remove-topology", ERROR_DUPLICATE_SERVICE_NAME, - L"removal requires zero devices or one present exact owned root devnode"); - outcome.exitCode = ExitCode::PreflightRejected; - return outcome; + contents->assign(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), contents->data(), + static_cast(contents->size()), &read, nullptr) || + static_cast(read) != contents->size()) { + return SetLastErrorDetail(error, + L"broker-settlement-request-read"); } - if (prior.devices.empty() && prior.packages.empty()) { - outcome.success = true; - outcome.exitCode = ExitCode::Success; - return outcome; + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"broker-settlement-request-read", + ERROR_FILE_INVALID); } - BackupDirectory backupRoot; - std::vector backups; - const auto rejectBeforeMutation = [&](Error failure) { - Error cleanupError; - if (!backupRoot.Cleanup(&backups, &cleanupError)) { - outcome.error = std::move(cleanupError); - } else { - outcome.error = std::move(failure); - } - outcome.exitCode = ExitCode::PreflightRejected; - }; - if (!BackupPackages(prior.packages, &backupRoot, &backups, &outcome.error)) { - Error failure = std::move(outcome.error); - rejectBeforeMutation(std::move(failure)); - return outcome; + return true; +} + +bool ReadProtectedBrokerSettlementRequest( + const std::filesystem::path& suppliedPath, + std::string* contents, + Error* error) { + return ReadProtectedBrokerSettlementArtifact( + suppliedPath, kBrokerSettlementRequestFile, contents, error); +} + +bool ReadProtectedBrokerSettlementFinal( + const std::filesystem::path& suppliedPath, + std::string* contents, + Error* error) { + return ReadProtectedBrokerSettlementArtifact( + suppliedPath, kBrokerSettlementFinalFile, contents, error); +} + +bool OpenInstallJournalTransactionDirectory( + std::string_view driverTransactionId, + const wchar_t* prefix, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + *exists = false; + if (!IsCanonicalLowerHex(driverTransactionId, 64U) || + (prefix != kInstallRecoverySettledPrefix && + prefix != kInstallRecoveryDiscardPrefix) || + !ResolveInstallRecoveryPaths(&directory->programData, + &directory->product, &directory->component, + &directory->transactions, &directory->active, error) || + !OpenStableDirectory(directory->programData, false, + &directory->programDataHandle, error)) { + return false; } - std::string recoveryRecord; - const std::filesystem::path recoveryPath = backupRoot.RecoveryRecordPath(); - Error recoveryError; - if (!BuildRemoveRecoveryRecord( - prior, backups, backupRoot.path(), &recoveryRecord, &recoveryError) || - !WriteProtectedRecoveryRecord(recoveryPath, recoveryRecord, &recoveryError) || - !backupRoot.ArmPreservation(recoveryPath, &recoveryError)) { - rejectBeforeMutation(std::move(recoveryError)); - return outcome; + bool productExists = false; + bool componentExists = false; + bool transactionsExists = false; + if (!OpenExistingInstallRecoveryDirectory(directory->product, false, + &directory->productHandle, &productExists, error)) { + return false; } - if (!CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-before-device", &outcome.error)) { - Error failure = std::move(outcome.error); - rejectBeforeMutation(std::move(failure)); + if (!productExists) return true; + if (!VerifyProtectedProductDirectorySecurity( + directory->productHandle.get(), nullptr, error) || + !OpenExistingInstallRecoveryDirectory(directory->component, true, + &directory->componentHandle, &componentExists, error)) { + return false; + } + if (!componentExists) return true; + if (!OpenExistingInstallRecoveryDirectory(directory->transactions, true, + &directory->transactionsHandle, &transactionsExists, error)) { + return false; + } + if (!transactionsExists) return true; + const std::wstring transactionWide( + driverTransactionId.begin(), driverTransactionId.end()); + directory->active = directory->transactions / + (std::wstring(prefix) + transactionWide); + return OpenExistingInstallRecoveryDirectory(directory->active, true, + &directory->activeHandle, exists, error); +} + +bool OpenSettledInstallJournalDirectory( + std::string_view driverTransactionId, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + return OpenInstallJournalTransactionDirectory( + driverTransactionId, kInstallRecoverySettledPrefix, + directory, exists, error); +} + +bool OpenDiscardingInstallJournalDirectory( + std::string_view driverTransactionId, + InstallRecoveryDirectory* directory, + bool* exists, + Error* error) { + return OpenInstallJournalTransactionDirectory( + driverTransactionId, kInstallRecoveryDiscardPrefix, + directory, exists, error); +} + +bool LoadSettlementInstallJournal( + std::string_view driverTransactionId, + LoadedInstallJournal* loaded, + bool* tombstone, + bool* exists, + Error* error) { + *tombstone = false; + *exists = false; + InstallRecoveryDirectory activeDirectory; + bool activeExists = false; + if (!activeDirectory.OpenChain( + false, nullptr, &activeExists, error)) { + return false; + } + if (activeExists) { + if (!LoadInstallJournal( + std::move(activeDirectory), loaded, error) || + !loaded->hasRecord || + loaded->state.transactionId != driverTransactionId) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-identity", + ERROR_REVISION_MISMATCH, + L"active driver journal belongs to a different transaction"); + } + return false; + } + *exists = true; + return true; + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + driverTransactionId, &settledDirectory, + &settledExists, error)) { + return false; + } + if (!settledExists) { + return true; + } + if (!LoadInstallJournal( + std::move(settledDirectory), loaded, error) || + !loaded->hasRecord || + loaded->state.transactionId != driverTransactionId) { + return false; + } + *tombstone = true; + *exists = true; + return true; +} + +bool ValidateBrokerSettlementJournalBinding( + const InstallJournalStateData& state, + const BrokerSettlementRequestData& request, + bool final, + Error* error) { + const BrokerSettlementBindingData& binding = request.binding; + const std::string& expectedDriverPending = final + ? state.brokerDriverPendingDigest : state.lastDigest; + if (!state.brokerRequired || !state.hasBrokerProof || + !state.brokerProofSuccess || !state.brokerProofChanged || + state.brokerDriverRollbackAuthorized || + state.brokerJournalState != "nested-ready" || + state.direction != InstallJournalDirection::Forward || + state.rollbackAuthorized || + binding.brokerTransactionId != + state.brokerJournalTransactionId || + binding.brokerOuterTransactionId != + state.brokerJournalOuterTransactionId || + binding.brokerCandidateSha256 != + state.brokerJournalCandidateSha256 || + binding.brokerNestedDigest != state.brokerJournalDigest || + binding.driverTransactionId != state.transactionId || + binding.driverPendingDigest != expectedDriverPending || + binding.settlementNonce != state.brokerSettlementNonce || + (final && + (request.requestSha256 != + state.brokerSettlementRequestSha256 || + request.brokerPendingDigest != + state.brokerGoPendingDigest))) { + return SetError(error, L"broker-settlement-journal-binding", + ERROR_REVISION_MISMATCH, + L"settlement request does not bind both exact pending journal identities"); + } + return true; +} + +bool ValidateBrokerSettlementFinalBinding( + const BrokerSettlementRequestData& request, + const BrokerSettlementFinalData& receipt, + Error* error) { + if (receipt.brokerTransactionId != + request.binding.brokerTransactionId || + receipt.brokerPendingDigest != request.brokerPendingDigest || + receipt.driverTransactionId != + request.binding.driverTransactionId || + receipt.driverPendingDigest != + request.binding.driverPendingDigest || + receipt.settlementNonce != request.binding.settlementNonce || + receipt.requestSha256 != request.requestSha256 || + receipt.state != "outer-settled" || + receipt.brokerSettledDigest == receipt.brokerPendingDigest || + receipt.driverSettledDigest == receipt.driverPendingDigest) { + return SetError(error, L"broker-settlement-final-binding", + ERROR_REVISION_MISMATCH, + L"protected final receipt does not bind the exact pending request"); + } + return true; +} + +bool ValidateBrokerSettlementFinalJournal( + const InstallJournalStateData& state, + const BrokerSettlementFinalData& receipt, + Error* error) { + if (state.phase != InstallJournalPhase::BrokerOuterSettled || + state.transactionId != receipt.driverTransactionId || + state.lastDigest != receipt.driverSettledDigest || + state.brokerJournalTransactionId != + receipt.brokerTransactionId || + state.brokerDriverPendingDigest != + receipt.driverPendingDigest || + state.brokerGoPendingDigest != + receipt.brokerPendingDigest || + state.brokerSettlementNonce != receipt.settlementNonce || + state.brokerSettlementRequestSha256 != receipt.requestSha256) { + return SetError(error, L"broker-settlement-final-journal", + ERROR_REVISION_MISMATCH, + L"protected final receipt does not bind the exact terminal driver journal"); + } + return true; +} + +bool ReconcileSettledBrokerOuterSettlement( + uint64_t deadlineUnixMs, + bool* handled, + Outcome* outcome, + Error* error) { + *handled = false; + std::filesystem::path product; + std::filesystem::path journalRoot; + std::filesystem::path active; + std::filesystem::path requestPath; + if (!ResolveBrokerSettlementRequestPath( + &product, &journalRoot, &active, &requestPath, error)) { + return false; + } + const DWORD attributes = GetFileAttributesW(requestPath.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD code = GetLastError(); + if (code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND) { + return true; + } + return SetError(error, + L"broker-settlement-replay-request-discovery", code); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + std::string contents; + BrokerSettlementRequestData request; + if (!ReadProtectedBrokerSettlementRequest( + requestPath, &contents, error) || + !ParseBrokerSettlementRequest(contents, &request, error)) { + return false; + } + const std::filesystem::path finalPath = + active / kBrokerSettlementFinalFile; + std::optional finalReceipt; + const DWORD finalAttributes = GetFileAttributesW(finalPath.c_str()); + if (finalAttributes != INVALID_FILE_ATTRIBUTES) { + std::string finalContents; + BrokerSettlementFinalData parsedFinal; + if (!ReadProtectedBrokerSettlementFinal( + finalPath, &finalContents, error) || + !ParseBrokerSettlementFinal( + finalContents, &parsedFinal, error) || + !ValidateBrokerSettlementFinalBinding( + request, parsedFinal, error)) { + return false; + } + finalReceipt = std::move(parsedFinal); + } else { + const DWORD finalError = GetLastError(); + if (finalError != ERROR_FILE_NOT_FOUND && + finalError != ERROR_PATH_NOT_FOUND) { + return SetError(error, + L"broker-settlement-replay-final-discovery", + finalError); + } + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + request.binding.driverTransactionId, + &settledDirectory, &settledExists, error)) { + return false; + } + if (settledExists) { + LoadedInstallJournal loaded; + if (!LoadInstallJournal(std::move(settledDirectory), + &loaded, error) || !loaded.hasRecord || + loaded.state.phase != + InstallJournalPhase::BrokerOuterSettled || + !ValidateBrokerSettlementJournalBinding( + loaded.state, request, true, error) || + (finalReceipt && + !ValidateBrokerSettlementFinalJournal( + loaded.state, *finalReceipt, error)) || + !RecoveryStateMatchesForward( + loaded, deadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-replay-tombstone", + ERROR_REVISION_MISMATCH); + } + return false; + } + } else if (!finalReceipt) { + return SetError(error, L"broker-settlement-replay-tombstone", + ERROR_FILE_NOT_FOUND, + L"published pending request has neither an exact settled driver journal nor a protected final receipt"); + } + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + outcome->rollback = L"not-needed"; + outcome->brokerBinding.present = true; + outcome->brokerBinding.transactionId = + request.binding.brokerTransactionId; + outcome->brokerBinding.outerTransactionId = + request.binding.brokerOuterTransactionId; + outcome->brokerBinding.candidateSha256 = + request.binding.brokerCandidateSha256; + outcome->brokerBinding.state = "nested-ready"; + outcome->brokerBinding.digest = + request.binding.brokerNestedDigest; + outcome->brokerBinding.driverTransactionId = + request.binding.driverTransactionId; + outcome->brokerBinding.driverDigest = + request.binding.driverPendingDigest; + outcome->brokerBinding.settlementNonce = + request.binding.settlementNonce; + outcome->brokerBinding.recovery = "replayed"; + *handled = true; + return true; +} + +bool AppendBrokerOuterSettled( + LoadedInstallJournal* loaded, + const BrokerSettlementRequestData& request, + Error* error) { + InstallJournalStateData next = loaded->state; + next.phase = InstallJournalPhase::BrokerOuterSettled; + next.brokerDriverPendingDigest = + request.binding.driverPendingDigest; + next.brokerSettlementRequestSha256 = request.requestSha256; + next.brokerGoPendingDigest = request.brokerPendingDigest; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS; + if (!ValidateInstallJournalTransition( + &loaded->state, next, error) || + !WriteInstallJournalRecord( + loaded->directory.active, &next, error)) { + return false; + } + loaded->state = std::move(next); + MarkTransactionMutationStarted(); + if (!PublishInstallRecoveryEvidence(loaded->directory.active, + loaded->state.sequence - 1U, error)) { + return false; + } + gActiveRecoveryRecordWritten = true; + return true; +} + +bool AcknowledgeBrokerOuterSettlement( + const BrokerSettlementAckOptions& options, + BrokerSettlementRequestData* receipt, + std::string* driverFinalDigest, + Error* error) { + if (!IsElevated()) { + return SetError(error, L"elevation", ERROR_ELEVATION_REQUIRED); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + TransactionMutex transactionMutex; + if (!transactionMutex.Acquire(error) || + !CheckTransactionDeadline(options.transactionDeadlineUnixMs, + L"broker-settlement-deadline", error)) { + return false; + } + std::string contents; + if (!ReadProtectedBrokerSettlementRequest( + options.requestPath, &contents, error) || + !ParseBrokerSettlementRequest(contents, receipt, error) || + receipt->requestSha256 != options.requestSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-request-hash", + ERROR_CRC); + } + return false; + } + LoadedInstallJournal loaded; + bool tombstone = false; + bool exists = false; + if (!LoadSettlementInstallJournal( + receipt->binding.driverTransactionId, &loaded, + &tombstone, &exists, error) || !exists) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-journal", + ERROR_FILE_NOT_FOUND); + } + return false; + } + const bool final = + loaded.state.phase == InstallJournalPhase::BrokerOuterSettled; + if ((!final && loaded.state.phase != + InstallJournalPhase::BrokerOuterSettlementPending) || + (tombstone && !final) || + !ValidateBrokerSettlementJournalBinding( + loaded.state, *receipt, final, error) || + !RecoveryStateMatchesForward( + loaded, options.transactionDeadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-driver-journal", + ERROR_INVALID_STATE); + } + return false; + } + if (!final && !AppendBrokerOuterSettled( + &loaded, *receipt, error)) { + return false; + } + *driverFinalDigest = loaded.state.lastDigest; + if (!tombstone) { + loaded.evidenceLocks.clear(); + std::filesystem::path retiredPath; + if (!RetireInstallRecoveryActiveDirectory( + &loaded.directory, loaded.state.transactionId, + error, true, &retiredPath)) { + return false; + } + } + return true; +} + +bool DiscardBrokerSettlementTombstone( + const BrokerSettlementDiscardOptions& options, + bool* discarded, + bool* retained, + Error* error) { + *discarded = false; + *retained = false; + if (!IsElevated()) { + return SetError(error, L"elevation", ERROR_ELEVATION_REQUIRED); + } + OuterPackageMutexWitness outerMutex; + if (!outerMutex.VerifyHeldByOuterOwner(error)) { + return false; + } + TransactionMutex transactionMutex; + if (!transactionMutex.Acquire(error) || + !CheckTransactionDeadline(options.transactionDeadlineUnixMs, + L"broker-settlement-discard-deadline", error)) { + return false; + } + std::string finalContents; + BrokerSettlementFinalData finalReceipt; + if (!ReadProtectedBrokerSettlementFinal( + options.brokerFinalReceiptPath, &finalContents, error) || + !ParseBrokerSettlementFinal( + finalContents, &finalReceipt, error) || + finalReceipt.receiptSha256 != + options.brokerFinalReceiptSha256 || + finalReceipt.brokerTransactionId != + options.brokerTransactionId || + finalReceipt.brokerSettledDigest != options.brokerDigest || + finalReceipt.driverTransactionId != + options.driverTransactionId || + finalReceipt.driverSettledDigest != options.driverDigest || + finalReceipt.settlementNonce != options.settlementNonce || + finalReceipt.requestSha256 != options.requestSha256) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-final-receipt", + ERROR_REVISION_MISMATCH, + L"discard request does not match the protected broker-final receipt"); + } + return false; + } + std::filesystem::path brokerProduct; + std::filesystem::path brokerRoot; + std::filesystem::path brokerActive; + std::filesystem::path requestPath; + std::string requestContents; + BrokerSettlementRequestData request; + if (!ResolveBrokerSettlementRequestPath( + &brokerProduct, &brokerRoot, &brokerActive, + &requestPath, error) || + !ReadProtectedBrokerSettlementRequest( + requestPath, &requestContents, error) || + !ParseBrokerSettlementRequest( + requestContents, &request, error) || + !ValidateBrokerSettlementFinalBinding( + request, finalReceipt, error)) { + return false; + } + InstallRecoveryDirectory activeDirectory; + bool activeExists = false; + if (!activeDirectory.OpenChain( + false, nullptr, &activeExists, error)) { + return false; + } + if (activeExists) { + return SetError(error, L"broker-settlement-discard-active", + ERROR_INSTALL_ALREADY_RUNNING, + L"an active driver journal blocks inert tombstone cleanup"); + } + InstallRecoveryDirectory settledDirectory; + bool settledExists = false; + if (!OpenSettledInstallJournalDirectory( + options.driverTransactionId, &settledDirectory, + &settledExists, error)) { + return false; + } + InstallRecoveryDirectory discardingDirectory; + bool discardingExists = false; + if (!OpenDiscardingInstallJournalDirectory( + options.driverTransactionId, &discardingDirectory, + &discardingExists, error) || + (settledExists && discardingExists)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-identity", + ERROR_ALREADY_EXISTS, + L"settled and discarding driver tombstones cannot coexist"); + } + return false; + } + if (!settledExists) { + if (discardingExists) { + const std::filesystem::path inert = + discardingDirectory.active; + discardingDirectory.activeHandle.reset(); + std::error_code ignored; + std::filesystem::remove_all(inert, ignored); + if (ignored) { + *retained = true; + std::wstring diagnostic = + L"VIIPER: inert driver settlement cleanup retained after error "; + diagnostic += std::to_wstring(ignored.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + } + return true; + } + LoadedInstallJournal loaded; + if (!LoadInstallJournal(std::move(settledDirectory), + &loaded, error) || !loaded.hasRecord || + !ValidateBrokerSettlementFinalJournal( + loaded.state, finalReceipt, error) || + !RecoveryStateMatchesForward( + loaded, options.transactionDeadlineUnixMs, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"broker-settlement-discard-binding", + ERROR_REVISION_MISMATCH); + } + return false; + } + const std::filesystem::path settled = loaded.directory.active; + const std::filesystem::path discarding = + loaded.directory.transactions / + (std::wstring(kInstallRecoveryDiscardPrefix) + + std::wstring(options.driverTransactionId.begin(), + options.driverTransactionId.end())); + loaded.evidenceLocks.clear(); + loaded.directory.activeHandle.reset(); + if (!MoveFileExW(settled.c_str(), discarding.c_str(), + MOVEFILE_WRITE_THROUGH)) { + return SetLastErrorDetail(error, + L"broker-settlement-discard-rename", + L"settled driver tombstone could not be atomically made inert"); + } + const DWORD settledAttributes = GetFileAttributesW(settled.c_str()); + const DWORD settledError = settledAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (settledAttributes != INVALID_FILE_ATTRIBUTES || + (settledError != ERROR_FILE_NOT_FOUND && + settledError != ERROR_PATH_NOT_FOUND)) { + return SetError(error, L"broker-settlement-discard-absence", + settledAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : settledError); + } + WinHandle discardingHandle; + if (!OpenStableDirectory( + discarding, true, &discardingHandle, error)) { + return false; + } + *discarded = true; + discardingHandle.reset(); + // The atomic rename is the authoritative discard. Recursive cleanup is + // inert and retryable from the exact transaction-bound discarding name. + std::error_code removalError; + std::filesystem::remove_all(discarding, removalError); + if (removalError) { + *retained = true; + std::wstring diagnostic = + L"VIIPER: inert driver settlement cleanup retained after error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L".\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +void EmitBrokerSettlementAck( + const BrokerSettlementRequestData& request, + std::string_view driverFinalDigest) { + std::cout + << "journal-settlement operation=broker-settlement-ack " + << "brokerTransactionId=" + << request.binding.brokerTransactionId + << " brokerPendingDigest=" << request.brokerPendingDigest + << " driverTransactionId=" + << request.binding.driverTransactionId + << " driverPendingDigest=" + << request.binding.driverPendingDigest + << " settlementNonce=" << request.binding.settlementNonce + << " requestSha256=" << request.requestSha256 + << " state=outer-settled digest=" << driverFinalDigest + << "\n"; + std::cout.flush(); +} + +void EmitBrokerSettlementDiscard( + const BrokerSettlementDiscardOptions& options, + bool discarded, + bool retained) { + std::cout + << "journal-discard operation=broker-settlement-discard " + << "brokerTransactionId=" << options.brokerTransactionId + << " brokerDigest=" << options.brokerDigest + << " driverTransactionId=" << options.driverTransactionId + << " driverDigest=" << options.driverDigest + << " settlementNonce=" << options.settlementNonce + << " requestSha256=" << options.requestSha256 + << " discarded=" << (discarded ? 1 : 0) + << " retained=" << (retained ? 1 : 0) << "\n"; + std::cout.flush(); +} + +const char* RemoveJournalPhaseName(RemoveJournalPhase phase) noexcept { + switch (phase) { + case RemoveJournalPhase::Prepared: return "Prepared"; + case RemoveJournalPhase::DeviceRemovalEntered: return "DeviceRemovalEntered"; + case RemoveJournalPhase::DeviceRemovalReturned: return "DeviceRemovalReturned"; + case RemoveJournalPhase::DeviceRemovalCommitted: return "DeviceRemovalCommitted"; + case RemoveJournalPhase::PackageRemovalEntered: return "PackageRemovalEntered"; + case RemoveJournalPhase::PackageRemovalReturned: return "PackageRemovalReturned"; + case RemoveJournalPhase::PackageRemovalCommitted: return "PackageRemovalCommitted"; + case RemoveJournalPhase::RollbackAdmitted: return "RollbackAdmitted"; + case RemoveJournalPhase::RollbackPackageEntered: return "RollbackPackageEntered"; + case RemoveJournalPhase::RollbackPackageReturned: return "RollbackPackageReturned"; + case RemoveJournalPhase::RollbackPackageCommitted: return "RollbackPackageCommitted"; + case RemoveJournalPhase::RollbackBindingEntered: return "RollbackBindingEntered"; + case RemoveJournalPhase::RollbackBindingReturned: return "RollbackBindingReturned"; + case RemoveJournalPhase::ForwardValidated: return "ForwardValidated"; + case RemoveJournalPhase::ExactPriorRestored: return "ExactPriorRestored"; + case RemoveJournalPhase::ForwardRebootPending: return "ForwardRebootPending"; + case RemoveJournalPhase::RestoreRebootPending: return "RestoreRebootPending"; + case RemoveJournalPhase::ManualReconciliationRequired: + return "ManualReconciliationRequired"; + } + return "ManualReconciliationRequired"; +} + +std::optional ParseRemoveJournalPhase( + std::string_view value) noexcept { + for (RemoveJournalPhase phase : { + RemoveJournalPhase::Prepared, + RemoveJournalPhase::DeviceRemovalEntered, + RemoveJournalPhase::DeviceRemovalReturned, + RemoveJournalPhase::DeviceRemovalCommitted, + RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalPhase::PackageRemovalCommitted, + RemoveJournalPhase::RollbackAdmitted, + RemoveJournalPhase::RollbackPackageEntered, + RemoveJournalPhase::RollbackPackageReturned, + RemoveJournalPhase::RollbackPackageCommitted, + RemoveJournalPhase::RollbackBindingEntered, + RemoveJournalPhase::RollbackBindingReturned, + RemoveJournalPhase::ForwardValidated, + RemoveJournalPhase::ExactPriorRestored, + RemoveJournalPhase::ForwardRebootPending, + RemoveJournalPhase::RestoreRebootPending, + RemoveJournalPhase::ManualReconciliationRequired}) { + if (value == RemoveJournalPhaseName(phase)) return phase; + } + return std::nullopt; +} + +const char* RemoveJournalDirectionName( + RemoveJournalDirection direction) noexcept { + return direction == RemoveJournalDirection::Rollback + ? "rollback" : "forward"; +} + +std::optional ParseRemoveJournalDirection( + std::string_view value) noexcept { + if (value == "forward") return RemoveJournalDirection::Forward; + if (value == "rollback") return RemoveJournalDirection::Rollback; + return std::nullopt; +} + +struct RemoveRecoveryDirectory { + std::filesystem::path programData; + std::filesystem::path root; + std::filesystem::path active; + WinHandle programDataHandle; + WinHandle rootHandle; + WinHandle activeHandle; + bool activeCreated = false; + + bool OpenChain(bool createActive, bool* exists, Error* error) { + *exists = false; + std::filesystem::path ignoredProduct; + std::filesystem::path ignoredComponent; + std::filesystem::path ignoredTransactions; + std::filesystem::path ignoredActive; + if (!ResolveInstallRecoveryPaths( + &programData, &ignoredProduct, &ignoredComponent, + &ignoredTransactions, &ignoredActive, error)) { + return false; + } + root = programData / kRemoveRecoveryRootDirectory; + active = root / kRemoveRecoveryActiveDirectory; + if (!OpenStableDirectory( + programData, false, &programDataHandle, error)) { + return false; + } + if (!createActive) { + bool rootExists = false; + bool activeExists = false; + if (!OpenExistingInstallRecoveryDirectory( + root, true, &rootHandle, &rootExists, error)) { + return false; + } + if (!rootExists) return true; + if (!OpenExistingInstallRecoveryDirectory( + active, true, &activeHandle, &activeExists, error)) { + return false; + } + *exists = activeExists; + return true; + } + bool created = false; + if (!CreateOrOpenInstallRecoveryDirectory( + root, true, true, &rootHandle, &created, error)) { + return false; + } + const bool opened = CreateOrOpenInstallRecoveryDirectory( + active, false, true, &activeHandle, &created, error); + activeCreated = created; + if (!opened) return false; + *exists = true; + return true; + } +}; + +bool PublishRemoveRecoveryEvidence( + const std::filesystem::path& active, + uint64_t sequence, + Error* error) { + std::wostringstream name; + name << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path record = active / name.str(); + const std::wstring activeValue = active.wstring(); + const std::wstring recordValue = record.wstring(); + if (activeValue.empty() || recordValue.empty() || + activeValue.size() >= gActiveBackupRoot.size() || + recordValue.size() >= gActiveRecoveryRecord.size()) { + return SetError(error, L"remove-journal-evidence", + ERROR_FILENAME_EXCED_RANGE); + } + ClearActiveRecoveryEvidence(); + std::copy(activeValue.begin(), activeValue.end(), + gActiveBackupRoot.begin()); + std::copy(recordValue.begin(), recordValue.end(), + gActiveRecoveryRecord.begin()); + gActiveBackupRootRetained = true; + return true; +} + +enum class RemoveRetirementTestFault { + None, + TemporaryTree, + TemporaryTreeActiveAbsencePostcheck, + TemporaryTreeRetainSettledTombstone, +}; + +bool RetireRemoveRecoveryActiveDirectory( + RemoveRecoveryDirectory* directory, + std::string_view transactionId, + Error* error, + RemoveRetirementTestFault testFault = + RemoveRetirementTestFault::None) { + if (directory == nullptr || !IsSha256Digest(transactionId) || + directory->active.filename() != kRemoveRecoveryActiveDirectory) { + return SetError(error, L"remove-journal-retire-identity", + ERROR_INVALID_PARAMETER); + } + const std::wstring transactionIdWide( + transactionId.begin(), transactionId.end()); + const std::filesystem::path tombstone = directory->root / + (std::wstring(kRemoveRecoverySettledPrefix) + transactionIdWide); + directory->activeHandle.reset(); + if (!MoveFileExW(directory->active.c_str(), tombstone.c_str(), + MOVEFILE_WRITE_THROUGH)) { + if (error != nullptr) { + error->recoveryBackup = directory->active.wstring(); + error->recoveryBackupRetained = true; + } + return SetLastErrorDetail(error, L"remove-journal-retire-rename", + L"terminal remove journal could not be atomically moved out of admission"); + } + const DWORD activeAttributes = testFault == + RemoveRetirementTestFault:: + TemporaryTreeActiveAbsencePostcheck + ? FILE_ATTRIBUTE_DIRECTORY + : GetFileAttributesW(directory->active.c_str()); + const DWORD activeError = activeAttributes == INVALID_FILE_ATTRIBUTES + ? GetLastError() : ERROR_SUCCESS; + if (activeAttributes != INVALID_FILE_ATTRIBUTES || + (activeError != ERROR_FILE_NOT_FOUND && + activeError != ERROR_PATH_NOT_FOUND)) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return SetError(error, L"remove-journal-retire-active-absence", + activeAttributes != INVALID_FILE_ATTRIBUTES + ? ERROR_ALREADY_EXISTS : activeError, + L"atomic retirement did not prove remove active-v2 absent"); + } + WinHandle tombstoneHandle; + bool tombstoneVerified = false; + if (testFault == RemoveRetirementTestFault::None) { + tombstoneVerified = OpenStableDirectory( + tombstone, true, &tombstoneHandle, error); + } else { + tombstoneHandle.reset(CreateFileW(tombstone.c_str(), + FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + FILE_ATTRIBUTE_TAG_INFO attributes{}; + tombstoneVerified = tombstoneHandle && + GetFileInformationByHandleEx(tombstoneHandle.get(), + FileAttributeTagInfo, &attributes, sizeof(attributes)) && + (attributes.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + if (!tombstoneVerified) { + SetLastErrorDetail(error, + L"self-test-remove-journal-retire-tombstone"); + } + } + if (!tombstoneVerified) { + if (error != nullptr) { + error->recoveryBackup = tombstone.wstring(); + error->recoveryBackupRetained = true; + } + return false; + } + ClearActiveRecoveryEvidence(); + tombstoneHandle.reset(); + + // The write-through rename plus the verified absence/open checks above are + // the authoritative retirement boundary. Cleanup is best-effort and must + // never re-admit a terminal transaction or convert it into rollback. + std::error_code removalError; + if (testFault == RemoveRetirementTestFault:: + TemporaryTreeRetainSettledTombstone) { + removalError = std::make_error_code(std::errc::permission_denied); + } else { + std::filesystem::remove_all(tombstone, removalError); + } + if (removalError) { + const std::wstring retained = tombstone.wstring(); + if (!retained.empty() && + retained.size() < gRetainedRemoveTombstone.size()) { + std::copy(retained.begin(), retained.end(), + gRetainedRemoveTombstone.begin()); + gRetainedRemoveTombstoneError = + static_cast(removalError.value()); + } + std::wstring diagnostic = + L"VIIPER: settled remove journal tombstone retained at \""; + diagnostic += retained; + diagnostic += L"\" after cleanup error "; + diagnostic += std::to_wstring(removalError.value()); + diagnostic += L"; active admission remains retired.\n"; + OutputDebugStringW(diagnostic.c_str()); + } + return true; +} + +struct RemoveJournalStateData { + RemoveJournalPhase phase = RemoveJournalPhase::Prepared; + RemoveJournalDirection direction = RemoveJournalDirection::Forward; + uint64_t sequence = 0; + std::string previousDigest = std::string(kZeroSha256); + std::string lastDigest; + std::string transactionId; + std::string bootIdentifier; + std::string pendingRebootBootIdentifier; + Snapshot prior; + bool hasPriorAbiProfile = false; + AbiCompatibilityProfile priorAbiProfile{}; + uint32_t packageCursor = 0; + uint32_t activePackageIndex = UINT32_MAX; + bool deviceMutationEntered = false; + bool bindingMutationEntered = false; + bool rebootRequired = false; + bool freshRebootRequired = false; + bool callSucceeded = true; + DWORD callError = ERROR_SUCCESS; + bool deadlineOverrun = false; +}; + +bool RemoveJournalPhaseIsPackageCall( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::PackageRemovalEntered || + phase == RemoveJournalPhase::PackageRemovalReturned || + phase == RemoveJournalPhase::RollbackPackageEntered || + phase == RemoveJournalPhase::RollbackPackageReturned; +} + +bool RemoveJournalPhaseIsAuthoritativeReturn( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::DeviceRemovalReturned || + phase == RemoveJournalPhase::PackageRemovalReturned || + phase == RemoveJournalPhase::RollbackPackageReturned || + phase == RemoveJournalPhase::RollbackBindingReturned; +} + +bool ValidateRemoveJournalStateShape( + const RemoveJournalStateData& state, + Error* error) { + const bool pending = + state.phase == RemoveJournalPhase::ForwardRebootPending || + state.phase == RemoveJournalPhase::RestoreRebootPending; + const bool packageCall = + RemoveJournalPhaseIsPackageCall(state.phase); + const bool priorNeedsProfile = state.prior.devices.size() == 1U && + state.prior.devices[0].started && + state.prior.devices[0].problem == 0; + if (!IsSha256Digest(state.previousDigest) || + !IsSha256Digest(state.transactionId) || + !IsCanonicalBootIdentifier(state.bootIdentifier) || + (!state.pendingRebootBootIdentifier.empty() && + !IsCanonicalBootIdentifier( + state.pendingRebootBootIdentifier)) || + state.sequence >= kMaximumRemoveRecoveryRecords || + state.prior.packages.size() > 32U || + state.prior.devices.size() > 1U || + state.packageCursor > state.prior.packages.size() || + (packageCall && + state.activePackageIndex >= state.prior.packages.size()) || + (!packageCall && state.activePackageIndex != UINT32_MAX) || + (state.direction == RemoveJournalDirection::Forward && + (state.phase == RemoveJournalPhase::RollbackAdmitted || + state.phase == RemoveJournalPhase::RollbackPackageEntered || + state.phase == RemoveJournalPhase::RollbackPackageReturned || + state.phase == RemoveJournalPhase::RollbackPackageCommitted || + state.phase == RemoveJournalPhase::RollbackBindingEntered || + state.phase == RemoveJournalPhase::RollbackBindingReturned || + state.phase == RemoveJournalPhase::ExactPriorRestored || + state.phase == RemoveJournalPhase::RestoreRebootPending)) || + (state.direction == RemoveJournalDirection::Rollback && + (state.phase == RemoveJournalPhase::DeviceRemovalEntered || + state.phase == RemoveJournalPhase::DeviceRemovalReturned || + state.phase == RemoveJournalPhase::DeviceRemovalCommitted || + state.phase == RemoveJournalPhase::PackageRemovalEntered || + state.phase == RemoveJournalPhase::PackageRemovalReturned || + state.phase == RemoveJournalPhase::PackageRemovalCommitted || + state.phase == RemoveJournalPhase::ForwardValidated || + state.phase == RemoveJournalPhase::ForwardRebootPending)) || + (state.prior.devices.empty() && + (state.deviceMutationEntered || + state.bindingMutationEntered || + state.phase == RemoveJournalPhase::DeviceRemovalEntered || + state.phase == RemoveJournalPhase::DeviceRemovalReturned || + state.phase == RemoveJournalPhase::DeviceRemovalCommitted || + state.phase == RemoveJournalPhase::RollbackBindingEntered || + state.phase == RemoveJournalPhase::RollbackBindingReturned)) || + (pending && state.pendingRebootBootIdentifier.empty()) || + (!state.rebootRequired && + !state.pendingRebootBootIdentifier.empty()) || + (state.freshRebootRequired && + (!state.rebootRequired || + state.pendingRebootBootIdentifier.empty() || + !RemoveJournalPhaseIsAuthoritativeReturn(state.phase))) || + (state.hasPriorAbiProfile != priorNeedsProfile) || + (state.hasPriorAbiProfile && + !IsKnownAbiCompatibilityProfile(state.priorAbiProfile))) { + return SetError(error, L"remove-journal-state", + ERROR_INVALID_DATA); + } + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + const PackageInfo& package = state.prior.packages[index]; + if (!IsSafePublishedInfName(package.publishedName) || + !IsSha256Digest(package.infSha256) || + !IsSha256Digest(package.sysSha256) || + !IsSha256Digest(package.catSha256) || + std::any_of(state.prior.packages.begin(), + state.prior.packages.begin() + + static_cast(index), + [&](const PackageInfo& earlier) { + return _wcsicmp(earlier.publishedName.c_str(), + package.publishedName.c_str()) == 0; + })) { + return SetError(error, L"remove-journal-prior-package", + ERROR_INVALID_DATA); + } + } + if (!state.prior.devices.empty()) { + const DeviceState& device = state.prior.devices[0]; + const size_t matches = static_cast(std::count_if( + state.prior.packages.begin(), state.prior.packages.end(), + [&](const PackageInfo& package) { + return _wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + SamePackageBytes(package, device.package); + })); + if (!device.present || + !IsOwnedGeneratedRootInstanceId(device.instanceId) || + _wcsicmp(device.service.c_str(), kServiceName) != 0 || + matches != 1U) { + return SetError(error, L"remove-journal-prior-device", + ERROR_INVALID_DATA); + } + } + return true; +} + +void AppendRemoveJournalSnapshot( + std::string* payload, + const RemoveJournalStateData& state) { + payload->append(",\"priorAbiProfile\":"); + if (state.hasPriorAbiProfile) { + payload->append("{\"minor\":"); + payload->append(std::to_string(state.priorAbiProfile.minor)); + payload->append(",\"capabilities\":"); + payload->append(std::to_string( + state.priorAbiProfile.capabilities)); + payload->append(",\"statsSize\":"); + payload->append(std::to_string(state.priorAbiProfile.statsSize)); + payload->append(",\"hasReservedPortFields\":"); + payload->append(state.priorAbiProfile.hasReservedPortFields + ? "true}" : "false}"); + } else { + payload->append("null"); + } + payload->append(",\"priorPackages\":["); + for (size_t index = 0; index < state.prior.packages.size(); ++index) { + if (index != 0) payload->push_back(','); + AppendPackageIdentityJson(payload, state.prior.packages[index], + std::wstring(kRemoveRecoveryPriorDirectory) + L"/" + + std::to_wstring(index) + L"/ViiperUde.inf"); + } + payload->append("],\"priorDevices\":["); + for (size_t index = 0; index < state.prior.devices.size(); ++index) { + if (index != 0) payload->push_back(','); + const DeviceState& device = state.prior.devices[index]; + payload->append("{\"instanceId\":"); + AppendJsonString(payload, device.instanceId); + payload->append(",\"present\":"); + payload->append(device.present ? "true" : "false"); + payload->append(",\"started\":"); + payload->append(device.started ? "true" : "false"); + payload->append(",\"problem\":"); + payload->append(std::to_string(device.problem)); + payload->append(",\"service\":"); + AppendJsonString(payload, device.service); + payload->append(",\"publishedInf\":"); + AppendJsonString(payload, device.publishedInf); + payload->append(",\"version\":"); + AppendJsonString(payload, VersionToString(device.version)); + payload->append(",\"packageInfSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.infSha256)); + payload->append(",\"packageSysSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.sysSha256)); + payload->append(",\"packageCatSha256\":"); + AppendJsonAsciiString(payload, + LowerAscii(device.package.catSha256)); + payload->push_back('}'); + } + payload->push_back(']'); +} + +bool BuildRemoveJournalPayload( + const RemoveJournalStateData& state, + std::string* payload, + Error* error) { + if (!ValidateRemoveJournalStateShape(state, error)) return false; + payload->clear(); + payload->append("{\"sequence\":"); + payload->append(std::to_string(state.sequence)); + payload->append(",\"previousSha256\":"); + AppendJsonAsciiString(payload, LowerAscii(state.previousDigest)); + payload->append(",\"phase\":"); + AppendJsonAsciiString(payload, RemoveJournalPhaseName(state.phase)); + payload->append(",\"direction\":"); + AppendJsonAsciiString(payload, + RemoveJournalDirectionName(state.direction)); + payload->append(",\"transactionId\":"); + AppendJsonAsciiString(payload, state.transactionId); + payload->append(",\"bootIdentifier\":"); + AppendJsonAsciiString(payload, state.bootIdentifier); + payload->append(",\"pendingRebootBootIdentifier\":"); + if (state.pendingRebootBootIdentifier.empty()) { + payload->append("null"); + } else { + AppendJsonAsciiString(payload, + state.pendingRebootBootIdentifier); + } + payload->append(",\"packageCursor\":"); + payload->append(std::to_string(state.packageCursor)); + payload->append(",\"activePackageIndex\":"); + if (state.activePackageIndex == UINT32_MAX) { + payload->append("null"); + } else { + payload->append(std::to_string(state.activePackageIndex)); + } + payload->append(",\"deviceMutationEntered\":"); + payload->append(state.deviceMutationEntered ? "true" : "false"); + payload->append(",\"bindingMutationEntered\":"); + payload->append(state.bindingMutationEntered ? "true" : "false"); + payload->append(",\"rebootRequired\":"); + payload->append(state.rebootRequired ? "true" : "false"); + payload->append(",\"freshRebootRequired\":"); + payload->append(state.freshRebootRequired ? "true" : "false"); + payload->append(",\"callSucceeded\":"); + payload->append(state.callSucceeded ? "true" : "false"); + payload->append(",\"callError\":"); + payload->append(std::to_string(state.callError)); + payload->append(",\"deadlineOverrun\":"); + payload->append(state.deadlineOverrun ? "true" : "false"); + AppendRemoveJournalSnapshot(payload, state); + payload->push_back('}'); + if (payload->size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"remove-journal-size", + ERROR_FILE_TOO_LARGE); + } + return true; +} + +bool WriteRemoveJournalRecord( + const std::filesystem::path& active, + RemoveJournalStateData* state, + Error* error) { + std::string payload; + std::string digest; + if (!BuildRemoveJournalPayload(*state, &payload, error) || + !Sha256Data(payload, &digest, error)) { + return false; + } + std::string record = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&record, kRemoveRecoveryKind); + record.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&record, digest); + record.append(",\"payload\":"); + AppendJsonUtf8String(&record, payload); + record.append("}\n"); + if (record.size() > kMaximumRecoveryRecordBytes) { + return SetError(error, L"remove-journal-size", + ERROR_FILE_TOO_LARGE); + } + std::wostringstream finalName; + finalName << kInstallRecoveryJournalPrefix << std::setw(8) + << std::setfill(L'0') << state->sequence + << kInstallRecoveryJournalSuffix; + const std::filesystem::path finalPath = active / finalName.str(); + const std::filesystem::path temporaryPath = active / + (finalName.str() + kInstallRecoveryTemporarySuffix); + LocalSecurityDescriptor security; + if (!security.Initialize(kRecoveryRecordSecurity, + L"remove-journal-file-security", error)) { + return false; + } + WinHandle file(CreateFileW(temporaryPath.c_str(), + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, security.attributes(), CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_WRITE_THROUGH, + nullptr)); + if (!file) return SetLastErrorDetail(error, L"remove-journal-create"); + const auto discard = [&]() noexcept { + file.reset(); + DeleteFileW(temporaryPath.c_str()); + }; + FILE_ATTRIBUTE_TAG_INFO attributes{}; + if (!GetFileInformationByHandleEx(file.get(), FileAttributeTagInfo, + &attributes, sizeof(attributes)) || + (attributes.FileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + !VerifyProtectedFileSystemSecurity(file.get(), false, + L"remove-journal-file-security", error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-create", + ERROR_REPARSE_TAG_MISMATCH); + } + discard(); + return false; + } + size_t offset = 0; + while (offset < record.size()) { + DWORD written = 0; + const DWORD requested = static_cast(std::min( + record.size() - offset, MAXDWORD)); + if (!WriteFile(file.get(), record.data() + offset, requested, + &written, nullptr) || written == 0) { + const DWORD code = GetLastError() == ERROR_SUCCESS + ? ERROR_WRITE_FAULT : GetLastError(); + SetError(error, L"remove-journal-write", code); + discard(); + return false; + } + offset += written; + } + if (!FlushFileBuffers(file.get())) { + SetLastErrorDetail(error, L"remove-journal-flush"); + discard(); + return false; + } + file.reset(); + if (!MoveFileExW(temporaryPath.c_str(), finalPath.c_str(), + MOVEFILE_WRITE_THROUGH)) { + const DWORD code = GetLastError(); + DeleteFileW(temporaryPath.c_str()); + return SetError(error, L"remove-journal-publish", code); + } + file.reset(CreateFileW(finalPath.c_str(), + GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); + if (!file || !VerifyProtectedFileSystemSecurity(file.get(), false, + L"remove-journal-file-security", error)) { + if (!file) SetLastErrorDetail(error, L"remove-journal-reopen"); + return false; + } + std::string observed(record.size(), '\0'); + DWORD read = 0; + if (!ReadFile(file.get(), observed.data(), + static_cast(observed.size()), &read, nullptr) || + read != observed.size() || observed != record) { + return SetError(error, L"remove-journal-readback", ERROR_CRC, + L"published remove record differs from flushed bytes"); + } + char trailing = 0; + DWORD trailingRead = 0; + if (!ReadFile(file.get(), &trailing, 1, &trailingRead, nullptr) || + trailingRead != 0) { + return SetError(error, L"remove-journal-readback", + ERROR_FILE_INVALID); + } + state->lastDigest = digest; + state->previousDigest = digest; + ++state->sequence; + gActiveRecoveryRecordWritten = true; + return true; +} + +struct LoadedRemoveJournal { + RemoveRecoveryDirectory directory; + RemoveJournalStateData state; + std::vector priorBackups; + std::vector evidenceLocks; + bool hasRecord = false; + bool poisoned = false; +}; + +bool RetireLoadedRemoveJournal( + LoadedRemoveJournal* loaded, + Error* error, + RemoveRetirementTestFault testFault = + RemoveRetirementTestFault::None) { + if (loaded == nullptr || !loaded->hasRecord || loaded->poisoned || + loaded->state.sequence == 0U || + !IsSha256Digest(loaded->state.transactionId) || + !IsSha256Digest(loaded->state.lastDigest) || + _stricmp(loaded->state.lastDigest.c_str(), + loaded->state.previousDigest.c_str()) != 0 || + loaded->state.rebootRequired || + loaded->state.freshRebootRequired || + !loaded->state.pendingRebootBootIdentifier.empty() || + !loaded->state.callSucceeded || + !((loaded->state.phase == RemoveJournalPhase::ForwardValidated && + loaded->state.direction == RemoveJournalDirection::Forward) || + (loaded->state.phase == RemoveJournalPhase::ExactPriorRestored && + loaded->state.direction == + RemoveJournalDirection::Rollback))) { + return SetError(error, L"remove-journal-retire-state", + ERROR_INVALID_STATE, + L"only a durable terminal remove record may release protected evidence locks"); + } + if (!ValidateRemoveJournalStateShape(loaded->state, error)) { + return false; + } + + const std::string transactionId = loaded->state.transactionId; + loaded->priorBackups.clear(); + loaded->evidenceLocks.clear(); + return RetireRemoveRecoveryActiveDirectory( + &loaded->directory, transactionId, error, testFault); +} + +bool AppendRemoveJournalRecord( + LoadedRemoveJournal* loaded, + RemoveJournalStateData next, + Error* error); + +bool ParseRemoveJournalPayload( + std::string_view payload, + const std::filesystem::path& active, + RemoveJournalStateData* state, + Error* error) { + JsonValue root; + std::string parseMessage; + if (!JsonParser(payload).Parse(&root, &parseMessage)) { + std::wstring message; + Utf8ToWide(parseMessage, &message, nullptr); + return SetError(error, L"remove-journal-parse", + ERROR_INVALID_DATA, + L"remove payload is malformed: " + message); + } + const JsonValue::Object* object = nullptr; + uint64_t sequence = 0; + uint64_t packageCursor = 0; + uint64_t callError = 0; + std::string previous; + std::string phase; + std::string direction; + if (!RequireJournalObject(root, &object, error) || + !RequireJournalUnsigned(*object, "sequence", + kMaximumRemoveRecoveryRecords - 1U, &sequence, error) || + !RequireJournalString(*object, "previousSha256", + &previous, error) || + !RequireJournalString(*object, "phase", &phase, error) || + !RequireJournalString(*object, "direction", &direction, error) || + !RequireJournalString(*object, "transactionId", + &state->transactionId, error) || + !RequireJournalString(*object, "bootIdentifier", + &state->bootIdentifier, error) || + !RequireJournalUnsigned(*object, "packageCursor", UINT32_MAX, + &packageCursor, error) || + !RequireJournalBool(*object, "deviceMutationEntered", + &state->deviceMutationEntered, error) || + !RequireJournalBool(*object, "bindingMutationEntered", + &state->bindingMutationEntered, error) || + !RequireJournalBool(*object, "rebootRequired", + &state->rebootRequired, error) || + !RequireJournalBool(*object, "freshRebootRequired", + &state->freshRebootRequired, error) || + !RequireJournalBool(*object, "callSucceeded", + &state->callSucceeded, error) || + !RequireJournalUnsigned(*object, "callError", MAXDWORD, + &callError, error) || + !RequireJournalBool(*object, "deadlineOverrun", + &state->deadlineOverrun, error)) { + return false; + } + const auto parsedPhase = ParseRemoveJournalPhase(phase); + const auto parsedDirection = ParseRemoveJournalDirection(direction); + if (!parsedPhase || !parsedDirection || + !IsSha256Digest(previous)) { + return SetError(error, L"remove-journal-state", + ERROR_INVALID_DATA); + } + state->phase = *parsedPhase; + state->direction = *parsedDirection; + state->sequence = sequence; + state->previousDigest = LowerAscii(std::move(previous)); + state->packageCursor = static_cast(packageCursor); + state->callError = static_cast(callError); + + const JsonValue* pendingNode = ObjectField( + *object, "pendingRebootBootIdentifier"); + if (pendingNode == nullptr) { + return SetError(error, L"remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(pendingNode->value)) { + state->pendingRebootBootIdentifier.clear(); + } else { + const auto* value = std::get_if( + &pendingNode->value); + if (value == nullptr || !IsCanonicalBootIdentifier(*value)) { + return SetError(error, L"remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + state->pendingRebootBootIdentifier = *value; + } + const JsonValue* activeIndexNode = ObjectField( + *object, "activePackageIndex"); + if (activeIndexNode == nullptr) { + return SetError(error, L"remove-journal-package-index", + ERROR_INVALID_DATA); + } + if (std::holds_alternative( + activeIndexNode->value)) { + state->activePackageIndex = UINT32_MAX; + } else { + const int64_t* value = std::get_if( + &activeIndexNode->value); + if (value == nullptr || *value < 0 || + static_cast(*value) > UINT32_MAX) { + return SetError(error, L"remove-journal-package-index", + ERROR_INVALID_DATA); + } + state->activePackageIndex = static_cast(*value); + } + + const JsonValue* profileNode = ObjectField( + *object, "priorAbiProfile"); + if (profileNode == nullptr) { + return SetError(error, L"remove-journal-prior-abi-profile", + ERROR_INVALID_DATA); + } + if (std::holds_alternative(profileNode->value)) { + state->hasPriorAbiProfile = false; + } else { + const JsonValue::Object* profile = nullptr; + uint64_t minor = 0; + uint64_t capabilities = 0; + uint64_t statsSize = 0; + if (!RequireJournalObject(*profileNode, &profile, error) || + profile->size() != 4U || + !RequireJournalUnsigned(*profile, "minor", UINT16_MAX, + &minor, error) || + !RequireJournalUnsigned(*profile, "capabilities", UINT32_MAX, + &capabilities, error) || + !RequireJournalUnsigned(*profile, "statsSize", MAXDWORD, + &statsSize, error) || + !RequireJournalBool(*profile, "hasReservedPortFields", + &state->priorAbiProfile.hasReservedPortFields, error)) { + return false; + } + state->priorAbiProfile.minor = + static_cast(minor); + state->priorAbiProfile.capabilities = + static_cast(capabilities); + state->priorAbiProfile.statsSize = + static_cast(statsSize); + state->hasPriorAbiProfile = true; + } + + const JsonValue::Array* packages = nullptr; + const JsonValue::Array* devices = nullptr; + if (!RequireJournalArray(*object, "priorPackages", &packages, error) || + !RequireJournalArray(*object, "priorDevices", &devices, error) || + packages->size() > 32U || devices->size() > 1U) { + return SetError(error, L"remove-journal-prior", + ERROR_INVALID_DATA); + } + state->prior.packages.clear(); + for (size_t index = 0; index < packages->size(); ++index) { + PackageInfo package; + std::filesystem::path backupInf; + const std::filesystem::path expected = active / + kRemoveRecoveryPriorDirectory / std::to_wstring(index) / + L"ViiperUde.inf"; + if (!ParseJournalPackageIdentity((*packages)[index], active, + true, &package, &backupInf, error) || + backupInf != expected) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-package-path", + ERROR_INVALID_NAME); + } + return false; + } + state->prior.packages.push_back(std::move(package)); + } + state->prior.devices.clear(); + for (const JsonValue& value : *devices) { + const JsonValue::Object* deviceObject = nullptr; + std::string instanceId; + std::string service; + std::string publishedInf; + std::string version; + std::string infSha; + std::string sysSha; + std::string catSha; + uint64_t problem = 0; + DeviceState device; + if (!RequireJournalObject(value, &deviceObject, error) || + !RequireJournalString(*deviceObject, "instanceId", + &instanceId, error) || + !RequireJournalBool(*deviceObject, "present", + &device.present, error) || + !RequireJournalBool(*deviceObject, "started", + &device.started, error) || + !RequireJournalUnsigned(*deviceObject, "problem", MAXDWORD, + &problem, error) || + !RequireJournalString(*deviceObject, "service", + &service, error) || + !RequireJournalString(*deviceObject, "publishedInf", + &publishedInf, error) || + !RequireJournalString(*deviceObject, "version", + &version, error) || + !RequireJournalString(*deviceObject, "packageInfSha256", + &infSha, error) || + !RequireJournalString(*deviceObject, "packageSysSha256", + &sysSha, error) || + !RequireJournalString(*deviceObject, "packageCatSha256", + &catSha, error) || + !Utf8ToWide(instanceId, &device.instanceId, error) || + !Utf8ToWide(service, &device.service, error) || + !Utf8ToWide(publishedInf, &device.publishedInf, error)) { + return false; + } + std::wstring wideVersion; + if (!Utf8ToWide(version, &wideVersion, error) || + !ParseVersion(wideVersion, &device.version) || + !IsSha256Digest(infSha) || !IsSha256Digest(sysSha) || + !IsSha256Digest(catSha)) { + return SetError(error, L"remove-journal-prior-device", + ERROR_INVALID_DATA); + } + device.problem = static_cast(problem); + size_t matches = 0; + for (const PackageInfo& package : state->prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0 && + package.version == device.version && + _stricmp(package.infSha256.c_str(), infSha.c_str()) == 0 && + _stricmp(package.sysSha256.c_str(), sysSha.c_str()) == 0 && + _stricmp(package.catSha256.c_str(), catSha.c_str()) == 0) { + device.package = package; + ++matches; + } + } + if (matches != 1U) { + return SetError(error, + L"remove-journal-prior-device-package", + ERROR_REVISION_MISMATCH); + } + state->prior.devices.push_back(std::move(device)); + } + std::string canonical; + if (!BuildRemoveJournalPayload(*state, &canonical, error) || + canonical != payload) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-canonical-payload", + ERROR_INVALID_DATA); + } + return false; + } + return true; +} + +bool ParseRemoveJournalEnvelope( + std::string_view record, + const std::filesystem::path& active, + RemoveJournalStateData* state, + std::string* digest, + Error* error) { + JsonValue root; + std::string message; + if (!JsonParser(record).Parse(&root, &message)) { + return SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA, + L"remove journal envelope is truncated or malformed"); + } + const JsonValue::Object* object = nullptr; + uint64_t schema = 0; + std::string kind; + std::string payloadDigest; + std::string payload; + if (!RequireJournalObject(root, &object, error) || + object->size() != 4U || + !RequireJournalUnsigned(*object, "schema", 2U, + &schema, error) || schema != 2U || + !RequireJournalString(*object, "kind", &kind, error) || + kind != kRemoveRecoveryKind || + !RequireJournalString(*object, "payloadSha256", + &payloadDigest, error) || + !RequireJournalString(*object, "payload", &payload, error) || + !IsSha256Digest(payloadDigest)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA); + } + return false; + } + std::string canonical = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&canonical, kRemoveRecoveryKind); + canonical.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&canonical, LowerAscii(payloadDigest)); + canonical.append(",\"payload\":"); + AppendJsonUtf8String(&canonical, payload); + canonical.append("}\n"); + std::string observed; + if (record != canonical || + !Sha256Data(payload, &observed, error) || + _stricmp(observed.c_str(), payloadDigest.c_str()) != 0 || + !ParseRemoveJournalPayload(payload, active, state, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", ERROR_CRC); + } + return false; + } + *digest = LowerAscii(std::move(payloadDigest)); + return true; +} + +bool SameRemoveJournalImmutableState( + const RemoveJournalStateData& left, + const RemoveJournalStateData& right) noexcept { + if (left.transactionId != right.transactionId || + left.bootIdentifier != right.bootIdentifier || + left.hasPriorAbiProfile != right.hasPriorAbiProfile || + (left.hasPriorAbiProfile && + !SameAbiCompatibilityProfile(left.priorAbiProfile, + right.priorAbiProfile)) || + !SamePackageInventory(left.prior.packages, + right.prior.packages) || + left.prior.devices.size() != right.prior.devices.size()) { + return false; + } + return left.prior.devices.empty() || + (SameRootBinding(left.prior.devices[0], right.prior.devices[0]) && + left.prior.devices[0].started == + right.prior.devices[0].started && + left.prior.devices[0].problem == + right.prior.devices[0].problem); +} + +bool LegalRemoveJournalTransition( + RemoveJournalPhase previous, + RemoveJournalPhase next, + RemoveJournalDirection direction) noexcept { + if (next == RemoveJournalPhase::ManualReconciliationRequired) { + return true; + } + if (direction == RemoveJournalDirection::Forward) { + switch (previous) { + case RemoveJournalPhase::Prepared: + return next == RemoveJournalPhase::DeviceRemovalEntered || + next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::DeviceRemovalEntered: + return next == RemoveJournalPhase::DeviceRemovalReturned || + next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::DeviceRemovalReturned: + return next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::DeviceRemovalCommitted: + return next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::PackageRemovalEntered: + return next == RemoveJournalPhase::PackageRemovalReturned || + next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::PackageRemovalReturned: + return next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::ForwardRebootPending; + case RemoveJournalPhase::PackageRemovalCommitted: + return next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + case RemoveJournalPhase::ForwardRebootPending: + return next == RemoveJournalPhase::DeviceRemovalEntered || + next == RemoveJournalPhase::DeviceRemovalCommitted || + next == RemoveJournalPhase::PackageRemovalCommitted || + next == RemoveJournalPhase::PackageRemovalEntered || + next == RemoveJournalPhase::ForwardValidated; + default: + return false; + } + } + switch (previous) { + case RemoveJournalPhase::RollbackAdmitted: + return next == RemoveJournalPhase::RestoreRebootPending || + next == RemoveJournalPhase::RollbackPackageEntered || + next == RemoveJournalPhase::RollbackBindingEntered || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackPackageCommitted: + case RemoveJournalPhase::RestoreRebootPending: + return next == RemoveJournalPhase::RollbackPackageEntered || + next == RemoveJournalPhase::RollbackBindingEntered || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackPackageEntered: + return next == RemoveJournalPhase::RollbackPackageReturned || + next == RemoveJournalPhase::RollbackPackageCommitted; + case RemoveJournalPhase::RollbackPackageReturned: + return next == RemoveJournalPhase::RollbackPackageCommitted || + next == RemoveJournalPhase::RestoreRebootPending; + case RemoveJournalPhase::RollbackBindingEntered: + return next == RemoveJournalPhase::RollbackBindingReturned || + next == RemoveJournalPhase::ExactPriorRestored; + case RemoveJournalPhase::RollbackBindingReturned: + return next == RemoveJournalPhase::ExactPriorRestored || + next == RemoveJournalPhase::RestoreRebootPending; + default: + return false; + } +} + +bool ValidateRemoveJournalTransition( + const RemoveJournalStateData* previous, + const RemoveJournalStateData& next, + Error* error) { + if (!ValidateRemoveJournalStateShape(next, error)) return false; + if (previous == nullptr) { + return (next.phase == RemoveJournalPhase::Prepared && + next.direction == RemoveJournalDirection::Forward && + next.sequence == 0U && + next.previousDigest == kZeroSha256 && + !next.deviceMutationEntered && + !next.bindingMutationEntered && + next.packageCursor == 0U) || + SetError(error, L"remove-journal-initial-state", + ERROR_INVALID_DATA); + } + const RemoveJournalStateData& prior = *previous; + const bool packageCursorAdvanced = + next.packageCursor != prior.packageCursor; + if (!SameRemoveJournalImmutableState(prior, next) || + next.packageCursor < prior.packageCursor || + (packageCursorAdvanced && + (next.phase != RemoveJournalPhase::PackageRemovalCommitted || + next.packageCursor != prior.packageCursor + 1U)) || + (prior.deviceMutationEntered && !next.deviceMutationEntered) || + (prior.bindingMutationEntered && !next.bindingMutationEntered) || + (!prior.deviceMutationEntered && next.deviceMutationEntered && + next.phase != RemoveJournalPhase::DeviceRemovalEntered) || + (!prior.bindingMutationEntered && next.bindingMutationEntered && + next.phase != RemoveJournalPhase::RollbackBindingEntered) || + (prior.direction == RemoveJournalDirection::Rollback && + next.direction != RemoveJournalDirection::Rollback)) { + return SetError(error, L"remove-journal-transition", + ERROR_INVALID_DATA, + L"remove journal immutable or sticky authority changed"); + } + const bool consumesCrossedRebootEpoch = + !prior.pendingRebootBootIdentifier.empty() && + prior.rebootRequired && !next.rebootRequired && + next.pendingRebootBootIdentifier.empty() && + (prior.phase == RemoveJournalPhase::ForwardRebootPending || + prior.phase == RemoveJournalPhase::RestoreRebootPending || + (prior.phase == RemoveJournalPhase::RollbackAdmitted && + prior.direction == RemoveJournalDirection::Rollback) || + RemoveJournalPhaseIsAuthoritativeReturn(prior.phase)); + if (!prior.pendingRebootBootIdentifier.empty() && + next.pendingRebootBootIdentifier != + prior.pendingRebootBootIdentifier && + !next.freshRebootRequired && !consumesCrossedRebootEpoch) { + return SetError(error, L"remove-journal-reboot-epoch-chain", + ERROR_INVALID_DATA); + } + if (prior.direction == RemoveJournalDirection::Forward && + next.direction == RemoveJournalDirection::Rollback) { + const bool directPendingAdmission = + next.phase == RemoveJournalPhase::RestoreRebootPending && + prior.rebootRequired && next.rebootRequired && + !prior.pendingRebootBootIdentifier.empty() && + next.pendingRebootBootIdentifier == + prior.pendingRebootBootIdentifier && + !next.freshRebootRequired; + return ((next.phase == RemoveJournalPhase::RollbackAdmitted || + directPendingAdmission) && + next.packageCursor == prior.packageCursor) || + SetError(error, L"remove-journal-direction-chain", + ERROR_INVALID_DATA); + } + if (!LegalRemoveJournalTransition( + prior.phase, next.phase, next.direction)) { + return SetError(error, L"remove-journal-phase-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::PackageRemovalEntered && + (next.activePackageIndex != next.packageCursor || + next.packageCursor != prior.packageCursor)) { + return SetError(error, L"remove-journal-package-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::PackageRemovalCommitted && + next.packageCursor != prior.packageCursor + 1U) { + return SetError(error, L"remove-journal-package-chain", + ERROR_INVALID_DATA); + } + if ((next.phase == RemoveJournalPhase::PackageRemovalReturned || + next.phase == RemoveJournalPhase::RollbackPackageReturned) && + (prior.activePackageIndex == UINT32_MAX || + next.activePackageIndex != prior.activePackageIndex)) { + return SetError(error, L"remove-journal-package-return-chain", + ERROR_INVALID_DATA); + } + if (next.phase == RemoveJournalPhase::RollbackPackageEntered && + next.activePackageIndex == UINT32_MAX) { + return SetError(error, L"remove-journal-package-admission-chain", + ERROR_INVALID_DATA); + } + return true; +} + +bool ValidateLoadedRemoveJournalEvidence( + LoadedRemoveJournal* loaded, + Error* error) { + WinHandle priorHandle; + const std::filesystem::path priorRoot = + loaded->directory.active / kRemoveRecoveryPriorDirectory; + if (!OpenStableDirectory( + priorRoot, true, &priorHandle, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->priorBackups.clear(); + for (size_t index = 0; + index < loaded->state.prior.packages.size(); ++index) { + PackageInfo& expected = loaded->state.prior.packages[index]; + const std::filesystem::path directory = + priorRoot / std::to_wstring(index); + WinHandle directoryHandle; + PackageInfo copy; + bool owned = false; + if (!OpenStableDirectory(directory, true, + &directoryHandle, error) || + !ValidateExactPackageDirectory(directory, error) || + !LoadOwnedPackage(directory / L"ViiperUde.inf", true, + false, ©, &owned, error) || !owned || + !(copy.version == expected.version) || + !SamePackageBytes(copy, expected)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-evidence", + ERROR_REVISION_MISMATCH); + } + return false; + } + loaded->evidenceLocks.push_back(std::move(directoryHandle)); + std::vector locks; + if (!LockPackageFiles(directory, &locks, error)) return false; + for (WinHandle& lock : locks) { + loaded->evidenceLocks.push_back(std::move(lock)); + } + expected.infPath = directory / L"ViiperUde.inf"; + loaded->priorBackups.push_back(PackageBackup{ + expected, directory, expected.infPath, {}}); + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : + loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + return true; +} + +bool LoadRemoveJournal( + RemoveRecoveryDirectory&& directory, + LoadedRemoveJournal* loaded, + Error* error) { + loaded->directory = std::move(directory); + std::map records; + std::optional> temporary; + std::error_code enumerationError; + for (std::filesystem::directory_iterator iterator( + loaded->directory.active, enumerationError), end; + !enumerationError && iterator != end; + iterator.increment(enumerationError)) { + const std::wstring name = iterator->path().filename().wstring(); + const DWORD attributes = GetFileAttributesW( + iterator->path().c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return SetError(error, L"remove-journal-discovery", + ERROR_REPARSE_TAG_MISMATCH); + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + name == kRemoveRecoveryPriorDirectory) { + continue; + } + uint64_t sequence = 0; + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalRecordFileName(name, &sequence)) { + if (sequence >= kMaximumRemoveRecoveryRecords || + !records.emplace(sequence, iterator->path()).second) { + return SetError(error, L"remove-journal-chain", + ERROR_DUPLICATE_SERVICE_NAME); + } + continue; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && + ParseJournalTemporaryFileName(name, &sequence)) { + if (sequence >= kMaximumRemoveRecoveryRecords || temporary) { + return SetError(error, L"remove-journal-temp-chain", + ERROR_INVALID_DATA); + } + temporary.emplace(sequence, iterator->path()); + continue; + } + return SetError(error, L"remove-journal-discovery", + ERROR_INVALID_DATA, + L"protected remove transaction has an unexpected entry"); + } + if (enumerationError) { + return SetError(error, L"remove-journal-discovery", + static_cast(enumerationError.value())); + } + if ((!records.empty() && + (records.begin()->first != 0U || + records.rbegin()->first + 1U != records.size())) || + (temporary && temporary->first != records.size())) { + return SetError(error, L"remove-journal-chain", + ERROR_INVALID_DATA, + L"remove journal sequence is absent or non-contiguous"); + } + if (temporary && + !ValidateAndDiscardInstallJournalTemporaryFile( + temporary->second, error)) { + return false; + } + if (records.empty()) { + loaded->hasRecord = false; + return true; + } + std::string priorDigest(kZeroSha256); + std::optional immutable; + std::optional previousState; + for (const auto& [expectedSequence, path] : records) { + std::string record; + std::string digest; + RemoveJournalStateData parsed; + if (!ReadInstallJournalFile(path, &record, error) || + !ParseRemoveJournalEnvelope(record, + loaded->directory.active, &parsed, &digest, error) || + parsed.sequence != expectedSequence || + _stricmp(parsed.previousDigest.c_str(), + priorDigest.c_str()) != 0 || + (immutable && !SameRemoveJournalImmutableState( + *immutable, parsed)) || + !ValidateRemoveJournalTransition( + previousState ? &*previousState : nullptr, + parsed, error)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-chain", ERROR_CRC); + } + return false; + } + if (!immutable) immutable = parsed; + previousState = parsed; + priorDigest = digest; + loaded->state = std::move(parsed); + loaded->state.lastDigest = digest; + } + loaded->state.previousDigest = priorDigest; + loaded->state.sequence = records.size(); + loaded->hasRecord = true; + return ValidateLoadedRemoveJournalEvidence(loaded, error); +} + +bool AppendRemoveJournalRecord( + LoadedRemoveJournal* loaded, + RemoveJournalStateData next, + Error* error) { + if (loaded == nullptr || !loaded->hasRecord || loaded->poisoned) { + return SetError(error, L"remove-journal-record", + ERROR_INVALID_STATE); + } + next.sequence = loaded->state.sequence; + next.previousDigest = loaded->state.previousDigest; + next.lastDigest = loaded->state.lastDigest; + if (!ValidateRemoveJournalTransition( + &loaded->state, next, error)) { + return false; + } + if (!WriteRemoveJournalRecord( + loaded->directory.active, &next, error)) { + loaded->poisoned = true; + return false; + } + loaded->state = std::move(next); + if (!PublishRemoveRecoveryEvidence( + loaded->directory.active, + loaded->state.sequence - 1U, error)) { + loaded->poisoned = true; + return false; + } + return true; +} + +bool PrepareRemoveJournal( + const Snapshot& capturedPrior, + const AbiCompatibilityProfile* priorAbiProfile, + LoadedRemoveJournal* loaded, + Error* error) { + bool exists = false; + if (!loaded->directory.OpenChain(true, &exists, error) || !exists || + !PublishRemoveRecoveryEvidence( + loaded->directory.active, 0U, error)) { + return false; + } + const std::filesystem::path priorRoot = + loaded->directory.active / kRemoveRecoveryPriorDirectory; + WinHandle priorHandle; + bool created = false; + if (!CreateOrOpenInstallRecoveryDirectory( + priorRoot, false, true, &priorHandle, &created, error) || + !created || + !BackupPackagesIntoDirectory(capturedPrior.packages, + priorRoot, &loaded->priorBackups, error)) { + return false; + } + loaded->evidenceLocks.push_back(std::move(priorHandle)); + loaded->state = RemoveJournalStateData{}; + loaded->state.prior = capturedPrior; + for (size_t index = 0; + index < loaded->state.prior.packages.size(); ++index) { + loaded->state.prior.packages[index].infPath = + loaded->priorBackups[index].infPath; + } + for (DeviceState& device : loaded->state.prior.devices) { + for (const PackageInfo& package : + loaded->state.prior.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + if (priorAbiProfile != nullptr) { + loaded->state.hasPriorAbiProfile = true; + loaded->state.priorAbiProfile = *priorAbiProfile; + } + if (!GenerateInstallTransactionId( + &loaded->state.transactionId, error) || + !GetBootIdentifier(&loaded->state.bootIdentifier, error) || + !ValidateRemoveJournalTransition(nullptr, + loaded->state, error) || + !WriteRemoveJournalRecord(loaded->directory.active, + &loaded->state, error)) { + loaded->poisoned = gActiveRecoveryRecordWritten; + return false; + } + loaded->hasRecord = true; + return PublishRemoveRecoveryEvidence( + loaded->directory.active, + loaded->state.sequence - 1U, error); +} + +enum class RemoveRootShape { + ExactPrior, + Absent, + PendingRemoval, + Manual, +}; + +bool CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase phase, + bool callSucceeded, + bool rebootRequired, + bool freshRebootRequired, + bool samePendingBoot, + RemoveRootShape root) noexcept { + const bool crossedAuthoritativeRebootBoundary = + phase == RemoveJournalPhase::ForwardRebootPending || + (phase == RemoveJournalPhase::DeviceRemovalReturned && + callSucceeded && freshRebootRequired); + return root == RemoveRootShape::PendingRemoval && + crossedAuthoritativeRebootBoundary && + rebootRequired && !samePendingBoot; +} + +bool ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase phase) noexcept { + return phase == RemoveJournalPhase::RollbackBindingEntered; +} + +bool ObserveRemoveRootShape( + const RemoveJournalStateData& state, + RemoveRootShape* shape, + Error* error) { + *shape = RemoveRootShape::Manual; + DeviceInfoSet set = OpenRootDevices(); + if (!set) { + return SetLastErrorDetail(error, + L"remove-journal-raw-root-open"); + } + struct RelatedRoot { + SP_DEVINFO_DATA data{}; + std::wstring instanceId; + InstallRecoveryHardwareIdObservation hardwareIds; + }; + std::vector related; + for (DWORD index = 0;; ++index) { + SP_DEVINFO_DATA data{}; + data.cbSize = sizeof(data); + if (!SetupDiEnumDeviceInfo(set.get(), index, &data)) { + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return SetLastErrorDetail(error, + L"remove-journal-raw-root-enumeration"); + } + break; + } + std::wstring instanceId; + InstallRecoveryHardwareIdObservation hardwareIds; + if (!ReadInstallRecoveryRootInstanceId( + set.get(), data, &instanceId, error) || + !ReadInstallRecoveryHardwareIds( + set.get(), data, &hardwareIds, error)) { + return false; + } + const bool transactionNamespace = + IsInGeneratedRootDeviceNamespace( + instanceId, kRootDeviceName); + const bool exactPriorInstance = + state.prior.devices.size() == 1U && + _wcsicmp(instanceId.c_str(), + state.prior.devices[0].instanceId.c_str()) == 0; + if (!hardwareIds.containsExpected && !transactionNamespace && + !exactPriorInstance) { + continue; + } + related.push_back(RelatedRoot{ + data, std::move(instanceId), hardwareIds}); + } + if (related.empty()) { + *shape = RemoveRootShape::Absent; + return true; + } + if (related.size() != 1U || state.prior.devices.empty()) { + return SetError(error, L"remove-journal-raw-root-authority", + related.size() > 1U + ? ERROR_DUPLICATE_SERVICE_NAME + : ERROR_REVISION_MISMATCH, + L"remove recovery found a foreign or ambiguous related root"); + } + RelatedRoot& root = related[0]; + const DeviceState& prior = state.prior.devices[0]; + bool present = false; + if (_wcsicmp(root.instanceId.c_str(), prior.instanceId.c_str()) != 0 || + IsEqualGUID(root.data.ClassGuid, GUID_DEVCLASS_USB) == FALSE || + !ReadDevicePresence(set.get(), root.data, &present, error)) { + return SetError(error, L"remove-journal-raw-root-authority", + ERROR_REVISION_MISMATCH, + L"related root does not match the captured exact instance and class"); + } + ULONG status = 0; + ULONG problem = 0; + const CONFIGRET configuration = CM_Get_DevNode_Status( + &status, &problem, root.data.DevInst, 0); + if (present && configuration != CR_SUCCESS) { + return SetError(error, L"remove-journal-raw-root-lifecycle", + ERROR_INVALID_DATA); + } + std::wstring service; + std::wstring publishedInf; + std::wstring driverVersion; + if (!ReadCanonicalInstallRecoveryService( + set.get(), root.data, &service, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverInfPath, + L"remove-journal-raw-root-driver-inf", + &publishedInf, error) || + !ReadCanonicalInstallRecoveryDevicePropertyString( + set.get(), root.data, DEVPKEY_Device_DriverVersion, + L"remove-journal-raw-root-driver-version", + &driverVersion, error)) { + return false; + } + Version observedVersion{}; + const bool exactBinding = + root.hardwareIds.exact && present && + _wcsicmp(service.c_str(), prior.service.c_str()) == 0 && + _wcsicmp(publishedInf.c_str(), prior.publishedInf.c_str()) == 0 && + ParseVersion(driverVersion, &observedVersion) && + observedVersion == prior.version; + if (exactBinding) { + *shape = RemoveRootShape::ExactPrior; + return true; + } + const bool removalWasAdmitted = state.deviceMutationEntered; + const bool pendingLifecycle = !present || + (configuration == CR_SUCCESS && problem == CM_PROB_WILL_BE_REMOVED); + const bool canonicalBindingFragment = + (service.empty() || + _wcsicmp(service.c_str(), prior.service.c_str()) == 0) && + (publishedInf.empty() || + _wcsicmp(publishedInf.c_str(), prior.publishedInf.c_str()) == 0) && + (driverVersion.empty() || + (ParseVersion(driverVersion, &observedVersion) && + observedVersion == prior.version)); + if (removalWasAdmitted && pendingLifecycle && + (root.hardwareIds.absent || root.hardwareIds.exact) && + canonicalBindingFragment) { + *shape = RemoveRootShape::PendingRemoval; + return true; + } + return SetError(error, L"remove-journal-raw-root-authority", + ERROR_REVISION_MISMATCH, + L"related root is outside exact prior, absent, or admitted-pending authority"); +} + +bool ObserveRemovePackagePrefix( + const RemoveJournalStateData& state, + uint32_t* removedPrefix, + std::vector* observed, + Error* error) { + if (!EnumerateOwnedPackages(observed, error)) return false; + for (const PackageInfo& current : *observed) { + const size_t matches = static_cast(std::count_if( + state.prior.packages.begin(), state.prior.packages.end(), + [&](const PackageInfo& prior) { + return SameJournalPackageIdentity(current, prior); + })); + if (matches != 1U) { + return SetError(error, + L"remove-journal-package-authority", + ERROR_REVISION_MISMATCH, + L"current Driver Store contains an identity outside the captured remove inventory"); + } + } + uint32_t prefix = 0; + while (prefix < state.prior.packages.size() && + std::none_of(observed->begin(), observed->end(), + [&](const PackageInfo& current) { + return SameJournalPackageIdentity( + current, state.prior.packages[prefix]); + })) { + ++prefix; + } + for (size_t index = prefix; + index < state.prior.packages.size(); ++index) { + const size_t matches = static_cast(std::count_if( + observed->begin(), observed->end(), + [&](const PackageInfo& current) { + return SameJournalPackageIdentity( + current, state.prior.packages[index]); + })); + if (matches != 1U) { + return SetError(error, + L"remove-journal-package-authority", + ERROR_REVISION_MISMATCH, + L"current Driver Store is not one exact captured suffix"); + } + } + *removedPrefix = prefix; + return true; +} + +bool CurrentRemoveStateMatchesPrior( + const RemoveJournalStateData& state, + uint64_t deadlineUnixMs, + Error* error) { + RemoveRootShape root = RemoveRootShape::Manual; + Snapshot observed; + if (!ObserveRemoveRootShape(state, &root, error) || + root != (state.prior.devices.empty() + ? RemoveRootShape::Absent + : RemoveRootShape::ExactPrior) || + !CaptureSnapshot(&observed, error) || + !SameCapturedRootState(state.prior, observed) || + !SamePackageInventory(state.prior.packages, + observed.packages)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"remove-journal-prior-state", + ERROR_REVISION_MISMATCH); + } + return false; + } + if (state.hasPriorAbiProfile && + !VerifyAbiHealth(deadlineUnixMs, nullptr, error, + AbiHealthPurpose::RollbackHealth, + &state.priorAbiProfile, nullptr)) { + return false; + } + return true; +} + +bool CurrentRemoveStateIsUninstalled( + const RemoveJournalStateData& state, + Error* error) { + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packages; + uint32_t removedPrefix = 0; + return ObserveRemoveRootShape(state, &root, error) && + root == RemoveRootShape::Absent && + ObserveRemovePackagePrefix( + state, &removedPrefix, &packages, error) && + removedPrefix == state.prior.packages.size() && + packages.empty(); +} + +bool RecordRemoveJournalPhase( + LoadedRemoveJournal* loaded, + RemoveJournalPhase phase, + bool callSucceeded, + DWORD callError, + bool rebootRequired, + bool freshRebootRequired, + bool deadlineOverrun, + Error* error, + std::optional activePackageIndex = std::nullopt, + std::optional packageCursor = std::nullopt) { + RemoveJournalStateData next = loaded->state; + next.phase = phase; + next.callSucceeded = callSucceeded; + next.callError = callError; + next.deadlineOverrun = deadlineOverrun; + next.freshRebootRequired = freshRebootRequired; + next.rebootRequired = rebootRequired; + next.activePackageIndex = activePackageIndex.value_or(UINT32_MAX); + if (packageCursor) next.packageCursor = *packageCursor; + if (phase == RemoveJournalPhase::DeviceRemovalEntered) { + next.deviceMutationEntered = true; + } + if (phase == RemoveJournalPhase::RollbackAdmitted || + phase == RemoveJournalPhase::RestoreRebootPending) { + next.direction = RemoveJournalDirection::Rollback; + } + if (phase == RemoveJournalPhase::RollbackBindingEntered) { + next.bindingMutationEntered = true; + } + if (freshRebootRequired) { + if (!GetBootIdentifier( + &next.pendingRebootBootIdentifier, error)) { + return false; + } + } + if (!rebootRequired) { + next.pendingRebootBootIdentifier.clear(); + } + return AppendRemoveJournalRecord( + loaded, std::move(next), error); +} + +void SetRemoveJournalRecoveryOutcome( + LoadedRemoveJournal* loaded, + const wchar_t* phase, + DWORD code, + std::wstring message, + ExitCode exitCode, + Outcome* outcome) { + SetError(&outcome->error, phase, code, std::move(message)); + outcome->exitCode = exitCode; + outcome->rebootRequired = exitCode == ExitCode::RebootRequired; + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } +} + +bool ObserveRemovePackageSubset( + const RemoveJournalStateData& state, + std::vector* present, + std::vector* observed, + Error* error) { + if (!EnumerateOwnedPackages(observed, error)) return false; + present->assign(state.prior.packages.size(), false); + for (const PackageInfo& current : *observed) { + size_t matchedIndex = state.prior.packages.size(); + size_t matches = 0; + for (size_t index = 0; + index < state.prior.packages.size(); ++index) { + if (SameJournalPackageIdentity( + current, state.prior.packages[index])) { + matchedIndex = index; + ++matches; + } + } + if (matches != 1U || (*present)[matchedIndex]) { + return SetError(error, + L"remove-journal-package-subset", + ERROR_REVISION_MISMATCH, + L"current Driver Store is not an exact subset of captured identities"); + } + (*present)[matchedIndex] = true; + } + return true; +} + +bool InvokeRemovePackageMutation( + const PackageInfo& package, + uint64_t deadlineUnixMs, + bool* rebootRequired, + bool* freshRebootRequired, + Error* error) { + if (!CheckTransactionDeadline(deadlineUnixMs, + L"remove-journal-package-deadline", error)) { + return false; + } + BOOL reboot = FALSE; + MarkTransactionMutationStarted(); + const BOOL removed = InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiUninstallDriverW", [&]() { + return DiUninstallDriverW( + nullptr, package.infPath.c_str(), 0, &reboot); + }); + const DWORD code = removed ? ERROR_SUCCESS : GetLastError(); + *freshRebootRequired = reboot != FALSE; + *rebootRequired = *rebootRequired || reboot != FALSE; + if (!removed) { + return SetError(error, L"remove-driver-package", code); + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"remove-driver-package-timeout", + ERROR_TIMEOUT, + L"package removal returned after its deadline; authoritative outcome is retained"); + } + return true; +} + +bool InvokeRestorePackageMutation( + const PackageInfo& package, + uint64_t deadlineUnixMs, + bool* rebootRequired, + bool* freshRebootRequired, + Error* error) { + if (!CheckTransactionDeadline(deadlineUnixMs, + L"remove-journal-restore-package-deadline", error)) { + return false; + } + BOOL reboot = FALSE; + MarkTransactionMutationStarted(); + const BOOL installed = InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiInstallDriverW", [&]() { + return DiInstallDriverW( + nullptr, package.infPath.c_str(), 0, &reboot); + }); + const DWORD code = installed ? ERROR_SUCCESS : GetLastError(); + *freshRebootRequired = reboot != FALSE; + *rebootRequired = *rebootRequired || reboot != FALSE; + if (!installed) { + return SetError(error, L"remove-rollback-package", code); + } + if (gLastSynchronousMutationTimedOut) { + return SetError(error, L"remove-rollback-package-timeout", + ERROR_TIMEOUT, + L"package restoration returned after its deadline; authoritative outcome is retained"); + } + return true; +} + +bool RetireRemoveJournalAsPrior( + LoadedRemoveJournal* loaded, + uint64_t deadlineUnixMs, + Outcome* outcome) { + Error error; + if (!CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error) || + (loaded->state.phase != RemoveJournalPhase::ExactPriorRestored && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ExactPriorRestored, + true, ERROR_SUCCESS, false, false, false, &error)) || + !CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error) || + !RetireLoadedRemoveJournal(loaded, &error)) { + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-prior-retire", error.code, + L"exact prior state could not be proven and atomically retired", + ExitCode::RollbackFailed, outcome); + if (error.code != ERROR_SUCCESS) { + outcome->error = std::move(error); + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } + } + return false; + } + outcome->success = true; + outcome->rollback = L"succeeded"; + outcome->exitCode = ExitCode::Success; + return true; +} + +bool RetireRemoveJournalAsUninstalled( + LoadedRemoveJournal* loaded, + Outcome* outcome) { + Error error; + if (!CurrentRemoveStateIsUninstalled(loaded->state, &error) || + (loaded->state.phase != RemoveJournalPhase::ForwardValidated && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ForwardValidated, + true, ERROR_SUCCESS, false, false, false, &error)) || + !CurrentRemoveStateIsUninstalled(loaded->state, &error) || + !RetireLoadedRemoveJournal(loaded, &error)) { + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-forward-retire", error.code, + L"exact uninstalled state could not be proven and atomically retired", + ExitCode::RollbackFailed, outcome); + if (error.code != ERROR_SUCCESS) { + outcome->error = std::move(error); + if (outcome->error.recoveryBackup.empty()) { + outcome->error.recoveryBackup = + loaded->directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + } + if (gActiveRecoveryRecord[0] != L'\0') { + outcome->error.recoveryRecord = + gActiveRecoveryRecord.data(); + outcome->error.recoveryRecordWritten = + gActiveRecoveryRecordWritten; + } + } + return false; + } + outcome->success = true; + outcome->changed = true; + outcome->exitCode = ExitCode::Success; + return true; +} + +bool FailRemoveJournalManual( + LoadedRemoveJournal* loaded, + std::wstring message, + const Error* cause, + Outcome* outcome) { + Error appendError; + if (loaded->state.phase != + RemoveJournalPhase::ManualReconciliationRequired) { + RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::ManualReconciliationRequired, + false, cause != nullptr ? cause->code : ERROR_INVALID_DATA, + loaded->state.rebootRequired, false, false, &appendError); + } + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-manual-reconciliation", + cause != nullptr && cause->code != ERROR_SUCCESS + ? cause->code : ERROR_INVALID_DATA, + std::move(message), ExitCode::RollbackFailed, outcome); + if (cause != nullptr) { + const std::wstring backup = !cause->recoveryBackup.empty() + ? cause->recoveryBackup : outcome->error.recoveryBackup; + const std::wstring record = outcome->error.recoveryRecord; + const bool backupRetained = !cause->recoveryBackup.empty() + ? cause->recoveryBackupRetained + : outcome->error.recoveryBackupRetained; + const bool recordWritten = + outcome->error.recoveryRecordWritten; + outcome->error = *cause; + outcome->error.recoveryBackup = backup; + outcome->error.recoveryRecord = record; + outcome->error.recoveryBackupRetained = backupRetained; + outcome->error.recoveryRecordWritten = recordWritten; + } + return false; +} + +bool ReturnRemoveJournalRebootPending( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + Outcome* outcome) { + const RemoveJournalPhase pendingPhase = + loaded->state.direction == RemoveJournalDirection::Rollback + ? RemoveJournalPhase::RestoreRebootPending + : RemoveJournalPhase::ForwardRebootPending; + Error error; + if (loaded->state.phase != pendingPhase) { + RemoveJournalStateData next = loaded->state; + next.phase = pendingPhase; + next.callSucceeded = true; + next.callError = ERROR_SUCCESS_REBOOT_REQUIRED; + next.deadlineOverrun = false; + next.freshRebootRequired = false; + next.rebootRequired = true; + next.activePackageIndex = UINT32_MAX; + if (next.pendingRebootBootIdentifier.empty()) { + next.pendingRebootBootIdentifier = currentBoot; + } + if (!AppendRemoveJournalRecord( + loaded, std::move(next), &error)) { + return FailRemoveJournalManual(loaded, + L"required restart boundary could not be published", + &error, outcome); + } + } + SetRemoveJournalRecoveryOutcome(loaded, + L"remove-journal-reboot-pending", + ERROR_SUCCESS_REBOOT_REQUIRED, + L"the protected remove transaction requires the recorded Windows restart before continuing", + ExitCode::RebootRequired, outcome); + return false; +} + +bool RunRemoveRollbackRecovery( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + uint64_t deadlineUnixMs, + Outcome* outcome) { + const bool samePendingBoot = + !loaded->state.pendingRebootBootIdentifier.empty() && + loaded->state.pendingRebootBootIdentifier == currentBoot; + if (loaded->state.rebootRequired && samePendingBoot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::ExactPriorRestored) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packagePresent; + std::vector observedPackages; + Error observationError; + if (!ObserveRemoveRootShape(loaded->state, &root, + &observationError) || + !ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &observationError)) { + return FailRemoveJournalManual(loaded, + L"rollback admission observed topology outside the exact captured subset", + &observationError, outcome); + } + if (root == RemoveRootShape::PendingRemoval) { + return FailRemoveJournalManual(loaded, + L"captured root still has an indeterminate removal lifecycle after its recorded restart", + nullptr, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned && + !loaded->state.callSucceeded) { + return FailRemoveJournalManual(loaded, + L"authoritative protected-package restoration returned failure", + nullptr, outcome); + } + if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageEntered || + loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned) { + const uint32_t interruptedIndex = + loaded->state.activePackageIndex; + if (interruptedIndex >= packagePresent.size()) { + return FailRemoveJournalManual(loaded, + L"interrupted rollback package index is outside the immutable prior inventory", + nullptr, outcome); + } + if (packagePresent[interruptedIndex]) { + Error commitError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageCommitted, + true, ERROR_SUCCESS, false, false, false, + &commitError)) { + return FailRemoveJournalManual(loaded, + L"observable exact package restoration could not be durably committed", + &commitError, outcome); + } + } else if (loaded->state.phase == + RemoveJournalPhase::RollbackPackageReturned) { + return FailRemoveJournalManual(loaded, + L"successful rollback package return did not restore its exact published identity after the recorded restart", + nullptr, outcome); + } + } + for (size_t index = 0; index < packagePresent.size(); ++index) { + if (packagePresent[index]) continue; + bool reboot = false; + Error error; + const bool reusingInterruptedAdmission = + loaded->state.phase == + RemoveJournalPhase::RollbackPackageEntered && + loaded->state.activePackageIndex == index; + if ((!reusingInterruptedAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageEntered, + true, ERROR_SUCCESS, false, false, false, &error, + static_cast(index))) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + (root != RemoveRootShape::Absent && + root != RemoveRootShape::ExactPrior) || + !ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + packagePresent[index]) { + return FailRemoveJournalManual(loaded, + L"rollback package authority changed after durable admission", + &error, outcome); + } + bool freshReboot = false; + const bool installed = InvokeRestorePackageMutation( + loaded->state.prior.packages[index], deadlineUnixMs, + &reboot, &freshReboot, &error); + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageReturned, + installed, + installed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError, + static_cast(index))) { + return FailRemoveJournalManual(loaded, + L"authoritative rollback package return could not be recorded", + &returnError, outcome); + } + if (!installed) { + return FailRemoveJournalManual(loaded, + L"exact protected package restoration failed", + &error, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + !packagePresent[index]) { + return FailRemoveJournalManual(loaded, + L"restored bytes did not regain the exact captured published package identity", + &error, outcome); + } + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackPackageCommitted, + true, ERROR_SUCCESS, reboot, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"exact package restoration commit could not be recorded", + &error, outcome); + } + } + Error error; + if (!ObserveRemovePackageSubset(loaded->state, + &packagePresent, &observedPackages, &error) || + std::any_of(packagePresent.begin(), packagePresent.end(), + [](bool present) { return !present; })) { + return FailRemoveJournalManual(loaded, + L"exact prior package inventory is incomplete before binding restoration", + &error, outcome); + } + if (CurrentRemoveStateMatchesPrior( + loaded->state, deadlineUnixMs, &error)) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + error = Error{}; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent) { + return FailRemoveJournalManual(loaded, + L"root topology is not exactly absent or prior before binding rollback", + &error, outcome); + } + if (loaded->state.prior.devices.empty()) { + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); + } + const bool reusingInterruptedBindingAdmission = + ReusesInterruptedRemoveBindingAdmission(loaded->state.phase); + if ((!reusingInterruptedBindingAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackBindingEntered, + true, ERROR_SUCCESS, false, false, false, &error)) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-pre-binding-inventory", &error)) { + return FailRemoveJournalManual(loaded, + L"binding rollback authority changed after durable admission", + &error, outcome); + } + Snapshot restorable = loaded->state.prior; + std::filesystem::path systemInf; + if (!GetSystemInfDirectory(&systemInf, &error)) { + return FailRemoveJournalManual(loaded, + L"system INF directory could not be resolved for exact binding rollback", + &error, outcome); + } + for (PackageInfo& package : restorable.packages) { + package.infPath = systemInf / package.publishedName; + } + for (DeviceState& device : restorable.devices) { + for (const PackageInfo& package : restorable.packages) { + if (_wcsicmp(package.publishedName.c_str(), + device.publishedInf.c_str()) == 0) { + device.package = package; + } + } + } + bool reboot = false; + const bool authoritativeRestored = + InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"remove-rollback-binding", [&]() { + return RestorePriorBinding(restorable, + RestorePriorBindingPolicy::RemoveJournalExactAbsence, + deadlineUnixMs, &reboot, &error); + }); + bool restored = authoritativeRestored; + if (restored && gLastSynchronousMutationTimedOut) { + restored = SetError(&error, + L"remove-rollback-binding-timeout", ERROR_TIMEOUT, + L"binding restoration returned after its deadline; authoritative outcome is retained"); + } + const bool freshReboot = reboot; + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::RollbackBindingReturned, + restored, restored ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError)) { + return FailRemoveJournalManual(loaded, + L"authoritative binding rollback return could not be recorded", + &returnError, outcome); + } + if (!restored) { + return FailRemoveJournalManual(loaded, + L"exact captured root restoration failed", + &error, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + return RetireRemoveJournalAsPrior( + loaded, deadlineUnixMs, outcome); +} + +bool AdmitRemoveRollback( + LoadedRemoveJournal* loaded, + DWORD failureCode, + const std::string& currentBoot, + Outcome* outcome) { + Error error; + const RemoveJournalPhase admissionPhase = loaded->state.rebootRequired + ? RemoveJournalPhase::RestoreRebootPending + : RemoveJournalPhase::RollbackAdmitted; + if (!RecordRemoveJournalPhase(loaded, + admissionPhase, + false, failureCode, + loaded->state.rebootRequired, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"rollback authority could not be durably admitted", + &error, outcome); + } + const uint64_t rollbackDeadline = FreshRemoveRollbackDeadline(); + return RunRemoveRollbackRecovery( + loaded, currentBoot, rollbackDeadline, outcome); +} + +bool RunRemoveForwardRecovery( + LoadedRemoveJournal* loaded, + const std::string& currentBoot, + uint64_t deadlineUnixMs, + Outcome* outcome) { + const bool samePendingBoot = + !loaded->state.pendingRebootBootIdentifier.empty() && + loaded->state.pendingRebootBootIdentifier == currentBoot; + if (loaded->state.rebootRequired && samePendingBoot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (loaded->state.phase == RemoveJournalPhase::ForwardValidated) { + return RetireRemoveJournalAsUninstalled(loaded, outcome); + } + if ((loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned || + loaded->state.phase == + RemoveJournalPhase::PackageRemovalReturned) && + !loaded->state.callSucceeded) { + return AdmitRemoveRollback(loaded, + loaded->state.callError, currentBoot, outcome); + } + + RemoveRootShape root = RemoveRootShape::Manual; + std::vector packages; + uint32_t removedPrefix = 0; + Error error; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error)) { + return FailRemoveJournalManual(loaded, + L"forward remove topology is outside the exact captured prefix authority", + &error, outcome); + } + if (removedPrefix < loaded->state.packageCursor || + removedPrefix > loaded->state.packageCursor + 1U) { + return FailRemoveJournalManual(loaded, + L"Driver Store removal progress skipped an unadmitted package boundary", + nullptr, outcome); + } + const bool hadPriorRoot = !loaded->state.prior.devices.empty(); + if (root == RemoveRootShape::PendingRemoval) { + if (CrossedRemoveRebootStillPendingRequiresManual( + loaded->state.phase, loaded->state.callSucceeded, + loaded->state.rebootRequired, + loaded->state.freshRebootRequired, + samePendingBoot, root)) { + return FailRemoveJournalManual(loaded, + L"the root remains pending removal after the recorded restart; no fresh authoritative reboot evidence permits another restart loop", + nullptr, outcome); + } + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (hadPriorRoot && root == RemoveRootShape::ExactPrior) { + if (loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned && + loaded->state.callSucceeded) { + return FailRemoveJournalManual(loaded, + L"successful non-pending device removal left the exact prior root present", + nullptr, outcome); + } + if (loaded->state.phase != RemoveJournalPhase::Prepared && + loaded->state.phase != + RemoveJournalPhase::DeviceRemovalEntered && + loaded->state.phase != + RemoveJournalPhase::ForwardRebootPending) { + return FailRemoveJournalManual(loaded, + L"prior root reappeared after a committed removal boundary", + nullptr, outcome); + } + const bool reusingInterruptedDeviceAdmission = + loaded->state.phase == + RemoveJournalPhase::DeviceRemovalEntered; + if ((!reusingInterruptedDeviceAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalEntered, + true, ERROR_SUCCESS, false, false, false, &error)) || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-device-post-admission-inventory", + &error) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::ExactPrior) { + return FailRemoveJournalManual(loaded, + L"device removal authority changed after durable admission", + &error, outcome); + } + bool reboot = false; + bool mutationStarted = false; + const bool authoritativeRemoved = + InvokeAuthoritativeSynchronousMutation( + deadlineUnixMs, L"DiUninstallDevice", [&]() { + return RemoveExactCapturedDevice( + loaded->state.prior.devices[0], deadlineUnixMs, + &mutationStarted, &reboot, &error); + }); + bool removed = authoritativeRemoved; + if (removed && gLastSynchronousMutationTimedOut) { + removed = SetError(&error, + L"remove-devnode-timeout", ERROR_TIMEOUT, + L"device removal returned after its deadline; authoritative outcome is retained"); + } + const bool freshReboot = reboot; + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalReturned, + removed, removed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError)) { + return FailRemoveJournalManual(loaded, + L"authoritative device removal return could not be recorded", + &returnError, outcome); + } + if (!removed) { + return AdmitRemoveRollback(loaded, error.code, + currentBoot, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !VerifyPackageInventory(loaded->state.prior.packages, + L"remove-journal-device-commit-inventory", &error) || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error)) { + return AdmitRemoveRollback(loaded, + error.code, currentBoot, outcome); + } + } else if (root == RemoveRootShape::Absent) { + if (hadPriorRoot && + !loaded->state.deviceMutationEntered) { + return FailRemoveJournalManual(loaded, + L"captured root disappeared before durable device-removal admission", + nullptr, outcome); + } + if (hadPriorRoot && + (loaded->state.phase == + RemoveJournalPhase::DeviceRemovalEntered || + loaded->state.phase == + RemoveJournalPhase::DeviceRemovalReturned || + loaded->state.phase == + RemoveJournalPhase::ForwardRebootPending)) { + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::DeviceRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error)) { + return FailRemoveJournalManual(loaded, + L"observed device removal could not be durably committed", + &error, outcome); + } + } + } else { + return FailRemoveJournalManual(loaded, + L"root topology is neither exact prior nor exact absent", + nullptr, outcome); + } + + if (removedPrefix == loaded->state.packageCursor + 1U) { + const bool admissibleObservedEffect = + loaded->state.phase == + RemoveJournalPhase::PackageRemovalEntered || + (loaded->state.phase == + RemoveJournalPhase::PackageRemovalReturned && + loaded->state.callSucceeded) || + loaded->state.phase == + RemoveJournalPhase::ForwardRebootPending; + if (!admissibleObservedEffect || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error, + std::nullopt, removedPrefix)) { + return FailRemoveJournalManual(loaded, + L"observed package removal lacks exact durable API authority", + &error, outcome); + } + } + while (loaded->state.packageCursor < + loaded->state.prior.packages.size()) { + const uint32_t index = loaded->state.packageCursor; + if (!ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index) { + return FailRemoveJournalManual(loaded, + L"package removal precondition changed outside the exact captured suffix", + &error, outcome); + } + const PackageInfo* current = nullptr; + for (const PackageInfo& package : packages) { + if (SameJournalPackageIdentity(package, + loaded->state.prior.packages[index])) { + current = &package; + } + } + const bool reusingInterruptedPackageAdmission = + loaded->state.phase == + RemoveJournalPhase::PackageRemovalEntered && + loaded->state.activePackageIndex == index; + if (current == nullptr || + (!reusingInterruptedPackageAdmission && + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalEntered, + true, ERROR_SUCCESS, false, false, false, &error, + index)) || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index) { + return FailRemoveJournalManual(loaded, + L"package removal authority changed after durable admission", + &error, outcome); + } + current = nullptr; + for (const PackageInfo& package : packages) { + if (SameJournalPackageIdentity(package, + loaded->state.prior.packages[index])) { + current = &package; + } + } + if (current == nullptr) { + return FailRemoveJournalManual(loaded, + L"admitted exact package disappeared before its API call", + nullptr, outcome); + } + bool reboot = false; + bool freshReboot = false; + const bool removed = InvokeRemovePackageMutation(*current, + deadlineUnixMs, &reboot, &freshReboot, &error); + Error returnError; + if (!RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalReturned, + removed, removed ? ERROR_SUCCESS : error.code, + reboot, freshReboot, + gLastSynchronousMutationTimedOut, &returnError, index)) { + return FailRemoveJournalManual(loaded, + L"authoritative package removal return could not be recorded", + &returnError, outcome); + } + if (!removed) { + return AdmitRemoveRollback(loaded, error.code, + currentBoot, outcome); + } + if (reboot) { + return ReturnRemoveJournalRebootPending( + loaded, currentBoot, outcome); + } + if (!ObserveRemovePackagePrefix(loaded->state, + &removedPrefix, &packages, &error) || + removedPrefix != index + 1U || + !ObserveRemoveRootShape(loaded->state, &root, &error) || + root != RemoveRootShape::Absent || + !RecordRemoveJournalPhase(loaded, + RemoveJournalPhase::PackageRemovalCommitted, + true, ERROR_SUCCESS, false, false, false, &error, + std::nullopt, index + 1U)) { + return AdmitRemoveRollback(loaded, + error.code, currentBoot, outcome); + } + } + return RetireRemoveJournalAsUninstalled(loaded, outcome); +} + +bool ReconcileRemoveJournal( + bool explicitRecovery, + uint64_t deadlineUnixMs, + Outcome* outcome) { + RemoveRecoveryDirectory directory; + bool exists = false; + if (!directory.OpenChain(false, &exists, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + return false; + } + if (!exists) return true; + LoadedRemoveJournal loaded; + if (!LoadRemoveJournal( + std::move(directory), &loaded, &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + outcome->error.recoveryBackup = loaded.directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + return false; + } + if (!loaded.hasRecord) { + std::string abandonedIdentity; + if (!GenerateInstallTransactionId( + &abandonedIdentity, &outcome->error) || + !RetireRemoveRecoveryActiveDirectory( + &loaded.directory, abandonedIdentity, + &outcome->error)) { + outcome->exitCode = ExitCode::RollbackFailed; + outcome->error.recoveryBackup = + loaded.directory.active.wstring(); + outcome->error.recoveryBackupRetained = true; + return false; + } + outcome->success = true; + outcome->exitCode = ExitCode::Success; + return true; + } + PublishRemoveRecoveryEvidence(loaded.directory.active, + loaded.state.sequence - 1U, nullptr); + + InstallRecoveryDirectory installDirectory; + bool installExists = false; + Error concurrencyError; + if (!installDirectory.OpenChain(false, nullptr, + &installExists, &concurrencyError) || installExists) { + return FailRemoveJournalManual(&loaded, + L"remove and install journals are simultaneously active; no automatic mutation is authorized", + installExists ? nullptr : &concurrencyError, outcome); + } + if (loaded.state.phase == + RemoveJournalPhase::ManualReconciliationRequired) { + return FailRemoveJournalManual(&loaded, + L"remove journal is latched for manual reconciliation", + nullptr, outcome); + } + std::string currentBoot; + if (!GetBootIdentifier(¤tBoot, &outcome->error)) { + return FailRemoveJournalManual(&loaded, + L"current boot epoch cannot be compared with the durable remove boundary", + &outcome->error, outcome); + } + const uint64_t recoveryDeadline = explicitRecovery + ? deadlineUnixMs + : std::min(deadlineUnixMs, + CurrentUnixMilliseconds() + kDriverRollbackCeilingMs); + return loaded.state.direction == RemoveJournalDirection::Rollback + ? RunRemoveRollbackRecovery(&loaded, currentBoot, + recoveryDeadline, outcome) + : RunRemoveForwardRecovery(&loaded, currentBoot, + recoveryDeadline, outcome); +} + +struct RemoveOptions { + uint64_t transactionDeadlineUnixMs = 0; +}; + +Outcome Remove(const RemoveOptions& options) { + Outcome outcome; + if (!ValidateTransactionDeadlineBudget( + options.transactionDeadlineUnixMs, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (!IsElevated()) { + SetError(&outcome.error, L"elevation", ERROR_ELEVATION_REQUIRED); + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + TransactionMutex mutex; + if (!mutex.Acquire(&outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Outcome recoveryOutcome; + if (!ReconcileRemoveJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome) || + !ReconcileInstallJournal( + false, options.transactionDeadlineUnixMs, &recoveryOutcome)) { + return recoveryOutcome; + } + if (!CheckTransactionDeadline( + options.transactionDeadlineUnixMs, + L"remove-deadline-before-snapshot", &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + Snapshot prior; + if (!CaptureSnapshot(&prior, &outcome.error)) { + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; + } + if (prior.devices.size() > 1 || + (!prior.devices.empty() && !prior.devices[0].present)) { + SetError(&outcome.error, L"remove-topology", ERROR_DUPLICATE_SERVICE_NAME, + L"removal requires zero devices or one present exact owned root devnode"); + outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - bool mutationStarted = false; - bool reboot = false; - Error mutationError; - bool mutationSucceeded = RemoveAllExactDevices( - options.transactionDeadlineUnixMs, &mutationStarted, &reboot, &mutationError); - outcome.changed = mutationStarted; - if (mutationSucceeded && !CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-after-device", &mutationError)) { - mutationSucceeded = false; - } - if (mutationSucceeded) { - for (const PackageInfo& package : prior.packages) { - if (!CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-before-package", &mutationError)) { - mutationSucceeded = false; - break; - } - mutationStarted = true; - outcome.changed = true; - if (!UninstallPackage(package, &reboot, &mutationError)) { - mutationSucceeded = false; - break; - } - if (!CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-after-package", &mutationError)) { - mutationSucceeded = false; - break; - } - } - } - if (mutationSucceeded && !reboot) { - if (!CheckTransactionDeadline( - options.transactionDeadlineUnixMs, L"remove-deadline-before-verify", &mutationError)) { - mutationSucceeded = false; + RemoveJournalStateData observationState; + observationState.prior = prior; + RemoveRootShape root = RemoveRootShape::Manual; + if (!ObserveRemoveRootShape( + observationState, &root, &outcome.error) || + root != (prior.devices.empty() + ? RemoveRootShape::Absent + : RemoveRootShape::ExactPrior)) { + if (outcome.error.code == ERROR_SUCCESS) { + SetError(&outcome.error, L"remove-raw-topology", + ERROR_REVISION_MISMATCH, + L"removal requires one exact captured root or exact root absence with no related partial roots"); } + outcome.exitCode = ExitCode::PreflightRejected; + return outcome; } - if (mutationSucceeded && !reboot) { - Snapshot verified; - if (!CaptureSnapshot(&verified, &mutationError) || - !verified.devices.empty() || !verified.packages.empty()) { - if (mutationError.code == ERROR_SUCCESS) { - SetError(&mutationError, L"remove-verification", ERROR_DEVICE_IN_USE); - } - mutationSucceeded = false; - } + if (prior.devices.empty() && prior.packages.empty()) { + outcome.success = true; + outcome.exitCode = ExitCode::Success; + return outcome; } - if (!mutationSucceeded) { - if (!mutationStarted) { - rejectBeforeMutation(std::move(mutationError)); + std::optional priorAbiProfile; + if (!prior.devices.empty() && prior.devices[0].started) { + AbiCompatibilityProfile negotiated{}; + if (!VerifyAbiHealth(options.transactionDeadlineUnixMs, + nullptr, &outcome.error, + AbiHealthPurpose::ExactCandidate, nullptr, + &negotiated)) { + outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - Error rollbackError; - bool rollbackReboot = reboot; - // Forward work owns the caller's absolute deadline. Rollback receives - // one fresh, bounded ceiling from the instant failure is observed; it - // must not inherit the unused portion of a long forward deadline and - // silently expand into a six-minute transaction. - const uint64_t rollbackDeadline = - CurrentUnixMilliseconds() + kDriverRollbackCeilingMs; - if (RollbackRemove( - prior, backups, rollbackDeadline, &rollbackReboot, &rollbackError)) { - outcome.rollback = L"succeeded"; - outcome.rebootRequired = rollbackReboot; - Error cleanupError; - if (!backupRoot.Cleanup(&backups, &cleanupError)) { - outcome.error = std::move(cleanupError); - return outcome; + priorAbiProfile = negotiated; + } + { + LoadedRemoveJournal journal; + if (!PrepareRemoveJournal(prior, + priorAbiProfile ? &*priorAbiProfile : nullptr, + &journal, &outcome.error)) { + outcome.exitCode = journal.hasRecord || journal.poisoned + ? ExitCode::RollbackFailed + : ExitCode::PreflightRejected; + if (!journal.directory.active.empty()) { + outcome.error.recoveryBackup = + journal.directory.active.wstring(); + outcome.error.recoveryBackupRetained = true; } - outcome.error = std::move(mutationError); return outcome; } - backupRoot.AttachRecoveryRecord(&rollbackError); - outcome.rollback = L"failed"; - outcome.rebootRequired = rollbackReboot; - outcome.error = std::move(rollbackError); + } + Outcome transactionOutcome; + const bool reconciled = ReconcileRemoveJournal( + true, options.transactionDeadlineUnixMs, + &transactionOutcome); + if (!reconciled) return transactionOutcome; + Snapshot finalState; + if (!CaptureSnapshot(&finalState, &outcome.error)) { outcome.exitCode = ExitCode::RollbackFailed; return outcome; } - Error cleanupError; - if (!backupRoot.Cleanup(&backups, &cleanupError)) { - outcome.rollback = L"failed"; - outcome.rebootRequired = reboot; - outcome.error = std::move(cleanupError); - outcome.exitCode = ExitCode::RollbackFailed; + if (finalState.devices.empty() && finalState.packages.empty()) { + outcome.success = true; + outcome.changed = true; + outcome.exitCode = ExitCode::Success; return outcome; } - outcome.success = true; - outcome.rebootRequired = reboot; - outcome.exitCode = reboot ? ExitCode::RebootRequired : ExitCode::Success; + SetError(&outcome.error, L"remove-transaction-rolled-back", + ERROR_OPERATION_ABORTED, + L"removal could not commit and the exact prior state was restored"); + outcome.changed = true; + outcome.rollback = L"succeeded"; + outcome.exitCode = ExitCode::Failure; return outcome; } @@ -11722,7 +15785,9 @@ Outcome Recover(uint64_t transactionDeadlineUnixMs) { outcome.exitCode = ExitCode::PreflightRejected; return outcome; } - if (!ReconcileInstallJournal( + if (!ReconcileRemoveJournal( + true, transactionDeadlineUnixMs, &outcome) || + !ReconcileInstallJournal( true, transactionDeadlineUnixMs, &outcome)) { return outcome; } @@ -11739,6 +15804,44 @@ enum class InstallJournalRecoveryModelAction { Manual, }; +enum class BrokerOuterRecoveryModelAction { + ReplayChild, + ValidateForward, + PublishPending, + EmitBinding, + ReplayAcknowledgement, + Manual, +}; + +BrokerOuterRecoveryModelAction ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase phase, + bool hasProof, + bool proofSuccess, + bool proofChanged, + bool rollbackAuthorized) noexcept { + if (phase == InstallJournalPhase::BrokerChildEntered && + !hasProof && !rollbackAuthorized) { + return BrokerOuterRecoveryModelAction::ReplayChild; + } + const bool exactForwardProof = hasProof && proofSuccess && + proofChanged && !rollbackAuthorized; + if (!exactForwardProof) { + return BrokerOuterRecoveryModelAction::Manual; + } + switch (phase) { + case InstallJournalPhase::BrokerChildSettled: + return BrokerOuterRecoveryModelAction::ValidateForward; + case InstallJournalPhase::ForwardValidated: + return BrokerOuterRecoveryModelAction::PublishPending; + case InstallJournalPhase::BrokerOuterSettlementPending: + return BrokerOuterRecoveryModelAction::EmitBinding; + case InstallJournalPhase::BrokerOuterSettled: + return BrokerOuterRecoveryModelAction::ReplayAcknowledgement; + default: + return BrokerOuterRecoveryModelAction::Manual; + } +} + InstallJournalRecoveryModelAction ClassifyInstallJournalRecoveryModel( InstallJournalPhase phase, bool chainValid, @@ -11865,6 +15968,45 @@ bool RunInstallJournalModelSelfTest(Error* error) { return SetError(error, L"self-test-install-journal-security-model", ERROR_INVALID_DATA); } + InstallJournalStateData finalBindingModel; + finalBindingModel.phase = InstallJournalPhase::BrokerOuterSettled; + finalBindingModel.lastDigest = std::string(64, 'a'); + finalBindingModel.brokerDriverPendingDigest = std::string(64, 'b'); + if (!IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase::BrokerOuterSettled) || + IsBrokerOuterSettlementContinuationPhase( + InstallJournalPhase::Prepared) || + BrokerOuterSettlementPendingDriverDigest(finalBindingModel) != + finalBindingModel.brokerDriverPendingDigest || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildEntered, + false, false, false, false) != + BrokerOuterRecoveryModelAction::ReplayChild || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildSettled, + true, true, true, false) != + BrokerOuterRecoveryModelAction::ValidateForward || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::ForwardValidated, + true, true, true, false) != + BrokerOuterRecoveryModelAction::PublishPending || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerOuterSettlementPending, + true, true, true, false) != + BrokerOuterRecoveryModelAction::EmitBinding || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerOuterSettled, + true, true, true, false) != + BrokerOuterRecoveryModelAction::ReplayAcknowledgement || + ClassifyBrokerOuterRecoveryModel( + InstallJournalPhase::BrokerChildSettled, + true, false, true, false) != + BrokerOuterRecoveryModelAction::Manual) { + return SetError(error, + L"self-test-broker-outer-cutpoint-model", + ERROR_INVALID_DATA, + L"child exit, proof, pending, acknowledgement, or retirement cut was not classified deterministically"); + } uint64_t recordSequence = 0; if (!ParseJournalRecordFileName( L"journal-00000042.json", &recordSequence) || @@ -12016,6 +16158,10 @@ bool RunInstallJournalModelSelfTest(Error* error) { InstallJournalStateData proofState = state; proofState.phase = InstallJournalPhase::BrokerChildSettled; proofState.brokerRequired = true; + proofState.brokerExecutableSha256 = std::string(64, '1'); + proofState.brokerTokenPath = + L"C:\\ProgramData\\VIIPER\\package.token"; + proofState.brokerTargetUserSid = modelTargetUserSid; proofState.brokerEntered = true; proofState.brokerSettled = true; proofState.hasBrokerProof = true; @@ -12025,6 +16171,19 @@ bool RunInstallJournalModelSelfTest(Error* error) { proofState.brokerProofExitCode = proof.exitCode; proofState.brokerDriverRollbackAuthorized = proof.rollbackAuthorized; + if (proof.changed) { + proofState.brokerJournalTransactionId = + std::string(32, '9'); + proofState.brokerJournalOuterTransactionId = + proofState.transactionId; + proofState.brokerJournalCandidateSha256 = + proofState.brokerExecutableSha256; + proofState.brokerJournalState = proof.success + ? "nested-ready" + : proof.rollbackAuthorized + ? "rollback-settled" : "manual"; + proofState.brokerJournalDigest = std::string(64, '8'); + } proofState.rollbackAuthorized = proof.rollbackAuthorized; proofState.direction = proof.rollbackAuthorized ? InstallJournalDirection::Rollback @@ -12057,6 +16216,173 @@ bool RunInstallJournalModelSelfTest(Error* error) { ERROR_INVALID_DATA); } } + BrokerSettlementRequestData settlementRequest; + settlementRequest.binding.brokerTransactionId = + std::string(32, '1'); + settlementRequest.binding.brokerOuterTransactionId = + std::string(64, '2'); + settlementRequest.binding.brokerCandidateSha256 = + std::string(64, '3'); + settlementRequest.binding.brokerNestedDigest = + std::string(64, '4'); + settlementRequest.binding.driverTransactionId = + std::string(64, '5'); + settlementRequest.binding.driverPendingDigest = + std::string(64, '6'); + settlementRequest.binding.settlementNonce = + std::string(64, '7'); + settlementRequest.brokerPendingDigest = std::string(64, '8'); + std::string settlementBindingJson; + AppendBrokerSettlementBindingJson( + &settlementBindingJson, settlementRequest.binding); + if (!Sha256Data(settlementBindingJson, + &settlementRequest.bindingSha256, error)) { + return false; + } + std::string settlementPayload = + "{\"schema\":1,\"bindingSha256\":"; + AppendJsonAsciiString( + &settlementPayload, settlementRequest.bindingSha256); + settlementPayload.append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString( + &settlementPayload, settlementRequest.brokerPendingDigest); + settlementPayload.append(",\"binding\":"); + settlementPayload.append(settlementBindingJson); + settlementPayload.push_back('}'); + if (!Sha256Data(settlementPayload, + &settlementRequest.payloadSha256, error)) { + return false; + } + std::string canonicalSettlementPayload; + std::string canonicalSettlementEnvelope; + BrokerSettlementRequestData parsedSettlement; + Error malformedSettlementError; + if (!BuildBrokerSettlementRequestJson(settlementRequest, + &canonicalSettlementPayload, + &canonicalSettlementEnvelope, error) || + !ParseBrokerSettlementRequest(canonicalSettlementEnvelope, + &parsedSettlement, error) || + parsedSettlement.binding.driverPendingDigest != + settlementRequest.binding.driverPendingDigest || + parsedSettlement.requestSha256.empty() || + ParseBrokerSettlementRequest( + canonicalSettlementEnvelope + " ", + &parsedSettlement, &malformedSettlementError) || + malformedSettlementError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-broker-settlement-envelope", + ERROR_INVALID_DATA, + L"canonical settlement parsing or corruption rejection changed"); + } + BrokerSettlementFinalData settlementFinal; + settlementFinal.brokerTransactionId = + settlementRequest.binding.brokerTransactionId; + settlementFinal.brokerPendingDigest = + settlementRequest.brokerPendingDigest; + settlementFinal.brokerSettledDigest = std::string(64, 'a'); + settlementFinal.driverTransactionId = + settlementRequest.binding.driverTransactionId; + settlementFinal.driverPendingDigest = + settlementRequest.binding.driverPendingDigest; + settlementFinal.driverSettledDigest = std::string(64, 'b'); + settlementFinal.settlementNonce = + settlementRequest.binding.settlementNonce; + settlementFinal.requestSha256 = + parsedSettlement.requestSha256; + settlementFinal.state = "outer-settled"; + std::string settlementFinalPayload = + "{\"schema\":1,\"brokerTransactionId\":"; + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerTransactionId); + settlementFinalPayload.append(",\"brokerPendingDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerPendingDigest); + settlementFinalPayload.append(",\"brokerSettledDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.brokerSettledDigest); + settlementFinalPayload.append(",\"driverTransactionId\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverTransactionId); + settlementFinalPayload.append(",\"driverPendingDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverPendingDigest); + settlementFinalPayload.append(",\"driverSettledDigest\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.driverSettledDigest); + settlementFinalPayload.append(",\"settlementNonce\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.settlementNonce); + settlementFinalPayload.append(",\"requestSha256\":"); + AppendJsonAsciiString(&settlementFinalPayload, + settlementFinal.requestSha256); + settlementFinalPayload.append(",\"state\":\"outer-settled\"}"); + if (!Sha256Data(settlementFinalPayload, + &settlementFinal.payloadSha256, error)) { + return false; + } + std::string canonicalFinalPayload; + std::string canonicalFinalEnvelope; + BrokerSettlementFinalData parsedFinal; + Error malformedFinalError; + if (!BuildBrokerSettlementFinalJson(settlementFinal, + &canonicalFinalPayload, &canonicalFinalEnvelope, error) || + !ParseBrokerSettlementFinal(canonicalFinalEnvelope, + &parsedFinal, error) || + !ValidateBrokerSettlementFinalBinding( + parsedSettlement, parsedFinal, error) || + parsedFinal.brokerSettledDigest != + settlementFinal.brokerSettledDigest || + parsedFinal.receiptSha256.empty() || + ParseBrokerSettlementFinal(canonicalFinalEnvelope + " ", + &parsedFinal, &malformedFinalError) || + malformedFinalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-broker-settlement-final-envelope", + ERROR_INVALID_DATA, + L"canonical final receipt parsing or corruption rejection changed"); + } + InstallJournalStateData outerChild = state; + outerChild.phase = InstallJournalPhase::BrokerChildSettled; + outerChild.brokerRequired = true; + outerChild.brokerExecutableSha256 = std::string(64, '3'); + outerChild.brokerTokenPath = + L"C:\\ProgramData\\VIIPER\\package.token"; + outerChild.brokerTargetUserSid = modelTargetUserSid; + outerChild.brokerEntered = true; + outerChild.brokerSettled = true; + outerChild.hasBrokerProof = true; + outerChild.brokerProofSuccess = true; + outerChild.brokerProofChanged = true; + outerChild.brokerProofRollback = "not-needed"; + outerChild.brokerProofExitCode = ERROR_SUCCESS; + outerChild.brokerJournalTransactionId = std::string(32, '1'); + outerChild.brokerJournalOuterTransactionId = + outerChild.transactionId; + outerChild.brokerJournalCandidateSha256 = + outerChild.brokerExecutableSha256; + outerChild.brokerJournalState = "nested-ready"; + outerChild.brokerJournalDigest = std::string(64, '4'); + InstallJournalStateData outerForward = outerChild; + outerForward.phase = InstallJournalPhase::ForwardValidated; + InstallJournalStateData outerPending = outerForward; + outerPending.phase = + InstallJournalPhase::BrokerOuterSettlementPending; + outerPending.brokerSettlementNonce = std::string(64, '7'); + InstallJournalStateData outerFinal = outerPending; + outerFinal.phase = InstallJournalPhase::BrokerOuterSettled; + outerFinal.brokerDriverPendingDigest = std::string(64, '6'); + outerFinal.brokerSettlementRequestSha256 = std::string(64, '9'); + outerFinal.brokerGoPendingDigest = std::string(64, '8'); + if (!ValidateInstallJournalTransition( + &outerChild, outerForward, error) || + !ValidateInstallJournalTransition( + &outerForward, outerPending, error) || + !ValidateInstallJournalTransition( + &outerPending, outerFinal, error)) { + return SetError(error, + L"self-test-broker-settlement-phase-chain", + ERROR_INVALID_DATA); + } InstallJournalStateData durableReceipt = returned; durableReceipt.phase = InstallJournalPhase::StageReceiptCaptured; durableReceipt.hasPublishedCandidate = true; @@ -12710,6 +17036,586 @@ bool RunInstallJournalModelSelfTest(Error* error) { return true; } +enum class RemoveJournalRecoveryModelAction { + ContinueForward, + ContinueRollback, + AdmitRollback, + RebootPending, + RetireForward, + RetirePrior, + Manual, +}; + +RemoveJournalRecoveryModelAction ClassifyRemoveJournalRecoveryModel( + RemoveJournalPhase phase, + RemoveJournalDirection direction, + bool chainValid, + bool securityValid, + bool samePendingBoot, + bool priorValid, + bool forwardValid, + bool callSucceeded) noexcept { + if (!chainValid || !securityValid || + phase == RemoveJournalPhase::ManualReconciliationRequired) { + return RemoveJournalRecoveryModelAction::Manual; + } + if ((phase == RemoveJournalPhase::ForwardRebootPending || + phase == RemoveJournalPhase::RestoreRebootPending) && + samePendingBoot) { + return RemoveJournalRecoveryModelAction::RebootPending; + } + if (forwardValid && direction == RemoveJournalDirection::Forward && + (phase == RemoveJournalPhase::ForwardValidated || + phase == RemoveJournalPhase::ForwardRebootPending)) { + return RemoveJournalRecoveryModelAction::RetireForward; + } + if (priorValid && + (direction == RemoveJournalDirection::Rollback || + phase == RemoveJournalPhase::Prepared)) { + return RemoveJournalRecoveryModelAction::RetirePrior; + } + if (direction == RemoveJournalDirection::Forward && + (phase == RemoveJournalPhase::DeviceRemovalReturned || + phase == RemoveJournalPhase::PackageRemovalReturned) && + !callSucceeded) { + return RemoveJournalRecoveryModelAction::AdmitRollback; + } + return direction == RemoveJournalDirection::Rollback + ? RemoveJournalRecoveryModelAction::ContinueRollback + : RemoveJournalRecoveryModelAction::ContinueForward; +} + +bool RunRemoveJournalRetirementSelfTest(Error* error) { + std::string rootIdentity; + if (!GenerateInstallTransactionId(&rootIdentity, error)) return false; + std::error_code pathError; + const std::filesystem::path temporary = + std::filesystem::temp_directory_path(pathError); + if (pathError) { + return SetError(error, L"self-test-remove-retire-temp", + static_cast(pathError.value())); + } + const std::filesystem::path testRoot = temporary / + (L"VIIPER-UdeCx-remove-retire-" + + std::wstring(rootIdentity.begin(), rootIdentity.end())); + if (!std::filesystem::create_directory(testRoot, pathError) || pathError) { + return SetError(error, L"self-test-remove-retire-root", + pathError ? static_cast(pathError.value()) + : ERROR_ALREADY_EXISTS); + } + struct Cleanup final { + std::filesystem::path root; + ~Cleanup() { + std::error_code ignored; + std::filesystem::remove_all(root, ignored); + } + } cleanup{testRoot}; + + const auto prepareLockedJournal = [&] ( + std::wstring_view caseName, + char transactionDigit, + LoadedRemoveJournal* loaded) { + loaded->directory.root = testRoot / caseName; + loaded->directory.active = loaded->directory.root / + kRemoveRecoveryActiveDirectory; + const std::filesystem::path prior = loaded->directory.active / + kRemoveRecoveryPriorDirectory; + std::error_code createError; + if (!std::filesystem::create_directories(prior, createError) || + createError) { + return SetError(error, L"self-test-remove-retire-tree", + createError ? static_cast(createError.value()) + : ERROR_ALREADY_EXISTS); + } + const std::filesystem::path evidence = prior / L"evidence.bin"; + WinHandle file(CreateFileW(evidence.c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, + nullptr)); + WinHandle directory(CreateFileW(prior.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + nullptr)); + if (!file || !directory) { + return SetLastErrorDetail(error, + L"self-test-remove-retire-evidence-open"); + } + PackageBackup backup; + backup.locks.push_back(std::move(file)); + loaded->priorBackups.push_back(std::move(backup)); + loaded->evidenceLocks.push_back(std::move(directory)); + loaded->state.phase = RemoveJournalPhase::ForwardValidated; + loaded->state.direction = RemoveJournalDirection::Forward; + loaded->state.sequence = 1U; + loaded->state.transactionId = std::string(64, transactionDigit); + loaded->state.bootIdentifier = std::string(32, 'd'); + loaded->state.previousDigest = std::string(64, 'e'); + loaded->state.lastDigest = loaded->state.previousDigest; + loaded->hasRecord = true; + return true; + }; + const auto activeIsAbsent = [] (const std::filesystem::path& active) { + const DWORD attributes = GetFileAttributesW(active.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES) return false; + const DWORD code = GetLastError(); + return code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND; + }; + + LoadedRemoveJournal shareBlocked; + if (!prepareLockedJournal(L"share", 'a', &shareBlocked)) return false; + const std::filesystem::path blockedDestination = + shareBlocked.directory.root / L"blocked"; + if (MoveFileExW(shareBlocked.directory.active.c_str(), + blockedDestination.c_str(), MOVEFILE_WRITE_THROUGH)) { + return SetError(error, L"self-test-remove-retire-share-mode", + ERROR_INVALID_DATA, + L"a parent rename unexpectedly bypassed a descendant handle without FILE_SHARE_DELETE"); + } + *error = Error{}; + if (!RetireLoadedRemoveJournal(&shareBlocked, error, + RemoveRetirementTestFault::TemporaryTree) || + !shareBlocked.priorBackups.empty() || + !shareBlocked.evidenceLocks.empty() || + !activeIsAbsent(shareBlocked.directory.active)) { + if (error->code == ERROR_SUCCESS) { + SetError(error, L"self-test-remove-retire-share-release", + ERROR_INVALID_DATA); + } + return false; + } + + LoadedRemoveJournal postcheck; + if (!prepareLockedJournal(L"postcheck", 'b', &postcheck)) return false; + const std::filesystem::path postcheckTombstone = + postcheck.directory.root / + (std::wstring(kRemoveRecoverySettledPrefix) + + std::wstring(64, L'b')); + Error postcheckError; + if (RetireLoadedRemoveJournal(&postcheck, &postcheckError, + RemoveRetirementTestFault:: + TemporaryTreeActiveAbsencePostcheck) || + postcheckError.recoveryBackup != postcheckTombstone.wstring() || + !postcheckError.recoveryBackupRetained || + !activeIsAbsent(postcheck.directory.active) || + !std::filesystem::is_directory(postcheckTombstone, pathError) || + pathError) { + return SetError(error, L"self-test-remove-retire-postcheck", + ERROR_INVALID_DATA, + L"post-rename failure did not preserve the exact settled tombstone evidence"); + } + + LoadedRemoveJournal cleanupFailure; + if (!prepareLockedJournal(L"cleanup", 'c', &cleanupFailure)) return false; + const std::filesystem::path retainedTombstone = + cleanupFailure.directory.root / + (std::wstring(kRemoveRecoverySettledPrefix) + + std::wstring(64, L'c')); + Error cleanupError; + if (!RetireLoadedRemoveJournal(&cleanupFailure, &cleanupError, + RemoveRetirementTestFault:: + TemporaryTreeRetainSettledTombstone) || + cleanupError.code != ERROR_SUCCESS || + !activeIsAbsent(cleanupFailure.directory.active) || + !std::filesystem::is_directory(retainedTombstone, pathError) || + pathError || + std::wstring(gRetainedRemoveTombstone.data()) != + retainedTombstone.wstring() || + gRetainedRemoveTombstoneError == ERROR_SUCCESS) { + return SetError(error, L"self-test-remove-retire-cleanup", + ERROR_INVALID_DATA, + L"settled cleanup failure changed terminal success or re-admitted active recovery"); + } + ClearRemoveRetirementWarning(); + return true; +} + +bool RunRemoveJournalModelSelfTest(Error* error) { + if (!RunRemoveJournalRetirementSelfTest(error)) return false; + for (RemoveJournalPhase phase : { + RemoveJournalPhase::Prepared, + RemoveJournalPhase::DeviceRemovalEntered, + RemoveJournalPhase::DeviceRemovalReturned, + RemoveJournalPhase::DeviceRemovalCommitted, + RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalPhase::PackageRemovalCommitted, + RemoveJournalPhase::RollbackAdmitted, + RemoveJournalPhase::RollbackPackageEntered, + RemoveJournalPhase::RollbackPackageReturned, + RemoveJournalPhase::RollbackPackageCommitted, + RemoveJournalPhase::RollbackBindingEntered, + RemoveJournalPhase::RollbackBindingReturned, + RemoveJournalPhase::ForwardValidated, + RemoveJournalPhase::ExactPriorRestored, + RemoveJournalPhase::ForwardRebootPending, + RemoveJournalPhase::RestoreRebootPending, + RemoveJournalPhase::ManualReconciliationRequired}) { + const char* name = RemoveJournalPhaseName(phase); + if (!ParseRemoveJournalPhase(name) || + *ParseRemoveJournalPhase(name) != phase) { + return SetError(error, + L"self-test-remove-journal-phase-roundtrip", + ERROR_INVALID_DATA); + } + } + PackageInfo package; + package.publishedName = L"oem42.inf"; + package.version.parts = {0, 1, 0, 37}; + package.infSha256 = std::string(64, 'a'); + package.sysSha256 = std::string(64, 'b'); + package.catSha256 = std::string(64, 'c'); + RemoveJournalStateData prepared; + prepared.transactionId = std::string(64, 'd'); + prepared.bootIdentifier = std::string(32, 'e'); + prepared.prior.packages.push_back(package); + if (!ValidateRemoveJournalTransition(nullptr, prepared, error)) { + return false; + } + prepared.sequence = 1U; + prepared.previousDigest = std::string(64, 'f'); + prepared.lastDigest = prepared.previousDigest; + RemoveJournalStateData entered = prepared; + entered.phase = RemoveJournalPhase::PackageRemovalEntered; + entered.activePackageIndex = 0U; + RemoveJournalStateData returned = entered; + returned.phase = RemoveJournalPhase::PackageRemovalReturned; + RemoveJournalStateData committed = returned; + committed.phase = RemoveJournalPhase::PackageRemovalCommitted; + committed.activePackageIndex = UINT32_MAX; + committed.packageCursor = 1U; + if (!ValidateRemoveJournalTransition(&prepared, entered, error) || + !ValidateRemoveJournalTransition(&entered, returned, error) || + !ValidateRemoveJournalTransition(&returned, committed, error)) { + return false; + } + DeviceState capturedTarget; + capturedTarget.instanceId = L"ROOT\\VIIPERUDE\\0000"; + capturedTarget.present = true; + capturedTarget.service = kServiceName; + capturedTarget.publishedInf = package.publishedName; + capturedTarget.version = package.version; + capturedTarget.package = package; + std::vector exactTargets{capturedTarget}; + std::vector duplicateTargets{ + capturedTarget, capturedTarget}; + DeviceState reboundTarget = capturedTarget; + reboundTarget.publishedInf = L"oem43.inf"; + if (!IsExactCapturedRemoveTarget(capturedTarget, exactTargets) || + IsExactCapturedRemoveTarget(capturedTarget, duplicateTargets) || + IsExactCapturedRemoveTarget( + capturedTarget, std::vector{reboundTarget})) { + return SetError(error, + L"self-test-remove-journal-single-device-authority", + ERROR_INVALID_DATA); + } + if (!CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::ForwardRebootPending, false, true, false, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::ForwardRebootPending, false, true, false, true, + RemoveRootShape::PendingRemoval) || + !CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, true, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, false, true, true, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, false, false, + RemoveRootShape::PendingRemoval) || + CrossedRemoveRebootStillPendingRequiresManual( + RemoveJournalPhase::DeviceRemovalReturned, true, true, true, true, + RemoveRootShape::PendingRemoval) || + !ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase::RollbackBindingEntered) || + ReusesInterruptedRemoveBindingAdmission( + RemoveJournalPhase::RollbackAdmitted)) { + return SetError(error, + L"self-test-remove-journal-recovery-cutpoint", + ERROR_INVALID_DATA); + } + if (!RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 0U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 0U, 0U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 1U) || + RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::RemoveJournalExactAbsence, 1U, 2U) || + !RestorePriorBindingTopologyAdmitsMutation( + RestorePriorBindingPolicy::InstallRollbackReconcile, 1U, 1U)) { + return SetError(error, + L"self-test-remove-journal-binding-exact-absence-race", + ERROR_INVALID_DATA, + L"remove rollback admitted mutation after a concurrent root appeared at its inner snapshot"); + } + const uint64_t deadlineNow = CurrentUnixMilliseconds(); + const uint64_t expiredForwardDeadline = deadlineNow == 0U + ? 0U : deadlineNow - 1U; + const uint64_t freshRollbackDeadline = FreshRemoveRollbackDeadline(); + const uint64_t deadlineAfter = CurrentUnixMilliseconds(); + if (freshRollbackDeadline <= expiredForwardDeadline || + freshRollbackDeadline < SaturatingDeadlineAfter( + deadlineNow, kDriverRollbackCeilingMs) || + freshRollbackDeadline > SaturatingDeadlineAfter( + deadlineAfter, kDriverRollbackCeilingMs) || + SaturatingDeadlineAfter( + std::numeric_limits::max() - 1U, 2U) != + std::numeric_limits::max()) { + return SetError(error, + L"self-test-remove-journal-fresh-rollback-deadline", + ERROR_INVALID_DATA); + } + + RemoveJournalStateData failedForwardReturn = entered; + failedForwardReturn.phase = + RemoveJournalPhase::PackageRemovalReturned; + failedForwardReturn.callSucceeded = false; + failedForwardReturn.callError = ERROR_GEN_FAILURE; + failedForwardReturn.rebootRequired = true; + failedForwardReturn.freshRebootRequired = true; + failedForwardReturn.pendingRebootBootIdentifier = + std::string(32, '1'); + RemoveJournalStateData directRollbackPending = failedForwardReturn; + directRollbackPending.phase = + RemoveJournalPhase::RestoreRebootPending; + directRollbackPending.direction = RemoveJournalDirection::Rollback; + directRollbackPending.activePackageIndex = UINT32_MAX; + directRollbackPending.freshRebootRequired = false; + RemoveJournalStateData crossedDirectRollback = directRollbackPending; + crossedDirectRollback.phase = + RemoveJournalPhase::RollbackPackageEntered; + crossedDirectRollback.activePackageIndex = 0U; + crossedDirectRollback.rebootRequired = false; + crossedDirectRollback.pendingRebootBootIdentifier.clear(); + crossedDirectRollback.callSucceeded = true; + crossedDirectRollback.callError = ERROR_SUCCESS; + RemoveJournalStateData legacyRollbackAdmission = failedForwardReturn; + legacyRollbackAdmission.phase = RemoveJournalPhase::RollbackAdmitted; + legacyRollbackAdmission.direction = RemoveJournalDirection::Rollback; + legacyRollbackAdmission.activePackageIndex = UINT32_MAX; + legacyRollbackAdmission.freshRebootRequired = false; + RemoveJournalStateData legacySameBootPending = legacyRollbackAdmission; + legacySameBootPending.phase = RemoveJournalPhase::RestoreRebootPending; + RemoveJournalStateData legacyCrossedRollback = legacyRollbackAdmission; + legacyCrossedRollback.phase = + RemoveJournalPhase::RollbackPackageEntered; + legacyCrossedRollback.activePackageIndex = 0U; + legacyCrossedRollback.rebootRequired = false; + legacyCrossedRollback.pendingRebootBootIdentifier.clear(); + legacyCrossedRollback.callSucceeded = true; + legacyCrossedRollback.callError = ERROR_SUCCESS; + if (!ValidateRemoveJournalTransition( + &entered, failedForwardReturn, error) || + !ValidateRemoveJournalTransition( + &failedForwardReturn, directRollbackPending, error) || + !ValidateRemoveJournalTransition( + &directRollbackPending, crossedDirectRollback, error) || + !ValidateRemoveJournalTransition( + &failedForwardReturn, legacyRollbackAdmission, error) || + !ValidateRemoveJournalTransition( + &legacyRollbackAdmission, legacySameBootPending, error) || + !ValidateRemoveJournalTransition( + &legacyRollbackAdmission, legacyCrossedRollback, error)) { + return SetError(error, + L"self-test-remove-journal-rollback-reboot-admission", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code); + } + + RemoveJournalStateData bindingPrepared; + bindingPrepared.transactionId = std::string(64, '7'); + bindingPrepared.bootIdentifier = std::string(32, '8'); + bindingPrepared.prior.packages.push_back(package); + bindingPrepared.prior.devices.push_back(capturedTarget); + if (!ValidateRemoveJournalTransition(nullptr, bindingPrepared, error)) { + return false; + } + bindingPrepared.sequence = 1U; + bindingPrepared.previousDigest = std::string(64, '9'); + bindingPrepared.lastDigest = bindingPrepared.previousDigest; + RemoveJournalStateData deviceRemovalEntered = bindingPrepared; + deviceRemovalEntered.phase = + RemoveJournalPhase::DeviceRemovalEntered; + deviceRemovalEntered.deviceMutationEntered = true; + RemoveJournalStateData deviceRemovalReturned = deviceRemovalEntered; + deviceRemovalReturned.phase = + RemoveJournalPhase::DeviceRemovalReturned; + deviceRemovalReturned.rebootRequired = true; + deviceRemovalReturned.freshRebootRequired = true; + deviceRemovalReturned.pendingRebootBootIdentifier = + bindingPrepared.bootIdentifier; + if (!ValidateRemoveJournalTransition( + &bindingPrepared, deviceRemovalEntered, error) || + !ValidateRemoveJournalTransition( + &deviceRemovalEntered, deviceRemovalReturned, error) || + !CrossedRemoveRebootStillPendingRequiresManual( + deviceRemovalReturned.phase, + deviceRemovalReturned.callSucceeded, + deviceRemovalReturned.rebootRequired, + deviceRemovalReturned.freshRebootRequired, + false, RemoveRootShape::PendingRemoval)) { + return SetError(error, + L"self-test-remove-journal-device-returned-pending-cut", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code, + L"a crossed successful device-removal reboot return could request a second reboot before its pending cutpoint"); + } + RemoveJournalStateData bindingRollback = bindingPrepared; + bindingRollback.phase = RemoveJournalPhase::RollbackAdmitted; + bindingRollback.direction = RemoveJournalDirection::Rollback; + bindingRollback.callSucceeded = false; + bindingRollback.callError = ERROR_GEN_FAILURE; + RemoveJournalStateData bindingEntered = bindingRollback; + bindingEntered.phase = RemoveJournalPhase::RollbackBindingEntered; + bindingEntered.bindingMutationEntered = true; + bindingEntered.callSucceeded = true; + bindingEntered.callError = ERROR_SUCCESS; + RemoveJournalStateData bindingExactEffect = bindingEntered; + bindingExactEffect.phase = RemoveJournalPhase::ExactPriorRestored; + if (!ValidateRemoveJournalTransition( + &bindingPrepared, bindingRollback, error) || + !ValidateRemoveJournalTransition( + &bindingRollback, bindingEntered, error) || + !ReusesInterruptedRemoveBindingAdmission(bindingEntered.phase) || + !ValidateRemoveJournalTransition( + &bindingEntered, bindingExactEffect, error)) { + return SetError(error, + L"self-test-remove-journal-binding-cutpoint", + error->code == ERROR_SUCCESS ? ERROR_INVALID_DATA : error->code); + } + RemoveJournalStateData illegalSkip = returned; + illegalSkip.phase = RemoveJournalPhase::PackageRemovalCommitted; + illegalSkip.activePackageIndex = UINT32_MAX; + illegalSkip.packageCursor = 2U; + Error illegalError; + if (ValidateRemoveJournalTransition( + &returned, illegalSkip, &illegalError) || + illegalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-remove-journal-package-cutpoint", + ERROR_INVALID_DATA); + } + RemoveJournalStateData rebootReturn = returned; + rebootReturn.rebootRequired = true; + rebootReturn.freshRebootRequired = true; + rebootReturn.pendingRebootBootIdentifier = + std::string(32, '1'); + RemoveJournalStateData rebootPending = rebootReturn; + rebootPending.phase = RemoveJournalPhase::ForwardRebootPending; + rebootPending.activePackageIndex = UINT32_MAX; + rebootPending.freshRebootRequired = false; + if (!ValidateRemoveJournalTransition( + &entered, rebootReturn, error) || + !ValidateRemoveJournalTransition( + &rebootReturn, rebootPending, error)) { + return false; + } + RemoveJournalStateData illegalEpoch = rebootPending; + illegalEpoch.phase = RemoveJournalPhase::PackageRemovalEntered; + illegalEpoch.activePackageIndex = 0U; + illegalEpoch.pendingRebootBootIdentifier = + std::string(32, '2'); + illegalEpoch.freshRebootRequired = false; + illegalError = Error{}; + if (ValidateRemoveJournalTransition( + &rebootPending, illegalEpoch, &illegalError) || + illegalError.code == ERROR_SUCCESS) { + return SetError(error, + L"self-test-remove-journal-reboot-epoch", + ERROR_INVALID_DATA); + } + RemoveJournalStateData crossedEpoch = rebootPending; + crossedEpoch.phase = RemoveJournalPhase::PackageRemovalEntered; + crossedEpoch.activePackageIndex = 0U; + crossedEpoch.rebootRequired = false; + crossedEpoch.pendingRebootBootIdentifier.clear(); + if (!ValidateRemoveJournalTransition( + &rebootPending, crossedEpoch, error)) { + return SetError(error, + L"self-test-remove-journal-crossed-reboot-epoch", + ERROR_INVALID_DATA); + } + std::string payload; + RemoveJournalStateData canonical = prepared; + canonical.sequence = 0U; + canonical.previousDigest = std::string(kZeroSha256); + canonical.lastDigest.clear(); + if (!BuildRemoveJournalPayload(canonical, &payload, error)) { + return false; + } + std::string digest; + if (!Sha256Data(payload, &digest, error)) return false; + std::string envelope = "{\"schema\":2,\"kind\":"; + AppendJsonAsciiString(&envelope, kRemoveRecoveryKind); + envelope.append(",\"payloadSha256\":"); + AppendJsonAsciiString(&envelope, digest); + envelope.append(",\"payload\":"); + AppendJsonUtf8String(&envelope, payload); + envelope.append("}\n"); + RemoveJournalStateData roundTrip; + std::string roundTripDigest; + if (!ParseRemoveJournalEnvelope(envelope, + std::filesystem::path( + LR"(C:\ProgramData\VIIPER-UdeCx-RemoveTransactions\active-v2)"), + &roundTrip, &roundTripDigest, error) || + roundTripDigest != digest || + !SameRemoveJournalImmutableState(canonical, roundTrip)) { + return SetError(error, + L"self-test-remove-journal-canonical-roundtrip", + ERROR_INVALID_DATA); + } + struct RecoveryCase { + RemoveJournalPhase phase; + RemoveJournalDirection direction; + bool chain; + bool security; + bool sameBoot; + bool prior; + bool forward; + bool callSucceeded; + RemoveJournalRecoveryModelAction expected; + }; + const std::array cases{{ + {RemoveJournalPhase::Prepared, + RemoveJournalDirection::Forward, true, true, false, + true, false, true, + RemoveJournalRecoveryModelAction::RetirePrior}, + {RemoveJournalPhase::PackageRemovalEntered, + RemoveJournalDirection::Forward, true, true, false, + false, false, true, + RemoveJournalRecoveryModelAction::ContinueForward}, + {RemoveJournalPhase::PackageRemovalReturned, + RemoveJournalDirection::Forward, true, true, false, + false, false, false, + RemoveJournalRecoveryModelAction::AdmitRollback}, + {RemoveJournalPhase::ForwardRebootPending, + RemoveJournalDirection::Forward, true, true, true, + false, false, true, + RemoveJournalRecoveryModelAction::RebootPending}, + {RemoveJournalPhase::ForwardValidated, + RemoveJournalDirection::Forward, true, true, false, + false, true, true, + RemoveJournalRecoveryModelAction::RetireForward}, + {RemoveJournalPhase::RollbackAdmitted, + RemoveJournalDirection::Rollback, true, true, false, + false, false, true, + RemoveJournalRecoveryModelAction::ContinueRollback}, + {RemoveJournalPhase::RollbackAdmitted, + RemoveJournalDirection::Rollback, false, true, false, + true, false, true, + RemoveJournalRecoveryModelAction::Manual}, + }}; + for (const RecoveryCase& test : cases) { + if (ClassifyRemoveJournalRecoveryModel(test.phase, + test.direction, test.chain, test.security, + test.sameBoot, test.prior, test.forward, + test.callSucceeded) != test.expected) { + return SetError(error, + L"self-test-remove-journal-recovery-model", + ERROR_INVALID_DATA); + } + } + return true; +} + Outcome SelfTest(); Outcome Status() { @@ -12743,7 +17649,8 @@ Outcome Status() { Outcome SelfTest() { Outcome outcome; - if (!RunInstallJournalModelSelfTest(&outcome.error)) { + if (!RunInstallJournalModelSelfTest(&outcome.error) || + !RunRemoveJournalModelSelfTest(&outcome.error)) { return outcome; } InstallOptions brokerCommandOptions; @@ -12879,7 +17786,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "6796b0cf22a80984b283662a50a3b364c46218e37766a2e1880b38851b65d9ad") { + "b6bdcfe32dec8eb48bfde2f70b72542695588d2483ab71218636ce0b733aa067") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } @@ -13041,8 +17948,9 @@ Outcome SelfTest() { return outcome; } pristineStats.ReservedPorts = 1; - if (!RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[1]) || - !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2])) { + if (RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[1]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[3])) { SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, L"a legacy ABI inspected a counter outside its returned statistics record"); return outcome; @@ -13156,8 +18064,6 @@ Outcome SelfTest() { L"rollback lifecycle comparison is not exact for stopped or running roots"); return outcome; } - const std::filesystem::path recoveryRoot = - LR"(C:\Windows\Temp\VIIPER-UDE-rollback-self-test)"; if (!IsSafeRecoveryRelativePath( std::filesystem::path(L"0") / L"ViiperUde.inf") || IsSafeRecoveryRelativePath(std::filesystem::path(L"..") / L"escape") || @@ -13170,60 +18076,6 @@ Outcome SelfTest() { L"rollback recovery relative-path validation is not fail-closed"); return outcome; } - PackageInfo recoveryPackage; - recoveryPackage.infPath = LR"(C:\Windows\INF\oem42.inf)"; - recoveryPackage.publishedName = L"oem42.inf"; - recoveryPackage.version.parts = {0, 1, 0, 6}; - recoveryPackage.infSha256 = std::string(64, 'A'); - recoveryPackage.sysSha256 = std::string(64, 'B'); - recoveryPackage.catSha256 = std::string(64, 'C'); - DeviceState recoveryDevice; - recoveryDevice.instanceId = LR"(ROOT\VIIPERUDE\0000)"; - recoveryDevice.present = true; - recoveryDevice.started = true; - recoveryDevice.service = kServiceName; - recoveryDevice.publishedInf = recoveryPackage.publishedName; - recoveryDevice.version = recoveryPackage.version; - recoveryDevice.package = recoveryPackage; - Snapshot recoverySnapshot; - recoverySnapshot.devices.push_back(std::move(recoveryDevice)); - recoverySnapshot.packages.push_back(recoveryPackage); - std::vector recoveryBackups; - recoveryBackups.push_back(PackageBackup{ - recoveryPackage, - recoveryRoot / L"0", - recoveryRoot / L"0" / L"ViiperUde.inf", - {}}); - std::string firstRecoveryRecord; - std::string secondRecoveryRecord; - Error recoveryRecordError; - JsonValue recoveryRecordValue; - std::string recoveryRecordParseError; - if (!BuildRemoveRecoveryRecord( - recoverySnapshot, recoveryBackups, recoveryRoot, - &firstRecoveryRecord, &recoveryRecordError) || - !BuildRemoveRecoveryRecord( - recoverySnapshot, recoveryBackups, recoveryRoot, - &secondRecoveryRecord, &recoveryRecordError) || - firstRecoveryRecord != secondRecoveryRecord || - !JsonParser(firstRecoveryRecord).Parse( - &recoveryRecordValue, &recoveryRecordParseError) || - firstRecoveryRecord.find("\"automaticRestore\":false") == std::string::npos || - firstRecoveryRecord.find( - "\"requiredValidation\":[\"inf-signature\"") == std::string::npos || - firstRecoveryRecord.find("\"state\":\"prepared-remove-transaction\"") == - std::string::npos || - firstRecoveryRecord.find("\"packageIndex\":0") == std::string::npos || - firstRecoveryRecord.find("\"backupInf\":\"0/ViiperUde.inf\"") == - std::string::npos || - firstRecoveryRecord.find("C:") != std::string::npos) { - if (recoveryRecordError.code == ERROR_SUCCESS) { - SetError(&recoveryRecordError, L"self-test-recovery-record", ERROR_INVALID_DATA, - L"rollback recovery record is not canonical and relative-path bound"); - } - outcome.error = std::move(recoveryRecordError); - return outcome; - } if (!IsSafeTargetUserSid(L"S-1-5-21-1-2-3-1001") || IsSafeTargetUserSid(L"S-1-5-21-bad") || QuoteWindowsArgument(LR"(C:\Program Files\VIIPER\viiper.exe)") != @@ -13238,6 +18090,33 @@ Outcome SelfTest() { const std::string brokerPreflightFailure = "result=error operation=native-package-broker-commit changed=0 " "rollback=not-needed exitCode=4\n"; + const std::string brokerNestedReady = + "result=success operation=native-package-broker-commit changed=1 " + "rollback=not-needed exitCode=0\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=nested-ready " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; + const std::string brokerRollbackSettled = + "result=error operation=native-package-broker-commit changed=1 " + "rollback=succeeded exitCode=1\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=rollback-settled " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; + const std::string brokerManual = + "result=error operation=native-package-broker-commit changed=1 " + "rollback=failed exitCode=3\n" + "journal-proof operation=native-package-broker-commit " + "transactionId=11111111111111111111111111111111 " + "outerTransactionId=2222222222222222222222222222222222222222222222222222222222222222 " + "candidateSha256=3333333333333333333333333333333333333333333333333333333333333333 " + "state=manual " + "digest=4444444444444444444444444444444444444444444444444444444444444444\n"; BrokerCommitProof brokerProof; Error brokerProofError; if (!ParseBrokerCommitProof( @@ -13366,8 +18245,21 @@ Outcome SelfTest() { brokerProof = {}; brokerProofError = {}; if (!ParseBrokerCommitProof( - "result=error operation=native-package-broker-commit changed=1 " - "rollback=succeeded exitCode=1\n", + brokerNestedReady, ERROR_SUCCESS, + &brokerProof, &brokerProofError) || + !brokerProof.success || !brokerProof.changed || + brokerProof.driverRollbackAuthorized || + !brokerProof.hasJournalProof || + brokerProof.journalState != "nested-ready") { + SetError(&outcome.error, L"self-test-broker-proof", + ERROR_INVALID_DATA, + L"nested-ready broker proof was rejected or unbound"); + return outcome; + } + brokerProof = {}; + brokerProofError = {}; + if (!ParseBrokerCommitProof( + brokerRollbackSettled, 1, &brokerProof, &brokerProofError) || brokerProof.success || !brokerProof.changed || !brokerProof.driverRollbackAuthorized) { @@ -13378,8 +18270,7 @@ Outcome SelfTest() { brokerProof = {}; brokerProofError = {}; if (!ParseBrokerCommitProof( - "result=error operation=native-package-broker-commit changed=1 " - "rollback=failed exitCode=3\n", + brokerManual, 3, &brokerProof, &brokerProofError) || brokerProof.success || !brokerProof.changed || brokerProof.driverRollbackAuthorized) { @@ -13741,6 +18632,110 @@ bool ParseRemoveOptions(int argc, wchar_t** argv, RemoveOptions* options, Error* return true; } +bool CopyCanonicalSettlementHex( + const wchar_t* value, + size_t length, + std::string* output, + Error* error) { + const std::wstring wide = value == nullptr ? L"" : value; + if (wide.size() != length) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"settlement identity has the wrong length"); + } + output->clear(); + output->reserve(wide.size()); + for (wchar_t character : wide) { + if (!((character >= L'0' && character <= L'9') || + (character >= L'a' && character <= L'f'))) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"settlement identity must be canonical lowercase hexadecimal"); + } + output->push_back(static_cast(character)); + } + return true; +} + +bool ParseSettlementDeadline( + const wchar_t* value, + uint64_t* deadline, + Error* error) { + const std::wstring text = value == nullptr ? L"" : value; + if (text.empty() || text.size() > 20U || + !std::all_of(text.begin(), text.end(), [](wchar_t character) { + return character >= L'0' && character <= L'9'; + })) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must contain only Unix-millisecond digits"); + } + const wchar_t* begin = text.data(); + wchar_t* end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(begin, &end, 10); + if (errno == ERANGE || end == begin || + end != begin + text.size() || parsed == 0U) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER, + L"transaction deadline must be positive Unix milliseconds"); + } + *deadline = static_cast(parsed); + return ValidateTransactionDeadlineBudget(*deadline, error); +} + +bool ParseBrokerSettlementAckOptions( + int argc, + wchar_t** argv, + BrokerSettlementAckOptions* options, + Error* error) { + if (argc != 8 || _wcsicmp(argv[2], L"--request") != 0 || + _wcsicmp(argv[4], L"--request-sha256") != 0 || + _wcsicmp(argv[6], + L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->requestPath = argv[3]; + return CopyCanonicalSettlementHex( + argv[5], 64U, &options->requestSha256, error) && + ParseSettlementDeadline(argv[7], + &options->transactionDeadlineUnixMs, error); +} + +bool ParseBrokerSettlementDiscardOptions( + int argc, + wchar_t** argv, + BrokerSettlementDiscardOptions* options, + Error* error) { + if (argc != 20 || + _wcsicmp(argv[2], L"--broker-transaction-id") != 0 || + _wcsicmp(argv[4], L"--broker-settled-digest") != 0 || + _wcsicmp(argv[6], L"--driver-transaction-id") != 0 || + _wcsicmp(argv[8], L"--driver-settled-digest") != 0 || + _wcsicmp(argv[10], L"--settlement-nonce") != 0 || + _wcsicmp(argv[12], L"--request-sha256") != 0 || + _wcsicmp(argv[14], L"--broker-final-receipt") != 0 || + _wcsicmp(argv[16], + L"--broker-final-receipt-sha256") != 0 || + _wcsicmp(argv[18], + L"--transaction-deadline-unix-ms") != 0) { + return SetError(error, L"arguments", ERROR_INVALID_PARAMETER); + } + options->brokerFinalReceiptPath = argv[15]; + return CopyCanonicalSettlementHex(argv[3], 32U, + &options->brokerTransactionId, error) && + CopyCanonicalSettlementHex(argv[5], 64U, + &options->brokerDigest, error) && + CopyCanonicalSettlementHex(argv[7], 64U, + &options->driverTransactionId, error) && + CopyCanonicalSettlementHex(argv[9], 64U, + &options->driverDigest, error) && + CopyCanonicalSettlementHex(argv[11], 64U, + &options->settlementNonce, error) && + CopyCanonicalSettlementHex(argv[13], 64U, + &options->requestSha256, error) && + CopyCanonicalSettlementHex(argv[17], 64U, + &options->brokerFinalReceiptSha256, error) && + ParseSettlementDeadline(argv[19], + &options->transactionDeadlineUnixMs, error); +} + void Usage() { std::wcerr << L"usage:\n" @@ -13764,6 +18759,8 @@ void Usage() { L"--transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe remove [--transaction-deadline-unix-ms ]\n" << L" ViiperUdeCtl.exe recover [--transaction-deadline-unix-ms ]\n" + << L" ViiperUdeCtl.exe broker-settlement-ack --request --request-sha256 <64 hex> --transaction-deadline-unix-ms \n" + << L" ViiperUdeCtl.exe broker-settlement-discard --broker-transaction-id <32 hex> --broker-settled-digest <64 hex> --driver-transaction-id <64 hex> --driver-settled-digest <64 hex> --settlement-nonce <64 hex> --request-sha256 <64 hex> --broker-final-receipt --broker-final-receipt-sha256 <64 hex> --transaction-deadline-unix-ms \n" << L" ViiperUdeCtl.exe status\n" << L" ViiperUdeCtl.exe self-test\n"; } @@ -13772,7 +18769,63 @@ void Usage() { int RunViiperUdeCtl(int argc, wchar_t** argv) { ClearActiveRecoveryEvidence(); + ClearRemoveRetirementWarning(); gTransactionMutationStarted = false; + if (argc >= 2 && + _wcsicmp(argv[1], L"broker-settlement-ack") == 0) { + BrokerSettlementAckOptions options; + Error error; + if (!ParseBrokerSettlementAckOptions( + argc, argv, &options, &error)) { + Usage(); + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"broker-settlement-ack", outcome); + return static_cast(outcome.exitCode); + } + BrokerSettlementRequestData receipt; + std::string driverFinalDigest; + if (!AcknowledgeBrokerOuterSettlement( + options, &receipt, &driverFinalDigest, &error)) { + Outcome outcome; + outcome.changed = gTransactionMutationStarted; + outcome.error = std::move(error); + outcome.rollback = outcome.changed ? L"failed" : L"not-needed"; + outcome.exitCode = outcome.changed + ? ExitCode::RollbackFailed : ExitCode::PreflightRejected; + EmitOutcome(L"broker-settlement-ack", outcome); + return static_cast(outcome.exitCode); + } + EmitBrokerSettlementAck(receipt, driverFinalDigest); + return 0; + } + if (argc >= 2 && + _wcsicmp(argv[1], L"broker-settlement-discard") == 0) { + BrokerSettlementDiscardOptions options; + Error error; + if (!ParseBrokerSettlementDiscardOptions( + argc, argv, &options, &error)) { + Usage(); + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::Usage; + EmitOutcome(L"broker-settlement-discard", outcome); + return static_cast(outcome.exitCode); + } + bool discarded = false; + bool retained = false; + if (!DiscardBrokerSettlementTombstone( + options, &discarded, &retained, &error)) { + Outcome outcome; + outcome.error = std::move(error); + outcome.exitCode = ExitCode::PreflightRejected; + EmitOutcome(L"broker-settlement-discard", outcome); + return static_cast(outcome.exitCode); + } + EmitBrokerSettlementDiscard(options, discarded, retained); + return 0; + } if (argc >= 3 && (_wcsicmp(argv[1], L"install") == 0 || _wcsicmp(argv[1], L"verify") == 0)) { InstallOptions options; @@ -13853,7 +18906,9 @@ const wchar_t* ExceptionOperation(int argc, wchar_t** argv) noexcept { return L"unknown"; } for (const wchar_t* operation : - {L"install", L"verify", L"remove", L"recover", L"status", L"self-test"}) { + {L"install", L"verify", L"remove", L"recover", L"status", + L"self-test", L"broker-settlement-ack", + L"broker-settlement-discard"}) { if (_wcsicmp(argv[1], operation) == 0) { return operation; } From 7270672f92e3ce935452490e35554bd21b1cd141 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 08:01:02 -0500 Subject: [PATCH 239/240] Update native UDE compatibility gate --- .../Test-ViiperUdeTargetCompatibility.ps1 | 110 ++++++++++++++++-- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 index 345299d9..50373d40 100644 --- a/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 +++ b/native/udecx/tools/Test-ViiperUdeTargetCompatibility.ps1 @@ -45,6 +45,35 @@ function Get-SingleProjectValue([string]$elementName) { return $nodes[0].InnerText.Trim() } +function Get-CFunctionBody( + [string]$Source, + [string]$FunctionName +) { + $escapedName = [regex]::Escape($FunctionName) + $definitionPattern = + "(?ms)^[ \t]*(?:static\s+)?(?:NTSTATUS|VOID)\s+$escapedName\s*\([^;{}]*?\)\s*\{" + $definitions = @([regex]::Matches($Source, $definitionPattern)) + if ($definitions.Count -ne 1) { + throw "Expected exactly one C definition for $FunctionName; found $($definitions.Count)." + } + + $openingBrace = $definitions[0].Index + $definitions[0].Length - 1 + $depth = 0 + for ($index = $openingBrace; $index -lt $Source.Length; $index++) { + if ($Source[$index] -eq '{') { + $depth++ + } elseif ($Source[$index] -eq '}') { + $depth-- + if ($depth -eq 0) { + return $Source.Substring( + $openingBrace + 1, + $index - $openingBrace - 1) + } + } + } + throw "Unbalanced C definition for $FunctionName." +} + $driverDate = Get-SingleProjectValue 'ViiperUdeDriverDate' $driverVersion = Get-SingleProjectValue 'ViiperUdeDriverVersion' $parsedDriverDate = [DateTime]::MinValue @@ -150,16 +179,62 @@ $allDriverCSource = (Get-ChildItem -LiteralPath $driverSourceDirectory -Filter ' ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n" foreach ($requiredTraceContract in @( - 'DECLSPEC_ALIGN(8) VIIPER_UDE_LIFECYCLE_TRACE_RECORD', - 'LifecycleTrace[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', + '#define VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS 64', + 'typedef struct VIIPER_UDE_LIFECYCLE_TRACE_SHARD', + 'DECLSPEC_ALIGN(SYSTEM_CACHE_ALIGNMENT_SIZE) volatile LONG64 WriteSequence;', + 'volatile LONG64 SlotStates[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', + 'VIIPER_UDE_LIFECYCLE_TRACE_RECORD Records[VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY];', 'volatile LONG64 LifecycleTraceSequence;', + 'volatile LONG LifecycleTraceStatus;', + 'WDFMEMORY LifecycleTraceStorage;', + 'VIIPER_UDE_LIFECYCLE_TRACE_SHARD *LifecycleTraceShards;', + 'ULONG LifecycleTraceShardCount;', '#define VIIPER_TRACE_LIFECYCLE')) { if (-not $header.Contains($requiredTraceContract)) { throw "Missing bounded lifecycle-flight-recorder contract: $requiredTraceContract" } } -if ($traceSource -notmatch 'InterlockedIncrement64[\s\S]*KeQueryPerformanceCounter[\s\S]*_ReturnAddress[\s\S]*KeGetCurrentIrql[\s\S]*InterlockedExchange64' -or - $traceSource -match 'ExAllocatePool|WdfMemoryCreate|WdfSpinLockAcquire|WdfWaitLockAcquire|KeWaitForSingleObject') { +$traceInitialize = Get-CFunctionBody $traceSource 'ViiperInitializeLifecycleTrace' +$traceHot = Get-CFunctionBody $traceSource 'ViiperTraceLifecycle' +if ($traceInitialize -notmatch + '(?s)WdfMemoryCreate\s*\(\s*&attributes\s*,\s*NonPagedPoolNx\s*,\s*0x56495554\s*,\s*storageSize\s*,\s*&controllerContext->LifecycleTraceStorage\s*,\s*&rawStorage\s*\)' -or + $traceInitialize -notmatch + '(?s)shardCount\s*=\s*maximumProcessors\s*>\s*VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS\s*\?\s*VIIPER_UDE_LIFECYCLE_TRACE_MAX_SHARDS\s*:\s*maximumProcessors\s*;' -or + $traceInitialize -notmatch + '(?s)RtlZeroMemory\s*\(\s*rawStorage\s*,\s*storageSize\s*\).*?controllerContext->LifecycleTraceShards\s*=.*?controllerContext->LifecycleTraceShardCount\s*=\s*shardCount\s*;') { + throw 'Lifecycle trace initialization must allocate, clear, and publish exact nonpaged shard storage.' +} +foreach ($requiredHotContract in @( + 'InterlockedIncrement64(&shard->WriteSequence)', + 'InterlockedCompareExchange64(', + 'VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD', + 'InterlockedIncrement64(', + '&controllerContext->LifecycleTraceSequence', + 'KeQueryPerformanceCounter(NULL)', + '_ReturnAddress()', + 'KeGetCurrentIrql()', + 'InterlockedExchange64(')) { + if (-not $traceHot.Contains($requiredHotContract)) { + throw "Lifecycle tracing hot path is missing: $requiredHotContract" + } +} +$statusUpdates = @([regex]::Matches( + $traceHot, + 'InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,')) +if ($statusUpdates.Count -ne 2 -or + $traceHot -notmatch + 'InterlockedIncrement64\s*\(\s*&shard->WriteSequence\s*\)' -or + $traceHot -notmatch + 'InterlockedCompareExchange64\s*\(\s*slotState\s*,\s*claimedSlotState\s*,\s*observedSlotState\s*\)' -or + $traceHot -notmatch + 'InterlockedIncrement64\s*\(\s*&controllerContext->LifecycleTraceSequence\s*\)' -or + $traceHot -notmatch + '(?s)Event\s*>=\s*VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG\s*&&\s*Event\s*<=\s*VIIPER_UDE_TRACE_OWNER_RUNDOWN_WATCHDOG.*?InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,\s*VIIPER_UDE_LIFECYCLE_TRACE_STATUS_WATCHDOG_FIRED\s*\)' -or + $traceHot -notmatch + '(?s)\(observedSlotState\s*&\s*1\)\s*!=\s*0\s*\|\|.*?>=\s*localSequence.*?InterlockedOr\s*\(\s*&controllerContext->LifecycleTraceStatus\s*,\s*VIIPER_UDE_LIFECYCLE_TRACE_STATUS_DROPPED_RECORD\s*\)' -or + $traceHot -notmatch + '(?s)InterlockedExchange64\s*\(\s*\(volatile LONG64 \*\)&record->PublishedSequence\s*,\s*0\s*\)\s*;\s*KeMemoryBarrier\s*\(\s*\)\s*;.*?KeMemoryBarrier\s*\(\s*\)\s*;\s*\(VOID\)InterlockedExchange64\s*\(\s*\(volatile LONG64 \*\)&record->PublishedSequence\s*,\s*\(LONG64\)sequence\s*\)\s*;\s*KeMemoryBarrier\s*\(\s*\)\s*;\s*\(VOID\)InterlockedExchange64\s*\(\s*slotState\s*,\s*\(LONG64\)\(localSequence\s*<<\s*1\)\s*\)\s*;' -or + $traceHot -match 'ExAllocatePool|WdfMemoryCreate|WdfSpinLockAcquire|WdfWaitLockAcquire|KeWaitForSingleObject') { throw 'Lifecycle tracing must remain preallocated, nonblocking, timestamped, and source-addressable.' } @@ -197,9 +272,23 @@ foreach ($requiredHeaderContract in @( throw "Missing native teardown contract in ViiperUde.h: $requiredHeaderContract" } } -if ($controllerSource -notmatch - 'KeWaitForSingleObject\s*\(\s*&context->FileCleanupsDrained') { - throw 'Terminal rundown must join any file cleanup admitted before ShuttingDown.' +$controllerRundownBody = Get-CFunctionBody $controllerSource 'ViiperWaitForControllerRundown' +if ($controllerRundownBody -notmatch + '(?s)watchdogWait\.QuadPart\s*=\s*-\s*\(LONGLONG\)VIIPER_UDE_RUNDOWN_WATCHDOG_INTERVAL_100NS\s*;.*?for\s*\(\s*;\s*;\s*\).*?KeWaitForSingleObject\s*\(\s*Event\s*,.*?&watchdogWait\s*\).*?waitStatus\s*!=\s*STATUS_TIMEOUT.*?return\s*;.*?VIIPER_TRACE_LIFECYCLE\s*\(.*?WatchdogEvent\s*,.*?STATUS_IO_TIMEOUT\s*,.*?InterlockedCompareExchange\s*\(\s*ActiveCounter\s*,\s*0\s*,\s*0\s*\)') { + throw 'Controller rundown must wait to completion while recording every bounded watchdog interval.' +} +$terminalCleanupBody = Get-CFunctionBody $controllerSource 'ViiperEvtDeviceSelfManagedIoCleanup' +$fileCleanupBody = Get-CFunctionBody $controllerSource 'ViiperEvtFileCleanup' +$deviceAddBody = Get-CFunctionBody $controllerSource 'ViiperEvtDeviceAdd' +if ($deviceAddBody -notmatch + 'KeInitializeEvent\s*\(\s*&context->FileCleanupsDrained\s*,\s*NotificationEvent\s*,\s*TRUE\s*\)\s*;' -or + $fileCleanupBody -notmatch + '(?s)WdfWaitLockAcquire\s*\(\s*context->OwnerLock\s*,\s*NULL\s*\)\s*;\s*if\s*\(\s*InterlockedCompareExchange\s*\(\s*&context->ShuttingDown\s*,\s*0\s*,\s*0\s*\)\s*==\s*0\s*&&\s*context->OwnerFile\s*==\s*FileObject\s*\)\s*\{.*?InterlockedCompareExchange\s*\(\s*&context->ActiveFileCleanups\s*,\s*0\s*,\s*0\s*\)\s*==\s*0.*?KeClearEvent\s*\(\s*&context->FileCleanupsDrained\s*\).*?InterlockedIncrement\s*\(\s*&context->ActiveFileCleanups\s*\).*?cleanupAdmitted\s*=\s*TRUE\s*;.*?\}\s*WdfWaitLockRelease\s*\(\s*context->OwnerLock\s*\)\s*;' -or + $fileCleanupBody -notmatch + '(?s)if\s*\(\s*cleanupAdmitted\s*\)\s*\{\s*WdfWaitLockAcquire\s*\(\s*context->OwnerLock\s*,\s*NULL\s*\)\s*;\s*remainingCleanups\s*=\s*InterlockedDecrement\s*\(\s*&context->ActiveFileCleanups\s*\)\s*;.*?if\s*\(\s*remainingCleanups\s*==\s*0\s*\)\s*\{\s*KeSetEvent\s*\(\s*&context->FileCleanupsDrained\s*,\s*IO_NO_INCREMENT\s*,\s*FALSE\s*\)\s*;\s*\}\s*WdfWaitLockRelease\s*\(\s*context->OwnerLock\s*\)\s*;\s*\}' -or + $terminalCleanupBody -notmatch + '(?s)InterlockedExchange\s*\(\s*&context->ShuttingDown\s*,\s*TRUE\s*\)\s*;.*?ViiperWaitForControllerRundown\s*\(\s*Device\s*,\s*&context->FileCleanupsDrained\s*,\s*VIIPER_UDE_TRACE_CONTROLLER_RUNDOWN_WATCHDOG\s*,\s*&context->ActiveFileCleanups\s*\)\s*;.*?WdfIoQueuePurgeSynchronously\s*\(\s*context->DefaultQueue\s*\)') { + throw 'Terminal rundown must account admitted file cleanup, close admission, join with watchdog telemetry, and only then purge queues.' } if ($controllerSource -notmatch 'pnpCallbacks\.EvtDeviceSelfManagedIoInit\s*=\s*ViiperEvtDeviceSelfManagedIoInit\s*;' -or @@ -629,11 +718,8 @@ foreach ($forbiddenCall in $forbiddenCleanupCalls) { if ($controllerCleanupMatch.Groups['body'].Value -match '\b(?:Wdf|Udecx)[A-Za-z0-9_]*\s*\(') { throw 'Controller EvtCleanup must not call any WDF/UdeCx child-backed API.' } -$selfManagedCleanupMatch = [regex]::Match( - $controllerSource, - '(?ms)^VOID\s+ViiperEvtDeviceSelfManagedIoCleanup\s*\([^)]*\)\s*\{(?.*?)^\}') -if (-not $selfManagedCleanupMatch.Success -or - $selfManagedCleanupMatch.Groups['body'].Value -notmatch +$selfManagedCleanupMatch = $terminalCleanupBody +if ($selfManagedCleanupMatch -notmatch 'ViiperPurgeOwnerOperations[\s\S]*ViiperDrainControllerEndpointOperations[\s\S]*BrokerOperationsDrained[\s\S]*ViiperDrainUrbCompletions[\s\S]*PendingOperations[\s\S]*PendingCompletions[\s\S]*CompletionQueue[\s\S]*CompletionDpcActive[\s\S]*ViiperBeginControllerShutdown') { throw 'Terminal rundown must join VIIPER-owned endpoint work and the completion DPC before asynchronously consuming children.' } From cece30774e9df8183dc74fe6cd25950f1a74d021 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 15 Aug 2026 10:16:10 -0500 Subject: [PATCH 240/240] feat(native-ude): publish exact device correlation receipts --- _testing/e2e/latency_gate_windows_test.go | 19 +- docs/api/overview.md | 83 ++++- .../native-udecx-package-install.md | 3 +- docs/architecture/native-udecx-signing.md | 4 +- docs/architecture/native-udecx.md | 8 + examples/go/virtual_ds4/main.go | 4 +- examples/go/virtual_ds4_cli/main.go | 4 +- examples/go/virtual_ds_and_edge_cli/main.go | 4 +- examples/go/virtual_keyboard/main.go | 4 +- examples/go/virtual_mouse/main.go | 4 +- examples/go/virtual_ns2pro/main.go | 4 +- examples/go/virtual_x360_pad/main.go | 4 +- internal/cmd/native_package_contract_test.go | 3 +- .../cmd/native_service_install_windows.go | 8 + .../native_service_install_windows_test.go | 16 + internal/cmd/native_transport_windows.go | 3 + internal/cmd/server.go | 1 + internal/codegen/scanner/dtos_test.go | 15 +- internal/codegen/scanner/payload.go | 9 +- internal/codegen/scanner/routes_test.go | 9 + .../server/api/device_stream_ownership.go | 135 +++++++- .../api/device_stream_ownership_test.go | 89 +++++ internal/server/api/handler/bus_device_add.go | 24 +- .../handler/bus_device_add_internal_test.go | 159 +++++++++ .../server/api/handler/bus_device_add_test.go | 6 +- .../server/api/handler/bus_device_remove.go | 5 + .../api/handler/bus_device_remove_native.go | 97 ++++++ .../server/api/handler/bus_devices_list.go | 28 +- .../api/handler/bus_devices_list_test.go | 4 +- internal/server/api/handler/bus_remove.go | 12 +- internal/server/api/handler/ping_test.go | 2 + internal/server/api/server.go | 52 ++- .../native_playstation_transport_soak_test.go | 15 +- internal/server/usb/native_transport_test.go | 307 ++++++++++++++++- internal/server/usb/server.go | 313 ++++++++++++++---- internal/transport/udecx/client_windows.go | 232 ++++++++++++- .../transport/udecx/client_windows_test.go | 38 ++- .../udecx/driver_lifecycle_contract_test.go | 42 +++ internal/transport/udecx/host.go | 112 +++++-- internal/transport/udecx/host_test.go | 118 ++++++- internal/transport/udecx/protocol.go | 86 ++++- .../transport/udecx/protocol_contract_test.go | 23 +- internal/transport/udecx/protocol_test.go | 83 ++++- native/udecx/driver/Device.c | 25 +- native/udecx/driver/Ioctl.c | 3 +- native/udecx/driver/ViiperUde.vcxproj | 4 +- native/udecx/include/ViiperUdeProtocol.h | 32 +- native/udecx/package/ViiperUde.inf | 2 +- .../tools/New-ViiperUdeAttestationPackage.ps1 | 4 +- .../tools/New-ViiperUdeLocalTestPackage.ps1 | 6 +- .../tools/Test-ViiperUdeCtlTransaction.ps1 | 1 + .../tools/Test-ViiperUdeReleaseBundle.ps1 | 6 +- .../tools/Test-ViiperUdeSignedPackage.ps1 | 6 +- native/udecx/tools/ViiperUdeCtl.cpp | 35 +- viiperclient/client.go | 54 ++- viiperclient/client_test.go | 69 ++++ viipertypes/native_remove_test.go | 36 ++ viipertypes/structs.go | 174 +++++++++- 58 files changed, 2382 insertions(+), 266 deletions(-) create mode 100644 internal/server/api/handler/bus_device_remove_native.go create mode 100644 viipertypes/native_remove_test.go diff --git a/_testing/e2e/latency_gate_windows_test.go b/_testing/e2e/latency_gate_windows_test.go index 3f656663..85ef0550 100644 --- a/_testing/e2e/latency_gate_windows_test.go +++ b/_testing/e2e/latency_gate_windows_test.go @@ -597,11 +597,12 @@ func runLiveLatencyTransport( return result } var ( - busCreated bool - deviceID string - gamepadID sdl.GamepadID - gamepad *sdl.Gamepad - stream *viiperclient.DeviceStream + busCreated bool + deviceID string + deviceRegistration *viipertypes.Device + gamepadID sdl.GamepadID + gamepad *sdl.Gamepad + stream *viiperclient.DeviceStream ) defer func() { if stream != nil { @@ -612,15 +613,16 @@ func runLiveLatencyTransport( if gamepad != nil { gamepad.Close() } - if deviceID != "" { + if deviceRegistration != nil { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _, removeErr := server.client.DeviceRemoveCtx(cleanupCtx, 1, deviceID) + _, removeErr := server.client.DeviceRemoveRegisteredCtx(cleanupCtx, deviceRegistration) cancel() if removeErr != nil { appendLatencyFailure(&result, "remove API device: %v", removeErr) } } - if busCreated { + if busCreated && (deviceRegistration == nil || + deviceRegistration.Transport != "native-ude") { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) _, removeErr := server.client.BusRemoveCtx(cleanupCtx, 1) cancel() @@ -703,6 +705,7 @@ func runLiveLatencyTransport( return result } deviceID = device.DevID + deviceRegistration = device result.Device = latency.DeviceProof{ BusID: 1, DeviceID: device.DevID, Type: device.Type, VendorID: controller.vendorID, ProductID: controller.productID, USBIPPort: device.USBIPPort, diff --git a/docs/api/overview.md b/docs/api/overview.md index 8640e248..f858a843 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -191,10 +191,12 @@ kept matched. "ready": true, "nativeUde": { "abiMajor": 1, - "abiMinor": 10, - "capabilities": 13, - "expectedDriverPackageVersion": "0.1.0.26", + "abiMinor": 14, + "capabilities": 61, + "expectedDriverPackageVersion": "0.1.0.38", "loadedDriverBuildIdentity": "<64 lowercase hexadecimal characters returned by the loaded kernel>", + "controllerSessionId": "", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", "maxDevices": 32, "maxDescriptorBytes": 262144, "maxTransferBytes": 1048576, @@ -246,10 +248,19 @@ kept matched. "devId": "1", "vid": "0x045e", "pid": "0x028e", - "type": "xbox360" - "deviceSpecific": { - "subType": 1 - } + "type": "xbox360", + "deviceSpecific": { + "subType": 1 + }, + "transport": "native-ude", + "nativeUde": { + "deviceId": "4294967297", + "deviceGeneration": 1, + "controllerSessionId": "123456789", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", + "usb20PortNumber": 1, + "usb30PortNumber": 0 + } } ] } @@ -282,10 +293,19 @@ kept matched. "devId": "1", "vid": "0x045e", "pid": "0x028e", - "type": "xbox360", - "deviceSpecific": { - "subType":7 - } + "type": "xbox360", + "deviceSpecific": { + "subType":7 + }, + "transport": "native-ude", + "nativeUde": { + "deviceId": "4294967297", + "deviceGeneration": 1, + "controllerSessionId": "123456789", + "controllerInstanceId": "ROOT\\VIIPERUDE\\0000", + "usb20PortNumber": 1, + "usb30PortNumber": 0 + } } ``` @@ -293,16 +313,43 @@ kept matched. After add, the server starts a connect timer (default `5s`). You must open a device stream before the timeout expires, otherwise the device is auto-removed. !!! info "Auto-attach" - If [auto-attach](../cli/server.md#api.auto-attach-local-client) is enabled (default), the server automatically attaches the new device to a local USBIP client on the same host (localhost only). Failures are logged but do not affect the API response. + In explicit USB/IP mode, [auto-attach](../cli/server.md#api.auto-attach-local-client) can attach the new device to a local USBIP client. Native UDE mode never performs USB/IP attach/detach; its response instead carries the authenticated `nativeUde` ownership tuple. Exactly one of `usb20PortNumber` and `usb30PortNumber` is nonzero. Treat `deviceId` and `controllerSessionId` as decimal strings rather than JSON numbers, and fail closed if any native ownership field is absent or inconsistent with `ping`. #### `bus/{id}/remove ` {.toc-anchor} -??? info "bus/{id}/remove - Remove a device from a bus" - **Request:** `bus/1/remove 1` - - **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) - - **Response:** `{ "busId": , "devId": "" }` +??? info "bus/{id}/remove - Remove a device from a bus" + **Request:** `bus/1/remove 1` + + **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) + + This legacy ID-only endpoint is available only in explicit USB/IP mode. + Native UDE clients must use `remove-native`; an ID-only native removal is + rejected because IDs can be reused after a controller restart. + + **Response:** `{ "busId": , "devId": "" }` + +#### `bus/{id}/remove-native ` {.toc-anchor} + +??? info "bus/{id}/remove-native - Conditionally remove one exact native UDE device" + **Request:** + + ```text + bus/1/remove-native {"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"123456789","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}} + ``` + + The payload must echo the exact `devId`, `transport`, and complete + `nativeUde` receipt returned by `add` or `list`. Field names, decimal string + encodings, and object shape are canonical; duplicate, missing, unknown, or + trailing JSON fields are rejected. + + VIIPER compares the receipt with the current native registration while + holding the same lifecycle lock used for unregistration. A stale receipt + returns `409 Conflict` and removes nothing. Clients must treat that result as + a retired lifetime and must never retry through the ID-only endpoint. A + successful removal owns empty-bus cleanup; clients must not issue a separate + `bus/remove` operation. + + **Response:** `{ "busId": 1, "devId": "1" }` ### Device Control / Feedback {#device-control--feedback} diff --git a/docs/architecture/native-udecx-package-install.md b/docs/architecture/native-udecx-package-install.md index 5ad9289a..98466d68 100644 --- a/docs/architecture/native-udecx-package-install.md +++ b/docs/architecture/native-udecx-package-install.md @@ -109,7 +109,8 @@ of the source-provenance evidence without becoming a user-machine dependency. 6. The nested command accepts a true no-op only when the protected service/image/credential state is canonical, no legacy owner is live, the service PID is stable, and authenticated `ping` proves `Ready=true`, ABI - 1.13, the exact capability mask, package version, and loaded-kernel build + 1.14, the exact capability mask, package version, controller session and + instance identities, and loaded-kernel build identity. Otherwise it performs the journaled repair. Exact forward health ends at durable `nested-ready`; it does not delete rollback material or claim outer success. A broker failure restores SCM, credential, image, legacy diff --git a/docs/architecture/native-udecx-signing.md b/docs/architecture/native-udecx-signing.md index 421ae1e0..7ce7573f 100644 --- a/docs/architecture/native-udecx-signing.md +++ b/docs/architecture/native-udecx-signing.md @@ -17,7 +17,7 @@ disposable-machine acknowledgement, elevation, the exact source revision and interactive-user SID, and a current boot entry reporting `TESTSIGNING Yes`. It imports only the artifact-bound certificate and then executes the normal package-to-service transaction through `viiper.exe native-package-install`; -the helper is never invoked as a standalone mutation. Authenticated ABI 1.13, +the helper is never invoked as a standalone mutation. Authenticated ABI 1.14, capability, package-version, and loaded-kernel identity health must succeed before the transaction commits. @@ -81,7 +81,7 @@ mode. That mode rejects the attestation EKU and requires a release-eligible names only `ViiperUde.cat`. - The schema-2 submission manifest identifies the exact reviewed bits and the SHA-256 build identity derived from source revision, four-part DriverVer, - ABI 1.13, and the exact capability mask. That same identity is compiled into + ABI 1.14, and the exact capability mask. That same identity is compiled into the SYS that the signed catalog seals and is returned by the loaded kernel. - Returned packages contain only the canonical INF, SYS, PDB, and CAT in one directory. The unchanged INF/PDB must match the submission manifest, and diff --git a/docs/architecture/native-udecx.md b/docs/architecture/native-udecx.md index d7ba2568..15bd522d 100644 --- a/docs/architecture/native-udecx.md +++ b/docs/architecture/native-udecx.md @@ -89,6 +89,14 @@ The kernel driver owns only Windows USB presentation and transfer lifecycle. 32-bit word remains explicitly reserved. Sizes never depend on compiler tail padding, and every field offset is guarded so a same-size reorder cannot silently desynchronize the C and Go layouts. +13. ABI 1.14 returns a fixed 40-byte receipt only after the exact + `UdecxUsbDevicePlugIn` succeeds. The receipt echoes device identity and + speed and carries the authoritative USB 2 or USB 3 controller port. User + mode binds it to the exact controller instance obtained from the opened + SetupAPI interface and to that file session's nonzero negotiated driver + nonce. Authenticated add/list/ping responses expose these values as an + opaque ownership tuple; consumers must not infer ownership from VID/PID, + enumeration order, or stream generation. ## Kernel/user transport diff --git a/examples/go/virtual_ds4/main.go b/examples/go/virtual_ds4/main.go index 9e4b8c97..66f50189 100644 --- a/examples/go/virtual_ds4/main.go +++ b/examples/go/virtual_ds4/main.go @@ -68,12 +68,12 @@ func main() { fmt.Printf("Created and connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_ds4_cli/main.go b/examples/go/virtual_ds4_cli/main.go index 42ad99c9..dde03856 100644 --- a/examples/go/virtual_ds4_cli/main.go +++ b/examples/go/virtual_ds4_cli/main.go @@ -93,10 +93,10 @@ func main() { fmt.Printf("Connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { _, _ = api.BusRemoveCtx(ctx, busID) } }() diff --git a/examples/go/virtual_ds_and_edge_cli/main.go b/examples/go/virtual_ds_and_edge_cli/main.go index 6a8aaa65..925bc405 100644 --- a/examples/go/virtual_ds_and_edge_cli/main.go +++ b/examples/go/virtual_ds_and_edge_cli/main.go @@ -102,10 +102,10 @@ func main() { fmt.Printf("Connected to %s device %s on bus %d\n", deviceType, addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { _, _ = api.BusRemoveCtx(ctx, busID) } }() diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 1399e3b1..2456cf50 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -68,12 +68,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 05d308ee..c590a497 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -65,12 +65,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_ns2pro/main.go b/examples/go/virtual_ns2pro/main.go index 2b65db9c..4de44d21 100644 --- a/examples/go/virtual_ns2pro/main.go +++ b/examples/go/virtual_ns2pro/main.go @@ -46,12 +46,12 @@ func main() { fmt.Printf("Created and connected to Switch 2 Pro device %s on bus %d\n", addResp.DevID, addResp.BusID) defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 026d43e6..2e1e3e34 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -68,12 +68,12 @@ func main() { // Cleanup on exit defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + if _, err := api.DeviceRemoveRegisteredCtx(ctx, addResp); err != nil { fmt.Printf("DeviceRemove error: %v\n", err) } else { fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } - if createdBus { + if createdBus && addResp.Transport != "native-ude" { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { fmt.Printf("BusRemove error: %v\n", err) } else { diff --git a/internal/cmd/native_package_contract_test.go b/internal/cmd/native_package_contract_test.go index c9327e5a..48bf32dc 100644 --- a/internal/cmd/native_package_contract_test.go +++ b/internal/cmd/native_package_contract_test.go @@ -203,7 +203,8 @@ func TestNativePackageProductionSourceContract(t *testing.T) { "PreparePreinstalledDriverOnDevice(", "CommitPreparedDriverBinding(", "requirePristineRuntime", "RequiresDriverMutation(", "RequiresPristineRuntimeProof(", "RuntimeStatsArePristine(", - "AbiCompatibilityProfile", "{13, 29, 152, true}", + "AbiCompatibilityProfile", "{14, 61, 152, true}", + "{13, 29, 152, true}", "{12, 29, 152, true}", "{11, 29, 144, false}", "{10, 13, 144, false}", "AbiCompatibilityProfilesAreValid()", "IsAbiRetryEligible(", diff --git a/internal/cmd/native_service_install_windows.go b/internal/cmd/native_service_install_windows.go index 1f4116ac..c4b8f6a7 100644 --- a/internal/cmd/native_service_install_windows.go +++ b/internal/cmd/native_service_install_windows.go @@ -1490,6 +1490,14 @@ func validateNativeBrokerPingAgainstIdentity( return fmt.Errorf("native broker package version=%q expected=%q", native.ExpectedDriverPackageVersion, udecx.DriverPackageVersion) } + if !udecx.IsCanonicalControllerSessionID(native.ControllerSessionID) { + return fmt.Errorf("native broker controller session identity=%q is not canonical", + native.ControllerSessionID) + } + if !udecx.IsCanonicalControllerInstanceID(native.ControllerInstanceID) { + return fmt.Errorf("native broker controller instance identity=%q is not canonical", + native.ControllerInstanceID) + } if len(native.LoadedDriverBuildIdentity) != 64 { return errors.New("native broker omitted the negotiated loaded-driver build identity") } diff --git a/internal/cmd/native_service_install_windows_test.go b/internal/cmd/native_service_install_windows_test.go index de6079fd..228fdbe1 100644 --- a/internal/cmd/native_service_install_windows_test.go +++ b/internal/cmd/native_service_install_windows_test.go @@ -1213,6 +1213,8 @@ func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { Capabilities: uint32(udecx.AdvertisedCapabilities), ExpectedDriverPackageVersion: udecx.DriverPackageVersion, LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, }, } if err := validateNativeBrokerPingAgainstIdentity(valid, expected); err != nil { @@ -1231,6 +1233,18 @@ func TestValidateNativeBrokerPingRequiresExactContract(t *testing.T) { "malformed loaded identity": func(p *viipertypes.PingResponse) { p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("z", 64) }, + "missing controller session identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerSessionID = "" + }, + "noncanonical controller session identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerSessionID = "017" + }, + "missing controller identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerInstanceID = "" + }, + "noncanonical controller identity": func(p *viipertypes.PingResponse) { + p.NativeUDE.ControllerInstanceID = `ROOT\VIIPERUDE\42` + }, "stale loaded identity with matching ABI and caps": func(p *viipertypes.PingResponse) { p.NativeUDE.LoadedDriverBuildIdentity = strings.Repeat("0", 64) }, @@ -1270,6 +1284,8 @@ func TestValidateNativeBrokerPingUsesInjectedBuildIdentity(t *testing.T) { Capabilities: uint32(udecx.AdvertisedCapabilities), ExpectedDriverPackageVersion: udecx.DriverPackageVersion, LoadedDriverBuildIdentity: udecx.BuildIdentityHex(expected), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, }, } if err := validateNativeBrokerPing(response); err != nil { diff --git a/internal/cmd/native_transport_windows.go b/internal/cmd/native_transport_windows.go index 13830bae..c0a45371 100644 --- a/internal/cmd/native_transport_windows.go +++ b/internal/cmd/native_transport_windows.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "strconv" serverusb "github.com/Alia5/VIIPER/internal/server/usb" "github.com/Alia5/VIIPER/internal/transport/udecx" @@ -38,6 +39,8 @@ func startNativeUDETransport(ctx context.Context, server *serverusb.Server) (nat Capabilities: uint32(client.Capabilities()), ExpectedDriverPackageVersion: udecx.DriverPackageVersion, LoadedDriverBuildIdentity: udecx.BuildIdentityHex(client.BuildIdentity()), + ControllerSessionID: strconv.FormatUint(client.ControllerSessionID(), 10), + ControllerInstanceID: client.ControllerInstanceID(), MaxDevices: limits.MaxDevices, MaxDescriptorBytes: limits.MaxDescriptorBytes, MaxTransferBytes: limits.MaxTransferBytes, MaxIsoPackets: limits.MaxIsoPackets, MaxPendingOperations: limits.MaxPendingOperations, diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 0765441d..09a176fb 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -152,6 +152,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) + r.Register("bus/{id}/remove-native", handler.BusDeviceRemoveNative(usbSrv)) r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) if s.APIServerConfig.AutoAttachLocalClient && transport == "usbip" { diff --git a/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go index b6588790..f5611d65 100644 --- a/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -20,13 +20,14 @@ func TestScanDTOs(t *testing.T) { // Expected DTOs expectedDTOs := map[string]bool{ - "APIError": true, - "BusListResponse": true, - "BusCreateResponse": true, - "BusRemoveResponse": true, - "Device": true, - "DevicesListResponse": true, - "DeviceRemoveResponse": true, + "APIError": true, + "BusListResponse": true, + "BusCreateResponse": true, + "BusRemoveResponse": true, + "Device": true, + "DevicesListResponse": true, + "DeviceRemoveResponse": true, + "NativeUDEDeviceRemoveRequest": true, } foundDTOs := make(map[string]bool) diff --git a/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go index fade2387..50f08ce5 100644 --- a/internal/codegen/scanner/payload.go +++ b/internal/codegen/scanner/payload.go @@ -86,10 +86,11 @@ func scanPayloadFile(filePath string, acc map[string]PayloadInfo) error { numericBitSize := "" jsonTargetType := "" - // Walk body - also track local variable declarations + // Collect local variable declarations in a separate pass so payload type + // inference is independent of AST visitation order (including variables + // declared inside the returned HandlerFunc literal). localVarTypes := make(map[string]string) ast.Inspect(funcDecl.Body, func(nn ast.Node) bool { - // Track local variable declarations (var x Type) if decl, ok := nn.(*ast.DeclStmt); ok { if gen, ok := decl.Decl.(*ast.GenDecl); ok && gen.Tok == token.VAR { for _, spec := range gen.Specs { @@ -101,6 +102,10 @@ func scanPayloadFile(filePath string, acc map[string]PayloadInfo) error { } } } + return true + }) + + ast.Inspect(funcDecl.Body, func(nn ast.Node) bool { // If statements for empty/non-empty checks if ifs, ok := nn.(*ast.IfStmt); ok { diff --git a/internal/codegen/scanner/routes_test.go b/internal/codegen/scanner/routes_test.go index 6ec9116f..e8595d60 100644 --- a/internal/codegen/scanner/routes_test.go +++ b/internal/codegen/scanner/routes_test.go @@ -29,6 +29,7 @@ func TestScannerSuite(t *testing.T) { "bus/{id}/list": true, "bus/{id}/add": true, "bus/{id}/remove": true, + "bus/{id}/remove-native": true, "bus/{busId}/{deviceid}": true, } found := make(map[string]bool) @@ -76,6 +77,14 @@ func TestScannerSuite(t *testing.T) { assertPayload("bus/create", PayloadNumeric, false) assertPayload("bus/remove", PayloadNumeric, true) assertPayload("bus/{id}/remove", PayloadString, true) + assertPayload("bus/{id}/remove-native", PayloadJSON, true) + for _, route := range enriched { + if route.Path == "bus/{id}/remove-native" && + route.Payload.RawType != "NativeUDEDeviceRemoveRequest" { + t.Errorf("native remove payload type=%q want NativeUDEDeviceRemoveRequest", + route.Payload.RawType) + } + } assertPayload("bus/list", PayloadNone, false) assertPayload("bus/{id}/list", PayloadNone, false) }, diff --git a/internal/server/api/device_stream_ownership.go b/internal/server/api/device_stream_ownership.go index 7094c1a1..5c36e74c 100644 --- a/internal/server/api/device_stream_ownership.go +++ b/internal/server/api/device_stream_ownership.go @@ -7,12 +7,34 @@ import ( "time" ) -// deviceStreamKey identifies the lifetime of one virtual device. Bus and -// device identifiers can eventually be reused, so the monotonically increasing -// generation in deviceStreamOwnership remains authoritative across reconnects. +// deviceStreamKey identifies one exact virtual-device lifetime. Bus and device +// identifiers can be reused, so the bus-owned cancellation channel fences a +// recreated successor from stale stream claims and cleanup timers. The local +// generation remains authoritative only for reconnects within that lifetime. type deviceStreamKey struct { - busID uint32 - devID string + busID uint32 + devID string + lifetime <-chan struct{} +} + +func newDeviceStreamKey(busID uint32, devID string, deviceContext context.Context) deviceStreamKey { + var lifetime <-chan struct{} + if deviceContext != nil { + lifetime = deviceContext.Done() + } + return deviceStreamKey{busID: busID, devID: devID, lifetime: lifetime} +} + +func deviceStreamLifetimeEnded(key deviceStreamKey) bool { + if key.lifetime == nil { + return false + } + select { + case <-key.lifetime: + return true + default: + return false + } } // deviceStreamCoordinator gives each virtual device exactly one current API @@ -48,17 +70,57 @@ type deviceStreamLease struct { finishOnce sync.Once } -func (c *deviceStreamCoordinator) claim(key deviceStreamKey, - conn net.Conn) *deviceStreamLease { - c.mu.Lock() +func (c *deviceStreamCoordinator) stateForKeyLocked( + key deviceStreamKey, +) *deviceStreamOwnership { if c.streams == nil { c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) } state := c.streams[key] - if state == nil { - state = &deviceStreamOwnership{} - c.streams[key] = state + if state != nil { + return state + } + + state = &deviceStreamOwnership{} + c.streams[key] = state + if key.lifetime != nil { + go c.watchLifetime(key, state) + } + return state +} + +// watchLifetime makes device removal authoritative even when a handler is +// blocked in a transport read. It removes only the state object created for +// this exact lifetime, then closes its current connection outside the +// coordinator lock so the handler can return and release its lease. +func (c *deviceStreamCoordinator) watchLifetime( + key deviceStreamKey, expected *deviceStreamOwnership, +) { + <-key.lifetime + + c.mu.Lock() + state := c.streams[key] + if state != expected { + c.mu.Unlock() + return + } + conn := state.conn + c.retireLocked(key, state) + c.mu.Unlock() + + if conn != nil { + _ = conn.Close() + } +} + +func (c *deviceStreamCoordinator) claim(key deviceStreamKey, + conn net.Conn) *deviceStreamLease { + c.mu.Lock() + if deviceStreamLifetimeEnded(key) { + c.mu.Unlock() + return nil } + state := c.stateForKeyLocked(key) if state.cleanupTimer != nil { state.cleanupTimer.Stop() @@ -94,6 +156,20 @@ func (c *deviceStreamCoordinator) claim(key deviceStreamKey, return lease } +func (c *deviceStreamCoordinator) retireLocked( + key deviceStreamKey, state *deviceStreamOwnership, +) { + if state.finalizeTimer != nil { + state.finalizeTimer.Stop() + state.finalizeTimer = nil + } + if state.cleanupTimer != nil { + state.cleanupTimer.Stop() + state.cleanupTimer = nil + } + delete(c.streams, key) +} + // waitForTurn waits until the displaced handler has returned. It reports false // when an even newer stream superseded this lease while it was waiting. func (l *deviceStreamLease) waitForTurn(ctx context.Context) bool { @@ -143,6 +219,11 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, state.done = nil generation := state.generation close(l.done) + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, state) + c.mu.Unlock() + return + } state.finalizeTimer = time.AfterFunc(reconnectGrace, func() { c.mu.Lock() defer c.mu.Unlock() @@ -151,6 +232,10 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, currentState.generation != generation || currentState.finalized { return } + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + return + } currentState.finalizeTimer = nil currentState.finalized = true if deviceContext != nil { @@ -173,6 +258,10 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, return } currentState.cleanupTimer = nil + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + return + } if !currentState.finalized { if currentState.finalizeTimer != nil { currentState.finalizeTimer.Stop() @@ -193,6 +282,9 @@ func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, if cleanup != nil { cleanup() } + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, currentState) + } }) c.mu.Unlock() }) @@ -213,6 +305,9 @@ func (l *deviceStreamLease) abandon() { state.active = false state.conn = nil state.done = nil + if deviceStreamLifetimeEnded(l.key) { + c.retireLocked(l.key, state) + } } c.mu.Unlock() }) @@ -223,14 +318,11 @@ func (l *deviceStreamLease) abandon() { func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, delay time.Duration, deviceContext context.Context, cleanup func()) { c.mu.Lock() - if c.streams == nil { - c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) - } - state := c.streams[key] - if state == nil { - state = &deviceStreamOwnership{} - c.streams[key] = state + if deviceStreamLifetimeEnded(key) { + c.mu.Unlock() + return } + state := c.stateForKeyLocked(key) if state.active { c.mu.Unlock() return @@ -248,6 +340,10 @@ func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, return } current.cleanupTimer = nil + if deviceStreamLifetimeEnded(key) { + c.retireLocked(key, current) + return + } if deviceContext != nil { select { case <-deviceContext.Done(): @@ -258,6 +354,9 @@ func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, if cleanup != nil { cleanup() } + if deviceStreamLifetimeEnded(key) { + c.retireLocked(key, current) + } }) c.mu.Unlock() } diff --git a/internal/server/api/device_stream_ownership_test.go b/internal/server/api/device_stream_ownership_test.go index 2c2bc382..30e2f806 100644 --- a/internal/server/api/device_stream_ownership_test.go +++ b/internal/server/api/device_stream_ownership_test.go @@ -208,6 +208,95 @@ func TestInitialCleanupCannotRemoveActivelyClaimedDevice(t *testing.T) { lease.abandon() } +func TestRecreatedDeviceLifetimeCannotShareStreamOrCleanupOwnership(t *testing.T) { + var coordinator deviceStreamCoordinator + oldContext, cancelOld := context.WithCancel(context.Background()) + newContext, cancelNew := context.WithCancel(context.Background()) + defer cancelNew() + oldKey := newDeviceStreamKey(20, "7", oldContext) + newKey := newDeviceStreamKey(20, "7", newContext) + + var successorCleanup atomic.Int32 + coordinator.scheduleCleanup(newKey, 35*time.Millisecond, newContext, func() { + successorCleanup.Add(1) + }) + + // The stale lifetime may have passed an earlier topology check, but once its + // bus context is retired it cannot claim the recreated successor's key or + // cancel the successor's initial cleanup timer. + cancelOld() + staleServer, staleClient := net.Pipe() + defer staleServer.Close() //nolint:errcheck + defer staleClient.Close() //nolint:errcheck + require.Nil(t, coordinator.claim(oldKey, staleServer)) + require.Eventually(t, func() bool { + return successorCleanup.Load() == 1 + }, time.Second, time.Millisecond) + + // A stale key also cannot displace or close an active successor stream. + successorServer, successorClient := net.Pipe() + defer successorServer.Close() //nolint:errcheck + defer successorClient.Close() //nolint:errcheck + successor := coordinator.claim(newKey, successorServer) + require.NotNil(t, successor) + require.True(t, successor.waitForTurn(newContext)) + + staleServer2, staleClient2 := net.Pipe() + defer staleServer2.Close() //nolint:errcheck + defer staleClient2.Close() //nolint:errcheck + require.Nil(t, coordinator.claim(oldKey, staleServer2)) + require.NoError(t, successorClient.SetReadDeadline(time.Now().Add(25*time.Millisecond))) + var one [1]byte + _, err := successorClient.Read(one[:]) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + require.True(t, netErr.Timeout(), "stale lifetime closed successor stream: %v", err) + successor.abandon() +} + +func TestDeviceLifetimeCancellationClosesOnlyItsActiveStreamAndRetiresState(t *testing.T) { + var coordinator deviceStreamCoordinator + oldContext, cancelOld := context.WithCancel(context.Background()) + newContext, cancelNew := context.WithCancel(context.Background()) + defer cancelNew() + oldKey := newDeviceStreamKey(27, "11", oldContext) + newKey := newDeviceStreamKey(27, "11", newContext) + + oldServer, oldClient := net.Pipe() + defer oldClient.Close() //nolint:errcheck + oldLease := coordinator.claim(oldKey, oldServer) + require.NotNil(t, oldLease) + require.True(t, oldLease.waitForTurn(oldContext)) + + newServer, newClient := net.Pipe() + defer newServer.Close() //nolint:errcheck + defer newClient.Close() //nolint:errcheck + newLease := coordinator.claim(newKey, newServer) + require.NotNil(t, newLease) + require.True(t, newLease.waitForTurn(newContext)) + + require.NoError(t, oldClient.SetReadDeadline(time.Now().Add(time.Second))) + cancelOld() + var one [1]byte + _, err := oldClient.Read(one[:]) + require.Error(t, err, "cancelled lifetime left its stream open") + require.Eventually(t, func() bool { + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + _, oldPresent := coordinator.streams[oldKey] + return !oldPresent && coordinator.streams[newKey] != nil + }, time.Second, time.Millisecond) + + require.NoError(t, newClient.SetReadDeadline(time.Now().Add(25*time.Millisecond))) + _, err = newClient.Read(one[:]) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + require.True(t, netErr.Timeout(), "old lifetime cancellation closed successor: %v", err) + + oldLease.abandon() + newLease.abandon() +} + func TestDeviceStreamCloseFirstReconnectCancelsPendingFinalization(t *testing.T) { var coordinator deviceStreamCoordinator key := deviceStreamKey{busID: 23, devID: "5"} diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index de7977d8..3bd56a61 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -11,6 +11,7 @@ import ( "github.com/Alia5/VIIPER/internal/server/api" apierror "github.com/Alia5/VIIPER/internal/server/api/error" usbs "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" "github.com/Alia5/VIIPER/viipertypes" ) @@ -66,7 +67,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("failed to create device: %v", err)) } - devCtx, err := s.AddDeviceToBus(req.Ctx, uint32(busID), dev) + devCtx, nativeRegistration, err := s.AddDeviceToBusWithRegistration(req.Ctx, uint32(busID), dev) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) } @@ -77,7 +78,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } apiSrv.ScheduleDeviceCleanup(uint32(busID), - fmt.Sprintf("%d", exportMeta.DevID), devCtx) + fmt.Sprintf("%d", exportMeta.DevID), devCtx, nativeRegistration) autoAttachResult := api.AutoAttachResult{} if apiSrv.Config().AutoAttachLocalClient && !s.NativeTransportEnabled() { @@ -96,6 +97,12 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } } + transport := "usbip" + var nativeInfo *viipertypes.NativeUDEDeviceInfo + if nativeRegistration != nil { + transport = "native-ude" + nativeInfo = nativeUDEDeviceInfo(*nativeRegistration) + } payload, err := json.Marshal(viipertypes.Device{ BusID: uint32(busID), DevID: fmt.Sprintf("%d", exportMeta.DevID), @@ -103,6 +110,8 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { Pid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDProduct), Type: name, DeviceSpecific: dev.GetDeviceSpecificArgs(), + Transport: transport, + NativeUDE: nativeInfo, USBIPPort: autoAttachResult.USBIPPort, USBIPOwnerSerial: autoAttachResult.USBIPOwnerSerial, }) @@ -114,3 +123,14 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { return nil } } + +func nativeUDEDeviceInfo(registration udecx.DeviceRegistration) *viipertypes.NativeUDEDeviceInfo { + return &viipertypes.NativeUDEDeviceInfo{ + DeviceID: strconv.FormatUint(registration.DeviceID, 10), + DeviceGeneration: registration.Generation, + ControllerSessionID: strconv.FormatUint(registration.ControllerSessionID, 10), + ControllerInstanceID: registration.ControllerInstanceID, + USB20PortNumber: registration.USB20PortNumber, + USB30PortNumber: registration.USB30PortNumber, + } +} diff --git a/internal/server/api/handler/bus_device_add_internal_test.go b/internal/server/api/handler/bus_device_add_internal_test.go index 63eadc94..b6b8d634 100644 --- a/internal/server/api/handler/bus_device_add_internal_test.go +++ b/internal/server/api/handler/bus_device_add_internal_test.go @@ -2,8 +2,11 @@ package handler import ( "context" + "encoding/json" "log/slog" + "strconv" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,11 +15,51 @@ import ( th "github.com/Alia5/VIIPER/internal/_testing" "github.com/Alia5/VIIPER/internal/server/api" usbs "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + usbdevice "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" "github.com/Alia5/VIIPER/virtualbus" ) +type apiNativeCorrelationDriver struct { + destroyed []udecx.DeviceIdentity +} + +func (*apiNativeCorrelationDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { + return udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, + ControllerSessionID: 17, + USB20PortNumber: 5, + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, + }, nil +} + +func (d *apiNativeCorrelationDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { + d.destroyed = append(d.destroyed, identity) + return nil +} +func (*apiNativeCorrelationDriver) Dequeue(ctx context.Context, _ []byte) (udecx.Operation, error) { + <-ctx.Done() + return udecx.Operation{}, ctx.Err() +} +func (*apiNativeCorrelationDriver) Complete(context.Context, udecx.Completion) error { return nil } +func (*apiNativeCorrelationDriver) QueryStats(context.Context) (udecx.Stats, error) { + return udecx.Stats{}, nil +} + +type apiNativeCorrelationProcessor struct{} + +func (*apiNativeCorrelationProcessor) Process(context.Context, usbdevice.Device, udecx.Operation) (udecx.Completion, error) { + return udecx.Completion{}, nil +} +func (*apiNativeCorrelationProcessor) Lifecycle(context.Context, usbdevice.Device, udecx.Operation) error { + return nil +} +func (*apiNativeCorrelationProcessor) Reset(usbdevice.Device, udecx.DeviceIdentity) {} + func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { const ownerSerial = "DS4W123456789AB" type attachCall struct { @@ -36,6 +79,7 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { } addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second apiSrv.Config().AutoAttachLocalClient = true apiSrv.Config().AutoAttachWindowsNative = true @@ -56,6 +100,7 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { "deviceSpecific": {"subType": 1}, "vid": "0x045e", "pid": "0x028e", + "transport": "usbip", "type": "xbox360", "usbipPort": 7, "usbipOwnerSerial": "DS4W123456789AB" @@ -66,3 +111,117 @@ func TestBusDeviceAddReturnsNativeAutoAttachMetadata(t *testing.T) { require.Equal(t, uint32(1), call.devID) require.True(t, call.native) } + +func TestBusDeviceAddAndListReturnExactNativeCorrelation(t *testing.T) { + const busID = uint32(81234) + addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second + apiSrv.Config().DeviceHandlerConnectTimeout = 30 * time.Second + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + require.NoError(t, s.AddBus(bus)) + host, err := udecx.NewHost(&apiNativeCorrelationDriver{}, &apiNativeCorrelationProcessor{}, 0) + require.NoError(t, err) + require.NoError(t, s.EnableNativeTransport(host)) + r.Register("bus/{id}/add", BusDeviceAdd(s, apiSrv)) + r.Register("bus/{id}/list", BusDevicesList(s)) + }) + defer done() + + client := viiperclient.NewTransport(addr) + response, err := client.Do("bus/{id}/add", `{"type":"xbox360"}`, + map[string]string{"id": strconv.FormatUint(uint64(busID), 10)}) + require.NoError(t, err) + var created viipertypes.Device + require.NoError(t, json.Unmarshal([]byte(response), &created)) + wantDeviceID := strconv.FormatUint(uint64(busID)<<32|1, 10) + require.Equal(t, "native-ude", created.Transport) + require.NotNil(t, created.NativeUDE) + require.Equal(t, wantDeviceID, created.NativeUDE.DeviceID) + require.Equal(t, uint32(1), created.NativeUDE.DeviceGeneration) + require.Equal(t, "17", created.NativeUDE.ControllerSessionID) + require.Equal(t, `ROOT\VIIPERUDE\0042`, created.NativeUDE.ControllerInstanceID) + require.Equal(t, uint32(5), created.NativeUDE.USB20PortNumber) + require.Zero(t, created.NativeUDE.USB30PortNumber) + require.Zero(t, created.USBIPPort) + require.Empty(t, created.USBIPOwnerSerial) + + response, err = client.Do("bus/{id}/list", nil, + map[string]string{"id": strconv.FormatUint(uint64(busID), 10)}) + require.NoError(t, err) + var listed viipertypes.DevicesListResponse + require.NoError(t, json.Unmarshal([]byte(response), &listed)) + require.Len(t, listed.Devices, 1) + require.Equal(t, created.NativeUDE, listed.Devices[0].NativeUDE) + require.Equal(t, "native-ude", listed.Devices[0].Transport) +} + +func TestNativeExactRemoveRejectsStaleReceiptAndPreservesSuccessor(t *testing.T) { + const busID = uint32(81235) + driver := &apiNativeCorrelationDriver{} + addr, _, done := th.StartAPIServer(t, func(r *api.Router, s *usbs.Server, apiSrv *api.Server) { + apiSrv.Config().ConnectionTimeout = time.Second + apiSrv.Config().DeviceHandlerConnectTimeout = 30 * time.Second + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + require.NoError(t, s.AddBus(bus)) + host, err := udecx.NewHost(driver, &apiNativeCorrelationProcessor{}, 0) + require.NoError(t, err) + require.NoError(t, s.EnableNativeTransport(host)) + r.Register("bus/{id}/add", BusDeviceAdd(s, apiSrv)) + r.Register("bus/{id}/list", BusDevicesList(s)) + r.Register("bus/{id}/remove", BusDeviceRemove(s)) + r.Register("bus/{id}/remove-native", BusDeviceRemoveNative(s)) + r.Register("bus/remove", BusRemove(s)) + }) + defer done() + + client := viiperclient.NewTransport(addr) + params := map[string]string{"id": strconv.FormatUint(uint64(busID), 10)} + response, err := client.Do("bus/{id}/add", `{"type":"xbox360"}`, params) + require.NoError(t, err) + var created viipertypes.Device + require.NoError(t, json.Unmarshal([]byte(response), &created)) + require.NotNil(t, created.NativeUDE) + + response, err = client.Do("bus/{id}/remove", created.DevID, params) + require.NoError(t, err) + var unsafeRemove viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &unsafeRemove)) + require.Equal(t, 409, unsafeRemove.Status) + require.Empty(t, driver.destroyed) + + staleNative := *created.NativeUDE + staleNative.DeviceGeneration++ + staleRequest := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: created.DevID, Transport: "native-ude", NativeUDE: &staleNative, + } + response, err = client.Do("bus/{id}/remove-native", staleRequest, params) + require.NoError(t, err) + var conflict viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &conflict)) + require.Equal(t, 409, conflict.Status) + require.Empty(t, driver.destroyed) + + response, err = client.Do("bus/remove", strconv.FormatUint(uint64(busID), 10), nil) + require.NoError(t, err) + var busConflict viipertypes.APIError + require.NoError(t, json.Unmarshal([]byte(response), &busConflict)) + require.Equal(t, 409, busConflict.Status) + require.Empty(t, driver.destroyed) + + response, err = client.Do("bus/{id}/list", nil, params) + require.NoError(t, err) + var listed viipertypes.DevicesListResponse + require.NoError(t, json.Unmarshal([]byte(response), &listed)) + require.Len(t, listed.Devices, 1) + require.Equal(t, created.NativeUDE, listed.Devices[0].NativeUDE) + + exactRequest := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: created.DevID, Transport: "native-ude", NativeUDE: created.NativeUDE, + } + response, err = client.Do("bus/{id}/remove-native", exactRequest, params) + require.NoError(t, err) + require.JSONEq(t, `{"busId":81235,"devId":"1"}`, response) + require.Len(t, driver.destroyed, 1) +} diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index b0393b1b..b6f3da95 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -44,7 +44,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80001"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "add device to existing bus with device specific args", @@ -59,7 +59,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80001"}, payload: `{"type": "xbox360", "deviceSpecific":{"subType": 7}}`, - expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 7}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 7}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "invalid device specific args", @@ -143,7 +143,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80005"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360", "transport":"usbip"}`, }, { name: "autoattach fails returns error", diff --git a/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go index d4ab4c3d..492a9d5f 100644 --- a/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -32,6 +32,11 @@ func BusDeviceRemove(s *usb.Server) api.HandlerFunc { if b == nil { return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } + if s.NativeTransportEnabled() { + return apierror.ErrConflict( + "native transport requires bus/{id}/remove-native with the exact correlation receipt", + ) + } if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { return apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } diff --git a/internal/server/api/handler/bus_device_remove_native.go b/internal/server/api/handler/bus_device_remove_native.go new file mode 100644 index 00000000..68c98077 --- /dev/null +++ b/internal/server/api/handler/bus_device_remove_native.go @@ -0,0 +1,97 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusDeviceRemoveNative performs a correlation-conditioned native removal. +// The full add/list receipt is compared atomically by usb.Server immediately +// before Unregister, so a delayed lifetime cannot remove an ID-reusing child. +func BusDeviceRemoveNative(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, _ *slog.Logger) error { + idStr, ok := req.Params["id"] + if !ok { + return apierror.ErrBadRequest("missing id parameter") + } + busID64, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + if req.Payload == "" { + return apierror.ErrBadRequest("missing payload") + } + + var removeRequest viipertypes.NativeUDEDeviceRemoveRequest + if err := json.Unmarshal([]byte(req.Payload), &removeRequest); err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + } + if removeRequest.Transport != "native-ude" { + return apierror.ErrBadRequest("transport must be exactly native-ude") + } + if removeRequest.NativeUDE == nil { + return apierror.ErrBadRequest("missing nativeUde correlation receipt") + } + + deviceID, err := parseCanonicalUint(removeRequest.DevID, 32) + if err != nil || deviceID == 0 { + return apierror.ErrBadRequest("devId must be a canonical nonzero uint32 decimal string") + } + nativeDeviceID, err := parseCanonicalUint(removeRequest.NativeUDE.DeviceID, 64) + if err != nil || nativeDeviceID == 0 { + return apierror.ErrBadRequest("nativeUde.deviceId must be a canonical nonzero uint64 decimal string") + } + controllerSessionID, err := parseCanonicalUint(removeRequest.NativeUDE.ControllerSessionID, 64) + if err != nil || controllerSessionID == 0 { + return apierror.ErrBadRequest("nativeUde.controllerSessionId must be a canonical nonzero uint64 decimal string") + } + + expected := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{ + DeviceID: nativeDeviceID, Generation: removeRequest.NativeUDE.DeviceGeneration, + }, + ControllerSessionID: controllerSessionID, + ControllerInstanceID: removeRequest.NativeUDE.ControllerInstanceID, + USB20PortNumber: removeRequest.NativeUDE.USB20PortNumber, + USB30PortNumber: removeRequest.NativeUDE.USB30PortNumber, + } + if err := s.RemoveNativeDeviceExact(uint32(busID64), removeRequest.DevID, expected); err != nil { + switch { + case errors.Is(err, usb.ErrInvalidNativeDeviceCorrelation): + return apierror.ErrBadRequest(err.Error()) + case errors.Is(err, usb.ErrNativeDeviceCorrelationMismatch): + return apierror.ErrConflict("native device correlation is stale; no device was removed") + case errors.Is(err, usb.ErrBusNotFound): + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID64)) + default: + return apierror.ErrInternal(fmt.Sprintf("failed to remove native device: %v", err)) + } + } + + response, err := json.Marshal(viipertypes.DeviceRemoveResponse{ + BusID: uint32(busID64), DevID: removeRequest.DevID, + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(response) + return nil + } +} + +func parseCanonicalUint(value string, bitSize int) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, bitSize) + if err != nil || strconv.FormatUint(parsed, 10) != value { + return 0, fmt.Errorf("non-canonical unsigned decimal value") + } + return parsed, nil +} diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go index 9207cbc6..aabc836e 100644 --- a/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "log/slog" "path/filepath" @@ -26,14 +27,27 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } - b := s.GetBus(uint32(busID)) - if b == nil { - return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + snapshots, err := s.SnapshotBusDevices(uint32(busID)) + if err != nil { + switch { + case errors.Is(err, usb.ErrBusNotFound): + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + case errors.Is(err, usb.ErrNativeDeviceCorrelationMismatch): + return apierror.ErrConflict("native bus topology changed during list") + default: + return apierror.ErrInternal(fmt.Sprintf("snapshot bus %d: %v", busID, err)) + } } - metas := b.GetAllDeviceMetas() - out := make([]viipertypes.Device, 0, len(metas)) - for _, m := range metas { + out := make([]viipertypes.Device, 0, len(snapshots)) + for _, snapshot := range snapshots { + m := snapshot.DeviceMeta dtype := inferDeviceType(m.Dev) + transport := "usbip" + var nativeInfo *viipertypes.NativeUDEDeviceInfo + if snapshot.NativeRegistration != nil { + transport = "native-ude" + nativeInfo = nativeUDEDeviceInfo(*snapshot.NativeRegistration) + } out = append(out, viipertypes.Device{ BusID: m.Meta.BusID, DevID: fmt.Sprintf("%d", m.Meta.DevID), @@ -41,6 +55,8 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { Pid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDProduct), Type: dtype, DeviceSpecific: m.Dev.GetDeviceSpecificArgs(), + Transport: transport, + NativeUDE: nativeInfo, }) } payload, err := json.Marshal(viipertypes.DevicesListResponse{Devices: out}) diff --git a/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go index fe9e80ba..c4c0764d 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -54,7 +54,7 @@ func TestBusDevicesList(t *testing.T) { } }, pathParams: map[string]string{"id": "60009"}, - expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"}]}`, }, { name: "list devices with multiple additions", @@ -82,7 +82,7 @@ func TestBusDevicesList(t *testing.T) { } }, pathParams: map[string]string{"id": "60010"}, - expectedResponse: `{"devices":[{"busId":60010,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"},{"busId":60010,"devId":"2","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + expectedResponse: `{"devices":[{"busId":60010,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"},{"busId":60010,"devId":"2","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360","transport":"usbip"}]}`, }, { name: "list devices on non-existing bus", diff --git a/internal/server/api/handler/bus_remove.go b/internal/server/api/handler/bus_remove.go index 845c2b6f..804a77f7 100644 --- a/internal/server/api/handler/bus_remove.go +++ b/internal/server/api/handler/bus_remove.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "log/slog" "strconv" @@ -22,7 +23,16 @@ func BusRemove(s *usb.Server) api.HandlerFunc { if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } - if err := s.RemoveBus(uint32(busID)); err != nil { + remove := s.RemoveBus + if s.NativeTransportEnabled() { + remove = s.RemoveBusIfEmpty + } + if err := remove(uint32(busID)); err != nil { + if errors.Is(err, usb.ErrBusNotEmpty) { + return apierror.ErrConflict( + "native transport refuses ID-only removal of a non-empty bus", + ) + } return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } out, err := json.Marshal(viipertypes.BusRemoveResponse{BusID: uint32(busID)}) diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index 56c84b7c..1aceb275 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -40,6 +40,8 @@ func TestPingReportsNegotiatedNativeBackend(t *testing.T) { ABIMajor: 1, ABIMinor: 10, Capabilities: 0x0d, ExpectedDriverPackageVersion: "0.1.0.29", LoadedDriverBuildIdentity: strings.Repeat("a", 64), + ControllerSessionID: "17", + ControllerInstanceID: `ROOT\VIIPERUDE\0042`, MaxDevices: 32, MaxDescriptorBytes: 262144, MaxTransferBytes: 1048576, MaxIsoPackets: 1024, MaxPendingOperations: 4096, diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 4f24c52b..8fd0f911 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -18,6 +18,7 @@ import ( "github.com/Alia5/VIIPER/internal/server/api/auth" apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/transport/udecx" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/viipertypes" ) @@ -82,11 +83,12 @@ func (s *Server) Config() *ServerConfig { return s.config } // generation owner used for reconnects. A stream that claims the device before // the timeout atomically cancels this cleanup. func (s *Server) ScheduleDeviceCleanup(busID uint32, devID string, - deviceContext context.Context) { - key := deviceStreamKey{busID: busID, devID: devID} + deviceContext context.Context, nativeRegistration *udecx.DeviceRegistration) { + expected := cloneNativeRegistration(nativeRegistration) + key := newDeviceStreamKey(busID, devID, deviceContext) s.deviceStreams.scheduleCleanup(key, s.config.DeviceHandlerConnectTimeout, deviceContext, func() { - if err := s.usbs.RemoveDeviceByID(busID, devID); err != nil { + if err := s.removeRegisteredDevice(busID, devID, expected); err != nil { s.logger.Error("timeout: failed to remove device", "busID", busID, "deviceID", devID, "error", err) } else { @@ -96,6 +98,29 @@ func (s *Server) ScheduleDeviceCleanup(busID uint32, devID string, }) } +func cloneNativeRegistration( + registration *udecx.DeviceRegistration, +) *udecx.DeviceRegistration { + if registration == nil { + return nil + } + copy := *registration + return © +} + +func (s *Server) removeRegisteredDevice( + busID uint32, devID string, nativeRegistration *udecx.DeviceRegistration, +) error { + if nativeRegistration != nil { + return s.usbs.RemoveNativeDeviceExact(busID, devID, *nativeRegistration) + } + if s.usbs.NativeTransportEnabled() { + return fmt.Errorf("%w: cleanup has no exact native registration for bus %d device %s", + usb.ErrNativeDeviceCorrelationMismatch, busID, devID) + } + return s.usbs.RemoveDeviceByID(busID, devID) +} + // Addr returns the actual address the server is listening on. // If Start hasn't been called yet, it returns the configured address. func (s *Server) Addr() string { @@ -339,11 +364,13 @@ func (s *Server) handleConn(conn net.Conn) { } var dev pusb.Device var devCtx context.Context + var devID uint32 metas := bus.GetAllDeviceMetas() for _, meta := range metas { if fmt.Sprintf("%d", meta.Meta.DevID) == devIDStr { dev = meta.Dev devCtx = bus.GetDeviceContext(dev) + devID = meta.Meta.DevID break } } @@ -351,9 +378,23 @@ func (s *Server) handleConn(conn net.Conn) { s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) return } + var nativeRegistration *udecx.DeviceRegistration + if s.usbs.NativeTransportEnabled() { + registration, found := s.usbs.NativeDeviceRegistrationForDevice( + uint32(busID), devID, dev, devCtx) + if !found { + s.writeError(w, apierror.ErrConflict( + "native device lifetime changed before stream admission")) + return + } + nativeRegistration = cloneNativeRegistration(®istration) + } - streamKey := deviceStreamKey{busID: uint32(busID), devID: devIDStr} + streamKey := newDeviceStreamKey(uint32(busID), devIDStr, devCtx) lease := s.deviceStreams.claim(streamKey, streamConn) + if lease == nil { + return + } handlerStarted := false defer func() { if !handlerStarted { @@ -366,7 +407,8 @@ func (s *Server) handleConn(conn net.Conn) { resetter.ResetMicrophonePCM() } }, func() { - if err := s.usbs.RemoveDeviceByID(uint32(busID), devIDStr); err != nil { + if err := s.removeRegisteredDevice( + uint32(busID), devIDStr, nativeRegistration); err != nil { connLogger.Error("disconnect timeout: failed to remove device", "busID", busID, "deviceID", devIDStr, "error", err) } else { diff --git a/internal/server/usb/native_playstation_transport_soak_test.go b/internal/server/usb/native_playstation_transport_soak_test.go index 647ed4d2..ff978faa 100644 --- a/internal/server/usb/native_playstation_transport_soak_test.go +++ b/internal/server/usb/native_playstation_transport_soak_test.go @@ -68,11 +68,22 @@ func newNativePlayStationSoakDriver() *nativePlayStationSoakDriver { } } -func (d *nativePlayStationSoakDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) error { +func (d *nativePlayStationSoakDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { d.mu.Lock() d.created = append(d.created, device) d.mu.Unlock() - return nil + registration := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + port := uint32((device.DeviceID-1)%udecx.MaxDevices + 1) + if device.Speed == udecx.DeviceSpeedSuper { + registration.USB30PortNumber = udecx.MaxDevices + port + } else { + registration.USB20PortNumber = port + } + return registration, nil } func (d *nativePlayStationSoakDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { diff --git a/internal/server/usb/native_transport_test.go b/internal/server/usb/native_transport_test.go index 3a643e31..e792c0af 100644 --- a/internal/server/usb/native_transport_test.go +++ b/internal/server/usb/native_transport_test.go @@ -24,21 +24,52 @@ type blockingNativeTransportTestDriver struct { allowCreate chan struct{} } +type blockingDestroyNativeTransportTestDriver struct { + nativeTransportTestDriver + destroyStarted chan struct{} + allowDestroy chan struct{} +} + +func (d *blockingDestroyNativeTransportTestDriver) DestroyDevice( + ctx context.Context, identity udecx.DeviceIdentity, +) error { + close(d.destroyStarted) + select { + case <-d.allowDestroy: + case <-ctx.Done(): + return ctx.Err() + } + return d.nativeTransportTestDriver.DestroyDevice(ctx, identity) +} + func (d *blockingNativeTransportTestDriver) CreateDevice( ctx context.Context, device udecx.CreateDevice, -) error { +) (udecx.DeviceRegistration, error) { close(d.createStarted) select { case <-d.allowCreate: case <-ctx.Done(): - return ctx.Err() + return udecx.DeviceRegistration{}, ctx.Err() } return d.nativeTransportTestDriver.CreateDevice(ctx, device) } -func (d *nativeTransportTestDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) error { +func (d *nativeTransportTestDriver) CreateDevice(_ context.Context, device udecx.CreateDevice) (udecx.DeviceRegistration, error) { d.created = append(d.created, device) - return d.createErr + if d.createErr != nil { + return udecx.DeviceRegistration{}, d.createErr + } + registration := udecx.DeviceRegistration{ + DeviceIdentity: udecx.DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + if device.Speed == udecx.DeviceSpeedSuper { + registration.USB30PortNumber = udecx.MaxDevices + 1 + } else { + registration.USB20PortNumber = 1 + } + return registration, nil } func (d *nativeTransportTestDriver) DestroyDevice(_ context.Context, identity udecx.DeviceIdentity) error { d.destroyed = append(d.destroyed, identity) @@ -110,6 +141,274 @@ func TestNativeTransportPublishesAndUnpublishesWithVirtualBus(t *testing.T) { } } +func TestNativeTransportExactRemoveCannotDeleteIDReusingSuccessor(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98104) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + + firstDevice := newNativeTransportTestDevice() + firstContext, first, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), firstDevice) + if err != nil || first == nil { + t.Fatalf("add first native device: registration=%v error=%v", first, err) + } + if captured, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, firstContext); !ok || + captured.DeviceIdentity != first.DeviceIdentity { + t.Fatalf("exact first lifetime capture: ok=%t captured=%+v want=%+v", + ok, captured.DeviceIdentity, first.DeviceIdentity) + } + unrelatedContext, cancelUnrelated := context.WithCancel(context.Background()) + defer cancelUnrelated() + if _, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, unrelatedContext); ok { + t.Fatal("native lifetime capture accepted an unrelated device context") + } + if err := server.RemoveNativeDeviceExact(bus.BusID(), "1", *first); err != nil { + t.Fatalf("remove first native device: %v", err) + } + + successorDevice := newNativeTransportTestDevice() + successorContext, successor, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), successorDevice) + if err != nil || successor == nil { + t.Fatalf("add successor native device: registration=%v error=%v", successor, err) + } + if successor.DeviceID != first.DeviceID || successor.Generation <= first.Generation { + t.Fatalf("successor identity=%+v did not reuse ID after first=%+v", successor.DeviceIdentity, first.DeviceIdentity) + } + + err = server.RemoveNativeDeviceExact(bus.BusID(), "1", *first) + if !errors.Is(err, ErrNativeDeviceCorrelationMismatch) { + t.Fatalf("stale exact remove error=%v want correlation mismatch", err) + } + if len(driver.destroyed) != 1 || len(bus.Devices()) != 1 { + t.Fatalf("stale removal mutated successor: destroyed=%d devices=%d want 1/1", + len(driver.destroyed), len(bus.Devices())) + } + if _, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, firstDevice, firstContext); ok { + t.Fatal("retired first device/context captured the successor registration") + } + if captured, ok := server.NativeDeviceRegistrationForDevice( + bus.BusID(), 1, successorDevice, successorContext); !ok || + captured.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("exact successor lifetime capture: ok=%t captured=%+v want=%+v", + ok, captured.DeviceIdentity, successor.DeviceIdentity) + } + snapshots, err := server.SnapshotBusDevices(bus.BusID()) + if err != nil || len(snapshots) != 1 || snapshots[0].NativeRegistration == nil || + snapshots[0].NativeRegistration.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("successor registration changed after stale remove: snapshots=%v error=%v want=%+v", + snapshots, err, successor.DeviceIdentity) + } + + if err := server.RemoveNativeDeviceExact(bus.BusID(), "1", *successor); err != nil { + t.Fatalf("remove exact successor: %v", err) + } + if len(driver.destroyed) != 2 || len(bus.Devices()) != 0 { + t.Fatalf("exact successor removal: destroyed=%d devices=%d want 2/0", + len(driver.destroyed), len(bus.Devices())) + } +} + +func TestNativeTransportExactRemoveRejectsMalformedOrStaleReceiptWithoutMutation(t *testing.T) { + mutations := []struct { + name string + edit func(*udecx.DeviceRegistration) + }{ + {"device id", func(r *udecx.DeviceRegistration) { r.DeviceID++ }}, + {"device generation", func(r *udecx.DeviceRegistration) { r.Generation++ }}, + {"controller session", func(r *udecx.DeviceRegistration) { r.ControllerSessionID++ }}, + {"controller root", func(r *udecx.DeviceRegistration) { r.ControllerInstanceID = `ROOT\VIIPERUDE\0001` }}, + {"usb port", func(r *udecx.DeviceRegistration) { r.USB20PortNumber++ }}, + {"two ports", func(r *udecx.DeviceRegistration) { r.USB30PortNumber = udecx.MaxDevices + 1 }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + driver := &nativeTransportTestDriver{} + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98105) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + _, registration, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || registration == nil { + t.Fatalf("add native device: registration=%v error=%v", registration, err) + } + stale := *registration + mutation.edit(&stale) + err = server.RemoveNativeDeviceExact(bus.BusID(), "1", stale) + if err == nil { + t.Fatal("malformed/stale exact remove unexpectedly succeeded") + } + if len(driver.destroyed) != 0 || len(bus.Devices()) != 1 { + t.Fatalf("rejected exact remove mutated device: destroyed=%d devices=%d", + len(driver.destroyed), len(bus.Devices())) + } + }) + } +} + +func TestNativeDeviceListSnapshotCannotObserveHalfRemovedRegistration(t *testing.T) { + driver := &blockingDestroyNativeTransportTestDriver{ + destroyStarted: make(chan struct{}), allowDestroy: make(chan struct{}), + } + host, err := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) + if err != nil { + t.Fatal(err) + } + server := New(ServerConfig{ + ConnectionTimeout: time.Second, BusCleanupTimeout: time.Hour, + }, slog.Default(), nil) + if err := server.EnableNativeTransport(host); err != nil { + t.Fatal(err) + } + bus, err := virtualbus.NewWithBusID(98108) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + _, registration, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || registration == nil { + t.Fatalf("add native device: registration=%v error=%v", registration, err) + } + + removeDone := make(chan error, 1) + go func() { + removeDone <- server.RemoveNativeDeviceExact(bus.BusID(), "1", *registration) + }() + <-driver.destroyStarted + + snapshotDone := make(chan struct { + snapshots []BusDeviceSnapshot + err error + }, 1) + go func() { + snapshots, snapshotErr := server.SnapshotBusDevices(bus.BusID()) + snapshotDone <- struct { + snapshots []BusDeviceSnapshot + err error + }{snapshots, snapshotErr} + }() + select { + case result := <-snapshotDone: + t.Fatalf("snapshot crossed in-progress removal: snapshots=%v error=%v", + result.snapshots, result.err) + case <-time.After(25 * time.Millisecond): + } + + close(driver.allowDestroy) + if err := <-removeDone; err != nil { + t.Fatal(err) + } + result := <-snapshotDone + if result.err != nil || len(result.snapshots) != 0 { + t.Fatalf("post-removal snapshot=%v error=%v want empty", result.snapshots, result.err) + } + + _, successor, err := server.AddDeviceToBusWithRegistration( + context.Background(), bus.BusID(), newNativeTransportTestDevice()) + if err != nil || successor == nil { + t.Fatalf("add successor: registration=%v error=%v", successor, err) + } + snapshots, err := server.SnapshotBusDevices(bus.BusID()) + if err != nil || len(snapshots) != 1 || snapshots[0].NativeRegistration == nil || + snapshots[0].NativeRegistration.DeviceIdentity != successor.DeviceIdentity { + t.Fatalf("successor snapshot=%v error=%v want exact %+v", + snapshots, err, successor.DeviceIdentity) + } +} + +func TestEmptyBusCleanupCannotRemoveReusedBusInstance(t *testing.T) { + server := New(ServerConfig{BusCleanupTimeout: 75 * time.Millisecond}, slog.Default(), nil) + oldBus, err := virtualbus.NewWithBusID(98106) + if err != nil { + t.Fatal(err) + } + if err := server.AddBus(oldBus); err != nil { + t.Fatal(err) + } + if _, err := oldBus.Add(newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if err := server.RemoveDeviceByID(oldBus.BusID(), "1"); err != nil { + t.Fatal(err) + } + if err := server.RemoveBus(oldBus.BusID()); err != nil { + t.Fatal(err) + } + + successor, err := virtualbus.NewWithBusID(oldBus.BusID()) + if err != nil { + t.Fatal(err) + } + defer successor.Close() + if err := server.AddBus(successor); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + if current := server.GetBus(successor.BusID()); current != successor { + t.Fatalf("stale empty-bus timer removed successor bus: current=%p successor=%p", current, successor) + } +} + +func TestEmptyBusRemovalRechecksNonemptyStateUnderLifecycleLock(t *testing.T) { + server := New(ServerConfig{}, slog.Default(), nil) + bus, err := virtualbus.NewWithBusID(98107) + if err != nil { + t.Fatal(err) + } + defer bus.Close() + if err := server.AddBus(bus); err != nil { + t.Fatal(err) + } + if _, err := bus.Add(newNativeTransportTestDevice()); err != nil { + t.Fatal(err) + } + if err := server.removeCurrentBusIfEmpty(bus.BusID(), bus); !errors.Is(err, ErrBusNotEmpty) { + t.Fatalf("nonempty exact bus cleanup error=%v want ErrBusNotEmpty", err) + } + if current := server.GetBus(bus.BusID()); current != bus || len(bus.Devices()) != 1 { + t.Fatalf("nonempty exact bus cleanup mutated bus: current=%p bus=%p devices=%d", + current, bus, len(bus.Devices())) + } +} + func TestNativeTransportRollsBackVirtualBusWhenPlugInFails(t *testing.T) { driver := &nativeTransportTestDriver{createErr: errors.New("driver rejected child")} host, _ := udecx.NewHost(driver, &nativeTransportTestProcessor{}, 1) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index eab6e446..c54e4e98 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -213,7 +213,7 @@ type Server struct { ln net.Listener nativeMu sync.Mutex native *udecx.Host - nativeIDs map[nativeDeviceKey]udecx.DeviceIdentity + nativeIDs map[nativeDeviceKey]udecx.DeviceRegistration } type nativeDeviceKey struct { @@ -221,6 +221,25 @@ type nativeDeviceKey struct { devID uint32 } +// BusDeviceSnapshot is one topology entry and its exact native mutation +// receipt captured under the server lifecycle lock. NativeRegistration is nil +// only when the server is operating in legacy USB/IP mode. +type BusDeviceSnapshot struct { + DeviceMeta virtualbus.DeviceMeta + NativeRegistration *udecx.DeviceRegistration +} + +var ( + // ErrNativeDeviceCorrelationMismatch means the caller's immutable receipt + // no longer identifies the device currently occupying the requested IDs. + // Callers must treat it as a benign stale lifetime and never retry by ID. + ErrNativeDeviceCorrelationMismatch = errors.New("native UDE device correlation mismatch") + ErrInvalidNativeDeviceCorrelation = errors.New("invalid native UDE device correlation") + ErrBusNotFound = errors.New("bus not found") + ErrBusNotEmpty = errors.New("bus is not empty") + ErrBusInstanceChanged = errors.New("bus instance changed") +) + func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Server { return &Server{ config: &config, @@ -229,7 +248,7 @@ func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Ser busses: make(map[uint32]*virtualbus.VirtualBus), alts: make(map[usb.Device]map[uint8]uint8), ready: make(chan struct{}), - nativeIDs: make(map[nativeDeviceKey]udecx.DeviceIdentity), + nativeIDs: make(map[nativeDeviceKey]udecx.DeviceRegistration), } } @@ -263,38 +282,132 @@ func nativeDeviceID(busID, devID uint32) uint64 { // AddDeviceToBus publishes a device transactionally. A failed native plug-in // rolls the in-memory bus back before the device becomes visible to clients. func (s *Server) AddDeviceToBus(ctx context.Context, busID uint32, dev usb.Device) (context.Context, error) { + deviceCtx, _, err := s.AddDeviceToBusWithRegistration(ctx, busID, dev) + return deviceCtx, err +} + +// AddDeviceToBusWithRegistration returns the source-authoritative native +// correlation receipt atomically with publication. USB/IP mode returns nil. +func (s *Server) AddDeviceToBusWithRegistration( + ctx context.Context, busID uint32, dev usb.Device, +) (context.Context, *udecx.DeviceRegistration, error) { s.lifecycleMu.Lock() defer s.lifecycleMu.Unlock() bus := s.GetBus(busID) if bus == nil { - return nil, fmt.Errorf("bus %d not found", busID) + return nil, nil, fmt.Errorf("bus %d not found", busID) } deviceCtx, err := bus.Add(dev) if err != nil { - return nil, err + return nil, nil, err } meta := device.GetDeviceMeta(deviceCtx) if meta == nil { _ = bus.Remove(dev) - return nil, errors.New("virtual bus returned no device metadata") + return nil, nil, errors.New("virtual bus returned no device metadata") } s.nativeMu.Lock() host := s.native if host == nil { s.nativeMu.Unlock() - return deviceCtx, nil + return deviceCtx, nil, nil } - identity, err := host.Register(ctx, nativeDeviceID(busID, meta.DevID), dev) + registration, err := host.RegisterWithCorrelation(ctx, nativeDeviceID(busID, meta.DevID), dev) if err != nil { s.nativeMu.Unlock() _ = bus.Remove(dev) - return nil, fmt.Errorf("plug native UDE device: %w", err) + return nil, nil, fmt.Errorf("plug native UDE device: %w", err) } - s.nativeIDs[nativeDeviceKey{busID: busID, devID: meta.DevID}] = identity + s.nativeIDs[nativeDeviceKey{busID: busID, devID: meta.DevID}] = registration s.nativeMu.Unlock() - return deviceCtx, nil + return deviceCtx, ®istration, nil +} + +// SnapshotBusDevices captures the entire bus topology and native registration +// table as one lifecycle transaction. A list response can therefore never pair +// an old device's metadata with a successor's remove-authority receipt after a +// bus/device ID is reused. +func (s *Server) SnapshotBusDevices(busID uint32) ([]BusDeviceSnapshot, error) { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return nil, fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + metas := bus.GetAllDeviceMetas() + snapshots := make([]BusDeviceSnapshot, 0, len(metas)) + + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + nativeTransport := s.native != nil + for _, meta := range metas { + snapshot := BusDeviceSnapshot{DeviceMeta: meta} + if nativeTransport { + registration, ok := s.nativeIDs[nativeDeviceKey{ + busID: meta.Meta.BusID, devID: meta.Meta.DevID, + }] + if !ok { + return nil, fmt.Errorf("%w: native device %d/%d has no receipt", + ErrNativeDeviceCorrelationMismatch, meta.Meta.BusID, meta.Meta.DevID) + } + copy := registration + snapshot.NativeRegistration = © + } + snapshots = append(snapshots, snapshot) + } + return snapshots, nil +} + +// NativeDeviceRegistrationForDevice returns a native registration only when +// the caller's exact device object and bus-owned lifetime context are still the +// current occupant of the requested slot. Holding lifecycleMu across the bus +// and native-table observations closes the interval in which removal has +// retired the registration but has not yet cancelled the old device context. +func (s *Server) NativeDeviceRegistrationForDevice( + busID, devID uint32, expectedDevice usb.Device, expectedContext context.Context, +) (udecx.DeviceRegistration, bool) { + if expectedDevice == nil || expectedContext == nil || expectedContext.Done() == nil { + return udecx.DeviceRegistration{}, false + } + + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return udecx.DeviceRegistration{}, false + } + + foundExactDevice := false + for _, meta := range bus.GetAllDeviceMetas() { + if meta.Meta.DevID == devID && meta.Dev == expectedDevice { + foundExactDevice = true + break + } + } + if !foundExactDevice { + return udecx.DeviceRegistration{}, false + } + currentContext := bus.GetDeviceContext(expectedDevice) + if currentContext == nil || currentContext.Done() == nil || + currentContext.Done() != expectedContext.Done() { + return udecx.DeviceRegistration{}, false + } + + s.nativeMu.Lock() + defer s.nativeMu.Unlock() + if s.native == nil { + return udecx.DeviceRegistration{}, false + } + registration, ok := s.nativeIDs[nativeDeviceKey{busID: busID, devID: devID}] + return registration, ok } // AddBus registers a bus with the server. If the bus number is already present, @@ -321,6 +434,41 @@ func (s *Server) RemoveBus(busID uint32) error { return s.removeBus(busID) } +// RemoveBusIfEmpty is the safe public native-transport bus cleanup. The empty +// proof and removal are one lifecycle transaction, so a concurrent device add +// cannot turn an empty cleanup request into a non-empty bus teardown. +func (s *Server) RemoveBusIfEmpty(busID uint32) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeCurrentBusIfEmptyLocked(busID, nil) +} + +func (s *Server) removeCurrentBusIfEmpty( + busID uint32, expected *virtualbus.VirtualBus, +) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + return s.removeCurrentBusIfEmptyLocked(busID, expected) +} + +func (s *Server) removeCurrentBusIfEmptyLocked( + busID uint32, expected *virtualbus.VirtualBus, +) error { + s.busesMu.Lock() + current := s.busses[busID] + s.busesMu.Unlock() + if current == nil { + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + if expected != nil && current != expected { + return fmt.Errorf("%w: %d", ErrBusInstanceChanged, busID) + } + if len(current.Devices()) != 0 { + return fmt.Errorf("%w: %d", ErrBusNotEmpty, busID) + } + return s.removeBus(busID) +} + func (s *Server) removeBus(busID uint32) error { s.busesMu.Lock() bus, ok := s.busses[busID] @@ -335,7 +483,7 @@ func (s *Server) removeBus(busID uint32) error { if len(devices) > 0 { s.logger.Warn(fmt.Sprintf("Removing non-empty bus %d with %d device(s) attached; removing devices", busID, len(devices))) for _, meta := range bus.GetAllDeviceMetas() { - if err := s.removeDevice(busID, meta.Meta.DevID, false); err != nil { + if err := s.removeDevice(busID, meta.Meta.DevID, false, nil); err != nil { return err } } @@ -358,55 +506,119 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { s.busesMu.Unlock() if !ok { - return fmt.Errorf("bus %d not found", busID) + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) } parsedDeviceID, err := strconv.ParseUint(deviceID, 10, 32) if err != nil { return fmt.Errorf("invalid device id %q: %w", deviceID, err) } - err = s.removeDevice(busID, uint32(parsedDeviceID), true) + err = s.removeDevice(busID, uint32(parsedDeviceID), true, nil) if err != nil { return err } + s.scheduleEmptyBusCleanup(busID, bus) + + return nil +} + +// RemoveNativeDeviceExact removes a native child only when the caller's full +// immutable correlation receipt still matches the registration occupying the +// requested bus/device IDs. Comparison and mutation share lifecycleMu and +// nativeMu, closing the lookup-then-remove race across controller restarts. +func (s *Server) RemoveNativeDeviceExact( + busID uint32, deviceID string, expected udecx.DeviceRegistration, +) error { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + parsedDeviceID, err := strconv.ParseUint(deviceID, 10, 32) + if err != nil || parsedDeviceID == 0 || strconv.FormatUint(parsedDeviceID, 10) != deviceID { + return fmt.Errorf("%w: non-canonical device id %q", ErrInvalidNativeDeviceCorrelation, deviceID) + } + deviceIDNumber := uint32(parsedDeviceID) + if err := validateExpectedNativeDeviceCorrelation(busID, deviceIDNumber, expected); err != nil { + return err + } + + s.busesMu.Lock() + bus := s.busses[busID] + s.busesMu.Unlock() + if bus == nil { + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) + } + if err := s.removeDevice(busID, deviceIDNumber, true, &expected); err != nil { + return err + } + s.scheduleEmptyBusCleanup(busID, bus) + return nil +} + +func validateExpectedNativeDeviceCorrelation( + busID, deviceID uint32, expected udecx.DeviceRegistration, +) error { + validUSB20 := expected.USB20PortNumber != 0 && + expected.USB20PortNumber <= udecx.MaxDevices && expected.USB30PortNumber == 0 + validUSB30 := expected.USB30PortNumber > udecx.MaxDevices && + expected.USB30PortNumber <= 2*udecx.MaxDevices && expected.USB20PortNumber == 0 + if expected.DeviceID == 0 || expected.DeviceID != nativeDeviceID(busID, deviceID) || + expected.Generation == 0 || expected.ControllerSessionID == 0 || + !udecx.IsCanonicalControllerInstanceID(expected.ControllerInstanceID) || + (!validUSB20 && !validUSB30) { + return fmt.Errorf("%w for bus %d device %d", ErrInvalidNativeDeviceCorrelation, busID, deviceID) + } + return nil +} + +func nativeDeviceCorrelationMatches( + current, expected udecx.DeviceRegistration, +) bool { + return current.DeviceIdentity == expected.DeviceIdentity && + current.ControllerSessionID == expected.ControllerSessionID && + strings.EqualFold(current.ControllerInstanceID, expected.ControllerInstanceID) && + current.USB20PortNumber == expected.USB20PortNumber && + current.USB30PortNumber == expected.USB30PortNumber +} + +func (s *Server) scheduleEmptyBusCleanup(busID uint32, bus *virtualbus.VirtualBus) { + remove := func() { + err := s.removeCurrentBusIfEmpty(busID, bus) + switch { + case err == nil: + s.logger.Info("timeout: removed empty bus", "busID", busID) + case errors.Is(err, ErrBusNotFound), errors.Is(err, ErrBusNotEmpty), + errors.Is(err, ErrBusInstanceChanged): + s.logger.Debug("empty bus cleanup retired without mutation", "busID", busID, "error", err) + default: + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } + } if emptyCtx := bus.GetBusEmptyContext(); emptyCtx != nil { go func() { - slog.Debug("Started bus cleanup goroutine (RemoveDeviceByID)") + slog.Debug("Started exact bus cleanup goroutine") select { case <-emptyCtx.Done(): // Cancelled - a new device was added return case <-time.After(s.config.BusCleanupTimeout): - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } + remove() } }() } else { s.logger.Debug("No bus empty context; Cleaning bus immediately") - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.removeBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } + go remove() } - - return nil } -func (s *Server) removeDevice(busID, deviceID uint32, requireBus bool) error { +func (s *Server) removeDevice( + busID, deviceID uint32, requireBus bool, expected *udecx.DeviceRegistration, +) error { s.busesMu.Lock() bus := s.busses[busID] s.busesMu.Unlock() if bus == nil { if requireBus { - return fmt.Errorf("bus %d not found", busID) + return fmt.Errorf("%w: %d", ErrBusNotFound, busID) } return nil } @@ -414,14 +626,19 @@ func (s *Server) removeDevice(busID, deviceID uint32, requireBus bool) error { key := nativeDeviceKey{busID: busID, devID: deviceID} s.nativeMu.Lock() host := s.native - identity, registered := s.nativeIDs[key] + registration, registered := s.nativeIDs[key] + if expected != nil && (host == nil || !registered || + !nativeDeviceCorrelationMatches(registration, *expected)) { + s.nativeMu.Unlock() + return fmt.Errorf("%w for bus %d device %d", ErrNativeDeviceCorrelationMismatch, busID, deviceID) + } if host != nil && registered { timeout := s.config.ConnectionTimeout if timeout <= 0 { timeout = 30 * time.Second } ctx, cancel := context.WithTimeout(context.Background(), timeout) - err := host.Unregister(ctx, identity) + err := host.Unregister(ctx, registration.DeviceIdentity) cancel() if err != nil { s.nativeMu.Unlock() @@ -910,33 +1127,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { case <-ctx.Done(): s.logger.Info("device removed, closing URB stream") busID := owningBus.BusID() - if emptyCtx := owningBus.GetBusEmptyContext(); emptyCtx != nil { - go func() { - slog.Debug("Started bus cleanup goroutine (HandleUrbStream ctx.Done)") - select { - case <-emptyCtx.Done(): - // Cancelled - a new device was added - return - case <-time.After(s.config.BusCleanupTimeout): - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } - } - }() - } else { - s.logger.Debug("No bus empty context; Cleaning bus immediately") - if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { - if err := s.RemoveBus(busID); err != nil { - s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) - } else { - s.logger.Info("timeout: removed empty bus", "busID", busID) - } - } - } + s.scheduleEmptyBusCleanup(busID, owningBus) return nil default: } diff --git a/internal/transport/udecx/client_windows.go b/internal/transport/udecx/client_windows.go index e0ae5883..8c50ed7d 100644 --- a/internal/transport/udecx/client_windows.go +++ b/internal/transport/udecx/client_windows.go @@ -11,6 +11,7 @@ import ( "fmt" "log/slog" "runtime" + "strings" "sync" "sync/atomic" "time" @@ -24,6 +25,8 @@ const ( crSuccess = 0 crBufferSmall = 0x1a cmGetDeviceInterfaceListPresent = 0 + digcfPresent = 0x00000002 + digcfDeviceInterface = 0x00000010 fileDeviceUnknown = 0x22 methodBuffered = 0 methodInDirect = 1 @@ -54,6 +57,25 @@ const ( cancellationWatchdogInterval = 5 * time.Second ) +type spDeviceInterfaceData struct { + CbSize uint32 + InterfaceClassGUID windows.GUID + Flags uint32 + Reserved uintptr +} + +type spDeviceInfoData struct { + CbSize uint32 + ClassGUID windows.GUID + DevInst uint32 + Reserved uintptr +} + +type spDeviceInterfaceDetailData struct { + CbSize uint32 + DevicePath [1]uint16 +} + // AcquisitionErrorKind identifies the only two controller-open failures that // can resolve without repairing or reconfiguring the installed driver. Keep // this set deliberately narrow: permission, ABI, ambiguity, and device faults @@ -111,11 +133,17 @@ var ( Data3: 0x4baa, Data4: [8]byte{0x97, 0x0f, 0x7f, 0x5d, 0xe6, 0xc4, 0x46, 0x87}, } - cfgmgr32 = windows.NewLazySystemDLL("cfgmgr32.dll") - procCMGetDeviceInterfaceListSize = cfgmgr32.NewProc("CM_Get_Device_Interface_List_SizeW") - procCMGetDeviceInterfaceList = cfgmgr32.NewProc("CM_Get_Device_Interface_ListW") - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - procSetFileCompletionModes = kernel32.NewProc("SetFileCompletionNotificationModes") + cfgmgr32 = windows.NewLazySystemDLL("cfgmgr32.dll") + procCMGetDeviceInterfaceListSize = cfgmgr32.NewProc("CM_Get_Device_Interface_List_SizeW") + procCMGetDeviceInterfaceList = cfgmgr32.NewProc("CM_Get_Device_Interface_ListW") + setupapi = windows.NewLazySystemDLL("setupapi.dll") + procSetupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW") + procSetupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces") + procSetupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW") + procSetupDiGetDeviceInstanceIdW = setupapi.NewProc("SetupDiGetDeviceInstanceIdW") + procSetupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procSetFileCompletionModes = kernel32.NewProc("SetFileCompletionNotificationModes") ) type Client struct { @@ -138,10 +166,11 @@ type Client struct { // driverNonce is the nonzero negotiated tag for this exact exclusive file // session. The Client and its Host are one-shot, so it cannot be inherited // by a successor handle or reused by a later worker/publication graph. - driverNonce uint64 - buildIdentity [BuildIdentitySize]byte - capabilities Capabilities - limits NegotiateResponse + driverNonce uint64 + buildIdentity [BuildIdentitySize]byte + controllerInstanceID string + capabilities Capabilities + limits NegotiateResponse // pendingObserver is a package-private synchronization seam for the // Windows IOCP stress harness. Production clients leave it nil. It runs // only after the overlapped issuer has returned ERROR_IO_PENDING, so tests can @@ -157,6 +186,10 @@ type Client struct { cancelIssuer func(windows.Handle, *windows.Overlapped) error cancellationWatchdog func() (<-chan time.Time, func()) slowCancellationObserver func(code uint32, elapsed time.Duration, count uint64) + // Deterministic seams for the committed-create rollback tests. Production + // leaves both nil and uses the exact driver plug-out plus owner-file close. + destroyForCreateRollback func(context.Context, DeviceIdentity) error + closeForCreateRollback func() error } type ioCompletion struct { @@ -180,11 +213,18 @@ type ioRequest struct { } func Open(ctx context.Context) (*Client, error) { + var selectedInterfacePath string handle, err := acquireNativeController(ctx, nativeAcquisitionOps{ discover: discoverNativeInterfacePaths, - open: openNativeController, - close: windows.CloseHandle, - wait: waitForNativeAcquisition, + open: func(openCtx context.Context, interfacePath string) (windows.Handle, error) { + handle, openErr := openNativeController(openCtx, interfacePath) + if openErr == nil && isUsableNativeHandle(handle) { + selectedInterfacePath = interfacePath + } + return handle, openErr + }, + close: windows.CloseHandle, + wait: waitForNativeAcquisition, }, nativeAcquisitionPolicy{ attempts: nativeAcquisitionAttempts, interval: nativeAcquisitionRetryInterval, @@ -192,6 +232,11 @@ func Open(ctx context.Context) (*Client, error) { if err != nil { return nil, err } + controllerInstanceID, err := controllerInstanceIDForInterfacePath(selectedInterfacePath) + if err != nil { + _ = windows.CloseHandle(handle) + return nil, fmt.Errorf("resolve native UDE controller identity: %w", err) + } completionPort, err := windows.CreateIoCompletionPort(handle, 0, 0, 0) if err != nil { @@ -203,6 +248,7 @@ func Open(ctx context.Context) (*Client, error) { completionPort: completionPort, pumpDone: make(chan struct{}), skipCompletionPortOnSuccess: enableSkipCompletionPortOnSuccess(handle), + controllerInstanceID: controllerInstanceID, } client.requestPool.New = func() any { return &ioRequest{done: make(chan ioCompletion, 1)} @@ -522,6 +568,18 @@ func (c *Client) BuildIdentity() [BuildIdentitySize]byte { return c.buildIdentity } +func (c *Client) ControllerInstanceID() string { + return c.controllerInstanceID +} + +// ControllerSessionID is the nonzero kernel-authored nonce for this exact +// exclusive controller file session. It is stable across API and stream +// reconnects through the same broker, and changes whenever that controller +// session is recreated. +func (c *Client) ControllerSessionID() uint64 { + return c.driverNonce +} + func (c *Client) negotiate(ctx context.Context) error { expectedBuildIdentity, err := ExpectedBuildIdentity() if err != nil { @@ -610,18 +668,83 @@ func validateNegotiation(negotiated NegotiateResponse, nonce uint64, expectedBui return nil } -func (c *Client) CreateDevice(ctx context.Context, device CreateDevice) error { +func (c *Client) CreateDevice(ctx context.Context, device CreateDevice) (DeviceRegistration, error) { limits := c.Limits() if uint32(len(device.DescriptorData)) > limits.MaxDescriptorBytes || device.MaxPendingOperations > limits.MaxPendingOperations { - return ErrLimitExceeded + return DeviceRegistration{}, ErrLimitExceeded } request, err := device.MarshalBinary() if err != nil { - return err + return DeviceRegistration{}, err } - _, err = c.ioctl(ctx, ioctlCreateDevice, request, nil) - return err + response := make([]byte, CreateDeviceResultSize) + written, err := c.ioctl(ctx, ioctlCreateDevice, request, response) + if err != nil { + return DeviceRegistration{}, err + } + if written != CreateDeviceResultSize { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("native UDE create receipt: %w", ErrInvalidSize)) + } + result, err := ParseCreateDeviceResult(response) + if err != nil { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("parse native UDE create receipt: %w", err)) + } + if result.DeviceID != device.DeviceID || result.Generation != device.Generation || + result.Speed != device.Speed { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + fmt.Errorf("%w: native UDE create receipt does not match request", ErrInvalidRange)) + } + if c.controllerInstanceID == "" { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + errors.New("native UDE controller instance identity is unavailable")) + } + controllerSessionID := c.ControllerSessionID() + if controllerSessionID == 0 { + return DeviceRegistration{}, c.rollbackCommittedCreate(device, + errors.New("native UDE controller session identity is unavailable")) + } + return DeviceRegistration{ + DeviceIdentity: DeviceIdentity{DeviceID: result.DeviceID, Generation: result.Generation}, + Speed: result.Speed, USB20PortNumber: result.USB20PortNumber, + USB30PortNumber: result.USB30PortNumber, + ControllerSessionID: controllerSessionID, + ControllerInstanceID: c.controllerInstanceID, + }, nil +} + +func (c *Client) rollbackCommittedCreate(device CreateDevice, receiptErr error) error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + defer cancel() + identity := DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation} + var cleanupErr error + if c.destroyForCreateRollback != nil { + cleanupErr = c.destroyForCreateRollback(cleanupCtx, identity) + } else { + cleanupErr = c.DestroyDevice(cleanupCtx, identity) + } + if cleanupErr != nil { + // A malformed successful receipt is already a terminal session fault. If + // its exact plug-out is rejected, close the exclusive file immediately; + // the kernel's owner-cleanup join is the final authority that prevents an + // unrouteable child from surviving this failed registration. + var closeErr error + if c.closeForCreateRollback != nil { + closeErr = c.closeForCreateRollback() + } else { + closeErr = c.Close() + } + rollbackErr := fmt.Errorf( + "rollback native UDE device after invalid create receipt: %w", cleanupErr) + if closeErr != nil { + return errors.Join(receiptErr, rollbackErr, + fmt.Errorf("close native UDE owner session after uncertain create rollback: %w", closeErr)) + } + return errors.Join(receiptErr, rollbackErr) + } + return receiptErr } func (c *Client) DestroyDevice(ctx context.Context, identity DeviceIdentity) error { @@ -862,6 +985,81 @@ func discoverInterfacePaths(ctx context.Context) ([]string, error) { return nil, errors.New("native UDE interface list changed repeatedly during discovery") } +func controllerInstanceIDForInterfacePath(interfacePath string) (string, error) { + if strings.TrimSpace(interfacePath) == "" { + return "", errors.New("native UDE interface path is empty") + } + setValue, _, setErr := procSetupDiGetClassDevsW.Call( + uintptr(unsafe.Pointer(&interfaceGUID)), 0, 0, + uintptr(digcfPresent|digcfDeviceInterface)) + set := windows.Handle(setValue) + if set == windows.InvalidHandle { + if setErr != nil && !errors.Is(setErr, windows.ERROR_SUCCESS) { + return "", fmt.Errorf("SetupDiGetClassDevsW: %w", setErr) + } + return "", errors.New("SetupDiGetClassDevsW returned an invalid handle") + } + defer procSetupDiDestroyDeviceInfoList.Call(uintptr(set)) + + for index := uint32(0); ; index++ { + interfaceData := spDeviceInterfaceData{CbSize: uint32(unsafe.Sizeof(spDeviceInterfaceData{}))} + ok, _, enumErr := procSetupDiEnumDeviceInterfaces.Call( + uintptr(set), 0, uintptr(unsafe.Pointer(&interfaceGUID)), uintptr(index), + uintptr(unsafe.Pointer(&interfaceData))) + if ok == 0 { + if errors.Is(enumErr, windows.ERROR_NO_MORE_ITEMS) { + break + } + return "", fmt.Errorf("SetupDiEnumDeviceInterfaces(%d): %w", index, enumErr) + } + + var required uint32 + _, _, sizeErr := procSetupDiGetDeviceInterfaceDetailW.Call( + uintptr(set), uintptr(unsafe.Pointer(&interfaceData)), 0, 0, + uintptr(unsafe.Pointer(&required)), 0) + if required < uint32(unsafe.Sizeof(spDeviceInterfaceDetailData{})) || + !errors.Is(sizeErr, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", fmt.Errorf("SetupDiGetDeviceInterfaceDetailW size query: %w", sizeErr) + } + detailBytes := make([]byte, required) + detail := (*spDeviceInterfaceDetailData)(unsafe.Pointer(&detailBytes[0])) + detail.CbSize = uint32(unsafe.Sizeof(spDeviceInterfaceDetailData{})) + deviceInfo := spDeviceInfoData{CbSize: uint32(unsafe.Sizeof(spDeviceInfoData{}))} + ok, _, detailErr := procSetupDiGetDeviceInterfaceDetailW.Call( + uintptr(set), uintptr(unsafe.Pointer(&interfaceData)), + uintptr(unsafe.Pointer(detail)), uintptr(required), 0, + uintptr(unsafe.Pointer(&deviceInfo))) + if ok == 0 { + return "", fmt.Errorf("SetupDiGetDeviceInterfaceDetailW: %w", detailErr) + } + candidate := windows.UTF16PtrToString(&detail.DevicePath[0]) + if !strings.EqualFold(candidate, interfacePath) { + continue + } + + var instanceChars uint32 + _, _, instanceSizeErr := procSetupDiGetDeviceInstanceIdW.Call( + uintptr(set), uintptr(unsafe.Pointer(&deviceInfo)), 0, 0, + uintptr(unsafe.Pointer(&instanceChars))) + if instanceChars < 2 || !errors.Is(instanceSizeErr, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", fmt.Errorf("SetupDiGetDeviceInstanceIdW size query: %w", instanceSizeErr) + } + instanceBuffer := make([]uint16, instanceChars) + ok, _, instanceErr := procSetupDiGetDeviceInstanceIdW.Call( + uintptr(set), uintptr(unsafe.Pointer(&deviceInfo)), + uintptr(unsafe.Pointer(&instanceBuffer[0])), uintptr(instanceChars), 0) + if ok == 0 { + return "", fmt.Errorf("SetupDiGetDeviceInstanceIdW: %w", instanceErr) + } + instanceID := windows.UTF16ToString(instanceBuffer) + if !IsCanonicalControllerInstanceID(instanceID) { + return "", errors.New("native UDE controller returned an invalid instance identity") + } + return instanceID, nil + } + return "", errors.New("opened native UDE interface was not present in the verified SetupAPI set") +} + func parseMultiSZ(raw []uint16) []string { result := make([]string, 0, 1) start := 0 diff --git a/internal/transport/udecx/client_windows_test.go b/internal/transport/udecx/client_windows_test.go index a4474efb..ce7ffbc3 100644 --- a/internal/transport/udecx/client_windows_test.go +++ b/internal/transport/udecx/client_windows_test.go @@ -131,7 +131,7 @@ func TestNegotiationRejectsStaleLoadedKernelDespiteMatchingOnDiskPackageContract func TestClientRejectsRequestsOutsideNegotiatedLimitsBeforeKernelIO(t *testing.T) { client := &Client{limits: validTestNegotiation()} client.limits.MaxDescriptorBytes = 1 - if err := client.CreateDevice(context.Background(), CreateDevice{ + if _, err := client.CreateDevice(context.Background(), CreateDevice{ DescriptorData: []byte{1, 2}, }); !errors.Is(err, ErrLimitExceeded) { t.Fatalf("CreateDevice error=%v want ErrLimitExceeded", err) @@ -161,6 +161,42 @@ func TestCompletionPoolReusesBoundedMediaBuffer(t *testing.T) { client.releaseCompletionBuffer(second) } +func TestCommittedCreateRollbackClosesUncertainOwnerSession(t *testing.T) { + receiptErr := errors.New("malformed create receipt") + destroyErr := errors.New("exact plug-out rejected") + closeErr := errors.New("owner close reported failure") + device := CreateDevice{DeviceID: 0x100000002, Generation: 7} + + t.Run("exact destroy settles without closing", func(t *testing.T) { + closed := false + client := &Client{ + destroyForCreateRollback: func(ctx context.Context, identity DeviceIdentity) error { + if _, ok := ctx.Deadline(); !ok || identity != (DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}) { + t.Fatalf("rollback context/identity=(%v, %+v)", ctx, identity) + } + return nil + }, + closeForCreateRollback: func() error { closed = true; return nil }, + } + err := client.rollbackCommittedCreate(device, receiptErr) + if !errors.Is(err, receiptErr) || closed { + t.Fatalf("rollback error=%v closed=%t", err, closed) + } + }) + + t.Run("failed destroy closes and joins every authority", func(t *testing.T) { + closeCalls := 0 + client := &Client{ + destroyForCreateRollback: func(context.Context, DeviceIdentity) error { return destroyErr }, + closeForCreateRollback: func() error { closeCalls++; return closeErr }, + } + err := client.rollbackCommittedCreate(device, receiptErr) + if closeCalls != 1 || !errors.Is(err, receiptErr) || !errors.Is(err, destroyErr) || !errors.Is(err, closeErr) { + t.Fatalf("rollback error=%v closeCalls=%d", err, closeCalls) + } + }) +} + func TestIOCTLCodesMatchPackedHeader(t *testing.T) { wants := map[string]struct{ got, want uint32 }{ "negotiate": {ioctlNegotiate, 0x22e400}, diff --git a/internal/transport/udecx/driver_lifecycle_contract_test.go b/internal/transport/udecx/driver_lifecycle_contract_test.go index ba29c3fa..e51dbcab 100644 --- a/internal/transport/udecx/driver_lifecycle_contract_test.go +++ b/internal/transport/udecx/driver_lifecycle_contract_test.go @@ -254,6 +254,48 @@ func TestKernelPlugInPublishesCleanupAccountingBeforeUdeCxExposure(t *testing.T) } } +func TestKernelCreatePublishesAuthoritativeCorrelationReceipt(t *testing.T) { + device := nativeContractSource(t, "native", "udecx", "driver", "Device.c") + create := normalizedContract(nativeCFunction(t, device, "ViiperCreateVirtualDevice")) + requireContractOrder(t, create, + "WdfRequestRetrieveInputBuffer(Request, sizeof(*input), (PVOID *)&input, &inputLength);", + "ViiperValidateCreateDevice(input, inputLength)", + "WdfRequestRetrieveOutputBuffer( Request, sizeof(*output), (PVOID *)&output, &outputLength);", + "deviceId = input->DeviceId;", + "generation = input->Generation;", + "requestedSpeed = input->Speed;", + "ViiperBeginOwnerAdmission(controller, Request, &ownerFile);", + "ViiperClaimDeviceSlot(", + "plugOptions.Usb30PortNumber = (USHORT)(VIIPER_UDE_USB20_PORT_COUNT + slot + 1);", + "plugOptions.Usb20PortNumber = (USHORT)(slot + 1);", + "status = UdecxUsbDevicePlugIn(device, &plugOptions);") + + plug := strings.Index(create, "status = UdecxUsbDevicePlugIn(device, &plugOptions);") + if plug < 0 { + t.Fatal("kernel create lost its authoritative UdeCx plug-in boundary") + } + postPlug := create[plug:] + requireContractOrder(t, postPlug, + "status = UdecxUsbDevicePlugIn(device, &plugOptions);", + "if (!NT_SUCCESS(status))", + "goto ExitAdmission;", + "RtlZeroMemory(output, sizeof(*output));", + "output->Header.Magic = VIIPER_UDE_MAGIC;", + "output->Header.Major = VIIPER_UDE_ABI_MAJOR;", + "output->Header.Minor = VIIPER_UDE_ABI_MINOR;", + "output->Header.Size = sizeof(*output);", + "output->DeviceId = deviceId;", + "output->Generation = generation;", + "output->Speed = requestedSpeed;", + "output->Usb20PortNumber = plugOptions.Usb20PortNumber;", + "output->Usb30PortNumber = plugOptions.Usb30PortNumber;", + "WdfRequestSetInformation(Request, sizeof(*output));", + "status = STATUS_SUCCESS;") + if strings.Contains(create[:plug], "WdfRequestSetInformation") { + t.Fatal("kernel create publishes a correlation receipt before UdeCx accepts the device") + } +} + type modeledPortDevice struct { slot int token uint64 diff --git a/internal/transport/udecx/host.go b/internal/transport/udecx/host.go index 00f62bc4..37aa183a 100644 --- a/internal/transport/udecx/host.go +++ b/internal/transport/udecx/host.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "math" + "strings" "sync" "sync/atomic" "time" @@ -32,7 +33,7 @@ var errInputSequenceExhausted = errors.New("native UDE input report sequence is // Windows UdeCx client. Keeping it as an interface makes ordering, teardown, // and stale-generation behavior testable without loading a kernel driver. type Driver interface { - CreateDevice(context.Context, CreateDevice) error + CreateDevice(context.Context, CreateDevice) (DeviceRegistration, error) // DestroyDevice returns an error only if removal was rejected before the // kernel transferred ownership to UdeCx. Once accepted, any terminal // UdeCx removal fault is recovered by restarting the controller and the @@ -157,22 +158,24 @@ type Host struct { processor OperationProcessor workers int - lifecycleMu sync.Mutex - lifecycles map[uint64]*deviceLifecycleGate - mu sync.RWMutex - devices map[uint64]*registeredDevice - generations map[uint64]uint32 - lanes map[laneKey]*operationLane - failedLanes map[laneKey]error - runCtx context.Context - runCancel context.CancelFunc - fatal chan error - started bool - running bool - laneWG sync.WaitGroup - operationMu sync.Mutex - operations map[uint64]*operationState - completed []uint64 + lifecycleMu sync.Mutex + lifecycles map[uint64]*deviceLifecycleGate + mu sync.RWMutex + devices map[uint64]*registeredDevice + generations map[uint64]uint32 + controllerSessionID uint64 + controllerInstanceID string + lanes map[laneKey]*operationLane + failedLanes map[laneKey]error + runCtx context.Context + runCancel context.CancelFunc + fatal chan error + started bool + running bool + laneWG sync.WaitGroup + operationMu sync.Mutex + operations map[uint64]*operationState + completed []uint64 inputPublisherStarts atomic.Uint64 legacyTransferFallbackStarts atomic.Uint64 @@ -293,12 +296,20 @@ func fastInputEndpoints(dev usb.Device) map[uint8]fastInputEndpoint { return result } -// Register publishes a USB device using a fresh generation. The routing entry -// is installed before the driver plugs in the child because Windows can submit -// its first descriptor request before CreateDevice returns. +// Register preserves the historical lifecycle API for callers that need only +// the exact device/generation identity. func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceIdentity, error) { + registration, err := h.RegisterWithCorrelation(ctx, deviceID, dev) + return registration.DeviceIdentity, err +} + +// RegisterWithCorrelation publishes a USB device using a fresh generation and +// returns the kernel-authored PnP correlation receipt. The routing entry is +// installed before the driver plugs in the child because Windows can submit +// its first descriptor request before CreateDevice returns. +func (h *Host) RegisterWithCorrelation(ctx context.Context, deviceID uint64, dev usb.Device) (DeviceRegistration, error) { if deviceID == 0 || dev == nil { - return DeviceIdentity{}, ErrInvalidRange + return DeviceRegistration{}, ErrInvalidRange } unlockLifecycle := h.lockDeviceLifecycle(deviceID) defer unlockLifecycle() @@ -310,15 +321,15 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D // instead of publishing a child into a terminal owner session. if h.started && (!h.running || h.runCtx == nil || h.runCtx.Err() != nil) { h.mu.Unlock() - return DeviceIdentity{}, errors.New("native UDE host session has stopped; open a fresh driver session") + return DeviceRegistration{}, errors.New("native UDE host session has stopped; open a fresh driver session") } if _, exists := h.devices[deviceID]; exists { h.mu.Unlock() - return DeviceIdentity{}, fmt.Errorf("native UDE device %d is already registered", deviceID) + return DeviceRegistration{}, fmt.Errorf("native UDE device %d is already registered", deviceID) } if h.generations[deviceID] == math.MaxUint32 { h.mu.Unlock() - return DeviceIdentity{}, fmt.Errorf( + return DeviceRegistration{}, fmt.Errorf( "native UDE device %d exhausted its generation space", deviceID) } generation := h.generations[deviceID] + 1 @@ -336,18 +347,45 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D h.generations[deviceID] = generation h.mu.Unlock() + var registration DeviceRegistration + driverCommitted := false snapshot, err := SnapshotDevice(deviceID, generation, dev) if err == nil { - err = h.driver.CreateDevice(ctx, snapshot) + registration, err = h.driver.CreateDevice(ctx, snapshot) + if err == nil { + driverCommitted = true + if !deviceRegistrationMatchesCreate(registration, snapshot) { + err = errors.New("native UDE driver returned an invalid device-correlation receipt") + } else { + h.mu.Lock() + if h.controllerSessionID == 0 { + h.controllerSessionID = registration.ControllerSessionID + h.controllerInstanceID = registration.ControllerInstanceID + } else if h.controllerSessionID != registration.ControllerSessionID || + !strings.EqualFold(h.controllerInstanceID, registration.ControllerInstanceID) { + err = errors.New("native UDE driver changed controller identity within one host session") + } + h.mu.Unlock() + } + } } if err != nil { + if driverCommitted { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), terminalCleanupTimeout) + cleanupErr := h.driver.DestroyDevice(cleanupCtx, identity) + cleanupCancel() + if cleanupErr != nil { + err = errors.Join(err, fmt.Errorf( + "rollback native UDE device after invalid correlation receipt: %w", cleanupErr)) + } + } h.mu.Lock() if h.devices[deviceID] == entry { delete(h.devices, deviceID) } h.mu.Unlock() cancel() - return DeviceIdentity{}, err + return DeviceRegistration{}, err } // CreateDevice is an overlapped PnP transaction and can outlive a fatal or @@ -374,15 +412,31 @@ func (h *Host) Register(ctx context.Context, deviceID uint64, dev usb.Device) (D cancel() if cleanupErr == nil { h.processor.Reset(dev, identity) - return DeviceIdentity{}, errors.New( + return DeviceRegistration{}, errors.New( "native UDE host session stopped while controller registration was in flight") } - return DeviceIdentity{}, errors.Join( + return DeviceRegistration{}, errors.Join( errors.New("native UDE host session stopped while controller registration was in flight"), fmt.Errorf("rollback native UDE device %d generation %d: %w", identity.DeviceID, identity.Generation, cleanupErr)) } - return identity, nil + return registration, nil +} + +func deviceRegistrationMatchesCreate(registration DeviceRegistration, requested CreateDevice) bool { + if registration.DeviceIdentity != (DeviceIdentity{ + DeviceID: requested.DeviceID, Generation: requested.Generation, + }) || registration.Speed != requested.Speed || registration.ControllerSessionID == 0 || + !IsCanonicalControllerInstanceID(registration.ControllerInstanceID) { + return false + } + if requested.Speed == DeviceSpeedSuper { + return registration.USB20PortNumber == 0 && + registration.USB30PortNumber > MaxDevices && + registration.USB30PortNumber <= 2*MaxDevices + } + return registration.USB30PortNumber == 0 && registration.USB20PortNumber != 0 && + registration.USB20PortNumber <= MaxDevices } func (h *Host) Unregister(ctx context.Context, identity DeviceIdentity) error { diff --git a/internal/transport/udecx/host_test.go b/internal/transport/udecx/host_test.go index f999275f..7d93fa3d 100644 --- a/internal/transport/udecx/host_test.go +++ b/internal/transport/udecx/host_test.go @@ -15,14 +15,15 @@ import ( ) type fakeHostDriver struct { - operations chan Operation - completions chan Completion - createErr error - mu sync.Mutex - created []CreateDevice - destroyed []DeviceIdentity - destroyErr error - completeErr error + operations chan Operation + completions chan Completion + createErr error + mutateRegistration func(*DeviceRegistration) + mu sync.Mutex + created []CreateDevice + destroyed []DeviceIdentity + destroyErr error + completeErr error } type fastInputDriver struct { @@ -72,13 +73,13 @@ type independentlyBlockingCreateDriver struct { func (d *independentlyBlockingCreateDriver) CreateDevice( ctx context.Context, device CreateDevice, -) error { +) (DeviceRegistration, error) { if device.DeviceID == d.blockedDevice { close(d.started) select { case <-d.release: case <-ctx.Done(): - return ctx.Err() + return DeviceRegistration{}, ctx.Err() } } return d.fakeHostDriver.CreateDevice(ctx, device) @@ -137,11 +138,28 @@ func newFakeHostDriver() *fakeHostDriver { operations: make(chan Operation, 16), completions: make(chan Completion, 16), } } -func (d *fakeHostDriver) CreateDevice(_ context.Context, device CreateDevice) error { +func (d *fakeHostDriver) CreateDevice(_ context.Context, device CreateDevice) (DeviceRegistration, error) { d.mu.Lock() defer d.mu.Unlock() d.created = append(d.created, device) - return d.createErr + if d.createErr != nil { + return DeviceRegistration{}, d.createErr + } + registration := DeviceRegistration{ + DeviceIdentity: DeviceIdentity{DeviceID: device.DeviceID, Generation: device.Generation}, + Speed: device.Speed, ControllerSessionID: 17, + ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + } + port := uint32((device.DeviceID-1)%MaxDevices + 1) + if device.Speed == DeviceSpeedSuper { + registration.USB30PortNumber = MaxDevices + port + } else { + registration.USB20PortNumber = port + } + if d.mutateRegistration != nil { + d.mutateRegistration(®istration) + } + return registration, nil } func (d *fakeHostDriver) DestroyDevice(_ context.Context, identity DeviceIdentity) error { d.mu.Lock() @@ -3323,6 +3341,82 @@ func TestHostRegisterFailureRollsBackButAdvancesGeneration(t *testing.T) { } } +func TestHostRejectsAndRollsBackMalformedCorrelationReceipt(t *testing.T) { + for name, mutate := range map[string]func(*DeviceRegistration){ + "zero session": func(r *DeviceRegistration) { r.ControllerSessionID = 0 }, + "wrong controller": func(r *DeviceRegistration) { + r.ControllerInstanceID = `ROOT\VIIPERUDE\42` + }, + "wrong device": func(r *DeviceRegistration) { r.DeviceID++ }, + "wrong generation": func(r *DeviceRegistration) { r.Generation++ }, + "wrong speed": func(r *DeviceRegistration) { r.Speed = DeviceSpeedSuper }, + "two ports": func(r *DeviceRegistration) { r.USB30PortNumber = MaxDevices + 1 }, + "USB2 port above range": func(r *DeviceRegistration) { + r.USB20PortNumber = MaxDevices + 1 + }, + } { + t.Run(name, func(t *testing.T) { + driver := newFakeHostDriver() + driver.mutateRegistration = mutate + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + if _, err = host.RegisterWithCorrelation(context.Background(), 41, hostTestDevice()); err == nil { + t.Fatal("malformed driver receipt was accepted") + } + driver.mu.Lock() + destroyed := append([]DeviceIdentity(nil), driver.destroyed...) + driver.mu.Unlock() + if len(destroyed) != 1 || destroyed[0] != (DeviceIdentity{DeviceID: 41, Generation: 1}) { + t.Fatalf("rollback identities=%+v", destroyed) + } + driver.mutateRegistration = nil + registration, err := host.RegisterWithCorrelation(context.Background(), 41, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + if registration.Generation != 2 || registration.ControllerSessionID != 17 { + t.Fatalf("replacement registration=%+v", registration) + } + }) + } +} + +func TestHostFencesControllerIdentityForItsLifetime(t *testing.T) { + driver := newFakeHostDriver() + processor := &recordingProcessor{ + processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 2), + } + host, err := NewHost(driver, processor, 1) + if err != nil { + t.Fatal(err) + } + first, err := host.RegisterWithCorrelation(context.Background(), 51, hostTestDevice()) + if err != nil { + t.Fatal(err) + } + driver.mutateRegistration = func(registration *DeviceRegistration) { + registration.ControllerSessionID++ + } + if _, err = host.RegisterWithCorrelation(context.Background(), 52, hostTestDevice()); err == nil { + t.Fatal("one Host accepted a device from a different controller session") + } + driver.mu.Lock() + destroyed := append([]DeviceIdentity(nil), driver.destroyed...) + driver.mu.Unlock() + if len(destroyed) != 1 || destroyed[0] != (DeviceIdentity{DeviceID: 52, Generation: 1}) { + t.Fatalf("mismatched-session rollback=%+v", destroyed) + } + driver.mutateRegistration = nil + if err = host.Unregister(context.Background(), first.DeviceIdentity); err != nil { + t.Fatal(err) + } +} + func TestHostUnregisterFailureKeepsDeviceRetryable(t *testing.T) { driver := newFakeHostDriver() processor := &recordingProcessor{processed: make(chan uint64, 1), resets: make(chan DeviceIdentity, 1)} diff --git a/internal/transport/udecx/protocol.go b/internal/transport/udecx/protocol.go index c065c4bf..6c752192 100644 --- a/internal/transport/udecx/protocol.go +++ b/internal/transport/udecx/protocol.go @@ -10,18 +10,19 @@ import ( "errors" "fmt" "math" + "strconv" "strings" ) const ( Magic uint32 = 0x45445556 ABIMajor uint16 = 1 - ABIMinor uint16 = 13 + ABIMinor uint16 = 14 // DriverPackageVersion is the native driver package version built and // shipped with this service. Runtime negotiation proves the loaded driver // carries this version in its source-bound build identity; package // installation additionally verifies DriverVer and the signed catalog. - DriverPackageVersion = "0.1.0.37" + DriverPackageVersion = "0.1.0.38" BuildIdentitySize = sha256.Size HeaderSize = 16 @@ -29,6 +30,7 @@ const ( NegotiateResponseSize = 88 DescriptorRecordSize = 16 CreateDeviceSize = 56 + CreateDeviceResultSize = 40 DeviceIdentitySize = 32 IsoPacketSize = 16 OperationSize = 108 @@ -83,10 +85,11 @@ const ( CapabilityDeviceLifecycle CapabilityInputReports CapabilityLifecycleTrace + CapabilityDeviceCorrelation ) const AdvertisedCapabilities = CapabilityIsochronous | CapabilityDeviceLifecycle | - CapabilityInputReports | CapabilityLifecycleTrace + CapabilityInputReports | CapabilityLifecycleTrace | CapabilityDeviceCorrelation const ( TraceSourceDevice uint8 = iota + 1 @@ -193,6 +196,27 @@ func BuildIdentityHex(identity [BuildIdentitySize]byte) string { return hex.EncodeToString(identity[:]) } +func IsCanonicalControllerInstanceID(value string) bool { + const prefix = `ROOT\VIIPERUDE\` + if len(value) != len(prefix)+4 || !strings.EqualFold(value[:len(prefix)], prefix) { + return false + } + for _, digit := range value[len(prefix):] { + if digit < '0' || digit > '9' { + return false + } + } + return true +} + +// IsCanonicalControllerSessionID accepts only the exact decimal encoding +// emitted by strconv.FormatUint for a nonzero kernel session nonce. This +// avoids lossy JSON-number handling and alternate textual identities. +func IsCanonicalControllerSessionID(value string) bool { + parsed, err := strconv.ParseUint(value, 10, 64) + return err == nil && parsed != 0 && strconv.FormatUint(parsed, 10) == value +} + type Header struct { Magic uint32 Major uint16 @@ -327,6 +351,62 @@ type DeviceIdentity struct { Generation uint32 } +// CreateDeviceResult is the kernel-authored receipt for the exact successful +// UdeCx plug-in. Exactly one port number is nonzero. It is deliberately not +// inferred from descriptors or Windows enumeration order. +type CreateDeviceResult struct { + DeviceID uint64 + Generation uint32 + Speed DeviceSpeed + USB20PortNumber uint32 + USB30PortNumber uint32 +} + +// DeviceRegistration binds the kernel receipt to the exact controller +// devnode whose exclusive interface handle produced it. ControllerInstanceID +// is queried from that interface while the handle is held, so callers can +// correlate HID and UAC descendants without a VID/PID or enumeration-order +// fallback. +type DeviceRegistration struct { + DeviceIdentity + Speed DeviceSpeed + USB20PortNumber uint32 + USB30PortNumber uint32 + ControllerSessionID uint64 + ControllerInstanceID string +} + +func ParseCreateDeviceResult(src []byte) (CreateDeviceResult, error) { + h, err := ParseHeader(src) + if err != nil { + return CreateDeviceResult{}, err + } + if h.Size != CreateDeviceResultSize || len(src) != CreateDeviceResultSize { + return CreateDeviceResult{}, ErrInvalidSize + } + result := CreateDeviceResult{ + DeviceID: binary.LittleEndian.Uint64(src[16:24]), + Generation: binary.LittleEndian.Uint32(src[24:28]), + Speed: DeviceSpeed(binary.LittleEndian.Uint32(src[28:32])), + USB20PortNumber: binary.LittleEndian.Uint32(src[32:36]), + USB30PortNumber: binary.LittleEndian.Uint32(src[36:40]), + } + if result.DeviceID == 0 || result.Generation == 0 || + result.Speed < DeviceSpeedLow || result.Speed > DeviceSpeedSuper || + (result.USB20PortNumber == 0) == (result.USB30PortNumber == 0) { + return CreateDeviceResult{}, ErrInvalidRange + } + if result.Speed == DeviceSpeedSuper { + if result.USB20PortNumber != 0 || result.USB30PortNumber <= MaxDevices || + result.USB30PortNumber > 2*MaxDevices { + return CreateDeviceResult{}, ErrInvalidRange + } + } else if result.USB30PortNumber != 0 || result.USB20PortNumber > MaxDevices { + return CreateDeviceResult{}, ErrInvalidRange + } + return result, nil +} + func (m DeviceIdentity) MarshalBinary() ([]byte, error) { if m.DeviceID == 0 || m.Generation == 0 { return nil, fmt.Errorf("%w: zero device identity", ErrInvalidRange) diff --git a/internal/transport/udecx/protocol_contract_test.go b/internal/transport/udecx/protocol_contract_test.go index 01fdfb21..b2c1ebf3 100644 --- a/internal/transport/udecx/protocol_contract_test.go +++ b/internal/transport/udecx/protocol_contract_test.go @@ -69,6 +69,15 @@ type contractCreateDevice struct { Reserved uint32 } +type contractCreateDeviceResult struct { + Header contractHeader + DeviceId uint64 + Generation uint32 + Speed uint32 + Usb20PortNumber uint32 + Usb30PortNumber uint32 +} + type contractDeviceIdentity struct { Header contractHeader DeviceId uint64 @@ -278,6 +287,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "VIIPER_UDE_CAP_DEVICE_LIFECYCLE": uint64(CapabilityDeviceLifecycle), "VIIPER_UDE_CAP_INPUT_REPORTS": uint64(CapabilityInputReports), "VIIPER_UDE_CAP_LIFECYCLE_TRACE": uint64(CapabilityLifecycleTrace), + "VIIPER_UDE_CAP_DEVICE_CORRELATION": uint64(CapabilityDeviceCorrelation), "VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY": LifecycleTraceCapacity, "VIIPER_UDE_TRACE_ENDPOINT_QUIESCENCE_WATCHDOG": uint64(TraceEndpointQuiescenceWatchdog), "VIIPER_UDE_TRACE_COMPLETION_RUNDOWN_WATCHDOG": uint64(TraceCompletionRundownWatchdog), @@ -300,6 +310,7 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { "NEGOTIATE_RESPONSE": reflect.TypeOf(contractNegotiateResponse{}), "DESCRIPTOR_RECORD": reflect.TypeOf(contractDescriptorRecord{}), "CREATE_DEVICE": reflect.TypeOf(contractCreateDevice{}), + "CREATE_DEVICE_RESULT": reflect.TypeOf(contractCreateDeviceResult{}), "DEVICE_IDENTITY": reflect.TypeOf(contractDeviceIdentity{}), "ISO_PACKET": reflect.TypeOf(contractISOPacket{}), "OPERATION": reflect.TypeOf(contractOperation{}), @@ -312,8 +323,9 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { wantSizes := map[string]uintptr{ "HEADER": HeaderSize, "NEGOTIATE_REQUEST": NegotiateRequestSize, "NEGOTIATE_RESPONSE": NegotiateResponseSize, "DESCRIPTOR_RECORD": DescriptorRecordSize, - "CREATE_DEVICE": CreateDeviceSize, "DEVICE_IDENTITY": DeviceIdentitySize, - "ISO_PACKET": IsoPacketSize, "OPERATION": OperationSize, "COMPLETION": CompletionSize, + "CREATE_DEVICE": CreateDeviceSize, "CREATE_DEVICE_RESULT": CreateDeviceResultSize, + "DEVICE_IDENTITY": DeviceIdentitySize, + "ISO_PACKET": IsoPacketSize, "OPERATION": OperationSize, "COMPLETION": CompletionSize, "INPUT_REPORT": InputReportSize, "STATS": StatsSize, "LIFECYCLE_TRACE_RECORD": LifecycleTraceRecordSize, "LIFECYCLE_TRACE": LifecycleTraceSize, @@ -395,10 +407,15 @@ func TestNativeProtocolHeaderMatchesGoContract(t *testing.T) { if !strings.Contains(header, `#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "`+DriverPackageVersion+`"`) { t.Fatalf("C driver package version does not match Go %q", DriverPackageVersion) } - advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\s*\|\s*VIIPER_UDE_CAP_LIFECYCLE_TRACE\)`).MatchString(header) + advertised := regexp.MustCompile(`(?s)#define\s+VIIPER_UDE_ADVERTISED_CAPABILITIES\s+\\\s*\(VIIPER_UDE_CAP_ISOCHRONOUS\s*\|\s*VIIPER_UDE_CAP_DEVICE_LIFECYCLE\s*\|\s*\\?\s*VIIPER_UDE_CAP_INPUT_REPORTS\s*\|\s*VIIPER_UDE_CAP_LIFECYCLE_TRACE\s*\|\s*\\?\s*VIIPER_UDE_CAP_DEVICE_CORRELATION\)`).MatchString(header) if !advertised { t.Fatal("C advertised capability identity tuple does not match Go") } + ioctl := nativeContractSource(t, "native", "udecx", "driver", "Ioctl.c") + negotiable := regexp.MustCompile(`(?s)input->RequestedCapabilities\s*&\s*~\(.*?VIIPER_UDE_CAP_ISOCHRONOUS.*?VIIPER_UDE_CAP_DEVICE_LIFECYCLE.*?VIIPER_UDE_CAP_INPUT_REPORTS.*?VIIPER_UDE_CAP_LIFECYCLE_TRACE.*?VIIPER_UDE_CAP_DEVICE_CORRELATION\)`).MatchString(ioctl) + if !negotiable { + t.Fatal("kernel negotiation rejects one or more advertised Go capabilities") + } } func TestKernelMicrosoftOS10StringExceptionMatchesGoContract(t *testing.T) { diff --git a/internal/transport/udecx/protocol_test.go b/internal/transport/udecx/protocol_test.go index 6eb9118d..8da1c0db 100644 --- a/internal/transport/udecx/protocol_test.go +++ b/internal/transport/udecx/protocol_test.go @@ -13,7 +13,7 @@ func TestBuildIdentityCanonicalVectorAndValidation(t *testing.T) { t.Parallel() const revision = "0123456789abcdef0123456789abcdef01234567" - const wantHex = "b6bdcfe32dec8eb48bfde2f70b72542695588d2483ab71218636ce0b733aa067" + const wantHex = "9a8c5a75d8c54569f3a8f7e1b2c9a68b8b40bf06494285fa93b56895a98ba3fe" identity, err := DeriveBuildIdentity(revision, DriverPackageVersion, ABIMajor, ABIMinor, AdvertisedCapabilities) if err != nil { @@ -84,7 +84,8 @@ func TestABISizes(t *testing.T) { for name, got := range map[string]int{ "header": HeaderSize, "negotiate request": NegotiateRequestSize, "negotiate response": NegotiateResponseSize, "descriptor": DescriptorRecordSize, - "create device": CreateDeviceSize, "identity": DeviceIdentitySize, + "create device": CreateDeviceSize, "create device result": CreateDeviceResultSize, + "identity": DeviceIdentitySize, "iso packet": IsoPacketSize, "operation": OperationSize, "completion": CompletionSize, "input report": InputReportSize, "stats": StatsSize, @@ -95,6 +96,84 @@ func TestABISizes(t *testing.T) { } } +func TestCreateDeviceResultRequiresExactPortCorrelation(t *testing.T) { + makeResult := func(speed DeviceSpeed, usb20, usb30 uint32) []byte { + raw := make([]byte, CreateDeviceResultSize) + header, _ := NewHeader(CreateDeviceResultSize) + putHeader(raw, header) + binary.LittleEndian.PutUint64(raw[16:24], 0x100000002) + binary.LittleEndian.PutUint32(raw[24:28], 7) + binary.LittleEndian.PutUint32(raw[28:32], uint32(speed)) + binary.LittleEndian.PutUint32(raw[32:36], usb20) + binary.LittleEndian.PutUint32(raw[36:40], usb30) + return raw + } + + for _, tc := range []struct { + name string + speed DeviceSpeed + usb20 uint32 + usb30 uint32 + wantErr bool + }{ + {name: "USB2", speed: DeviceSpeedHigh, usb20: 3}, + {name: "USB3", speed: DeviceSpeedSuper, usb30: MaxDevices + 3}, + {name: "no port", speed: DeviceSpeedHigh, wantErr: true}, + {name: "two ports", speed: DeviceSpeedHigh, usb20: 1, usb30: 33, wantErr: true}, + {name: "USB2 on USB3 field", speed: DeviceSpeedHigh, usb30: 33, wantErr: true}, + {name: "USB3 on USB2 field", speed: DeviceSpeedSuper, usb20: 1, wantErr: true}, + {name: "USB2 port above controller range", speed: DeviceSpeedHigh, usb20: MaxDevices + 1, wantErr: true}, + {name: "USB3 port below controller range", speed: DeviceSpeedSuper, usb30: MaxDevices, wantErr: true}, + {name: "USB3 port above controller range", speed: DeviceSpeedSuper, usb30: 2*MaxDevices + 1, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseCreateDeviceResult(makeResult(tc.speed, tc.usb20, tc.usb30)) + if tc.wantErr { + if !errors.Is(err, ErrInvalidRange) { + t.Fatalf("error=%v want ErrInvalidRange", err) + } + return + } + if err != nil || result.DeviceID != 0x100000002 || result.Generation != 7 || + result.USB20PortNumber != tc.usb20 || result.USB30PortNumber != tc.usb30 { + t.Fatalf("result=%+v error=%v", result, err) + } + }) + } +} + +func TestCanonicalControllerSessionID(t *testing.T) { + for value, want := range map[string]bool{ + "1": true, + "18446744073709551615": true, + "": false, + "0": false, + "01": false, + "+1": false, + " 1": false, + "18446744073709551616": false, + } { + if got := IsCanonicalControllerSessionID(value); got != want { + t.Errorf("IsCanonicalControllerSessionID(%q)=%t want %t", value, got, want) + } + } +} + +func TestCanonicalControllerInstanceID(t *testing.T) { + for value, want := range map[string]bool{ + `ROOT\VIIPERUDE\0000`: true, + `root\viiperude\0042`: true, + `ROOT\VIIPERUDE\42`: false, + `ROOT\VIIPERUDE\000A`: false, + `ROOT\OTHER\0000`: false, + ` ROOT\VIIPERUDE\0000`: false, + } { + if got := IsCanonicalControllerInstanceID(value); got != want { + t.Errorf("IsCanonicalControllerInstanceID(%q)=%t want %t", value, got, want) + } + } +} + func TestHeaderRejectsMalformedInput(t *testing.T) { valid, err := NewHeader(HeaderSize) if err != nil { diff --git a/native/udecx/driver/Device.c b/native/udecx/driver/Device.c index d3a9938e..0396d393 100644 --- a/native/udecx/driver/Device.c +++ b/native/udecx/driver/Device.c @@ -636,6 +636,8 @@ ViiperCreateVirtualDevice( VIIPER_UDE_CONTROLLER_CONTEXT *controllerContext = ViiperGetControllerContext(controller); VIIPER_UDE_CREATE_DEVICE *input; size_t inputLength; + VIIPER_UDE_CREATE_DEVICE_RESULT *output; + size_t outputLength; WDFFILEOBJECT ownerFile; PUDECXUSBDEVICE_INIT deviceInit; UDECX_USB_DEVICE_STATE_CHANGE_CALLBACKS callbacks; @@ -647,6 +649,7 @@ ViiperCreateVirtualDevice( UDECX_USB_DEVICE_PLUG_IN_OPTIONS plugOptions; ULONG slot; ULONG generation; + ULONG requestedSpeed; ULONGLONG deviceId; ULONGLONG portReservation; @@ -659,8 +662,18 @@ ViiperCreateVirtualDevice( InterlockedIncrement64(&controllerContext->InvalidMessages); return STATUS_INVALID_PARAMETER; } + // Validate the complete output contract before acquiring ownership or + // mutating UdeCx. METHOD_BUFFERED aliases the input and output system + // buffer, so do not write the receipt until every input descriptor has + // been consumed and PlugIn has committed successfully. + status = WdfRequestRetrieveOutputBuffer( + Request, sizeof(*output), (PVOID *)&output, &outputLength); + if (!NT_SUCCESS(status)) { + return status; + } deviceId = input->DeviceId; generation = input->Generation; + requestedSpeed = input->Speed; speed = ViiperMapSpeed(input->Speed); if (speed == (UDECX_USB_DEVICE_SPEED)0) { return STATUS_NOT_SUPPORTED; @@ -781,7 +794,17 @@ ViiperCreateVirtualDevice( goto ExitAdmission; } - WdfRequestSetInformation(Request, 0); + RtlZeroMemory(output, sizeof(*output)); + output->Header.Magic = VIIPER_UDE_MAGIC; + output->Header.Major = VIIPER_UDE_ABI_MAJOR; + output->Header.Minor = VIIPER_UDE_ABI_MINOR; + output->Header.Size = sizeof(*output); + output->DeviceId = deviceId; + output->Generation = generation; + output->Speed = requestedSpeed; + output->Usb20PortNumber = plugOptions.Usb20PortNumber; + output->Usb30PortNumber = plugOptions.Usb30PortNumber; + WdfRequestSetInformation(Request, sizeof(*output)); status = STATUS_SUCCESS; ExitAdmission: diff --git a/native/udecx/driver/Ioctl.c b/native/udecx/driver/Ioctl.c index ac16dc5d..5a679859 100644 --- a/native/udecx/driver/Ioctl.c +++ b/native/udecx/driver/Ioctl.c @@ -61,7 +61,8 @@ ViiperHandleNegotiate( input->ClientNonce == 0 || input->Reserved != 0 || (input->RequestedCapabilities & ~(VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_STREAMS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | - VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE)) != 0) { + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE | + VIIPER_UDE_CAP_DEVICE_CORRELATION)) != 0) { return STATUS_INVALID_PARAMETER; } if (input->Header.Major != VIIPER_UDE_ABI_MAJOR || diff --git a/native/udecx/driver/ViiperUde.vcxproj b/native/udecx/driver/ViiperUde.vcxproj index 3afa1b62..13b33664 100644 --- a/native/udecx/driver/ViiperUde.vcxproj +++ b/native/udecx/driver/ViiperUde.vcxproj @@ -14,7 +14,7 @@ 17.0 x64 08/15/2026 - 0.1.0.37 + 0.1.0.38 $(VIIPER_NATIVE_SOURCE_REVISION) @@ -113,7 +113,7 @@ - + diff --git a/native/udecx/include/ViiperUdeProtocol.h b/native/udecx/include/ViiperUdeProtocol.h index 8f234c97..e400e34e 100644 --- a/native/udecx/include/ViiperUdeProtocol.h +++ b/native/udecx/include/ViiperUdeProtocol.h @@ -35,8 +35,8 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_MAGIC VIIPER_UDE_UINT32_C(0x45445556) /* "VUDE" little-endian */ #define VIIPER_UDE_ABI_MAJOR VIIPER_UDE_UINT16_C(1) -#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(13) -#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.37" +#define VIIPER_UDE_ABI_MINOR VIIPER_UDE_UINT16_C(14) +#define VIIPER_UDE_DRIVER_PACKAGE_VERSION "0.1.0.38" #define VIIPER_UDE_BUILD_IDENTITY_BYTES VIIPER_UDE_UINT32_C(32) /* Canonical controller interface GUID: {32d03f48-725b-4baa-970f-7f5de6c44687}. */ @@ -71,9 +71,11 @@ typedef int32_t VIIPER_UDE_INT32; #define VIIPER_UDE_CAP_DEVICE_LIFECYCLE VIIPER_UDE_UINT32_C(0x00000004) #define VIIPER_UDE_CAP_INPUT_REPORTS VIIPER_UDE_UINT32_C(0x00000008) #define VIIPER_UDE_CAP_LIFECYCLE_TRACE VIIPER_UDE_UINT32_C(0x00000010) +#define VIIPER_UDE_CAP_DEVICE_CORRELATION VIIPER_UDE_UINT32_C(0x00000020) #define VIIPER_UDE_ADVERTISED_CAPABILITIES \ (VIIPER_UDE_CAP_ISOCHRONOUS | VIIPER_UDE_CAP_DEVICE_LIFECYCLE | \ - VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE) + VIIPER_UDE_CAP_INPUT_REPORTS | VIIPER_UDE_CAP_LIFECYCLE_TRACE | \ + VIIPER_UDE_CAP_DEVICE_CORRELATION) #define VIIPER_UDE_LIFECYCLE_TRACE_CAPACITY VIIPER_UDE_UINT32_C(512) @@ -192,6 +194,21 @@ typedef struct VIIPER_UDE_CREATE_DEVICE { VIIPER_UDE_UINT32 Reserved; } VIIPER_UDE_CREATE_DEVICE; +/* + * Authoritative receipt for a successful UdecxUsbDevicePlugIn call. Exactly + * one port field is nonzero. Returning the actual plug-in options prevents + * user mode from guessing PnP ownership from VID/PID, enumeration order, or a + * reconnect-local stream generation. + */ +typedef struct VIIPER_UDE_CREATE_DEVICE_RESULT { + VIIPER_UDE_HEADER Header; + VIIPER_UDE_UINT64 DeviceId; + VIIPER_UDE_UINT32 Generation; + VIIPER_UDE_UINT32 Speed; + VIIPER_UDE_UINT32 Usb20PortNumber; + VIIPER_UDE_UINT32 Usb30PortNumber; +} VIIPER_UDE_CREATE_DEVICE_RESULT; + typedef struct VIIPER_UDE_DEVICE_IDENTITY { VIIPER_UDE_HEADER Header; VIIPER_UDE_UINT64 DeviceId; @@ -342,6 +359,7 @@ static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE_ static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40, "VIIPER_UDE_CREATE_DEVICE_RESULT ABI drift"); static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); @@ -356,6 +374,7 @@ _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_REQUEST) == 32, "VIIPER_UDE_NEGOTIATE _Static_assert(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88, "VIIPER_UDE_NEGOTIATE_RESPONSE ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16, "VIIPER_UDE_DESCRIPTOR_RECORD ABI drift"); _Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56, "VIIPER_UDE_CREATE_DEVICE ABI drift"); +_Static_assert(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40, "VIIPER_UDE_CREATE_DEVICE_RESULT ABI drift"); _Static_assert(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32, "VIIPER_UDE_DEVICE_IDENTITY ABI drift"); _Static_assert(sizeof(VIIPER_UDE_ISO_PACKET) == 16, "VIIPER_UDE_ISO_PACKET ABI drift"); _Static_assert(sizeof(VIIPER_UDE_OPERATION) == 108, "VIIPER_UDE_OPERATION ABI drift"); @@ -378,6 +397,7 @@ typedef char VIIPER_UDE_ABI_NEGOTIATE_REQUEST_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_ typedef char VIIPER_UDE_ABI_NEGOTIATE_RESPONSE_SIZE[(sizeof(VIIPER_UDE_NEGOTIATE_RESPONSE) == 88) ? 1 : -1]; typedef char VIIPER_UDE_ABI_DESCRIPTOR_RECORD_SIZE[(sizeof(VIIPER_UDE_DESCRIPTOR_RECORD) == 16) ? 1 : -1]; typedef char VIIPER_UDE_ABI_CREATE_DEVICE_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE) == 56) ? 1 : -1]; +typedef char VIIPER_UDE_ABI_CREATE_DEVICE_RESULT_SIZE[(sizeof(VIIPER_UDE_CREATE_DEVICE_RESULT) == 40) ? 1 : -1]; typedef char VIIPER_UDE_ABI_DEVICE_IDENTITY_SIZE[(sizeof(VIIPER_UDE_DEVICE_IDENTITY) == 32) ? 1 : -1]; typedef char VIIPER_UDE_ABI_ISO_PACKET_SIZE[(sizeof(VIIPER_UDE_ISO_PACKET) == 16) ? 1 : -1]; typedef char VIIPER_UDE_ABI_OPERATION_SIZE[(sizeof(VIIPER_UDE_OPERATION) == 108) ? 1 : -1]; @@ -433,6 +453,12 @@ VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, DescriptorDataLength, 44); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, MaxPendingOperations, 48); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE, Reserved, 52); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, DeviceId, 16); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Generation, 24); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Speed, 28); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Usb20PortNumber, 32); +VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_CREATE_DEVICE_RESULT, Usb30PortNumber, 36); + VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, DeviceId, 16); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Generation, 24); VIIPER_UDE_ASSERT_OFFSET(VIIPER_UDE_DEVICE_IDENTITY, Reserved, 28); diff --git a/native/udecx/package/ViiperUde.inf b/native/udecx/package/ViiperUde.inf index ff2b3913..ec171222 100644 --- a/native/udecx/package/ViiperUde.inf +++ b/native/udecx/package/ViiperUde.inf @@ -4,7 +4,7 @@ Class=USB ClassGuid={36FC9E60-C465-11CF-8056-444553540000} Provider=%ProviderName% CatalogFile=ViiperUde.cat -DriverVer=08/15/2026,0.1.0.37 +DriverVer=08/15/2026,0.1.0.38 PnpLockDown=1 [DestinationDirs] diff --git a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 index ae09924f..e8e4be0b 100644 --- a/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeAttestationPackage.ps1 @@ -81,8 +81,8 @@ if ($versionNodes.Count -ne 1) { } $driverPackageVersion = $versionNodes[0].InnerText.Trim() $driverABIMajor = 1 -$driverABIMinor = 13 -$driverCapabilities = [uint32]29 +$driverABIMinor = 14 +$driverCapabilities = [uint32]61 $driverBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $SourceRevision ` -DriverPackageVersion $driverPackageVersion ` diff --git a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 index 098b5a3a..3204813a 100644 --- a/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 +++ b/native/udecx/tools/New-ViiperUdeLocalTestPackage.ps1 @@ -106,7 +106,7 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $source = $SourceRevision.ToLowerInvariant() $buildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $source -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 13 -Capabilities 29 + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 $manifest = [ordered]@{ schema = 2 @@ -117,8 +117,8 @@ $manifest = [ordered]@{ sourceRevision = $source driverPackageVersion = $driverVersion driverABIMajor = 1 - driverABIMinor = 13 - driverCapabilities = '0x0000001d' + driverABIMinor = 14 + driverCapabilities = '0x0000003d' driverBuildIdentity = $buildIdentity testSignerCertificateSha256 = $certificateSha256 files = @( diff --git a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 index f2bcd284..5ec6fe46 100644 --- a/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 +++ b/native/udecx/tools/Test-ViiperUdeCtlTransaction.ps1 @@ -91,6 +91,7 @@ $requiredContracts = [ordered]@{ 'self-test-pristine-runtime-stats' 'loaded-kernel build identity negotiation' = 'response\.BuildIdentity' 'exact negotiated capability identity' = 'response\.Capabilities == profile\.capabilities' + 'explicit ABI 1.14 profile' = '\{14, 61, 152, true\}' 'explicit ABI 1.13 profile' = '\{13, 29, 152, true\}' 'explicit ABI 1.12 profile' = '\{12, 29, 152, true\}' 'explicit ABI 1.11 profile' = '\{11, 29, 144, false\}' diff --git a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 index dd7ed631..93c57b36 100644 --- a/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 +++ b/native/udecx/tools/Test-ViiperUdeReleaseBundle.ps1 @@ -156,12 +156,12 @@ $driverVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverVersion ` - -ABIMajor 1 -ABIMinor 13 -Capabilities 29 + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision -or [string]$manifest.driverPackageVersion -cne $driverVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 13 -or - [string]$manifest.driverCapabilities -cne '0x0000001d' -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 14 -or + [string]$manifest.driverCapabilities -cne '0x0000003d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity -or -not [bool]$manifest.releaseEligible -or [string]$manifest.signingRoute -cne 'HLK/WHCP') { diff --git a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 index ba22659d..154ec275 100644 --- a/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 +++ b/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1 @@ -739,12 +739,12 @@ $driverPackageVersion = $versionNodes[0].InnerText.Trim() $expectedBuildIdentity = & (Join-Path $PSScriptRoot 'Get-ViiperUdeBuildIdentity.ps1') ` -SourceRevision $ExpectedSourceRevision ` -DriverPackageVersion $driverPackageVersion ` - -ABIMajor 1 -ABIMinor 13 -Capabilities 29 + -ABIMajor 1 -ABIMinor 14 -Capabilities 61 if ($manifest.schema -ne 2 -or [string]$manifest.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or [string]$manifest.driverPackageVersion -cne $driverPackageVersion -or - [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 13 -or - [string]$manifest.driverCapabilities -cne '0x0000001d' -or + [int]$manifest.driverABIMajor -ne 1 -or [int]$manifest.driverABIMinor -ne 14 -or + [string]$manifest.driverCapabilities -cne '0x0000003d' -or [string]$manifest.driverBuildIdentity -cne $expectedBuildIdentity) { throw 'The submission manifest schema, source revision, or native loaded-build identity does not match the reviewed source.' } diff --git a/native/udecx/tools/ViiperUdeCtl.cpp b/native/udecx/tools/ViiperUdeCtl.cpp index 308d2bd8..e252f682 100644 --- a/native/udecx/tools/ViiperUdeCtl.cpp +++ b/native/udecx/tools/ViiperUdeCtl.cpp @@ -103,7 +103,8 @@ struct AbiCompatibilityProfile { bool hasReservedPortFields; }; -constexpr std::array kAbiCompatibilityProfiles{{ +constexpr std::array kAbiCompatibilityProfiles{{ + {14, 61, 152, true}, {13, 29, 152, true}, {12, 29, 152, true}, {11, 29, 144, false}, @@ -115,26 +116,31 @@ constexpr bool AbiCompatibilityProfilesAreValid() noexcept { kAbiCompatibilityProfiles[0].capabilities == VIIPER_UDE_ADVERTISED_CAPABILITIES && kAbiCompatibilityProfiles[0].statsSize == sizeof(VIIPER_UDE_STATS) && kAbiCompatibilityProfiles[0].hasReservedPortFields && - kAbiCompatibilityProfiles[1].minor == 12 && + kAbiCompatibilityProfiles[1].minor == 13 && kAbiCompatibilityProfiles[1].capabilities == 29 && kAbiCompatibilityProfiles[1].statsSize == 152 && kAbiCompatibilityProfiles[1].hasReservedPortFields && - kAbiCompatibilityProfiles[2].minor == 11 && + kAbiCompatibilityProfiles[2].minor == 12 && kAbiCompatibilityProfiles[2].capabilities == 29 && - kAbiCompatibilityProfiles[2].statsSize == 144 && - !kAbiCompatibilityProfiles[2].hasReservedPortFields && - kAbiCompatibilityProfiles[3].minor == 10 && - kAbiCompatibilityProfiles[3].capabilities == 13 && + kAbiCompatibilityProfiles[2].statsSize == 152 && + kAbiCompatibilityProfiles[2].hasReservedPortFields && + kAbiCompatibilityProfiles[3].minor == 11 && + kAbiCompatibilityProfiles[3].capabilities == 29 && kAbiCompatibilityProfiles[3].statsSize == 144 && !kAbiCompatibilityProfiles[3].hasReservedPortFields && + kAbiCompatibilityProfiles[4].minor == 10 && + kAbiCompatibilityProfiles[4].capabilities == 13 && + kAbiCompatibilityProfiles[4].statsSize == 144 && + !kAbiCompatibilityProfiles[4].hasReservedPortFields && kAbiCompatibilityProfiles[0].minor == kAbiCompatibilityProfiles[1].minor + 1 && kAbiCompatibilityProfiles[1].minor == kAbiCompatibilityProfiles[2].minor + 1 && - kAbiCompatibilityProfiles[2].minor == kAbiCompatibilityProfiles[3].minor + 1; + kAbiCompatibilityProfiles[2].minor == kAbiCompatibilityProfiles[3].minor + 1 && + kAbiCompatibilityProfiles[3].minor == kAbiCompatibilityProfiles[4].minor + 1; } static_assert(VIIPER_UDE_ABI_MAJOR == 1, "ABI compatibility table major drift"); -static_assert(VIIPER_UDE_ABI_MINOR == 13, "ABI compatibility table current minor drift"); -static_assert(VIIPER_UDE_ADVERTISED_CAPABILITIES == 29, +static_assert(VIIPER_UDE_ABI_MINOR == 14, "ABI compatibility table current minor drift"); +static_assert(VIIPER_UDE_ADVERTISED_CAPABILITIES == 61, "ABI compatibility table current capabilities drift"); static_assert(sizeof(VIIPER_UDE_STATS) == 152, "ABI compatibility table current statistics size drift"); @@ -17259,7 +17265,7 @@ bool RunRemoveJournalModelSelfTest(Error* error) { } PackageInfo package; package.publishedName = L"oem42.inf"; - package.version.parts = {0, 1, 0, 37}; + package.version.parts = {0, 1, 0, 38}; package.infSha256 = std::string(64, 'a'); package.sysSha256 = std::string(64, 'b'); package.catSha256 = std::string(64, 'c'); @@ -17786,7 +17792,7 @@ Outcome SelfTest() { "0123456789abcdef0123456789abcdef01234567", &buildIdentity, &outcome.error) || buildIdentity != - "b6bdcfe32dec8eb48bfde2f70b72542695588d2483ab71218636ce0b733aa067") { + "9a8c5a75d8c54569f3a8f7e1b2c9a68b8b40bf06494285fa93b56895a98ba3fe") { if (outcome.error.code == ERROR_SUCCESS) { SetError(&outcome.error, L"self-test-build-identity", ERROR_INVALID_DATA); } @@ -17949,8 +17955,9 @@ Outcome SelfTest() { } pristineStats.ReservedPorts = 1; if (RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[1]) || - !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2]) || - !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[3])) { + RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[2]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[3]) || + !RuntimeStatsArePristine(pristineStats, kAbiCompatibilityProfiles[4])) { SetError(&outcome.error, L"self-test-pristine-runtime-stats", ERROR_INVALID_DATA, L"a legacy ABI inspected a counter outside its returned statistics record"); return outcome; diff --git a/viiperclient/client.go b/viiperclient/client.go index dbb1327c..266ec741 100644 --- a/viiperclient/client.go +++ b/viiperclient/client.go @@ -131,9 +131,11 @@ func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, return parse[viipertypes.Device](raw) } -// DeviceRemove removes a device from the specified bus by its device ID. +// DeviceRemove removes a USB/IP device from the specified bus by its device ID. // The devID parameter is the device number (e.g., "1") on the given bus. // Active USB-IP connections to the device will be closed. +// Native UDE callers must use DeviceRemoveRegistered so the exact correlation +// receipt is compared atomically and an ID-reusing successor is preserved. // Returns the removed device's bus and device ID or an error if not found. func (c *Client) DeviceRemove(busID uint32, devID string) (*viipertypes.DeviceRemoveResponse, error) { return c.DeviceRemoveCtx(context.Background(), busID, devID) @@ -149,6 +151,56 @@ func (c *Client) DeviceRemoveCtx(ctx context.Context, busID uint32, devID string return parse[viipertypes.DeviceRemoveResponse](raw) } +// DeviceRemoveNative conditionally removes the exact native registration +// identified by the immutable add/list receipt. +func (c *Client) DeviceRemoveNative( + busID uint32, devID string, native *viipertypes.NativeUDEDeviceInfo, +) (*viipertypes.DeviceRemoveResponse, error) { + return c.DeviceRemoveNativeCtx(context.Background(), busID, devID, native) +} + +func (c *Client) DeviceRemoveNativeCtx( + ctx context.Context, busID uint32, devID string, native *viipertypes.NativeUDEDeviceInfo, +) (*viipertypes.DeviceRemoveResponse, error) { + if native == nil { + return nil, errors.New("native UDE removal requires the exact correlation receipt") + } + request := viipertypes.NativeUDEDeviceRemoveRequest{ + DevID: devID, Transport: "native-ude", NativeUDE: native, + } + pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} + const path = "bus/{id}/remove-native" + raw, err := c.transport.DoCtx(ctx, path, request, pathParams) + if err != nil { + return nil, err + } + return parse[viipertypes.DeviceRemoveResponse](raw) +} + +// DeviceRemoveRegistered selects the only safe removal contract for the +// transport recorded in a DeviceAdd/DevicesList result. +func (c *Client) DeviceRemoveRegistered( + device *viipertypes.Device, +) (*viipertypes.DeviceRemoveResponse, error) { + return c.DeviceRemoveRegisteredCtx(context.Background(), device) +} + +func (c *Client) DeviceRemoveRegisteredCtx( + ctx context.Context, device *viipertypes.Device, +) (*viipertypes.DeviceRemoveResponse, error) { + if device == nil { + return nil, errors.New("device removal requires a device registration") + } + switch device.Transport { + case "native-ude": + return c.DeviceRemoveNativeCtx(ctx, device.BusID, device.DevID, device.NativeUDE) + case "", "usbip": + return c.DeviceRemoveCtx(ctx, device.BusID, device.DevID) + default: + return nil, fmt.Errorf("unsupported device transport %q", device.Transport) + } +} + // DevicesList retrieves a list of all devices attached to the specified bus. // Each device entry includes bus ID, device ID, VID, PID, and device type. func (c *Client) DevicesList(busID uint32) (*viipertypes.DevicesListResponse, error) { diff --git a/viiperclient/client_test.go b/viiperclient/client_test.go index 48c14414..e8100660 100644 --- a/viiperclient/client_test.go +++ b/viiperclient/client_test.go @@ -115,3 +115,72 @@ func TestContextCancellation(t *testing.T) { _, err := c.BusListCtx(ctx) assert.Error(t, err) } + +func TestDeviceRemoveRegisteredUsesTransportScopedAuthority(t *testing.T) { + tests := []struct { + name string + device *viipertypes.Device + wantPath string + wantErr string + }{ + { + name: "native exact receipt", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "native-ude", + NativeUDE: &viipertypes.NativeUDEDeviceInfo{ + DeviceID: "4294967297", DeviceGeneration: 2, + ControllerSessionID: "17", ControllerInstanceID: `ROOT\VIIPERUDE\0000`, + USB20PortNumber: 1, + }, + }, + wantPath: "bus/{id}/remove-native", + }, + { + name: "usbip legacy id", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "usbip", + }, + wantPath: "bus/{id}/remove", + }, + { + name: "native missing receipt", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "native-ude", + }, + wantErr: "exact correlation receipt", + }, + { + name: "unknown transport", + device: &viipertypes.Device{ + BusID: 1, DevID: "1", Transport: "future", + }, + wantErr: "unsupported device transport", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var gotPath string + var gotPayload any + client := viiperclient.WithTransport(viiperclient.NewMockTransport( + func(path string, payload any, _ map[string]string) (string, error) { + gotPath, gotPayload = path, payload + return `{"busId":1,"devId":"1"}`, nil + }, + )) + _, err := client.DeviceRemoveRegistered(test.device) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + assert.Empty(t, gotPath) + return + } + assert.NoError(t, err) + assert.Equal(t, test.wantPath, gotPath) + if test.device.Transport == "native-ude" { + request, ok := gotPayload.(viipertypes.NativeUDEDeviceRemoveRequest) + assert.True(t, ok) + assert.Equal(t, test.device.DevID, request.DevID) + assert.Equal(t, test.device.NativeUDE, request.NativeUDE) + } + }) + } +} diff --git a/viipertypes/native_remove_test.go b/viipertypes/native_remove_test.go new file mode 100644 index 00000000..f1b77281 --- /dev/null +++ b/viipertypes/native_remove_test.go @@ -0,0 +1,36 @@ +package viipertypes + +import ( + "encoding/json" + "testing" +) + +func TestNativeUDEDeviceRemoveRequestRejectsAmbiguousJSON(t *testing.T) { + valid := `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}` + tests := []struct { + name string + payload string + valid bool + }{ + {"canonical", valid, true}, + {"unknown top-level", valid[:len(valid)-1] + `,"extra":1}`, false}, + {"unknown nested", `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0,"extra":1}}`, false}, + {"noncanonical top-level case", `{"DevId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"noncanonical nested case", `{"devId":"1","transport":"native-ude","nativeUde":{"DeviceId":"4294967297","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"duplicate top-level", `{"devId":"1","devId":"2","transport":"native-ude","nativeUde":null}`, false}, + {"duplicate nested", `{"devId":"1","transport":"native-ude","nativeUde":{"deviceId":"4294967297","deviceId":"4294967298","deviceGeneration":1,"controllerSessionId":"17","controllerInstanceId":"ROOT\\VIIPERUDE\\0000","usb20PortNumber":1,"usb30PortNumber":0}}`, false}, + {"trailing value", valid + `{}`, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var request NativeUDEDeviceRemoveRequest + err := json.Unmarshal([]byte(test.payload), &request) + if test.valid && err != nil { + t.Fatalf("canonical request rejected: %v", err) + } + if !test.valid && err == nil { + t.Fatal("ambiguous request accepted") + } + }) + } +} diff --git a/viipertypes/structs.go b/viipertypes/structs.go index 4c7a6742..17a4eebd 100644 --- a/viipertypes/structs.go +++ b/viipertypes/structs.go @@ -1,8 +1,10 @@ package viipertypes import ( + "bytes" "encoding/json" "fmt" + "io" "math" "strconv" "strings" @@ -53,6 +55,8 @@ type NativeUDEInfo struct { // the currently loaded kernel image during ABI negotiation. It is not an // on-disk hash or a broker-computed status echo. LoadedDriverBuildIdentity string `json:"loadedDriverBuildIdentity"` + ControllerSessionID string `json:"controllerSessionId"` + ControllerInstanceID string `json:"controllerInstanceId"` MaxDevices uint32 `json:"maxDevices"` MaxDescriptorBytes uint32 `json:"maxDescriptorBytes"` MaxTransferBytes uint32 `json:"maxTransferBytes"` @@ -73,14 +77,28 @@ type BusRemoveResponse struct { } type Device struct { - BusID uint32 `json:"busId"` - DevID string `json:"devId"` - Vid string `json:"vid"` - Pid string `json:"pid"` - Type string `json:"type"` - DeviceSpecific map[string]any `json:"deviceSpecific"` - USBIPPort int32 `json:"usbipPort,omitempty"` - USBIPOwnerSerial string `json:"usbipOwnerSerial,omitempty"` + BusID uint32 `json:"busId"` + DevID string `json:"devId"` + Vid string `json:"vid"` + Pid string `json:"pid"` + Type string `json:"type"` + DeviceSpecific map[string]any `json:"deviceSpecific"` + Transport string `json:"transport"` + NativeUDE *NativeUDEDeviceInfo `json:"nativeUde,omitempty"` + USBIPPort int32 `json:"usbipPort,omitempty"` + USBIPOwnerSerial string `json:"usbipOwnerSerial,omitempty"` +} + +// NativeUDEDeviceInfo is the exact kernel/controller receipt used to +// correlate one API device with its Windows HID and UAC descendants. DeviceID +// is decimal text so every JSON consumer preserves the full uint64 value. +type NativeUDEDeviceInfo struct { + DeviceID string `json:"deviceId"` + DeviceGeneration uint32 `json:"deviceGeneration"` + ControllerSessionID string `json:"controllerSessionId"` + ControllerInstanceID string `json:"controllerInstanceId"` + USB20PortNumber uint32 `json:"usb20PortNumber"` + USB30PortNumber uint32 `json:"usb30PortNumber"` } type DevicesListResponse struct { @@ -92,6 +110,146 @@ type DeviceRemoveResponse struct { DevID string `json:"devId"` } +// NativeUDEDeviceRemoveRequest is a compare-and-remove request. Native clients +// must echo the exact correlation receipt returned by add/list so a delayed +// cleanup cannot remove a successor that reused the same bus and device IDs. +type NativeUDEDeviceRemoveRequest struct { + DevID string `json:"devId"` + Transport string `json:"transport"` + NativeUDE *NativeUDEDeviceInfo `json:"nativeUde"` +} + +// UnmarshalJSON rejects unknown, duplicate, and trailing fields. The echoed +// correlation receipt is mutation authority, so ambiguous JSON is not +// accepted even when encoding/json could otherwise choose a last value. +func (r *NativeUDEDeviceRemoveRequest) UnmarshalJSON(data []byte) error { + if err := rejectDuplicateJSONKeys(data); err != nil { + return err + } + if err := validateNativeRemoveJSONFieldNames(data); err != nil { + return err + } + type requestAlias NativeUDEDeviceRemoveRequest + var decoded requestAlias + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("native remove request contains trailing JSON") + } + return fmt.Errorf("native remove request contains trailing JSON: %w", err) + } + *r = NativeUDEDeviceRemoveRequest(decoded) + return nil +} + +func validateNativeRemoveJSONFieldNames(data []byte) error { + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return err + } + topFields := []string{"devId", "transport", "nativeUde"} + if len(top) != len(topFields) { + return fmt.Errorf("native remove request must contain exactly devId, transport, and nativeUde") + } + for _, field := range topFields { + if _, ok := top[field]; !ok { + return fmt.Errorf("native remove request is missing canonical JSON field %q", field) + } + } + + var native map[string]json.RawMessage + if err := json.Unmarshal(top["nativeUde"], &native); err != nil { + return fmt.Errorf("nativeUde must be an object: %w", err) + } + nativeFields := []string{ + "deviceId", "deviceGeneration", "controllerSessionId", + "controllerInstanceId", "usb20PortNumber", "usb30PortNumber", + } + if len(native) != len(nativeFields) { + return fmt.Errorf("nativeUde must contain the exact correlation receipt fields") + } + for _, field := range nativeFields { + if _, ok := native[field]; !ok { + return fmt.Errorf("nativeUde is missing canonical JSON field %q", field) + } + } + return nil +} + +func rejectDuplicateJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("native remove request contains trailing JSON") + } + return fmt.Errorf("native remove request contains trailing JSON: %w", err) + } + return nil +} + +func walkUniqueJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("native remove request contains a non-string JSON object key") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("native remove request contains duplicate JSON field %q", key) + } + seen[key] = struct{}{} + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim('}') { + return fmt.Errorf("native remove request has malformed JSON object") + } + case '[': + for decoder.More() { + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim(']') { + return fmt.Errorf("native remove request has malformed JSON array") + } + default: + return fmt.Errorf("native remove request has unexpected JSON delimiter %q", delimiter) + } + return nil +} + type DeviceCreateRequest struct { Type *string `json:"type"` IDVendor *uint16 `json:"idVendor,omitempty"`