diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f1936..9951111 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,4 +19,13 @@ jobs: with: go-version: "1.25.x" - run: go test ./... + - run: go test ./... + env: + CGO_ENABLED: "0" + - if: runner.os == 'Linux' + run: go test -race ./... + - if: runner.os == 'Linux' + run: go test -coverprofile=coverage.out ./... + - if: runner.os == 'Linux' + run: test -z "$(gofmt -l .)" - run: go vet ./... diff --git a/README.md b/README.md index 086568f..d9f987d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ # teleop +[![Go Reference](https://pkg.go.dev/badge/github.com/open-ships/teleop.svg)](https://pkg.go.dev/github.com/open-ships/teleop) + Normalized, loss-aware game-controller input for Go teleoperation and autonomy applications. `teleop` separates controller hardware and operating-system APIs from application control logic. It exposes transport-independent state snapshots, -an ordered event stream, optional gestures and semantic actions, and -hash-chained audit recording. The included `xbox` provider works on Linux, -macOS, and Windows. +an ordered event stream, optional gestures and semantic actions, fail-closed +output gating, and signed, hash-chained audit recording. The included `xbox` +provider works on Linux, macOS, and Windows. > [!IMPORTANT] -> `teleop` transports operator input; it is not a safety controller. Applications -> must provide their own authorization, command timeout, dead-man switch, -> emergency-stop, and safe-state behavior. A backend cannot report input that -> the operating system, driver, radio, or polling API never delivered. - -The API is pre-v1 while hardware mappings are validated across controller -generations and operating systems. +> `teleop` transports operator input and can gate it, but it is not a certified +> safety controller. The `safety` package provides a command timeout, dead-man +> switch, loop watchdog, and latching emergency stop. It does not replace a +> hardware emergency stop, a safety-rated interlock, or an actuator that reaches +> a safe state on its own when commands stop arriving — that last one is the +> control that actually protects people, and it lives in the receiving system, +> not here. A backend cannot report input that the operating system, driver, +> radio, or polling API never delivered. + +The API has completed its pre-v1 review, and hardware mappings have been +validated across the supported controller generations and operating systems. +The `v1.0.0` tag begins the Go module compatibility commitment. ## Features @@ -28,12 +35,18 @@ generations and operating systems. - Lossless delivery for command consumers and latest-value delivery for UIs - Tap, double-tap, hold, chord, stick-region, and trigger-threshold gestures - Application-defined action bindings with causal event IDs +- Application command records linked to the input that caused them +- Command timeout, dead-man switch, and loop watchdog that fail closed +- Monotonic event timing and wall-clock step detection - Append-only, hash-chained JSON Lines audit logs +- Ed25519-signed Merkle tree heads, checkpoints, and external anchoring +- Build, configuration, and operator provenance recorded per session - Fake and replay input sources for tests - Interactive terminal monitor and machine-readable JSON output -The library packages use only the Go standard library and do not install a -global registry. The optional terminal monitor uses Bubble Tea and Lip Gloss. +The library packages do not install a global registry. Platform syscall +wrappers use `golang.org/x/sys`; the optional terminal monitor uses Bubble Tea +and Lip Gloss. ## Install @@ -110,6 +123,13 @@ forward := teleop.ApplyRadialDeadZone(state.LeftStick, 0.12).Y armed := state.Button(xbox.ButtonA) ``` +`SnapshotWithMeta` also returns the last physical observation and receive +times, connection state, and whether the snapshot was synthesized. Use those +timestamps to enforce an application-specific command timeout. Game-controller +backends are change-driven, so a held control can legitimately produce no new +observations; observation age alone is not proof of disconnect. Confirmed +disconnects always synthesize a neutral state and release events. + ## Input model Controls are named by physical position so application bindings remain stable @@ -129,7 +149,9 @@ across controller brands. For example, `xbox.ButtonA` is an alias for Each backend observation produces an `ObservationEvent`, followed by any corresponding `ButtonEvent`, `StickEvent`, or `TriggerEvent`. Events carry a controller session, sequence number, observation time, and -causal event IDs. +causal event IDs. They also carry a session-relative monotonic timestamp, so +ordering and durations remain meaningful if the host wall clock is corrected; +a material correction is reported as a `ClockEvent`. ### Delivery policies @@ -165,6 +187,23 @@ controller, err := provider.Open( ) ``` +Record the application command at the same decision boundary that sends it to +the machine. Link it to the input or derived event IDs that produced it so the +audit trail records both operator intent and the command actually considered: + +```go +err := controller.RecordCommand(ctx, teleop.Command{ + Name: "drive.set", + Payload: map[string]float64{"forward": forward}, + Authorized: armed, + Causes: []teleop.EventID{event.Header().ID}, +}) +``` + +`RecordCommand` is non-blocking; `teleop.ErrPipelineOverflow` means the +command could not be admitted to the bounded audit pipeline and should be +treated as a safety fault. + Recognized `gesture.Event` and mapped `action.Event` values appear in the same subscriptions and audit sinks as the input events that caused them. @@ -217,12 +256,14 @@ emits a `GapEvent`. ## Auditing and replay -An audit sink receives every canonical and derived event before subscribers: +The controller accepts every canonical and derived event into each bounded +audit queue before delivering it to subscribers. Sink I/O runs independently +so filesystem latency does not delay the input path: ```go file, err := os.OpenFile( "controller.jsonl", - os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600, ) if err != nil { @@ -240,16 +281,107 @@ controller, err := provider.Open( ) ``` -`audit.ReadAll` verifies a completed log's hash chain and footer. Use -`audit.ReadPartial` for a running or interrupted log. `audit.Observations` and -`testkit.NewReplaySource` can then reproduce its canonical input without -controller hardware. +`audit.ReadAll` verifies a completed log's integrity chain and footer. An +unkeyed SHA-256 chain detects corruption but can be recomputed by an attacker; +use `audit.WithHMAC(key)` and `audit.ReadAuthenticated` when adversarial +tamper-evidence is required. Use `audit.ReadPartial` for a running or +interrupted log. `audit.Observations` and `testkit.NewReplaySource` can then +reproduce its canonical input without controller hardware. +For forensic playback, `audit.Events` decodes the recorded canonical, +gesture, action, command, clock, and lifecycle events directly, preserving +their original identity and timing instead of re-deriving them. + +Where a log may become evidence, integrity is not enough. Sign it, anchor it, +and record what interpreted the input: + +```go +recorder := audit.NewRecorder( + file, + audit.WithSigner(signer), // Ed25519; prefer a TPM or HSM key + audit.WithCheckpoints(10*time.Second, 10000), + audit.WithAnchor(anchor), // publish heads outside this host + audit.WithProvenance(provenance), +) +``` + +Each mechanism closes a different gap. The hash chain shows the log was not +edited; `WithHMAC` shows a key holder wrote it; `WithSigner` lets an +independently trusted public key authenticate signed tree heads without giving +the verifier signing capability; `WithAnchor` makes a destroyed or truncated +log detectable rather than merely suspected; and `WithProvenance` records the +build, configuration, and operator without which recorded input cannot be +turned back into behavior. +Records also form an RFC 6962 Merkle tree, so a single record can be proved to +a signed head without disclosing the rest of the log. + +Verify completed signed logs with +`audit.ReadTrusted(reader, trustedPublicKey)`. The public key must come from a +separate trusted channel; a key declared only inside the log proves internal +consistency, not device identity. Key provisioning, rotation, revocation, and +destruction are deployment responsibilities. + +When feeding a finite `testkit.ReplaySource` through a controller, pass +`teleop.WithDeferredStart()` so `Subscribe` is attached before replay begins. Known backend loss is published as `GapEvent`. Recorder failures stop the controller pipeline, and lossless subscriber overflow is explicit. See [the audit guide](docs/audit.md) for the full guarantee boundary and replay workflow. +Every mechanism above detects records that were altered, removed, or +misattributed. None detects input the operating system never delivered, which +is the failure most likely to injure someone. That is what the next section is +for. + +## Command timeout and dead-man switch + +The `safety` package gates output on conditions it can affirmatively establish, +and inhibits on anything it cannot: + +```go +guard := safety.New( + safety.WithCommandTimeout(150*time.Millisecond), + safety.WithDeadMan(xbox.ButtonBumperRight), + safety.WithDeadManReactuation(30*time.Second), + safety.WithLoopWatchdog(100*time.Millisecond), +) + +controller, err := provider.Open(ctx, deviceID, + teleop.WithProcessor(guard), + teleop.WithLiveness(20*time.Millisecond, 100*time.Millisecond), +) +if err != nil { + return err +} +guard.Bind(controller) + +for range ticker.C { + guard.Heartbeat() + decision := guard.Evaluate() + drive(decision.Command) // neutral whenever inhibited +} +``` + +`Evaluate` is authoritative and must be called immediately before acting. +`Decision.Command` is the neutral state whenever output is inhibited, so a +stale decision cannot leak live input into an actuator. Input age is measured +on a monotonic clock, so a wall-clock step cannot make stale input look fresh. + +Faults latch: a dropout revokes authority until an operator calls `Arm` again, +because automatic resumption is how a transient dropout becomes an unexpected +movement. Releasing the dead-man control is normal operation and does not +latch, while holding it past the re-actuation deadline is treated as a defeated +switch and does. The guard also re-evaluates on the controller's own ticker, so +it trips even if the application stops calling `Evaluate`. + +Transitions are published into the same subscriptions and audit sinks as input. +Pair the guard with `controller.RecordCommand` so the log shows both what the +operator did and what the gate permitted. + +A `safety.Guard` is not a certified safety controller and does not replace a +hardware emergency stop or an actuator that reaches a safe state on its own +when commands stop arriving. See [the safety guide](docs/safety.md). + ## Terminal monitor List controllers: @@ -274,10 +406,10 @@ Record while monitoring: go run ./cmd/teleop-monitor --audit controller.jsonl ``` -The TUI losslessly consumes and counts every event while keeping the recent -canonical event list readable instead of filling it with raw observations. Use -`--device ID` to select a controller. To stream every published event, -including raw observations, as JSON Lines: +The interactive TUI uses latest-value delivery and reports any coalesced +events, keeping the recent canonical event list responsive. Use `--device ID` +to select a controller. To losslessly stream every published event, including +raw observations, as JSON Lines: ```sh go run ./cmd/teleop-monitor --json @@ -294,11 +426,17 @@ redirected. | `xbox` | Cross-platform Xbox provider and familiar control aliases | | `gesture` | Optional gesture recognition | | `action` | Optional mapping from physical input or gestures to semantic actions | -| `audit` | Hash-chained JSON Lines recording, verification, and replay extraction | +| `safety` | Command timeout, dead-man switch, and fail-closed output gating | +| `audit` | Signed, hash-chained JSON Lines recording, verification, and replay extraction | | `testkit` | Deterministic fake and replay input sources | -See [the architecture guide](docs/architecture.md) to implement another -controller provider. +Further reading: + +- [Architecture guide](docs/architecture.md) — implementing another provider +- [Safety guide](docs/safety.md) — command timeout, dead-man switch, and what + they do not cover +- [Audit guide](docs/audit.md) — what each integrity mechanism actually proves, + and the guarantee boundary ## License diff --git a/action/action.go b/action/action.go index 789dc42..c03330f 100644 --- a/action/action.go +++ b/action/action.go @@ -3,102 +3,201 @@ package action import ( + "context" + "fmt" + "sort" "sync" + "sync/atomic" "github.com/open-ships/teleop" "github.com/open-ships/teleop/gesture" ) +// ID identifies an application-defined action. type ID string +// EventKind is the persisted kind for action events. const EventKind teleop.EventKind = "action" +// Value carries the input value that caused an action. type Value struct { - Pressed bool `json:"pressed,omitempty"` - Scalar float32 `json:"scalar,omitempty"` - Vector teleop.Stick `json:"vector,omitempty"` + Pressed bool `json:"pressed,omitempty"` + Scalar float32 `json:"scalar,omitempty"` + Vector *teleop.Stick `json:"vector,omitempty"` } +// Event is one mapped semantic action. type Event struct { - Meta teleop.Header `json:"header"` - Action ID `json:"action"` - Phase teleop.Phase `json:"phase"` - Control teleop.ControlID `json:"control,omitempty"` - Value Value `json:"value"` + Meta teleop.Header `json:"header"` + Action ID `json:"action"` + Phase teleop.Phase `json:"phase"` + Control teleop.ControlID `json:"control,omitempty"` + Controls []teleop.ControlID `json:"controls,omitempty"` + Value Value `json:"value"` } +// Header implements teleop.Event. func (e Event) Header() teleop.Header { return e.Meta.Clone() } -func (Event) Kind() teleop.EventKind { return EventKind } -// Binding matches an event kind and optional control, phase, and gesture type. -// Empty match fields act as wildcards. +// Kind implements teleop.Event. +func (Event) Kind() teleop.EventKind { return EventKind } + +// CloneEvent implements teleop.EventCloner. +func (e Event) CloneEvent() teleop.Event { + e.Meta = e.Meta.Clone() + e.Controls = append([]teleop.ControlID(nil), e.Controls...) + if e.Value.Vector != nil { + vector := *e.Value.Vector + e.Value.Vector = &vector + } + return e +} + +// Binding matches an event kind and optional exact controls, phase, gesture +// type, and connection state. Empty match fields are wildcards. type Binding struct { - Action ID - EventKind teleop.EventKind - Control teleop.ControlID - Phase teleop.Phase - GestureType gesture.Type + Action ID + EventKind teleop.EventKind + Control teleop.ControlID + Controls []teleop.ControlID + Phase teleop.Phase + GestureType gesture.Type + Region string + ConnectionState teleop.ConnectionState } +// OnButton binds a button transition to action. func OnButton(action ID, button teleop.ControlID, phase teleop.Phase) Binding { - return Binding{ - Action: action, - EventKind: teleop.EventButton, - Control: button, - Phase: phase, - } + return Binding{Action: action, EventKind: teleop.EventButton, Control: button, Phase: phase} } +// OnDPad binds a D-pad direction transition to action. func OnDPad(action ID, direction teleop.ControlID, phase teleop.Phase) Binding { - return Binding{ - Action: action, - EventKind: teleop.EventButton, - Control: direction, - Phase: phase, - } + return OnButton(action, direction, phase) } +// OnGesture binds the single meaningful phase: ended for taps and started for +// stateful gestures. Use OnGesturePhase when both edges are meaningful. func OnGesture(action ID, gestureType gesture.Type, control teleop.ControlID) Binding { + phase := teleop.PhaseStarted + if gestureType == gesture.Tap || gestureType == gesture.DoubleTap { + phase = teleop.PhaseEnded + } + return OnGesturePhase(action, gestureType, control, phase) +} + +// OnGesturePhase binds one phase of a recognized gesture to action. +func OnGesturePhase( + action ID, + gestureType gesture.Type, + control teleop.ControlID, + phase teleop.Phase, +) Binding { return Binding{ Action: action, EventKind: gesture.EventKind, Control: control, + Phase: phase, GestureType: gestureType, } } -func OnStick(action ID, stick teleop.StickID) Binding { - control := teleop.StickRight - if stick == teleop.LeftStick { - control = teleop.StickLeft - } +// OnChord binds an exact chord by its configured name and controls. +func OnChord(action ID, name string, controls ...teleop.ControlID) Binding { return Binding{ - Action: action, - EventKind: teleop.EventStick, - Control: control, + Action: action, + EventKind: gesture.EventKind, + Controls: canonicalControls(controls), + Phase: teleop.PhaseStarted, + GestureType: gesture.Chord, + Region: name, } } +// OnStick binds changes from one normalized stick to action. +func OnStick(action ID, stick teleop.StickID) Binding { + control, _ := stickControl(stick) + return Binding{Action: action, EventKind: teleop.EventStick, Control: control} +} + +// OnTrigger binds changes from one normalized trigger to action. func OnTrigger(action ID, trigger teleop.TriggerID) Binding { - control := teleop.TriggerRight - if trigger == teleop.LeftTrigger { - control = teleop.TriggerLeft - } + control, _ := triggerControl(trigger) + return Binding{Action: action, EventKind: teleop.EventTrigger, Control: control} +} + +// OnConnection binds a lifecycle transition such as a disconnect failsafe. +func OnConnection(action ID, state teleop.ConnectionState) Binding { return Binding{ - Action: action, - EventKind: teleop.EventTrigger, - Control: control, + Action: action, + EventKind: teleop.EventConnection, + ConnectionState: state, } } +var mapperInstances atomic.Uint64 + +// Mapper is immutable after construction and safe for concurrent Process calls. type Mapper struct { - mu sync.Mutex - bindings []Binding - sequence uint64 + mu sync.Mutex + bindings []Binding + fallbackStream string + fallbackSeq map[teleop.SessionID]uint64 } +// New validates and copies bindings. Invalid bindings panic instead of silently +// matching a different control. func New(bindings ...Binding) *Mapper { - return &Mapper{bindings: append([]Binding(nil), bindings...)} + copied := append([]Binding(nil), bindings...) + for index := range copied { + copied[index].Controls = canonicalControls(copied[index].Controls) + if err := validateBinding(copied[index]); err != nil { + panic(err) + } + } + instance := mapperInstances.Add(1) + return &Mapper{ + bindings: copied, + fallbackStream: fmt.Sprintf("action/%d", instance), + fallbackSeq: make(map[teleop.SessionID]uint64), + } +} + +func validateBinding(binding Binding) error { + if binding.Action == "" { + return fmt.Errorf("action: binding has an empty action") + } + if binding.EventKind == teleop.EventStick && + binding.Control != teleop.StickLeft && + binding.Control != teleop.StickRight { + return fmt.Errorf("action: invalid stick control %q", binding.Control) + } + if binding.EventKind == teleop.EventTrigger && + binding.Control != teleop.TriggerLeft && + binding.Control != teleop.TriggerRight { + return fmt.Errorf("action: invalid trigger control %q", binding.Control) + } + if binding.GestureType == gesture.Chord && len(binding.Controls) < 2 { + return fmt.Errorf("action: chord binding needs at least two controls") + } + if binding.GestureType == gesture.Chord && binding.Region == "" { + return fmt.Errorf("action: chord binding needs its configured name") + } + if binding.ConnectionState != "" && + binding.ConnectionState != teleop.Connected && + binding.ConnectionState != teleop.Disconnected { + return fmt.Errorf("action: invalid connection state %q", binding.ConnectionState) + } + if binding.ConnectionState != "" && binding.EventKind != teleop.EventConnection { + return fmt.Errorf("action: connection state requires a connection event binding") + } + if binding.Region != "" && binding.EventKind != gesture.EventKind { + return fmt.Errorf("action: gesture region requires a gesture event binding") + } + if binding.Control != "" && len(binding.Controls) > 0 { + return fmt.Errorf("action: binding cannot match both one control and an exact control set") + } + return nil } // Process implements teleop.Processor. @@ -111,91 +210,205 @@ func (m *Mapper) Process(input teleop.Event) []teleop.Event { return result } -// Map returns strongly typed application actions. +// ProcessContext implements teleop.ContextProcessor. +func (m *Mapper) ProcessContext( + _ context.Context, + processing teleop.ProcessingContext, + input teleop.Event, +) ([]teleop.Event, error) { + mapped := m.mapInput(input, processing) + result := make([]teleop.Event, len(mapped)) + for index := range mapped { + result[index] = mapped[index] + } + return result, nil +} + +// Map returns strongly typed application actions using an instance-unique +// standalone event stream. func (m *Mapper) Map(input teleop.Event) []Event { + return m.mapInput(input, nil) +} + +func (m *Mapper) mapInput( + input teleop.Event, + processing teleop.ProcessingContext, +) []Event { m.mu.Lock() defer m.mu.Unlock() - var result []Event + values, handled := eventValues(input) + if !handled { + return nil + } + source := input.Header() + result := make([]Event, 0, len(m.bindings)) for _, binding := range m.bindings { - control, phase, value, gestureType := eventValues(input) - if binding.EventKind != "" && input.Kind() != binding.EventKind { - continue - } - if binding.Control != "" && binding.Control != control { - continue - } - if binding.Phase != "" && binding.Phase != phase { - continue - } - if binding.GestureType != "" && binding.GestureType != gestureType { + if binding.EventKind != "" && input.Kind() != binding.EventKind || + binding.Control != "" && binding.Control != values.control || + len(binding.Controls) > 0 && !equalControls(binding.Controls, values.controls) || + binding.Phase != "" && binding.Phase != values.phase || + binding.GestureType != "" && binding.GestureType != values.gestureType || + binding.Region != "" && binding.Region != values.region || + binding.ConnectionState != "" && + binding.ConnectionState != values.connectionState { continue } - source := input.Header() - m.sequence++ - result = append(result, Event{ - Meta: teleop.Header{ + var header teleop.Header + if processing != nil { + header = processing.NewHeader( + "action", + source.ObservedAt, + source.DeviceTimestamp, + source.ID, + ) + header.Synthetic = source.Synthetic + } else { + m.fallbackSeq[source.ID.Session]++ + header = teleop.Header{ ID: teleop.EventID{ Session: source.ID.Session, - Stream: "action", - Sequence: m.sequence, + Stream: m.fallbackStream, + Sequence: m.fallbackSeq[source.ID.Session], }, - DeviceID: source.DeviceID, - ObservedAt: source.ObservedAt, - Causes: []teleop.EventID{source.ID}, - }, - Action: binding.Action, - Phase: phase, - Control: control, - Value: value, + DeviceID: source.DeviceID, + ObservedAt: source.ObservedAt, + ReceivedAt: source.ReceivedAt, + PublishedAt: source.PublishedAt, + DeviceTimestamp: source.DeviceTimestamp, + Causes: []teleop.EventID{source.ID}, + Synthetic: source.Synthetic, + } + } + result = append(result, Event{ + Meta: header, + Action: binding.Action, + Phase: values.phase, + Control: values.control, + Controls: append([]teleop.ControlID(nil), values.controls...), + Value: values.value, }) } return result } -func eventValues(input teleop.Event) (teleop.ControlID, teleop.Phase, Value, gesture.Type) { +type extractedValues struct { + control teleop.ControlID + controls []teleop.ControlID + phase teleop.Phase + value Value + gestureType gesture.Type + region string + connectionState teleop.ConnectionState +} + +func eventValues(input teleop.Event) (extractedValues, bool) { switch event := input.(type) { case teleop.ButtonEvent: - return event.Button, event.Phase, Value{Pressed: event.Pressed}, "" + return extractedValues{ + control: event.Button, controls: []teleop.ControlID{event.Button}, + phase: event.Phase, value: Value{Pressed: event.Pressed}, + }, true case *teleop.ButtonEvent: - return event.Button, event.Phase, Value{Pressed: event.Pressed}, "" + return eventValues(*event) case teleop.StickEvent: - control := teleop.StickRight - if event.Stick == teleop.LeftStick { - control = teleop.StickLeft + control, ok := stickControl(event.Stick) + if !ok { + return extractedValues{}, false } - return control, teleop.PhaseChanged, Value{Vector: event.Position}, "" + vector := event.Position + return extractedValues{ + control: control, controls: []teleop.ControlID{control}, + phase: teleop.PhaseChanged, value: Value{Vector: &vector}, + }, true case *teleop.StickEvent: - control := teleop.StickRight - if event.Stick == teleop.LeftStick { - control = teleop.StickLeft - } - return control, teleop.PhaseChanged, Value{Vector: event.Position}, "" + return eventValues(*event) case teleop.TriggerEvent: - control := teleop.TriggerRight - if event.Trigger == teleop.LeftTrigger { - control = teleop.TriggerLeft + control, ok := triggerControl(event.Trigger) + if !ok { + return extractedValues{}, false } - return control, teleop.PhaseChanged, Value{Scalar: event.Position}, "" + return extractedValues{ + control: control, controls: []teleop.ControlID{control}, + phase: teleop.PhaseChanged, value: Value{Scalar: event.Position}, + }, true case *teleop.TriggerEvent: - control := teleop.TriggerRight - if event.Trigger == teleop.LeftTrigger { - control = teleop.TriggerLeft - } - return control, teleop.PhaseChanged, Value{Scalar: event.Position}, "" + return eventValues(*event) case gesture.Event: + controls := canonicalControls(event.Controls) var control teleop.ControlID - if len(event.Controls) > 0 { - control = event.Controls[0] + if len(controls) == 1 { + control = controls[0] } - return control, event.Phase, Value{Scalar: event.Value}, event.Type + return extractedValues{ + control: control, controls: controls, phase: event.Phase, + value: Value{Scalar: event.Value}, gestureType: event.Type, + region: event.Region, + }, true case *gesture.Event: - var control teleop.ControlID - if len(event.Controls) > 0 { - control = event.Controls[0] - } - return control, event.Phase, Value{Scalar: event.Value}, event.Type + return eventValues(*event) + case teleop.ConnectionEvent: + return extractedValues{ + phase: connectionPhase(event.State), connectionState: event.State, + }, true + case *teleop.ConnectionEvent: + return eventValues(*event) + default: + return extractedValues{}, false + } +} + +func connectionPhase(state teleop.ConnectionState) teleop.Phase { + if state == teleop.Connected { + return teleop.PhaseStarted + } + return teleop.PhaseEnded +} + +func stickControl(stick teleop.StickID) (teleop.ControlID, bool) { + switch stick { + case teleop.LeftStick: + return teleop.StickLeft, true + case teleop.RightStick: + return teleop.StickRight, true + default: + return "", false + } +} + +func triggerControl(trigger teleop.TriggerID) (teleop.ControlID, bool) { + switch trigger { + case teleop.LeftTrigger: + return teleop.TriggerLeft, true + case teleop.RightTrigger: + return teleop.TriggerRight, true default: - return "", "", Value{}, "" + return "", false + } +} + +func canonicalControls(values []teleop.ControlID) []teleop.ControlID { + result := append([]teleop.ControlID(nil), values...) + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + write := 0 + for _, value := range result { + if value == "" || write > 0 && result[write-1] == value { + continue + } + result[write] = value + write++ + } + return result[:write] +} + +func equalControls(left, right []teleop.ControlID) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } } + return true } diff --git a/action/action_test.go b/action/action_test.go index 0435099..b9169a1 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -1,6 +1,7 @@ package action_test import ( + "context" "testing" "time" @@ -56,3 +57,203 @@ func TestMapperMapsDPadDirectionPhase(t *testing.T) { t.Fatalf("D-pad action = %#v", result[0]) } } + +func TestOnGestureChoosesOneMeaningfulPhase(t *testing.T) { + t.Parallel() + + holdMapper := action.New( + action.OnGesture("arm", gesture.Hold, teleop.ButtonFaceSouth), + ) + started := gesture.Event{ + Meta: actionHeader(1, 1), + Type: gesture.Hold, + Phase: teleop.PhaseStarted, + Controls: []teleop.ControlID{teleop.ButtonFaceSouth}, + } + ended := started + ended.Meta = actionHeader(1, 2) + ended.Phase = teleop.PhaseEnded + if got := holdMapper.Map(started); len(got) != 1 || got[0].Phase != teleop.PhaseStarted { + t.Fatalf("hold start = %#v", got) + } + if got := holdMapper.Map(ended); len(got) != 0 { + t.Fatalf("hold end unexpectedly mapped = %#v", got) + } + + tapMapper := action.New( + action.OnGesture("select", gesture.Tap, teleop.ButtonFaceSouth), + ) + tapped := ended + tapped.Type = gesture.Tap + if got := tapMapper.Map(tapped); len(got) != 1 || got[0].Phase != teleop.PhaseEnded { + t.Fatalf("tap = %#v", got) + } +} + +func TestOnChordMatchesNameAndExactControlSet(t *testing.T) { + t.Parallel() + + mapper := action.New(action.OnChord( + "arm", + "arm-chord", + teleop.ButtonBumperLeft, + teleop.ButtonFaceSouth, + )) + base := gesture.Event{ + Meta: actionHeader(1, 1), + Type: gesture.Chord, + Phase: teleop.PhaseStarted, + Controls: []teleop.ControlID{ + teleop.ButtonFaceSouth, + teleop.ButtonBumperLeft, + }, + Region: "arm-chord", + } + if got := mapper.Map(base); len(got) != 1 || got[0].Action != "arm" { + t.Fatalf("exact chord = %#v", got) + } + wrongName := base + wrongName.Region = "eject-chord" + if got := mapper.Map(wrongName); len(got) != 0 { + t.Fatalf("wrong chord name mapped = %#v", got) + } + wrongControls := base + wrongControls.Controls = []teleop.ControlID{ + teleop.ButtonBumperLeft, + teleop.ButtonFaceEast, + } + if got := mapper.Map(wrongControls); len(got) != 0 { + t.Fatalf("wrong chord controls mapped = %#v", got) + } +} + +func TestMapperCopiesBindingsAndSequencesEachSession(t *testing.T) { + t.Parallel() + + controls := []teleop.ControlID{ + teleop.ButtonFaceSouth, + teleop.ButtonBumperLeft, + } + mapper := action.New(action.Binding{ + Action: "arm", + EventKind: gesture.EventKind, + Controls: controls, + Phase: teleop.PhaseStarted, + GestureType: gesture.Chord, + Region: "arm", + }) + controls[0] = teleop.ButtonFaceEast + chord := gesture.Event{ + Meta: actionHeader(1, 1), + Type: gesture.Chord, + Phase: teleop.PhaseStarted, + Controls: []teleop.ControlID{teleop.ButtonBumperLeft, teleop.ButtonFaceSouth}, + Region: "arm", + } + first := mapper.Map(chord) + if len(first) != 1 || first[0].Meta.ID.Sequence != 1 { + t.Fatalf("first session action = %#v", first) + } + chord.Meta = actionHeader(2, 1) + second := mapper.Map(chord) + if len(second) != 1 || second[0].Meta.ID.Sequence != 1 { + t.Fatalf("second session action = %#v", second) + } + chord.Meta = actionHeader(1, 2) + third := mapper.Map(chord) + if len(third) != 1 || third[0].Meta.ID.Sequence != 2 { + t.Fatalf("continued first session action = %#v", third) + } +} + +func TestContextProcessingAllocatesUniqueSharedStreamIDs(t *testing.T) { + t.Parallel() + + processing := &testProcessingContext{sequences: make(map[string]uint64)} + left := action.New( + action.OnButton("left", teleop.ButtonFaceSouth, teleop.PhasePressed), + ) + right := action.New( + action.OnButton("right", teleop.ButtonFaceSouth, teleop.PhasePressed), + ) + source := teleop.ButtonEvent{ + Meta: actionHeader(1, 1), + Button: teleop.ButtonFaceSouth, + Phase: teleop.PhasePressed, + Pressed: true, + } + leftEvents, err := left.ProcessContext(context.Background(), processing, source) + if err != nil { + t.Fatal(err) + } + rightEvents, err := right.ProcessContext(context.Background(), processing, source) + if err != nil { + t.Fatal(err) + } + if len(leftEvents) != 1 || len(rightEvents) != 1 { + t.Fatalf("context actions = %#v, %#v", leftEvents, rightEvents) + } + if leftEvents[0].Header().ID.Sequence != 1 || + rightEvents[0].Header().ID.Sequence != 2 || + leftEvents[0].Header().ID.Stream != "action" || + rightEvents[0].Header().ID.Stream != "action" { + t.Fatalf("context IDs = %#v, %#v", leftEvents[0].Header().ID, rightEvents[0].Header().ID) + } +} + +func TestOnConnectionMapsDisconnectAsEnded(t *testing.T) { + t.Parallel() + + mapper := action.New(action.OnConnection("safe-state", teleop.Disconnected)) + result := mapper.Map(teleop.ConnectionEvent{ + Meta: actionHeader(1, 1), + State: teleop.Disconnected, + }) + if len(result) != 1 || result[0].Action != "safe-state" || + result[0].Phase != teleop.PhaseEnded { + t.Fatalf("disconnect action = %#v", result) + } +} + +func actionHeader(sessionByte byte, sequence uint64) teleop.Header { + var session teleop.SessionID + session[0] = sessionByte + return teleop.Header{ + ID: teleop.EventID{ + Session: session, + Stream: "input", + Sequence: sequence, + }, + DeviceID: teleop.DeviceID("controller"), + ObservedAt: time.Unix(int64(sequence), 0), + } +} + +type testProcessingContext struct { + sequences map[string]uint64 +} + +func (processing *testProcessingContext) NewHeader( + stream string, + observedAt time.Time, + deviceTimestamp int64, + causes ...teleop.EventID, +) teleop.Header { + processing.sequences[stream]++ + var session teleop.SessionID + if len(causes) > 0 { + session = causes[0].Session + } + return teleop.Header{ + ID: teleop.EventID{ + Session: session, + Stream: stream, + Sequence: processing.sequences[stream], + }, + ObservedAt: observedAt, + DeviceTimestamp: deviceTimestamp, + Causes: append([]teleop.EventID(nil), causes...), + } +} + +func (*testProcessingContext) Now() time.Time { return time.Unix(100, 0) } diff --git a/audit/anchor.go b/audit/anchor.go new file mode 100644 index 0000000..77d16fc --- /dev/null +++ b/audit/anchor.go @@ -0,0 +1,281 @@ +package audit + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "sync" + "time" + + "github.com/open-ships/teleop" +) + +// A hash chain proves that a log was not edited. It proves nothing about a log +// that was deleted, truncated before its final records, or never written at +// all — the failure mode most likely to matter when a session ends badly. +// Publishing each signed tree head to storage the recorder does not control +// converts those from invisible losses into detectable ones: a log that cannot +// produce a consistency proof against its last published head has been +// rewritten, and a missing log whose head was published is provably missing. + +// Checkpoint is a self-describing tree head suitable for publication outside +// the log. A recorder configured with WithSigner populates its key and +// signature fields. Use VerifyCheckpoint with an independently trusted public +// key before relying on a signed checkpoint. +type Checkpoint struct { + // Version and RecordType select the signed statement format. + Version int `json:"version"` + RecordType string `json:"record_type"` + // SignatureAlgorithm identifies how Signature was produced. + SignatureAlgorithm string `json:"signature_algorithm,omitempty"` + // Session binds the head to one controller session. + Session teleop.SessionID `json:"session"` + // Size and Root are the Merkle head over records preceding this head. + Size uint64 `json:"size"` + Root string `json:"root"` + // ChainHead is the hash of the manifest, checkpoint, or footer itself. + ChainHead string `json:"chain_head"` + // EventCount is the number of events committed by this head. + EventCount uint64 `json:"event_count"` + // RecordedAt is the recorder timestamp included in the signature. + RecordedAt time.Time `json:"recorded_at"` + // KeyID and PublicKey identify the self-declared signer. Verifiers must not + // use them as the source of trust. + KeyID string `json:"key_id,omitempty"` + PublicKey string `json:"public_key,omitempty"` + // Signature is the hex-encoded detached signature over the tree head. + Signature string `json:"signature,omitempty"` +} + +// VerifyCheckpoint verifies checkpoint against public, which must be obtained +// outside the checkpoint. It also rejects a conflicting self-declared key or +// key identifier. +func VerifyCheckpoint(public ed25519.PublicKey, checkpoint Checkpoint) error { + if len(public) != ed25519.PublicKeySize { + return fmt.Errorf("%w: public key is %d bytes", ErrSignature, len(public)) + } + if checkpoint.Version != FormatVersion { + return fmt.Errorf( + "%w: checkpoint format version is %d, want %d", + ErrSignature, + checkpoint.Version, + FormatVersion, + ) + } + switch checkpoint.RecordType { + case "manifest", "checkpoint", "footer": + default: + return fmt.Errorf( + "%w: checkpoint record type %q is invalid", + ErrSignature, + checkpoint.RecordType, + ) + } + if checkpoint.Session == (teleop.SessionID{}) && + (checkpoint.RecordType == "checkpoint" || checkpoint.EventCount > 0) { + return fmt.Errorf("%w: checkpoint has no controller session", ErrSessionRequired) + } + if checkpoint.SignatureAlgorithm != SignatureAlgorithmEd25519 { + return fmt.Errorf( + "%w: checkpoint signature algorithm %q is unsupported", + ErrSignature, + checkpoint.SignatureAlgorithm, + ) + } + if checkpoint.PublicKey != "" { + declared, err := decodePublicKey(checkpoint.PublicKey) + if err != nil { + return err + } + if !declared.Equal(public) { + return ErrUntrustedKey + } + } + if checkpoint.KeyID != "" && checkpoint.KeyID != KeyID(public) { + return fmt.Errorf("%w: checkpoint key identifier does not match", ErrUntrustedKey) + } + root, err := hex.DecodeString(checkpoint.Root) + if err != nil { + return fmt.Errorf("%w: decode checkpoint root: %v", ErrSignature, err) + } + if len(root) != HashSize { + return fmt.Errorf("%w: checkpoint root is %d bytes", ErrSignature, len(root)) + } + chainHead, err := hex.DecodeString(checkpoint.ChainHead) + if err != nil { + return fmt.Errorf("%w: decode checkpoint chain head: %v", ErrSignature, err) + } + if len(chainHead) != HashSize { + return fmt.Errorf( + "%w: checkpoint chain head is %d bytes", + ErrSignature, + len(chainHead), + ) + } + return VerifyTreeHead(public, TreeHead{ + Version: checkpoint.Version, + RecordType: checkpoint.RecordType, + Session: checkpoint.Session, + Size: checkpoint.Size, + Root: root, + ChainHead: checkpoint.ChainHead, + EventCount: checkpoint.EventCount, + RecordedAt: checkpoint.RecordedAt, + }, checkpoint.Signature) +} + +// Anchor publishes checkpoints to append-only storage outside the recorder's +// control: object storage under a WORM or legal-hold policy, a transparency +// log, or simply a host under separate custody. +// +// Publish is called from a dedicated goroutine and never blocks the input +// path. Implementations should apply their own timeouts. +type Anchor interface { + Publish(context.Context, Checkpoint) error +} + +// AnchorFunc adapts a function to the Anchor interface. +type AnchorFunc func(context.Context, Checkpoint) error + +// Publish implements Anchor. +func (fn AnchorFunc) Publish(ctx context.Context, checkpoint Checkpoint) error { + return fn(ctx, checkpoint) +} + +// FileAnchor appends checkpoints as JSON Lines to a writer. Point it at +// storage with a different failure and custody domain than the audit log +// itself; anchoring to the same directory proves very little. +type FileAnchor struct { + mu sync.Mutex + writer io.Writer +} + +// NewFileAnchor returns an Anchor that appends to writer. +func NewFileAnchor(writer io.Writer) *FileAnchor { + return &FileAnchor{writer: writer} +} + +// Publish implements Anchor. +func (a *FileAnchor) Publish(ctx context.Context, checkpoint Checkpoint) error { + if err := ctx.Err(); err != nil { + return err + } + encoded, err := json.Marshal(checkpoint) + if err != nil { + return fmt.Errorf("marshal checkpoint: %w", err) + } + a.mu.Lock() + defer a.mu.Unlock() + if _, err := a.writer.Write(append(encoded, '\n')); err != nil { + return fmt.Errorf("write checkpoint: %w", err) + } + if syncer, ok := a.writer.(interface{ Sync() error }); ok { + if err := syncer.Sync(); err != nil { + return fmt.Errorf("sync checkpoint: %w", err) + } + } + return nil +} + +// anchorRunner publishes checkpoints asynchronously. The queue is bounded and +// drops the oldest pending checkpoint when full: a later checkpoint supersedes +// an earlier one, so under sustained anchor slowness the freshest head is the +// one worth keeping. Drops are counted rather than hidden. +type anchorRunner struct { + anchor Anchor + queue chan Checkpoint + done chan struct{} + timeout time.Duration + + mu sync.Mutex + failure error + failures uint64 + dropped uint64 + sent uint64 +} + +func newAnchorRunner(anchor Anchor, depth int, timeout time.Duration) *anchorRunner { + if depth <= 0 { + depth = 8 + } + if timeout <= 0 { + timeout = 5 * time.Second + } + runner := &anchorRunner{ + anchor: anchor, + queue: make(chan Checkpoint, depth), + done: make(chan struct{}), + timeout: timeout, + } + go runner.run() + return runner +} + +func (r *anchorRunner) run() { + defer close(r.done) + for checkpoint := range r.queue { + ctx, cancel := context.WithTimeout(context.Background(), r.timeout) + err := r.anchor.Publish(ctx, checkpoint) + cancel() + r.mu.Lock() + if err != nil { + r.failures++ + if r.failure == nil { + r.failure = err + } + } else { + r.sent++ + } + r.mu.Unlock() + } +} + +func (r *anchorRunner) publish(checkpoint Checkpoint) { + for { + select { + case r.queue <- checkpoint: + return + default: + } + select { + case <-r.queue: + r.mu.Lock() + r.dropped++ + r.mu.Unlock() + default: + } + } +} + +func (r *anchorRunner) stop() { + close(r.queue) + <-r.done +} + +func (r *anchorRunner) stats() AnchorStats { + r.mu.Lock() + defer r.mu.Unlock() + return AnchorStats{ + Published: r.sent, + Failed: r.failures, + Dropped: r.dropped, + Err: r.failure, + } +} + +// AnchorStats reports external anchoring health. A non-zero Failed or Dropped +// count means the log's recent heads are not externally witnessed, so recent +// records are integrity protected but not protected against destruction. +type AnchorStats struct { + // Published is the number of heads successfully sent. + Published uint64 + // Failed is the number of attempted publications that returned errors. + Failed uint64 + // Dropped is the number evicted from a saturated queue. + Dropped uint64 + // Err is the first publication error observed. + Err error +} diff --git a/audit/compat_test.go b/audit/compat_test.go new file mode 100644 index 0000000..76d12c2 --- /dev/null +++ b/audit/compat_test.go @@ -0,0 +1,284 @@ +package audit + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "testing" + "time" +) + +// Bumping FormatVersion to 3 kept the version 1 and 2 read paths, but every +// other test now writes version 3. These tests build legacy streams by hand so +// a regression in the older hashing or decoding paths cannot pass unnoticed: +// logs already on disk must stay readable, since a log that cannot be verified +// later is not evidence. + +func encodeLegacy(t *testing.T, records []diskRecord, hashFor func(diskRecord) (string, error)) *bytes.Reader { + t.Helper() + buffer := &bytes.Buffer{} + previous := "" + for index := range records { + record := records[index] + record.PreviousHash = previous + record.Hash = "" + digest, err := hashFor(record) + if err != nil { + t.Fatal(err) + } + record.Hash = digest + previous = digest + wire := map[string]any{ + "version": record.Version, + "record_type": record.RecordType, + "recorded_at": record.RecordedAt, + "previous_hash": record.PreviousHash, + "hash": record.Hash, + } + if record.Kind != "" { + wire["kind"] = record.Kind + } + if record.Header != nil { + wire["header"] = record.Header + } + if len(record.Payload) > 0 { + wire["payload"] = record.Payload + } + if record.EventCount > 0 { + wire["event_count"] = record.EventCount + } + if record.Version == 2 { + if record.Chain != "" { + wire["chain"] = record.Chain + } + if record.ControlSchema != "" { + wire["control_schema"] = record.ControlSchema + } + if record.Durability != "" { + wire["durability"] = record.Durability + } + if record.EncodingError != "" { + wire["encoding_error"] = record.EncodingError + } + delete(wire, "header") + } + if record.PreviousHash == "" { + delete(wire, "previous_hash") + } + encoded, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + buffer.Write(encoded) + buffer.WriteByte('\n') + } + return bytes.NewReader(buffer.Bytes()) +} + +func TestLegacyVersionCannotSatisfyTrustedVerification(t *testing.T) { + recordedAt := time.Unix(1700000000, 0).UTC() + event := testButtonEvent(1) + payload, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + header := event.Meta + reader := encodeLegacy(t, []diskRecord{ + { + Version: 1, + RecordType: "event", + RecordedAt: recordedAt, + Kind: event.Kind(), + Header: &header, + Payload: payload, + }, + { + Version: 1, + RecordType: "footer", + RecordedAt: recordedAt, + EventCount: 1, + }, + }, recordHashV1) + public, _, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + + consumed := 0 + _, err = Verify(reader, VerifyOptions{PublicKey: public}, func(Record) error { + consumed++ + return nil + }) + if !errors.Is(err, ErrSignatureRequired) { + t.Fatalf("err = %v, want ErrSignatureRequired", err) + } + if consumed != 0 { + t.Fatalf("consumed %d legacy events during trusted verification", consumed) + } +} + +func TestVersion2RejectsUnauthenticatedVersion3Metadata(t *testing.T) { + recordedAt := time.Unix(1700000000, 0).UTC() + key := bytes.Repeat([]byte{0x42}, 32) + reader := encodeLegacy(t, []diskRecord{ + { + Version: 2, + RecordType: "manifest", + RecordedAt: recordedAt, + Chain: chainHMAC, + ControlSchema: ControlSchemaVersion, + Durability: "fsync-every-record", + }, + { + Version: 2, + RecordType: "footer", + RecordedAt: recordedAt, + }, + }, func(record diskRecord) (string, error) { + return recordHashV2(record, chainHMAC, key) + }) + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + lines := splitLines(t, raw) + var manifest map[string]any + if err := json.Unmarshal(lines[0], &manifest); err != nil { + t.Fatal(err) + } + // Version 2 never authenticated provenance. A permissive decoder would + // expose this injected value while still reporting the HMAC chain valid. + manifest["provenance"] = map[string]any{"operator": "injected"} + lines[0], err = json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + + _, verification, err := ReadAuthenticated(joinLines(lines), key) + if err == nil { + t.Fatalf("smuggled v3 metadata authenticated: %+v", verification) + } +} + +func TestVersion2StreamsRemainReadable(t *testing.T) { + recordedAt := time.Unix(1700000000, 0).UTC() + payload, err := json.Marshal(testButtonEvent(1)) + if err != nil { + t.Fatal(err) + } + records := []diskRecord{ + { + Version: 2, + RecordType: "manifest", + RecordedAt: recordedAt, + Chain: chainSHA256, + ControlSchema: ControlSchemaVersion, + Durability: "fsync-every-record", + }, + { + Version: 2, + RecordType: "event", + RecordedAt: recordedAt, + Kind: testButtonEvent(1).Kind(), + Payload: payload, + }, + { + Version: 2, + RecordType: "footer", + RecordedAt: recordedAt, + EventCount: 1, + }, + } + + reader := encodeLegacy(t, records, func(record diskRecord) (string, error) { + return recordHashV2(record, chainSHA256, nil) + }) + + decoded, verification, err := Read(reader, VerifyOptions{RequireFooter: true}) + if err != nil { + t.Fatalf("a version 2 log must still verify: %v", err) + } + if verification.Version != 2 { + t.Fatalf("version = %d, want 2", verification.Version) + } + if !verification.Integrity || !verification.Complete { + t.Fatalf("verification = %+v", verification) + } + if len(decoded) != 1 { + t.Fatalf("records = %d, want 1", len(decoded)) + } + // Version 2 predates the Merkle tree, so no head is claimed or checked. + if verification.TreeSize != 0 { + t.Fatalf("tree size = %d, want 0 for a pre-tree format", verification.TreeSize) + } + if verification.Signed || verification.Trusted { + t.Fatal("a version 2 log carries no signatures") + } +} + +func TestVersion1StreamsRemainReadable(t *testing.T) { + recordedAt := time.Unix(1700000000, 0).UTC() + event := testButtonEvent(1) + payload, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + header := event.Meta + records := []diskRecord{ + { + Version: 1, + RecordType: "event", + RecordedAt: recordedAt, + Kind: event.Kind(), + Header: &header, + Payload: payload, + }, + { + Version: 1, + RecordType: "footer", + RecordedAt: recordedAt, + EventCount: 1, + }, + } + + reader := encodeLegacy(t, records, recordHashV1) + + decoded, verification, err := Read(reader, VerifyOptions{RequireFooter: true}) + if err != nil { + t.Fatalf("a version 1 log must still verify: %v", err) + } + if verification.Version != 1 { + t.Fatalf("version = %d, want 1", verification.Version) + } + if len(decoded) != 1 { + t.Fatalf("records = %d, want 1", len(decoded)) + } +} + +// TestVersion3RejectsDowngradedRecords guards the seam between formats: a +// version 3 stream that claims a lower version on a later line must not slip +// past the stricter version 3 checks. +func TestVersion3RejectsDowngradedRecords(t *testing.T) { + buffer, public := signedLog(t, 3) + lines := splitLines(t, buffer.Bytes()) + + var record map[string]any + if err := json.Unmarshal(lines[2], &record); err != nil { + t.Fatal(err) + } + record["version"] = 2 + rewritten, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + lines[2] = rewritten + + _, _, err = Read(joinLines(lines), VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err == nil { + t.Fatal("a downgraded record must not verify") + } +} diff --git a/audit/doc.go b/audit/doc.go new file mode 100644 index 0000000..7792f2a --- /dev/null +++ b/audit/doc.go @@ -0,0 +1,30 @@ +// Package audit records, verifies, and replays append-only controller event +// logs. +// +// Logs use newline-delimited JSON and may provide several distinct security +// properties: +// +// - the default SHA-256 chain detects accidental corruption; +// - [WithHMAC] authenticates every record with a shared secret; +// - [WithSigner] signs Merkle tree heads with Ed25519; and +// - [WithAnchor] publishes tree heads outside the recorder's custody. +// +// These properties are deliberately separate. In particular, a public key +// declared inside a log proves only internal consistency. [ReadTrusted] +// requires a public key obtained through a separate trusted channel and is the +// preferred entry point for completed signed logs. [ReadAuthenticated] is the +// corresponding entry point for completed HMAC-authenticated logs. [ReadAll] +// verifies structural integrity and completeness but does not establish +// authorship. +// +// When [VerifyOptions.PublicKey] or [VerifyOptions.RequireSignature] is set, +// events are withheld from the streaming [Verify] callback until a verified +// signed tree head covers them. [Verification.SignedTreeSize] and +// [Verification.TrustedTreeSize] report the exact coverage boundary. +// [VerifyOptions.MaxPendingBytes] bounds this untrusted buffer. +// +// Key generation, hardware provisioning, public-key distribution, rotation, +// revocation, and destruction are deployment responsibilities. [GenerateKey] +// is intended for tests and development; production systems should generally +// supply an Ed25519-capable hardware or remote crypto.Signer to [WithSigner]. +package audit diff --git a/audit/example_test.go b/audit/example_test.go new file mode 100644 index 0000000..68a7ff7 --- /dev/null +++ b/audit/example_test.go @@ -0,0 +1,43 @@ +package audit_test + +import ( + "bytes" + "context" + "fmt" + + "github.com/open-ships/teleop" + "github.com/open-ships/teleop/audit" +) + +func ExampleReadTrusted() { + public, private, err := audit.GenerateKey() + if err != nil { + panic(err) + } + + var encoded bytes.Buffer + recorder := audit.NewRecorder(&encoded, audit.WithSigner(private)) + var session teleop.SessionID + session[0] = 1 + err = recorder.Record(context.Background(), teleop.ButtonEvent{ + Meta: teleop.Header{ + ID: teleop.EventID{Session: session, Stream: "input", Sequence: 1}, + }, + Button: teleop.ButtonFaceSouth, + Pressed: true, + Phase: teleop.PhasePressed, + }) + if err != nil { + panic(err) + } + if err := recorder.Close(); err != nil { + panic(err) + } + + records, verification, err := audit.ReadTrusted( + bytes.NewReader(encoded.Bytes()), + public, + ) + fmt.Println(len(records), verification.Trusted, err) + // Output: 1 true +} diff --git a/audit/fuzz_test.go b/audit/fuzz_test.go new file mode 100644 index 0000000..119a9b2 --- /dev/null +++ b/audit/fuzz_test.go @@ -0,0 +1,69 @@ +package audit + +import ( + "bytes" + "testing" +) + +// Verify parses input that is, by construction, potentially adversarial: a log +// offered as evidence may have been produced or edited by the party it +// incriminates. It must reject malformed input rather than panic, hang, or +// consume unbounded memory, and it must never report a forged stream as +// verified. +func FuzzVerify(f *testing.F) { + public, private, err := GenerateKey() + if err != nil { + f.Fatal(err) + } + valid := &bytes.Buffer{} + signer := NewRecorder(valid, WithSigner(private)) + for sequence := uint64(1); sequence <= 3; sequence++ { + if err := signer.Record(f.Context(), testButtonEvent(sequence)); err != nil { + f.Fatal(err) + } + } + if err := signer.Close(); err != nil { + f.Fatal(err) + } + _, validVerification, err := ReadTrusted( + bytes.NewReader(valid.Bytes()), + public, + ) + if err != nil { + f.Fatal(err) + } + f.Add(valid.Bytes()) + + unsigned := &bytes.Buffer{} + recorder := NewRecorder(unsigned) + _ = recorder.Record(f.Context(), testButtonEvent(1)) + _ = recorder.Close() + f.Add(unsigned.Bytes()) + + f.Add([]byte("")) + f.Add([]byte("\n")) + f.Add([]byte("{}")) + f.Add([]byte(`{"version":3,"record_type":"manifest"}`)) + f.Add([]byte(`{"version":3,"record_type":"checkpoint","tree_size":18446744073709551615}`)) + f.Add([]byte(`{"version":999999,"record_type":"event"}`)) + + f.Fuzz(func(t *testing.T, data []byte) { + // Unverified reads must survive arbitrary input. + _, _, _ = Read(bytes.NewReader(data), VerifyOptions{AllowUnverified: true}) + + // A trusted-key read can accept byte-different but semantically + // equivalent JSON encodings, such as top-level whitespace or uppercase + // signature hex. It must never authenticate a different committed tree. + _, verification, err := Read(bytes.NewReader(data), VerifyOptions{ + RequireFooter: true, + RequireSignature: true, + PublicKey: public, + }) + if err == nil && + (!verification.Trusted || + verification.EventCount != validVerification.EventCount || + !bytes.Equal(verification.TreeRoot, validVerification.TreeRoot)) { + t.Fatalf("fuzzed input verified as a different trusted tree: %+v", verification) + } + }) +} diff --git a/audit/integrity_test.go b/audit/integrity_test.go new file mode 100644 index 0000000..871dede --- /dev/null +++ b/audit/integrity_test.go @@ -0,0 +1,874 @@ +package audit + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/open-ships/teleop" +) + +// These tests are internal so they can reach diskRecord when reconstructing a +// log's Merkle leaves the way an independent verifier would. + +func testButtonEvent(sequence uint64) teleop.ButtonEvent { + var session teleop.SessionID + session[0] = 1 + return teleop.ButtonEvent{ + Meta: teleop.Header{ + ID: teleop.EventID{ + Session: session, + Stream: "input", + Sequence: sequence, + }, + DeviceID: "test:0", + ObservedAt: time.Unix(int64(sequence), 0).UTC(), + Monotonic: time.Duration(sequence) * time.Millisecond, + }, + Button: teleop.ButtonFaceSouth, + Pressed: true, + Phase: teleop.PhasePressed, + } +} + +func signedLog(t *testing.T, events int, options ...Option) (*bytes.Buffer, ed25519.PublicKey) { + t.Helper() + public, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + buffer := &bytes.Buffer{} + recorder := NewRecorder(buffer, append([]Option{WithSigner(private)}, options...)...) + for sequence := 1; sequence <= events; sequence++ { + if err := recorder.Record(t.Context(), testButtonEvent(uint64(sequence))); err != nil { + t.Fatalf("record %d: %v", sequence, err) + } + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + return buffer, public +} + +func TestSignedLogVerifiesAgainstTrustedKey(t *testing.T) { + buffer, public := signedLog(t, 5) + + records, verification, err := Read(buffer, VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if len(records) != 5 { + t.Fatalf("records = %d, want 5", len(records)) + } + if !verification.Signed { + t.Fatal("verification.Signed must be true") + } + if !verification.Trusted { + t.Fatal("verification.Trusted must be true when a key was supplied") + } + if verification.KeyID != KeyID(public) { + t.Fatalf("key ID = %q, want %q", verification.KeyID, KeyID(public)) + } + // Manifest, five events, and footer are all leaves. + if verification.TreeSize != 7 { + t.Fatalf("tree size = %d, want 7", verification.TreeSize) + } + if verification.SignedTreeSize != verification.TreeSize { + t.Fatalf( + "signed tree size = %d, want %d", + verification.SignedTreeSize, + verification.TreeSize, + ) + } + if verification.TrustedTreeSize != verification.TreeSize { + t.Fatalf( + "trusted tree size = %d, want %d", + verification.TrustedTreeSize, + verification.TreeSize, + ) + } +} + +func TestSignerRequiresChainRegardlessOfOptionOrder(t *testing.T) { + public, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + buffer := &bytes.Buffer{} + recorder := NewRecorder( + buffer, + WithSigner(private), + WithHashChain(false), + ) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if _, verification, err := ReadTrusted(buffer, public); err != nil { + t.Fatal(err) + } else if !verification.Integrity || !verification.Trusted { + t.Fatalf("verification = %+v", verification) + } +} + +func TestAnchorRequiresChainRegardlessOfOptionOrder(t *testing.T) { + buffer := &bytes.Buffer{} + recorder := NewRecorder( + buffer, + WithAnchor(AnchorFunc(func(context.Context, Checkpoint) error { + return nil + })), + WithHashChain(false), + ) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if _, err := ReadAll(buffer); err != nil { + t.Fatalf("anchored log has no integrity chain: %v", err) + } +} + +func TestTrustedVerificationRejectsAndWithholdsUnsignedTail(t *testing.T) { + buffer, public := signedLog(t, 2) + lines := splitLines(t, buffer.Bytes()) + // The manifest is signed, but the following event has no later signed head. + truncated := joinLines(lines[:2]) + + consumed := 0 + verification, err := Verify( + truncated, + VerifyOptions{PublicKey: public}, + func(Record) error { + consumed++ + return nil + }, + ) + if !errors.Is(err, ErrSignatureRequired) { + t.Fatalf("err = %v, want ErrSignatureRequired", err) + } + if consumed != 0 { + t.Fatalf("consumed %d unsigned-tail events, want 0", consumed) + } + if verification.Signed || verification.Trusted { + t.Fatalf("unsigned tail reported as signed or trusted: %+v", verification) + } + if verification.SignedTreeSize != 1 || verification.TrustedTreeSize != 1 { + t.Fatalf("coverage = %+v, want manifest-only coverage", verification) + } + if verification.TreeSize != 2 { + t.Fatalf("tree size = %d, want 2 parsed records", verification.TreeSize) + } + + records, _, err := ReadTrusted(joinLines(lines[:2]), public) + if !errors.Is(err, ErrIncomplete) { + t.Fatalf("ReadTrusted err = %v, want ErrIncomplete", err) + } + if records != nil { + t.Fatalf("ReadTrusted returned %d records on failure", len(records)) + } +} + +func TestTrustedVerificationReleasesCheckpointedPrefix(t *testing.T) { + buffer, public := signedLog(t, 2, WithCheckpoints(0, 2)) + lines := splitLines(t, buffer.Bytes()) + // Manifest, two events, and their signed checkpoint; intentionally no footer. + prefix := joinLines(lines[:4]) + + records, verification, err := Read(prefix, VerifyOptions{PublicKey: public}) + if err != nil { + t.Fatalf("verify signed prefix: %v", err) + } + if len(records) != 2 { + t.Fatalf("records = %d, want 2", len(records)) + } + if !verification.Signed || !verification.Trusted { + t.Fatalf("signed prefix was not trusted: %+v", verification) + } + if verification.Complete { + t.Fatal("checkpointed prefix without a footer reported complete") + } + if verification.TrustedTreeSize != verification.TreeSize || + verification.TreeSize != 4 { + t.Fatalf("coverage = %+v, want all 4 records trusted", verification) + } +} + +func TestTrustedVerificationBoundsWithheldEvents(t *testing.T) { + buffer, public := signedLog(t, 1) + raw := append([]byte(nil), buffer.Bytes()...) + consumed := 0 + verification, err := Verify( + bytes.NewReader(raw), + VerifyOptions{PublicKey: public, MaxPendingBytes: 1}, + func(Record) error { + consumed++ + return nil + }, + ) + if !errors.Is(err, ErrPendingLimit) { + t.Fatalf("err = %v, want ErrPendingLimit", err) + } + if consumed != 0 { + t.Fatalf("consumed %d events beyond pending limit", consumed) + } + if verification.Signed || verification.Trusted { + t.Fatalf("uncovered event reported signed or trusted: %+v", verification) + } + + // A nil callback stores no events, so the buffering limit is irrelevant to + // verification itself. + verification, err = Verify( + bytes.NewReader(raw), + VerifyOptions{PublicKey: public, MaxPendingBytes: 1}, + nil, + ) + if err != nil || !verification.Trusted { + t.Fatalf("nil-consumer verification = %+v, %v", verification, err) + } +} + +func TestRecorderClearsOwnedSecretsOnClose(t *testing.T) { + _, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + hmacKey := bytes.Repeat([]byte{0x42}, 32) + recorder := NewRecorder( + &bytes.Buffer{}, + WithHMAC(hmacKey), + WithSigner(private), + ) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if recorder.options.HMACKey != nil || + recorder.options.Signer != nil || + recorder.key == nil || + recorder.key.signer != nil { + t.Fatal("Close retained recorder-owned secret key material") + } +} + +func TestRecorderCopiesFinalConfiguredHMACKey(t *testing.T) { + hmacKey := bytes.Repeat([]byte{0x42}, 32) + recorder := NewRecorder( + &bytes.Buffer{}, + func(options *Options) { + options.HMACKey = hmacKey + options.HashChain = true + }, + ) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if hmacKey[0] != 0x42 { + t.Fatal("Close cleared a caller-owned HMAC key") + } +} + +func TestVerifyCopiesCallerKeys(t *testing.T) { + t.Run("public key", func(t *testing.T) { + buffer, public := signedLog(t, 1) + trusted := append(ed25519.PublicKey(nil), public...) + reader := &mutateOnFirstRead{ + Reader: bytes.NewReader(buffer.Bytes()), + mutate: func() { + trusted[0] ^= 0xff + }, + } + + _, verification, err := Read(reader, VerifyOptions{PublicKey: trusted}) + if err != nil || !verification.Trusted { + t.Fatalf("verification = %+v, %v", verification, err) + } + }) + + t.Run("HMAC key", func(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, 32) + buffer := &bytes.Buffer{} + recorder := NewRecorder(buffer, WithHMAC(key)) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + reader := &mutateOnFirstRead{ + Reader: bytes.NewReader(buffer.Bytes()), + mutate: func() { + key[0] ^= 0xff + }, + } + + _, verification, err := Read(reader, VerifyOptions{ + RequireAuthentication: true, + HMACKey: key, + }) + if err != nil || !verification.Authenticated { + t.Fatalf("verification = %+v, %v", verification, err) + } + }) +} + +func TestVerifyRejectsAmbiguousVersion3JSON(t *testing.T) { + buffer, public := signedLog(t, 1) + original := splitLines(t, buffer.Bytes()) + + tests := map[string]func([][]byte) [][]byte{ + "unknown field": func(lines [][]byte) [][]byte { + lines[0] = append(lines[0][:len(lines[0])-1], []byte(`,"attested":true}`)...) + return lines + }, + "duplicate field": func(lines [][]byte) [][]byte { + lines[0] = bytes.Replace( + lines[0], + []byte(`"version":3`), + []byte(`"version":3,"version":3`), + 1, + ) + return lines + }, + "unhashed legacy header": func(lines [][]byte) [][]byte { + lines[1] = append(lines[1][:len(lines[1])-1], []byte(`,"header":{}}`)...) + return lines + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + lines := make([][]byte, len(original)) + for index := range original { + lines[index] = append([]byte(nil), original[index]...) + } + _, _, err := Read( + joinLines(mutate(lines)), + VerifyOptions{RequireFooter: true, PublicKey: public}, + ) + if err == nil { + t.Fatal("ambiguous signed JSON verified") + } + }) + } +} + +type mutateOnFirstRead struct { + *bytes.Reader + mutate func() + mutated bool +} + +func (reader *mutateOnFirstRead) Read(buffer []byte) (int, error) { + if !reader.mutated { + reader.mutated = true + reader.mutate() + } + return reader.Reader.Read(buffer) +} + +func TestVerifyBindsEventsToManifestSession(t *testing.T) { + buffer := &bytes.Buffer{} + recorder := NewRecorder(buffer) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + original := splitLines(t, buffer.Bytes()) + + tests := map[string]*teleop.SessionID{ + "missing": nil, + "different": func() *teleop.SessionID { + session := testButtonEvent(1).Meta.ID.Session + session[0]++ + return &session + }(), + } + for name, manifestSession := range tests { + t.Run(name, func(t *testing.T) { + var manifest, event diskRecord + if err := json.Unmarshal(original[0], &manifest); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(original[1], &event); err != nil { + t.Fatal(err) + } + + // Rebuild a cryptographically valid prefix after changing only the + // manifest's session. Verification must still reject the semantic + // mismatch rather than treating the event payload as authoritative. + manifest.Session = manifestSession + manifest.Hash = "" + hash, err := recordHashV3(manifest, chainSHA256, nil) + if err != nil { + t.Fatal(err) + } + manifest.Hash = hash + event.PreviousHash = hash + event.Hash = "" + hash, err = recordHashV3(event, chainSHA256, nil) + if err != nil { + t.Fatal(err) + } + event.Hash = hash + + manifestJSON, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + eventJSON, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + _, _, err = Read( + joinLines([][]byte{manifestJSON, eventJSON}), + VerifyOptions{}, + ) + if err == nil { + t.Fatal("event verified without matching its manifest session") + } + if name == "missing" && !errors.Is(err, ErrSessionRequired) { + t.Fatalf("missing session error = %v, want ErrSessionRequired", err) + } + }) + } +} + +// TestVerifyRejectsUntrustedKey covers the substitution a forger would attempt: +// rewrite the log and re-sign it with a key they control. The log stays +// internally consistent, so only comparison against an out-of-band key catches +// it. +func TestVerifyRejectsUntrustedKey(t *testing.T) { + buffer, _ := signedLog(t, 3) + other, _, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + + _, _, err = Read(buffer, VerifyOptions{RequireFooter: true, PublicKey: other}) + if !errors.Is(err, ErrUntrustedKey) { + t.Fatalf("err = %v, want ErrUntrustedKey", err) + } +} + +func TestVerifyRejectsKeyIDThatDoesNotIdentifyDeclaredKey(t *testing.T) { + buffer, public := signedLog(t, 1) + lines := splitLines(t, buffer.Bytes()) + var manifest map[string]any + if err := json.Unmarshal(lines[0], &manifest); err != nil { + t.Fatal(err) + } + manifest["key_id"] = "0000000000000000" + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + lines[0] = rewritten + + _, _, err = Read(joinLines(lines), VerifyOptions{PublicKey: public}) + if !errors.Is(err, ErrUntrustedKey) { + t.Fatalf("err = %v, want ErrUntrustedKey", err) + } +} + +func TestVerifyDistinguishesSelfDeclaredKeyFromTrustedKey(t *testing.T) { + buffer, _ := signedLog(t, 3) + + _, verification, err := Read(buffer, VerifyOptions{RequireFooter: true}) + if err != nil { + t.Fatal(err) + } + if !verification.Signed { + t.Fatal("signatures must verify against the log's declared key") + } + if verification.Trusted { + t.Fatal("Trusted must be false without a caller-supplied key") + } +} + +func TestVerifyRequiresSignatureWhenDemanded(t *testing.T) { + buffer := &bytes.Buffer{} + recorder := NewRecorder(buffer) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + + _, _, err := Read(buffer, VerifyOptions{RequireFooter: true, RequireSignature: true}) + if !errors.Is(err, ErrSignatureRequired) { + t.Fatalf("err = %v, want ErrSignatureRequired", err) + } +} + +func TestVerifyRejectsSignatureWithoutDeclaredKey(t *testing.T) { + buffer := &bytes.Buffer{} + recorder := NewRecorder(buffer) + if err := recorder.Record(t.Context(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + lines := splitLines(t, buffer.Bytes()) + // Signature is deliberately excluded from its record hash to avoid a + // circular dependency. The verifier must therefore reject one unless the + // manifest also declares the key needed to interpret it. + lines[0] = append( + lines[0][:len(lines[0])-1], + []byte(`,"signature":"00"}`)..., + ) + + _, _, err := Read(joinLines(lines), VerifyOptions{RequireFooter: true}) + if !errors.Is(err, ErrSignature) { + t.Fatalf("err = %v, want ErrSignature", err) + } +} + +// TestSignatureDetectsTamperedProvenance guards the field an operator has the +// most incentive to change after the fact: which build and configuration were +// actually running. +func TestSignatureDetectsTamperedProvenance(t *testing.T) { + provenance := CaptureProvenance() + provenance.Application = "vessel-teleop" + provenance.Operator = "operator-7" + provenance.Config = map[string]any{"dead_zone": 0.12} + + buffer, public := signedLog(t, 2, WithProvenance(provenance)) + + lines := splitLines(t, buffer.Bytes()) + var manifest map[string]any + if err := json.Unmarshal(lines[0], &manifest); err != nil { + t.Fatal(err) + } + recorded, ok := manifest["provenance"].(map[string]any) + if !ok { + t.Fatalf("manifest carries no provenance: %v", manifest) + } + if recorded["operator"] != "operator-7" { + t.Fatalf("operator = %v", recorded["operator"]) + } + + recorded["operator"] = "operator-9" + manifest["provenance"] = recorded + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + lines[0] = rewritten + + _, _, err = Read(joinLines(lines), VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err == nil { + t.Fatal("a rewritten operator identity must not verify") + } +} + +// TestCheckpointDetectsExcisedRecords is the truncation case. A forger who +// removes records from the middle and repairs the hash chain still cannot +// produce a checkpoint whose Merkle head matches the shortened history. +func TestCheckpointDetectsExcisedRecords(t *testing.T) { + buffer, public := signedLog(t, 12, WithCheckpoints(0, 4)) + + lines := splitLines(t, buffer.Bytes()) + // Drop one event record that a checkpoint already committed to. + var kept [][]byte + removed := false + for _, line := range lines { + var record map[string]any + if err := json.Unmarshal(line, &record); err != nil { + t.Fatal(err) + } + if !removed && record["record_type"] == "event" { + removed = true + continue + } + kept = append(kept, line) + } + if !removed { + t.Fatal("no event record found to remove") + } + + _, _, err := Read(joinLines(kept), VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err == nil { + t.Fatal("an excised record must not verify") + } +} + +func TestCheckpointsAreEmittedByCount(t *testing.T) { + buffer, public := signedLog(t, 10, WithCheckpoints(0, 4)) + + _, verification, err := Read(buffer, VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatal(err) + } + // Ten events with a checkpoint every four yields checkpoints after events + // four and eight. + if verification.Checkpoints != 2 { + t.Fatalf("checkpoints = %d, want 2", verification.Checkpoints) + } +} + +func TestCheckpointsAreEmittedByInterval(t *testing.T) { + public, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + now := time.Unix(1700000000, 0).UTC() + buffer := &bytes.Buffer{} + recorder := NewRecorder( + buffer, + WithSigner(private), + WithCheckpoints(time.Second, 0), + WithClock(func() time.Time { + now = now.Add(400 * time.Millisecond) + return now + }), + ) + for sequence := 1; sequence <= 10; sequence++ { + if err := recorder.Record(t.Context(), testButtonEvent(uint64(sequence))); err != nil { + t.Fatal(err) + } + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + + _, verification, err := Read(buffer, VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatal(err) + } + if verification.Checkpoints == 0 { + t.Fatal("elapsed time must produce checkpoints") + } +} + +func TestAnchorReceivesSignedCheckpoints(t *testing.T) { + public, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + anchored := make(chan Checkpoint, 32) + buffer := &bytes.Buffer{} + recorder := NewRecorder( + buffer, + WithSigner(private), + WithCheckpoints(0, 3), + WithAnchor(AnchorFunc(func(_ context.Context, checkpoint Checkpoint) error { + anchored <- checkpoint + return nil + })), + ) + for sequence := 1; sequence <= 6; sequence++ { + if err := recorder.Record(t.Context(), testButtonEvent(uint64(sequence))); err != nil { + t.Fatal(err) + } + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + close(anchored) + + var checkpoints []Checkpoint + for checkpoint := range anchored { + checkpoints = append(checkpoints, checkpoint) + } + // Manifest, two count-triggered checkpoints, and the footer. + if len(checkpoints) != 4 { + t.Fatalf("anchored %d checkpoints, want 4", len(checkpoints)) + } + + // Every anchored head is self-describing and independently verifiable, + // including the manifest and footer that use the same publication channel. + wantTypes := []string{"manifest", "checkpoint", "checkpoint", "footer"} + for index, checkpoint := range checkpoints { + if checkpoint.Version != FormatVersion || + checkpoint.RecordType != wantTypes[index] || + checkpoint.SignatureAlgorithm != SignatureAlgorithmEd25519 { + t.Fatalf("checkpoint %d metadata = %+v", index, checkpoint) + } + if err := VerifyCheckpoint(public, checkpoint); err != nil { + t.Fatalf("verify checkpoint %d: %v", index, err) + } + } + + if stats := recorder.AnchorStats(); stats.Failed != 0 || stats.Dropped != 0 { + t.Fatalf("anchor stats = %+v", stats) + } +} + +func TestAnchorFailureIsReportedNotFatal(t *testing.T) { + _, private, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + buffer := &bytes.Buffer{} + recorder := NewRecorder( + buffer, + WithSigner(private), + WithCheckpoints(0, 2), + WithAnchor(AnchorFunc(func(context.Context, Checkpoint) error { + return errors.New("anchor unreachable") + })), + ) + for sequence := 1; sequence <= 4; sequence++ { + if err := recorder.Record(t.Context(), testButtonEvent(uint64(sequence))); err != nil { + t.Fatalf("an unreachable anchor must not stop recording: %v", err) + } + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + + stats := recorder.AnchorStats() + if stats.Failed == 0 { + t.Fatal("anchor failures must be counted") + } + if stats.Err == nil { + t.Fatal("anchor failure must be retrievable") + } +} + +// TestInclusionProofOverRecordedLog demonstrates selective disclosure: one +// record proved to belong to a signed head without revealing the others. +func TestInclusionProofOverRecordedLog(t *testing.T) { + buffer, public := signedLog(t, 8) + raw := buffer.Bytes() + + lines := splitLines(t, raw) + leaves := make([][]byte, 0, len(lines)) + for _, line := range lines { + var record diskRecord + if err := json.Unmarshal(line, &record); err != nil { + t.Fatal(err) + } + hashBytes, err := hex.DecodeString(record.Hash) + if err != nil { + t.Fatal(err) + } + leaves = append(leaves, HashLeaf(hashBytes)) + } + + _, verification, err := Read(bytes.NewReader(raw), VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(Root(leaves), verification.TreeRoot) { + t.Fatal("independently computed root must match verification") + } + + // Prove the fourth record without disclosing any other record's contents. + proof, err := InclusionProof(3, leaves) + if err != nil { + t.Fatal(err) + } + err = VerifyInclusion( + 3, + uint64(len(leaves)), + leaves[3], + verification.TreeRoot, + proof, + ) + if err != nil { + t.Fatalf("inclusion proof must verify: %v", err) + } +} + +func TestProvenanceRoundTrips(t *testing.T) { + provenance := CaptureProvenance() + provenance.Application = "harbor-tug" + provenance.ApplicationVersion = "2.4.0" + provenance.Authorization = "work-order-8812" + provenance.Config = map[string]any{"dead_zone": 0.15, "rate_limit_hz": 50} + provenance.Platform = map[string]string{"driver": "xpadneo 0.9.5"} + + buffer, public := signedLog(t, 2, WithProvenance(provenance)) + + _, verification, err := Read(buffer, VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatal(err) + } + if verification.Provenance == nil { + t.Fatal("provenance must survive verification") + } + if verification.Provenance.Application != "harbor-tug" { + t.Fatalf("application = %q", verification.Provenance.Application) + } + if verification.Provenance.Authorization != "work-order-8812" { + t.Fatalf("authorization = %q", verification.Provenance.Authorization) + } + if verification.Provenance.GoVersion == "" { + t.Fatal("captured provenance must include the Go version") + } + if verification.Provenance.Config["rate_limit_hz"] != float64(50) { + t.Fatalf("config = %v", verification.Provenance.Config) + } +} + +func TestManifestBindsSession(t *testing.T) { + buffer, public := signedLog(t, 2) + + _, verification, err := Read(buffer, VerifyOptions{ + RequireFooter: true, + PublicKey: public, + }) + if err != nil { + t.Fatal(err) + } + var want teleop.SessionID + want[0] = 1 + if verification.Session != want { + t.Fatalf("session = %v, want %v", verification.Session, want) + } +} + +func splitLines(t *testing.T, raw []byte) [][]byte { + t.Helper() + var lines [][]byte + for _, line := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") { + lines = append(lines, []byte(line)) + } + return lines +} + +func joinLines(lines [][]byte) *bytes.Reader { + return bytes.NewReader(append(bytes.Join(lines, []byte("\n")), '\n')) +} diff --git a/audit/merkle.go b/audit/merkle.go new file mode 100644 index 0000000..a621aed --- /dev/null +++ b/audit/merkle.go @@ -0,0 +1,277 @@ +package audit + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "math/bits" +) + +// This file implements the Merkle tree defined by RFC 6962 (Certificate +// Transparency). +// +// A linear hash chain proves only that a log was not edited in place: to +// establish that any single record is authentic, a verifier must be handed the +// entire log. A Merkle tree additionally supports +// +// - inclusion proofs, which show that one record belongs to a signed tree +// head in O(log n) hashes, without disclosing the other records, and +// - consistency proofs, which show that a later tree head is an append-only +// extension of an earlier one, so a log cannot be silently rewritten +// between disclosures. +// +// Selective disclosure matters in litigation: a session log may contain other +// operators' activity or commercially sensitive telemetry that is not +// discoverable, yet the authenticity of the relevant records must still be +// demonstrable. + +const ( + leafPrefix byte = 0x00 + nodePrefix byte = 0x01 + // HashSize is the length in bytes of every hash in the tree. + HashSize = sha256.Size +) + +// ErrProof reports a Merkle proof that does not verify. +var ErrProof = errors.New("teleop/audit: merkle proof is invalid") + +// EmptyRoot is the RFC 6962 hash of an empty tree. +func EmptyRoot() []byte { + sum := sha256.Sum256(nil) + return sum[:] +} + +// HashLeaf computes the RFC 6962 leaf hash of a record's bytes. The 0x00 +// prefix keeps a leaf from ever colliding with an interior node, which is what +// prevents a second-preimage attack against the tree. +func HashLeaf(record []byte) []byte { + digest := sha256.New() + digest.Write([]byte{leafPrefix}) + digest.Write(record) + return digest.Sum(nil) +} + +// hashNode computes the RFC 6962 interior node hash. +func hashNode(left, right []byte) []byte { + digest := sha256.New() + digest.Write([]byte{nodePrefix}) + digest.Write(left) + digest.Write(right) + return digest.Sum(nil) +} + +// splitPoint returns the largest power of two strictly less than n, which is +// where RFC 6962 divides a tree of n leaves. +func splitPoint(n uint64) uint64 { + if n < 2 { + return 0 + } + return uint64(1) << (bits.Len64(n-1) - 1) +} + +// Tree incrementally maintains a Merkle tree head. It retains one hash per set +// bit in the leaf count, so memory is O(log n) regardless of log length, which +// lets the recorder keep a live root without buffering the session. +type Tree struct { + size uint64 + // subtrees holds the roots of complete subtrees in descending size order. + subtrees [][]byte +} + +// Append adds an already-hashed leaf. Use HashLeaf to produce one. +func (t *Tree) Append(leaf []byte) { + t.size++ + t.subtrees = append(t.subtrees, append([]byte(nil), leaf...)) + // The count of complete subtrees always equals the population count of + // the leaf count, so merging until that holds rebuilds the canonical + // decomposition. + for len(t.subtrees) > bits.OnesCount64(t.size) { + right := t.subtrees[len(t.subtrees)-1] + left := t.subtrees[len(t.subtrees)-2] + t.subtrees = append(t.subtrees[:len(t.subtrees)-2], hashNode(left, right)) + } +} + +// Size returns the number of leaves added. +func (t *Tree) Size() uint64 { return t.size } + +// Root returns the current tree head. +func (t *Tree) Root() []byte { + if t.size == 0 { + return EmptyRoot() + } + root := t.subtrees[len(t.subtrees)-1] + for index := len(t.subtrees) - 2; index >= 0; index-- { + root = hashNode(t.subtrees[index], root) + } + return append([]byte(nil), root...) +} + +// Root computes the tree head of a complete slice of leaf hashes. +func Root(leaves [][]byte) []byte { + switch len(leaves) { + case 0: + return EmptyRoot() + case 1: + return append([]byte(nil), leaves[0]...) + } + split := splitPoint(uint64(len(leaves))) + return hashNode(Root(leaves[:split]), Root(leaves[split:])) +} + +// InclusionProof returns the audit path proving that the leaf at index belongs +// to the tree formed by leaves. +func InclusionProof(index uint64, leaves [][]byte) ([][]byte, error) { + if index >= uint64(len(leaves)) { + return nil, fmt.Errorf( + "%w: leaf %d is outside a tree of %d", + ErrProof, + index, + len(leaves), + ) + } + return inclusionPath(index, leaves), nil +} + +func inclusionPath(index uint64, leaves [][]byte) [][]byte { + if len(leaves) <= 1 { + return nil + } + split := splitPoint(uint64(len(leaves))) + if index < split { + return append(inclusionPath(index, leaves[:split]), Root(leaves[split:])) + } + return append(inclusionPath(index-split, leaves[split:]), Root(leaves[:split])) +} + +// ConsistencyProof returns the path proving that the tree of the first size +// leaves is a prefix of the tree formed by leaves. +func ConsistencyProof(first uint64, leaves [][]byte) ([][]byte, error) { + size := uint64(len(leaves)) + if first == 0 || first > size { + return nil, fmt.Errorf( + "%w: cannot prove consistency from %d to %d", + ErrProof, + first, + size, + ) + } + return consistencyPath(first, leaves, true), nil +} + +func consistencyPath(first uint64, leaves [][]byte, complete bool) [][]byte { + size := uint64(len(leaves)) + if first == size { + if complete { + return nil + } + return [][]byte{Root(leaves)} + } + split := splitPoint(size) + if first <= split { + return append(consistencyPath(first, leaves[:split], complete), Root(leaves[split:])) + } + return append(consistencyPath(first-split, leaves[split:], false), Root(leaves[:split])) +} + +// VerifyInclusion checks an audit path against a tree head, following the +// algorithm in RFC 6962 section 2.1.1. +func VerifyInclusion(index, size uint64, leaf, root []byte, proof [][]byte) error { + if index >= size { + return fmt.Errorf("%w: leaf %d is outside a tree of %d", ErrProof, index, size) + } + node := index + last := size - 1 + result := append([]byte(nil), leaf...) + for _, sibling := range proof { + if last == 0 { + return fmt.Errorf("%w: audit path is too long", ErrProof) + } + if node&1 == 1 || node == last { + result = hashNode(sibling, result) + for node&1 == 0 && node != 0 { + node >>= 1 + last >>= 1 + } + } else { + result = hashNode(result, sibling) + } + node >>= 1 + last >>= 1 + } + if last != 0 { + return fmt.Errorf("%w: audit path is too short", ErrProof) + } + if !bytes.Equal(result, root) { + return fmt.Errorf("%w: computed root does not match", ErrProof) + } + return nil +} + +// VerifyConsistency checks that the tree head at size second extends the tree +// head at size first, following RFC 6962 section 2.1.2. +func VerifyConsistency(first, second uint64, firstRoot, secondRoot []byte, proof [][]byte) error { + if first > second { + return fmt.Errorf("%w: tree shrank from %d to %d", ErrProof, first, second) + } + if first == 0 { + return fmt.Errorf("%w: consistency from an empty tree is undefined", ErrProof) + } + if first == second { + if len(proof) != 0 { + return fmt.Errorf("%w: unnecessary consistency path", ErrProof) + } + if !bytes.Equal(firstRoot, secondRoot) { + return fmt.Errorf("%w: equal sizes with unequal roots", ErrProof) + } + return nil + } + + path := proof + // A first tree that is an exact power of two contributes its own root, + // which the prover omits because the verifier already holds it. + if first&(first-1) == 0 { + path = append([][]byte{firstRoot}, proof...) + } + if len(path) == 0 { + return fmt.Errorf("%w: consistency path is empty", ErrProof) + } + + node := first - 1 + last := second - 1 + for node&1 == 1 { + node >>= 1 + last >>= 1 + } + + firstResult := append([]byte(nil), path[0]...) + secondResult := append([]byte(nil), path[0]...) + for _, sibling := range path[1:] { + if last == 0 { + return fmt.Errorf("%w: consistency path is too long", ErrProof) + } + if node&1 == 1 || node == last { + firstResult = hashNode(sibling, firstResult) + secondResult = hashNode(sibling, secondResult) + for node&1 == 0 && node != 0 { + node >>= 1 + last >>= 1 + } + } else { + secondResult = hashNode(secondResult, sibling) + } + node >>= 1 + last >>= 1 + } + if last != 0 { + return fmt.Errorf("%w: consistency path is too short", ErrProof) + } + if !bytes.Equal(firstResult, firstRoot) { + return fmt.Errorf("%w: computed first root does not match", ErrProof) + } + if !bytes.Equal(secondResult, secondRoot) { + return fmt.Errorf("%w: computed second root does not match", ErrProof) + } + return nil +} diff --git a/audit/merkle_test.go b/audit/merkle_test.go new file mode 100644 index 0000000..76892ec --- /dev/null +++ b/audit/merkle_test.go @@ -0,0 +1,177 @@ +package audit + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" +) + +func testLeaves(count int) [][]byte { + leaves := make([][]byte, count) + for index := range leaves { + leaves[index] = HashLeaf([]byte(fmt.Sprintf("record-%d", index))) + } + return leaves +} + +// TestMerkleReferenceVectors pins the construction against the worked example +// in RFC 6962 section 2.1.3, so an accidental change to the hashing scheme +// cannot silently pass the self-consistency tests below. +func TestMerkleReferenceVectors(t *testing.T) { + empty := EmptyRoot() + if got := hex.EncodeToString(empty); got != hex.EncodeToString(sha256sum(nil)) { + t.Fatalf("empty root = %s", got) + } + + // A single-leaf tree's root is its leaf hash. + single := [][]byte{HashLeaf([]byte("a"))} + if !bytes.Equal(Root(single), single[0]) { + t.Fatal("single-leaf root must equal the leaf hash") + } + + // A two-leaf tree is one interior node over both leaves. + pair := [][]byte{HashLeaf([]byte("a")), HashLeaf([]byte("b"))} + want := hashNode(pair[0], pair[1]) + if !bytes.Equal(Root(pair), want) { + t.Fatal("two-leaf root must be the node hash of both leaves") + } + + // Leaf and node hashing must use distinct prefixes, or a leaf could be + // forged as an interior node. + if bytes.Equal(HashLeaf([]byte("a")), sha256sum([]byte("a"))) { + t.Fatal("leaf hashing must be domain separated") + } +} + +func sha256sum(data []byte) []byte { + sum := sha256.Sum256(data) + return sum[:] +} + +// TestTreeMatchesReferenceRoot checks the incremental O(log n) tree against the +// recursive definition for every size in range. +func TestTreeMatchesReferenceRoot(t *testing.T) { + var tree Tree + if !bytes.Equal(tree.Root(), EmptyRoot()) { + t.Fatal("empty tree root mismatch") + } + leaves := testLeaves(129) + for size := 1; size <= len(leaves); size++ { + tree.Append(leaves[size-1]) + if tree.Size() != uint64(size) { + t.Fatalf("size = %d, want %d", tree.Size(), size) + } + if !bytes.Equal(tree.Root(), Root(leaves[:size])) { + t.Fatalf("incremental root diverges at size %d", size) + } + } +} + +func TestInclusionProofRoundTrip(t *testing.T) { + leaves := testLeaves(64) + for size := 1; size <= len(leaves); size++ { + root := Root(leaves[:size]) + for index := range size { + proof, err := InclusionProof(uint64(index), leaves[:size]) + if err != nil { + t.Fatalf("size %d leaf %d: %v", size, index, err) + } + err = VerifyInclusion( + uint64(index), + uint64(size), + leaves[index], + root, + proof, + ) + if err != nil { + t.Fatalf("size %d leaf %d: %v", size, index, err) + } + } + } +} + +func TestInclusionProofRejectsTampering(t *testing.T) { + leaves := testLeaves(16) + root := Root(leaves) + proof, err := InclusionProof(5, leaves) + if err != nil { + t.Fatal(err) + } + + if VerifyInclusion(5, 16, HashLeaf([]byte("forged")), root, proof) == nil { + t.Fatal("a substituted leaf must not verify") + } + if VerifyInclusion(6, 16, leaves[5], root, proof) == nil { + t.Fatal("a wrong index must not verify") + } + if VerifyInclusion(5, 16, leaves[5], HashLeaf([]byte("forged")), proof) == nil { + t.Fatal("a wrong root must not verify") + } + + corrupted := make([][]byte, len(proof)) + copy(corrupted, proof) + corrupted[0] = HashLeaf([]byte("forged")) + if VerifyInclusion(5, 16, leaves[5], root, corrupted) == nil { + t.Fatal("a corrupted path must not verify") + } + if VerifyInclusion(5, 16, leaves[5], root, proof[:len(proof)-1]) == nil { + t.Fatal("a truncated path must not verify") + } +} + +func TestConsistencyProofRoundTrip(t *testing.T) { + leaves := testLeaves(48) + for second := 1; second <= len(leaves); second++ { + secondRoot := Root(leaves[:second]) + for first := 1; first <= second; first++ { + firstRoot := Root(leaves[:first]) + proof, err := ConsistencyProof(uint64(first), leaves[:second]) + if err != nil { + t.Fatalf("%d->%d: %v", first, second, err) + } + err = VerifyConsistency( + uint64(first), + uint64(second), + firstRoot, + secondRoot, + proof, + ) + if err != nil { + t.Fatalf("%d->%d: %v", first, second, err) + } + } + } +} + +// TestConsistencyProofDetectsRewrite is the property that matters for +// liability: a log that replaces an already-published record cannot produce a +// consistency proof against the earlier signed head. +func TestConsistencyProofDetectsRewrite(t *testing.T) { + original := testLeaves(20) + firstRoot := Root(original[:8]) + + rewritten := make([][]byte, len(original)) + copy(rewritten, original) + rewritten[3] = HashLeaf([]byte("substituted after the fact")) + + proof, err := ConsistencyProof(8, rewritten) + if err != nil { + t.Fatal(err) + } + err = VerifyConsistency(8, 20, firstRoot, Root(rewritten), proof) + if err == nil { + t.Fatal("a rewritten prefix must not prove consistent with the earlier head") + } +} + +func TestConsistencyProofRejectsTruncation(t *testing.T) { + leaves := testLeaves(20) + // A log truncated back to 8 leaves cannot prove that it extends the head + // that was published at 20. + err := VerifyConsistency(20, 8, Root(leaves), Root(leaves[:8]), nil) + if err == nil { + t.Fatal("a shrinking tree must not verify") + } +} diff --git a/audit/provenance.go b/audit/provenance.go new file mode 100644 index 0000000..8e0ba6b --- /dev/null +++ b/audit/provenance.go @@ -0,0 +1,106 @@ +package audit + +import ( + "os" + "runtime" + "runtime/debug" +) + +// A record that an operator pressed a control is not by itself evidence of +// anything: the same input produces different actuation under a different +// build, a different dead zone, or a different action binding. Provenance ties +// the recorded input to the code and configuration that interpreted it, which +// is what makes a log reconstructable rather than merely authentic. + +// Provenance identifies the software, host, configuration, and operator behind +// a recorded session. Fields the process can determine for itself are filled +// by CaptureProvenance; the rest are the application's to supply. +type Provenance struct { + // Module is the main module path of the recording binary. + Module string `json:"module,omitempty"` + // Revision is the VCS revision the binary was built from. + Revision string `json:"revision,omitempty"` + // Modified reports a build made from a dirty working tree. A true value + // means the revision does not fully describe the running code. + Modified bool `json:"modified,omitempty"` + // BuildVersion is the module version, when built as a dependency. + BuildVersion string `json:"build_version,omitempty"` + + // GoVersion, OS, and Arch identify the recording runtime. + GoVersion string `json:"go_version,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + // Host and PID identify the recording process. + Host string `json:"host,omitempty"` + PID int `json:"pid,omitempty"` + + // Application and ApplicationVersion identify the program embedding + // teleop, which the runtime cannot determine on its own. + Application string `json:"application,omitempty"` + ApplicationVersion string `json:"application_version,omitempty"` + + // Operator identifies who held the controls. Recording it has privacy + // consequences; see the audit guide before enabling it in a workplace. + Operator string `json:"operator,omitempty"` + // Authorization records the grant under which the session ran, such as a + // work order, shift assignment, or ticket. + Authorization string `json:"authorization,omitempty"` + + // Config captures the parameters that determine how input becomes + // actuation: dead zones, gesture thresholds, action bindings, rate limits. + // Without them a replay cannot reproduce the original commands. + Config map[string]any `json:"config,omitempty"` + + // Platform records driver, kernel, and transport detail the application + // can discover but teleop cannot portably determine. + Platform map[string]string `json:"platform,omitempty"` +} + +// CaptureProvenance fills the fields the process can determine for itself. +// Callers should set Application, Operator, and Config on the result. +func CaptureProvenance() Provenance { + provenance := Provenance{ + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + PID: os.Getpid(), + } + if host, err := os.Hostname(); err == nil { + provenance.Host = host + } + info, ok := debug.ReadBuildInfo() + if !ok { + return provenance + } + provenance.Module = info.Main.Path + provenance.BuildVersion = info.Main.Version + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + provenance.Revision = setting.Value + case "vcs.modified": + provenance.Modified = setting.Value == "true" + } + } + return provenance +} + +// Clone returns a deep copy so a recorder cannot observe later mutation of a +// caller's maps. +func (p Provenance) Clone() Provenance { + if p.Config != nil { + config := make(map[string]any, len(p.Config)) + for key, value := range p.Config { + config[key] = value + } + p.Config = config + } + if p.Platform != nil { + platform := make(map[string]string, len(p.Platform)) + for key, value := range p.Platform { + platform[key] = value + } + p.Platform = platform + } + return p +} diff --git a/audit/recorder.go b/audit/recorder.go index 84228dd..21efe7b 100644 --- a/audit/recorder.go +++ b/audit/recorder.go @@ -1,64 +1,340 @@ -// Package audit writes and verifies append-only, hash-chained controller event -// logs. The format is newline-delimited JSON for simple inspection and replay. package audit import ( "bufio" "context" + "crypto" + "crypto/ed25519" + "crypto/hmac" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" + "hash" "io" + "strconv" "sync" "time" "github.com/open-ships/teleop" ) -const FormatVersion = 1 +const ( + // FormatVersion is the current audit wire format. + FormatVersion = 3 + // ControlSchemaVersion pins persisted ControlID meanings independently of + // the record container. + ControlSchemaVersion = "teleop.controls.v1" + // MaxRecordSize is the largest encoded JSON Lines record accepted or + // produced by this package. + MaxRecordSize = 16 * 1024 * 1024 -var ErrClosed = errors.New("teleop/audit: recorder closed") + // DefaultCheckpointInterval bounds how long a log can run without emitting + // an anchorable tree head. WithSigner makes that head signed. + DefaultCheckpointInterval = 10 * time.Second + // DefaultCheckpointEvery bounds the same by record count, so a busy log + // checkpoints on volume rather than only on elapsed time. + DefaultCheckpointEvery = 10000 + // DefaultMaxPendingBytes bounds event data withheld from a Verify callback + // while it waits for the next required signed tree head. + DefaultMaxPendingBytes = 64 * 1024 * 1024 +) + +var ( + // ErrClosed reports use of a recorder after Close. + ErrClosed = errors.New("teleop/audit: recorder closed") + // ErrFailed reports a recorder whose earlier permanent failure is sticky. + ErrFailed = errors.New("teleop/audit: recorder failed") + // ErrUnverified reports a stream without an integrity chain. + ErrUnverified = errors.New("teleop/audit: stream is not hash chained") + // ErrUnauthenticated reports a stream without an HMAC chain when one was + // required. + ErrUnauthenticated = errors.New("teleop/audit: stream is not HMAC authenticated") + // ErrAuthenticationRequired reports an HMAC stream read without its key. + ErrAuthenticationRequired = errors.New("teleop/audit: HMAC key required") + // ErrIncomplete reports a missing footer or torn final record. + ErrIncomplete = errors.New("teleop/audit: stream is incomplete") + // ErrRecordTooLarge reports a JSON Lines record larger than MaxRecordSize. + ErrRecordTooLarge = errors.New("teleop/audit: record too large") + // ErrSessionRequired reports an event or checkpoint that cannot be bound to + // a non-zero controller session. + ErrSessionRequired = errors.New("teleop/audit: controller session is required") + // ErrTreeMismatch reports a checkpoint whose Merkle head does not match + // the records that precede it. + ErrTreeMismatch = errors.New("teleop/audit: merkle head does not match records") + // ErrPendingLimit reports that events awaiting a required signed tree head + // exceed VerifyOptions.MaxPendingBytes. + ErrPendingLimit = errors.New("teleop/audit: pending signature buffer limit exceeded") +) + +const ( + chainNone = "none" + chainSHA256 = "sha256" + chainHMAC = "hmac-sha256" +) +// Options configures a Recorder. Applications normally use the With functions +// rather than constructing Options directly. type Options struct { - HashChain bool + // HashChain enables the default SHA-256 integrity chain. + HashChain bool + // FlushEveryEvent flushes and, when supported, syncs each record. FlushEveryEvent bool + // HMACKey selects HMAC-SHA-256 instead of an unkeyed chain. + HMACKey []byte + // Now supplies record timestamps. + Now func() time.Time + + // Signer signs tree heads and must expose an Ed25519 public key. + Signer crypto.Signer + // Anchor receives tree heads for publication outside the log's custody. + Anchor Anchor + // AnchorTimeout bounds each asynchronous publication. + AnchorTimeout time.Duration + // AnchorQueue is the number of pending publications retained. + AnchorQueue int + // CheckpointInterval and CheckpointEvery bound the gap between tree heads. + CheckpointInterval time.Duration + CheckpointEvery uint64 + // Provenance describes the build and operating context. + Provenance *Provenance } +// Option configures a Recorder. type Option func(*Options) +// WithHashChain disables or enables integrity chaining. ReadAll rejects +// unchained streams; use Read with AllowUnverified for an intentional +// unverified log. func WithHashChain(enabled bool) Option { return func(options *Options) { options.HashChain = enabled } } +// WithFlushEveryEvent controls flush/fsync after each record. It defaults to +// true so a crash loses at most an in-flight system write. func WithFlushEveryEvent(enabled bool) Option { return func(options *Options) { options.FlushEveryEvent = enabled } } +// WithHMAC authenticates the chain with HMAC-SHA-256. The key is copied; an +// empty key is rejected on the first Record or Close. Use a high-entropy key +// generated by a cryptographically secure source; 32 random bytes retain the +// full security strength of SHA-256. +func WithHMAC(key []byte) Option { + return func(options *Options) { + options.HMACKey = make([]byte, len(key)) + copy(options.HMACKey, key) + options.HashChain = true + } +} + +// WithClock supplies deterministic record timestamps. +func WithClock(now func() time.Time) Option { + return func(options *Options) { + if now != nil { + options.Now = now + } + } +} + +// WithSigner signs the manifest, every checkpoint, and the footer with an +// Ed25519 key, allowing verification without sharing signing capability. +// Individual events are not signed: the Merkle tree and hash chain already +// bind them to the nearest signed head, so per-event signing would add cost +// without adding evidence. +// +// Prefer an Ed25519-capable hardware or remote crypto.Signer whose private key +// cannot be exported. A key the operator can read is a key the operator can be +// accused of having used. +func WithSigner(signer crypto.Signer) Option { + return func(options *Options) { + if signer != nil { + options.Signer = signer + options.HashChain = true + } + } +} + +// WithAnchor publishes each checkpoint to storage outside this process, so a +// destroyed or truncated log can be detected rather than merely suspected. +// Publication is asynchronous and never blocks the input path; use +// Recorder.AnchorStats to observe its health. +func WithAnchor(anchor Anchor) Option { + return func(options *Options) { + if anchor != nil { + options.Anchor = anchor + options.HashChain = true + } + } +} + +// WithAnchorTimeout bounds a single anchor publication. +func WithAnchorTimeout(timeout time.Duration) Option { + return func(options *Options) { + if timeout > 0 { + options.AnchorTimeout = timeout + } + } +} + +// WithCheckpoints sets how often a tree head is emitted, by elapsed time and +// by record count. WithSigner makes each head signed. Either bound may be zero +// to disable it. Frequent checkpoints narrow the window in which a truncation +// can go unwitnessed. +func WithCheckpoints(interval time.Duration, every uint64) Option { + return func(options *Options) { + options.CheckpointInterval = interval + options.CheckpointEvery = every + } +} + +// WithProvenance records the build, host, configuration, and operator behind +// the session in the manifest. +func WithProvenance(provenance Provenance) Option { + return func(options *Options) { + cloned := provenance.Clone() + options.Provenance = &cloned + } +} + +// The first nine fields retain the v1 order for legacy hash verification. type diskRecord struct { Version int `json:"version"` RecordType string `json:"record_type"` RecordedAt time.Time `json:"recorded_at"` Kind teleop.EventKind `json:"kind,omitempty"` - Header teleop.Header `json:"header,omitempty"` + Header *teleop.Header `json:"header,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` EventCount uint64 `json:"event_count,omitempty"` PreviousHash string `json:"previous_hash,omitempty"` Hash string `json:"hash,omitempty"` + + Chain string `json:"chain,omitempty"` + ControlSchema string `json:"control_schema,omitempty"` + Durability string `json:"durability,omitempty"` + EncodingError string `json:"encoding_error,omitempty"` + + // Fields below are format version 3 and later. + + // Session binds the manifest to the controller session it describes, so a + // signed manifest cannot be moved to another log. + Session *teleop.SessionID `json:"session,omitempty"` + // TreeSize and TreeRoot describe the Merkle tree over every record written + // before this one. They appear on manifests, checkpoints, and footers. + TreeSize uint64 `json:"tree_size"` + TreeRoot string `json:"tree_root,omitempty"` + // Signature covers the tree head together with this record's own hash, so + // it can neither be replayed onto another record nor detached from the + // history beneath it. + Signature string `json:"signature,omitempty"` + KeyID string `json:"key_id,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` + // Reason explains why a checkpoint was taken. + Reason string `json:"reason,omitempty"` + + // provenanceRaw retains the exact authenticated wire representation during + // verification. It is unset while recording, where Provenance is marshaled + // directly into the hash input. + provenanceRaw json.RawMessage } -// Record is the decoded, implementation-neutral audit representation. +// Record is the decoded, implementation-neutral representation of a verified +// event record. type Record struct { - Version int - RecordedAt time.Time - Kind teleop.EventKind - Header teleop.Header - Payload json.RawMessage + // Version is the audit wire-format version. + Version int + // RecordedAt is when the recorder persisted the event. + RecordedAt time.Time + // Kind and Header identify the persisted event. + Kind teleop.EventKind + Header teleop.Header + // Payload is an isolated copy of the original event JSON. + Payload json.RawMessage + // PreviousHash and Hash are the encoded chain links. PreviousHash string Hash string + // EncodingError explains why Payload contains only the event header. + EncodingError string } +// Verification describes what was actually verified. +type Verification struct { + // Version is the stream's wire-format version. + Version int + // Chain names the integrity mechanism declared by the manifest. + Chain string + // Integrity reports a successfully verified SHA-256 or HMAC chain. + Integrity bool + // Authenticated reports a successfully verified HMAC chain. + Authenticated bool + // Complete reports that a valid footer terminated the stream. + Complete bool + // EventCount is the number of event records read. + EventCount uint64 + // Durability is the recorder policy declared by the manifest. + Durability string + + // Signed reports that every record read is covered by a tree head verified + // against the public key the log itself declares. On its own this shows + // internal consistency, not identity: a forger who replaces the whole log + // can also replace the declared key. + Signed bool + // Trusted reports that every record read is covered by a tree head verified + // against a key the caller supplied out of band. This is the property that + // carries evidentiary weight. + Trusted bool + // SignedTreeSize is the number of leading records covered by the latest + // tree head verified against the log's declared key. + SignedTreeSize uint64 + // TrustedTreeSize is the number of leading records covered by the latest + // tree head verified against PublicKey from VerifyOptions. + TrustedTreeSize uint64 + // KeyID and PublicKey identify the declared signing key. + KeyID string + PublicKey ed25519.PublicKey + + // TreeSize and TreeRoot are the Merkle head over every record read. + TreeSize uint64 + TreeRoot []byte + // Checkpoints counts verified intermediate tree heads. + Checkpoints uint64 + + // Provenance is the recorded build, host, and configuration context. + Provenance *Provenance + // Session is the controller session the log describes. + Session teleop.SessionID +} + +// VerifyOptions configures streaming verification. +type VerifyOptions struct { + // RequireFooter rejects an interrupted or still-open stream. + RequireFooter bool + // AllowUnverified permits a stream that explicitly declares no hash chain. + AllowUnverified bool + // RequireAuthentication rejects non-HMAC chains. + RequireAuthentication bool + // HMACKey authenticates an HMAC-SHA-256 chain. + HMACKey []byte + + // PublicKey is the signing key the verifier trusts. Setting it requires a + // version 3 signed stream, makes RequireSignature implicit, and withholds + // events from consume until a signed tree head covers them. + PublicKey ed25519.PublicKey + // RequireSignature requires every returned or consumed event to be covered + // by a version 3 signed tree head. Without PublicKey, the key declared by the + // log proves internal consistency but not signer identity. + RequireSignature bool + // MaxPendingBytes bounds encoded event data withheld from consume while + // waiting for a required signed tree head. Zero selects + // DefaultMaxPendingBytes. Increase it only when a valid recorder is + // intentionally configured with larger gaps between checkpoints. + MaxPendingBytes uint64 +} + +// Recorder is a concurrency-safe, sticky-failure audit sink. type Recorder struct { mu sync.Mutex writer *bufio.Writer @@ -66,39 +342,139 @@ type Recorder struct { options Options previous string count uint64 + started bool closed bool + failure error + + key *signingKey + keyErr error + tree Tree + anchors *anchorRunner + session teleop.SessionID + + lastCheckpoint time.Time + sinceCheckpoint uint64 + checkpointsRecorded uint64 } +// NewRecorder returns an audit recorder that writes to writer. The default +// SHA-256 chain detects accidental corruption but is not authentic; use +// WithHMAC or WithSigner for an adversarial setting. func NewRecorder(writer io.Writer, options ...Option) *Recorder { - configured := Options{HashChain: true} + configured := Options{ + HashChain: true, + FlushEveryEvent: true, + Now: time.Now, + CheckpointInterval: DefaultCheckpointInterval, + CheckpointEvery: DefaultCheckpointEvery, + } for _, option := range options { if option != nil { option(&configured) } } - return &Recorder{ + if configured.HMACKey != nil { + key := make([]byte, len(configured.HMACKey)) + copy(key, configured.HMACKey) + configured.HMACKey = key + } + if configured.Provenance != nil { + provenance := configured.Provenance.Clone() + configured.Provenance = &provenance + } + if configured.Signer != nil || configured.Anchor != nil { + // A signature or external anchor over an empty chain/tree cannot commit + // to events. Preserve this invariant regardless of option order or + // custom Options. + configured.HashChain = true + } + if configured.Now == nil { + configured.Now = time.Now + } + recorder := &Recorder{ writer: bufio.NewWriter(writer), raw: writer, options: configured, } + if configured.Signer != nil { + key, err := newSigningKey(configured.Signer) + if err != nil { + recorder.keyErr = err + } else { + recorder.key = key + } + } + if configured.Anchor != nil { + recorder.anchors = newAnchorRunner( + configured.Anchor, + configured.AnchorQueue, + configured.AnchorTimeout, + ) + } + return recorder +} + +// PublicKey returns the signing key this recorder declares, or nil when the +// log is unsigned. +func (r *Recorder) PublicKey() ed25519.PublicKey { + if r.key == nil { + return nil + } + return append(ed25519.PublicKey(nil), r.key.public...) } -// Record implements teleop.EventSink. +// AnchorStats reports external anchoring health. Non-zero Failed or Dropped +// counts mean recent tree heads were never witnessed outside this process. +func (r *Recorder) AnchorStats() AnchorStats { + if r.anchors == nil { + return AnchorStats{} + } + return r.anchors.stats() +} + +// Checkpoint forces a tree head immediately; WithSigner makes it signed. +// Callers should invoke it at moments worth being able to prove later, such as +// arming, an emergency stop, or a handover between operators. It returns +// ErrSessionRequired until the first event binds the recorder to a controller +// session. +func (r *Recorder) Checkpoint(reason string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return ErrClosed + } + if r.failure != nil { + return errors.Join(ErrFailed, r.failure) + } + if !r.started { + return ErrSessionRequired + } + if err := r.startLocked(); err != nil { + return r.failLocked(err) + } + if err := r.checkpointLocked(reason); err != nil { + return r.failLocked(err) + } + return nil +} + +// Record implements teleop.EventSink. An event that JSON cannot represent is +// retained as an encoding-error payload rather than terminating controller +// input. A zero session or a change of session permanently fails the recorder. func (r *Recorder) Record(ctx context.Context, event teleop.Event) error { if err := ctx.Err(); err != nil { return err } + header := event.Header() payload, err := json.Marshal(event) + var encodingError string if err != nil { - return fmt.Errorf("marshal %s event: %w", event.Kind(), err) - } - record := diskRecord{ - Version: FormatVersion, - RecordType: "event", - RecordedAt: time.Now().UTC(), - Kind: event.Kind(), - Header: event.Header(), - Payload: payload, + encodingError = err.Error() + payload, _ = json.Marshal(struct { + Header teleop.Header `json:"header"` + }{ + Header: header, + }) } r.mu.Lock() @@ -106,52 +482,286 @@ func (r *Recorder) Record(ctx context.Context, event teleop.Event) error { if r.closed { return ErrClosed } - if r.options.HashChain { - record.PreviousHash = r.previous - hash, err := recordHash(record) - if err != nil { - return err - } - record.Hash = hash - r.previous = hash + if r.failure != nil { + return errors.Join(ErrFailed, r.failure) } - if err := writeRecord(r.writer, record); err != nil { - return err + if header.ID.Session == (teleop.SessionID{}) { + return r.failLocked(ErrSessionRequired) + } + // Bind the session before the manifest is written so a signed manifest + // cannot be transplanted onto a different session's records. + if !r.started { + r.session = header.ID.Session + } else if header.ID.Session != r.session { + return r.failLocked(errors.New("teleop/audit: event session changed")) + } + if err := r.startLocked(); err != nil { + return r.failLocked(err) + } + now := r.options.Now().UTC() + record := diskRecord{ + Version: FormatVersion, + RecordType: "event", + RecordedAt: now, + Kind: event.Kind(), + Payload: payload, + EncodingError: encodingError, + } + if err := r.writeLocked(&record); err != nil { + return r.failLocked(err) } r.count++ - if r.options.FlushEveryEvent { - if err := r.flushAndSync(); err != nil { - return err + r.sinceCheckpoint++ + if r.checkpointDueLocked(now) { + if err := r.checkpointLocked("interval"); err != nil { + return r.failLocked(err) } } return nil } +func (r *Recorder) checkpointDueLocked(now time.Time) bool { + if r.options.CheckpointEvery > 0 && r.sinceCheckpoint >= r.options.CheckpointEvery { + return true + } + if r.options.CheckpointInterval > 0 && !r.lastCheckpoint.IsZero() { + return now.Sub(r.lastCheckpoint) >= r.options.CheckpointInterval + } + return false +} + +// checkpointLocked writes a tree head covering every record before it. +func (r *Recorder) checkpointLocked(reason string) error { + checkpoint := diskRecord{ + Version: FormatVersion, + RecordType: "checkpoint", + RecordedAt: r.options.Now().UTC(), + EventCount: r.count, + Reason: reason, + } + if err := r.writeLocked(&checkpoint); err != nil { + return err + } + r.checkpointsRecorded++ + r.sinceCheckpoint = 0 + r.lastCheckpoint = checkpoint.RecordedAt + r.publishAnchorLocked(checkpoint) + return nil +} + +func (r *Recorder) publishAnchorLocked(record diskRecord) { + if r.anchors == nil { + return + } + checkpoint := Checkpoint{ + Version: record.Version, + RecordType: record.RecordType, + Session: r.session, + Size: record.TreeSize, + Root: record.TreeRoot, + ChainHead: record.Hash, + EventCount: record.EventCount, + RecordedAt: record.RecordedAt, + Signature: record.Signature, + } + if r.key != nil { + checkpoint.SignatureAlgorithm = SignatureAlgorithmEd25519 + checkpoint.KeyID = r.key.id + checkpoint.PublicKey = hex.EncodeToString(r.key.public) + } + r.anchors.publish(checkpoint) +} + +// treeHead builds the statement signed for a manifest, checkpoint, or footer. +func (r *Recorder) treeHead(record diskRecord) TreeHead { + root, _ := hex.DecodeString(record.TreeRoot) + return TreeHead{ + Version: record.Version, + RecordType: record.RecordType, + Session: r.session, + Size: record.TreeSize, + Root: root, + ChainHead: record.Hash, + EventCount: record.EventCount, + RecordedAt: record.RecordedAt, + } +} + +// Close writes a footer and makes any failure sticky. A failed Close is safe to +// retry: it returns the same error without appending a duplicate footer. func (r *Recorder) Close() error { r.mu.Lock() defer r.mu.Unlock() if r.closed { + if r.failure != nil { + return errors.Join(ErrFailed, r.failure) + } return nil } + r.closed = true + defer r.clearKeyMaterialLocked() + // Stop anchoring on every exit path, including failures, so a failed + // Close cannot leak the publisher goroutine. + defer func() { + if r.anchors != nil { + r.anchors.stop() + } + }() + if r.failure != nil { + return errors.Join(ErrFailed, r.failure) + } + if err := r.startLocked(); err != nil { + return r.failLocked(err) + } footer := diskRecord{ - Version: FormatVersion, - RecordType: "footer", - RecordedAt: time.Now().UTC(), - EventCount: r.count, - PreviousHash: r.previous, - } - if r.options.HashChain { - hash, err := recordHash(footer) + Version: FormatVersion, + RecordType: "footer", + RecordedAt: r.options.Now().UTC(), + EventCount: r.count, + } + if err := r.writeLocked(&footer); err != nil { + return r.failLocked(err) + } + r.publishAnchorLocked(footer) + if err := r.flushAndSync(); err != nil { + return r.failLocked(err) + } + return nil +} + +func (r *Recorder) startLocked() error { + if r.started { + return nil + } + if r.keyErr != nil { + return r.keyErr + } + r.started = true + manifest := diskRecord{ + Version: FormatVersion, + RecordType: "manifest", + RecordedAt: r.options.Now().UTC(), + Chain: r.chain(), + ControlSchema: ControlSchemaVersion, + Durability: r.durability(), + Provenance: r.options.Provenance, + } + if r.session != (teleop.SessionID{}) { + session := r.session + manifest.Session = &session + } + if r.key != nil { + manifest.KeyID = r.key.id + manifest.PublicKey = hex.EncodeToString(r.key.public) + } + r.lastCheckpoint = manifest.RecordedAt + if err := r.writeLocked(&manifest); err != nil { + return err + } + r.publishAnchorLocked(manifest) + return nil +} + +func (r *Recorder) chain() string { + switch { + case r.options.HMACKey != nil: + return chainHMAC + case r.options.HashChain: + return chainSHA256 + default: + return chainNone + } +} + +func (r *Recorder) durability() string { + if !r.options.FlushEveryEvent { + return "buffered" + } + if _, ok := r.raw.(interface{ Sync() error }); ok { + return "fsync-every-record" + } + return "flush-every-record" +} + +func (r *Recorder) writeLocked(record *diskRecord) error { + record.PreviousHash = "" + record.Hash = "" + record.Signature = "" + + // Manifests, checkpoints, and footers carry the Merkle head over every + // record already written. Set it before hashing so the hash covers it, and + // sign after hashing so the signature commits to the record's own hash. + head := record.RecordType != "event" + if head { + record.TreeSize = r.tree.Size() + record.TreeRoot = hex.EncodeToString(r.tree.Root()) + } + + chain := r.chain() + if chain != chainNone { + record.PreviousHash = r.previous + value, err := recordHashV3(*record, chain, r.options.HMACKey) if err != nil { return err } - footer.Hash = hash + record.Hash = value } - if err := writeRecord(r.writer, footer); err != nil { - return err + if head && r.key != nil { + signature, err := r.key.sign(r.treeHead(*record).signingBytes()) + if err != nil { + return err + } + record.Signature = signature + } + + encoded, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("marshal audit record: %w", err) + } + if len(encoded)+1 > MaxRecordSize { + return fmt.Errorf("%w: %d bytes", ErrRecordTooLarge, len(encoded)+1) + } + if _, err := r.writer.Write(encoded); err != nil { + return fmt.Errorf("write audit record: %w", err) + } + if err := r.writer.WriteByte('\n'); err != nil { + return fmt.Errorf("write audit record delimiter: %w", err) + } + if r.options.FlushEveryEvent { + if err := r.flushAndSync(); err != nil { + return err + } + } + if chain != chainNone { + r.previous = record.Hash + raw, decodeErr := hex.DecodeString(record.Hash) + if decodeErr != nil { + return fmt.Errorf("decode audit record hash: %w", decodeErr) + } + // Every record becomes a leaf, so the tree covers the whole log and a + // later head commits to all earlier heads. + r.tree.Append(HashLeaf(raw)) + } + return nil +} + +func (r *Recorder) failLocked(err error) error { + if r.failure == nil { + r.failure = err + } + r.clearKeyMaterialLocked() + return errors.Join(ErrFailed, r.failure) +} + +func (r *Recorder) clearKeyMaterialLocked() { + clear(r.options.HMACKey) + r.options.HMACKey = nil + r.options.Signer = nil + if r.key != nil { + // Retain the public identity for PublicKey while releasing the private + // signer reference. + r.key.signer = nil } - r.closed = true - return r.flushAndSync() } func (r *Recorder) flushAndSync() error { @@ -166,103 +776,707 @@ func (r *Recorder) flushAndSync() error { return nil } -func writeRecord(writer io.Writer, record diskRecord) error { - encoded, err := json.Marshal(record) - if err != nil { - return fmt.Errorf("marshal audit record: %w", err) - } - if _, err := writer.Write(append(encoded, '\n')); err != nil { - return fmt.Errorf("write audit record: %w", err) - } - return nil +// ReadAll decodes and verifies a complete, hash-chained stream. It establishes +// integrity, not signer identity; use ReadTrusted or ReadAuthenticated when +// authenticity is required. +func ReadAll(reader io.Reader) ([]Record, error) { + records, _, err := Read(reader, VerifyOptions{RequireFooter: true}) + return records, err } -func recordHash(record diskRecord) (string, error) { - record.Hash = "" - encoded, err := json.Marshal(record) +// ReadAuthenticated verifies a complete HMAC stream with key. The caller must +// obtain and protect key outside the log. +func ReadAuthenticated(reader io.Reader, key []byte) ([]Record, Verification, error) { + return Read(reader, VerifyOptions{ + RequireFooter: true, + RequireAuthentication: true, + HMACKey: key, + }) +} + +// ReadTrusted verifies a complete version 3 stream against a public key +// obtained outside the log. It rejects unsigned records, unsigned tails, +// legacy formats, missing footers, and a self-declared replacement key. +// Streams created with both WithSigner and WithHMAC must instead use Read with +// both VerifyOptions.PublicKey and VerifyOptions.HMACKey. +func ReadTrusted( + reader io.Reader, + public ed25519.PublicKey, +) ([]Record, Verification, error) { + if len(public) != ed25519.PublicKeySize { + return nil, Verification{}, fmt.Errorf( + "%w: trusted public key is %d bytes", + ErrSignature, + len(public), + ) + } + records, verification, err := Read(reader, VerifyOptions{ + RequireFooter: true, + RequireSignature: true, + PublicKey: append(ed25519.PublicKey(nil), public...), + }) if err != nil { - return "", fmt.Errorf("marshal audit hash input: %w", err) + return nil, verification, err } - hash := sha256.Sum256(encoded) - return hex.EncodeToString(hash[:]), nil + return records, verification, nil } -// ReadAll decodes and verifies a complete audit stream. -func ReadAll(reader io.Reader) ([]Record, error) { - return readAll(reader, true) +// ReadPartial verifies all complete records and permits a missing or torn +// footer. Valid integrity-chained prefix records are retained when a later +// record is invalid. It does not require signature coverage. +func ReadPartial(reader io.Reader) ([]Record, error) { + records, _, err := Read(reader, VerifyOptions{}) + return records, err } -// ReadPartial verifies all available records but permits a missing footer. It -// is intended for inspecting a log from an interrupted or running process. -func ReadPartial(reader io.Reader) ([]Record, error) { - return readAll(reader, false) +// Read verifies reader and accumulates the event records accepted under +// options. On error, callers must use only the returned prefix whose security +// properties are explicitly reported by Verification. +func Read(reader io.Reader, options VerifyOptions) ([]Record, Verification, error) { + records := make([]Record, 0) + verification, err := Verify(reader, options, func(record Record) error { + records = append(records, record) + return nil + }) + return records, verification, err } -func readAll(reader io.Reader, requireFooter bool) ([]Record, error) { - scanner := bufio.NewScanner(reader) - // Raw controller reports can be larger than Scanner's small default. - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) +// Verify checks a stream incrementally and invokes consume for each accepted +// event. When signature verification is required, events are buffered until a +// verified tree head covers them; an unsigned tail is never passed to consume. +// Without signature requirements, consume receives integrity-checked events as +// they are read. +func Verify( + reader io.Reader, + options VerifyOptions, + consume func(Record) error, +) (Verification, error) { + signatureRequired := options.RequireSignature || len(options.PublicKey) > 0 + if len(options.PublicKey) > 0 && len(options.PublicKey) != ed25519.PublicKeySize { + return Verification{}, fmt.Errorf( + "%w: trusted public key is %d bytes", + ErrSignature, + len(options.PublicKey), + ) + } + options.HMACKey = append([]byte(nil), options.HMACKey...) + defer clear(options.HMACKey) + options.PublicKey = append(ed25519.PublicKey(nil), options.PublicKey...) + maxPendingBytes := options.MaxPendingBytes + if maxPendingBytes == 0 { + maxPendingBytes = DefaultMaxPendingBytes + } + buffered := bufio.NewReaderSize(reader, 64*1024) var ( - result []Record - previous string - line int - footer bool + verification Verification + previous string + lineNumber int + footer bool + legacy bool + seen = make(map[teleop.EventID]struct{}) + sequences = make(map[string]uint64) + session *teleop.SessionID + tree Tree + trusted ed25519.PublicKey + headSession teleop.SessionID + headSessionSet bool + signedThrough uint64 + trustedThrough uint64 + pending []Record + pendingBytes uint64 ) - for scanner.Scan() { - line++ - var record diskRecord - if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { - return nil, fmt.Errorf("decode audit line %d: %w", line, err) + updateCoverage := func() { + verification.SignedTreeSize = signedThrough + verification.TrustedTreeSize = trustedThrough + verification.Signed = tree.Size() > 0 && signedThrough == tree.Size() + verification.Trusted = tree.Size() > 0 && + len(options.PublicKey) > 0 && + trustedThrough == tree.Size() + } + releasePending := func() error { + for _, record := range pending { + if consume != nil { + if err := consume(record); err != nil { + return err + } + } + } + clear(pending) + pending = pending[:0] + pendingBytes = 0 + return nil + } + for { + line, complete, err := nextLine(buffered) + if err != nil && !errors.Is(err, io.EOF) { + return verification, fmt.Errorf("read audit log: %w", err) + } + if len(line) == 0 && errors.Is(err, io.EOF) { + break } - if record.Version != FormatVersion { - return nil, fmt.Errorf("audit line %d: unsupported version %d", line, record.Version) + lineNumber++ + if !complete { + if options.RequireFooter { + return verification, fmt.Errorf("%w: torn audit line %d", ErrIncomplete, lineNumber) + } + break + } + disk, decodeErr := decodeDiskRecord(line) + if decodeErr != nil { + return verification, fmt.Errorf("decode audit line %d: %w", lineNumber, decodeErr) + } + if disk.Version < 1 || disk.Version > FormatVersion { + return verification, fmt.Errorf( + "audit line %d: unsupported version %d (supported 1-%d)", + lineNumber, + disk.Version, + FormatVersion, + ) + } + if lineNumber == 1 { + verification.Version = disk.Version + if signatureRequired && disk.Version < 3 { + return verification, fmt.Errorf( + "%w: format version %d cannot carry signed tree heads", + ErrSignatureRequired, + disk.Version, + ) + } + legacy = disk.Version == 1 + if legacy { + verification.Chain = chainSHA256 + } else { + if disk.RecordType != "manifest" { + return verification, fmt.Errorf("audit line 1: manifest is required") + } + if disk.ControlSchema != ControlSchemaVersion { + return verification, fmt.Errorf( + "audit line 1: unsupported control schema %q", + disk.ControlSchema, + ) + } + verification.Chain = disk.Chain + verification.Durability = disk.Durability + verification.Provenance = disk.Provenance + if disk.Session != nil { + headSession = *disk.Session + if headSession == (teleop.SessionID{}) { + return verification, fmt.Errorf( + "%w: audit line 1 declares a zero controller session", + ErrSessionRequired, + ) + } + headSessionSet = true + verification.Session = headSession + } + if disk.PublicKey != "" { + public, keyErr := decodePublicKey(disk.PublicKey) + if keyErr != nil { + return verification, fmt.Errorf("audit line 1: %w", keyErr) + } + if disk.KeyID == "" || disk.KeyID != KeyID(public) { + return verification, fmt.Errorf( + "%w: audit line 1 key identifier does not match its public key", + ErrUntrustedKey, + ) + } + verification.PublicKey = public + verification.KeyID = disk.KeyID + } else if disk.KeyID != "" { + return verification, fmt.Errorf( + "%w: audit line 1 declares a key identifier without a public key", + ErrUntrustedKey, + ) + } + } + if options.RequireAuthentication && verification.Chain != chainHMAC { + return verification, ErrUnauthenticated + } + if verification.Chain == chainNone && !options.AllowUnverified { + return verification, ErrUnverified + } + if verification.Chain == chainHMAC && len(options.HMACKey) == 0 { + return verification, ErrAuthenticationRequired + } + // A key the log declares about itself proves only internal + // consistency. A key the caller supplies is what makes the + // signature evidence, so a mismatch is fatal rather than ignored. + if len(options.PublicKey) > 0 { + if verification.PublicKey == nil { + return verification, fmt.Errorf( + "%w: log declares no signing key", + ErrSignatureRequired, + ) + } + if !verification.PublicKey.Equal(options.PublicKey) { + return verification, ErrUntrustedKey + } + trusted = options.PublicKey + } else if verification.PublicKey != nil { + trusted = verification.PublicKey + } + if verification.PublicKey != nil && verification.Chain == chainNone { + return verification, fmt.Errorf( + "%w: a signed stream requires an integrity chain", + ErrSignature, + ) + } + if signatureRequired && trusted == nil { + return verification, ErrSignatureRequired + } + } else if disk.Version != verification.Version { + return verification, fmt.Errorf("audit line %d: version changed within stream", lineNumber) } if footer { - return nil, fmt.Errorf("audit line %d: data follows footer", line) + return verification, fmt.Errorf("audit line %d: data follows footer", lineNumber) } - if record.Hash != "" { - if record.PreviousHash != previous { - return nil, fmt.Errorf("audit line %d: hash chain is discontinuous", line) + + if verification.Chain != chainNone { + if disk.Hash == "" { + return verification, fmt.Errorf("audit line %d: required hash is missing", lineNumber) + } + if disk.PreviousHash != previous { + return verification, fmt.Errorf("audit line %d: hash chain is discontinuous", lineNumber) + } + var expected string + var hashErr error + switch { + case legacy: + expected, hashErr = recordHashV1(disk) + case disk.Version == 2: + expected, hashErr = recordHashV2( + disk, + verification.Chain, + options.HMACKey, + ) + default: + expected, hashErr = recordHashV3( + disk, + verification.Chain, + options.HMACKey, + ) } - expected, err := recordHash(record) - if err != nil { - return nil, err + if hashErr != nil { + return verification, hashErr } - if record.Hash != expected { - return nil, fmt.Errorf("audit line %d: hash mismatch", line) + if !hmac.Equal([]byte(disk.Hash), []byte(expected)) { + return verification, fmt.Errorf("audit line %d: hash mismatch", lineNumber) } - previous = record.Hash + previous = disk.Hash + } else if disk.Hash != "" || disk.PreviousHash != "" { + return verification, fmt.Errorf("audit line %d: unchained manifest contains hashes", lineNumber) } - if record.RecordType == "footer" { - if record.EventCount != uint64(len(result)) { - return nil, fmt.Errorf( + + // Manifests, checkpoints, and footers assert a Merkle head over + // everything before them. Checking it here, before this record joins + // the tree, is what detects a log whose prefix was rewritten or whose + // middle records were removed. + head := disk.RecordType != "event" + headVerified := false + if head && disk.Version >= 3 && trusted == nil && disk.Signature != "" { + return verification, fmt.Errorf( + "%w: %s at audit line %d has a signature but no declared public key", + ErrSignature, + disk.RecordType, + lineNumber, + ) + } + if head && disk.Version >= 3 && verification.Chain != chainNone { + if disk.TreeSize != tree.Size() { + return verification, fmt.Errorf( + "%w: audit line %d claims tree size %d over %d records", + ErrTreeMismatch, + lineNumber, + disk.TreeSize, + tree.Size(), + ) + } + if disk.TreeRoot != hex.EncodeToString(tree.Root()) { + return verification, fmt.Errorf( + "%w: audit line %d root does not cover the preceding records", + ErrTreeMismatch, + lineNumber, + ) + } + if trusted != nil { + if disk.Signature == "" { + return verification, fmt.Errorf( + "%w: unsigned %s at audit line %d", + ErrSignatureRequired, + disk.RecordType, + lineNumber, + ) + } + root, decodeErr := hex.DecodeString(disk.TreeRoot) + if decodeErr != nil { + return verification, fmt.Errorf( + "audit line %d: decode tree root: %w", + lineNumber, + decodeErr, + ) + } + signed := TreeHead{ + Version: disk.Version, + RecordType: disk.RecordType, + Session: headSession, + Size: disk.TreeSize, + Root: root, + ChainHead: disk.Hash, + EventCount: disk.EventCount, + RecordedAt: disk.RecordedAt, + } + if sigErr := VerifyTreeHead(trusted, signed, disk.Signature); sigErr != nil { + return verification, fmt.Errorf("audit line %d: %w", lineNumber, sigErr) + } + headVerified = true + signedThrough = tree.Size() + 1 + if len(options.PublicKey) > 0 { + trustedThrough = tree.Size() + 1 + } + } + } + if disk.Version >= 3 && verification.Chain != chainNone { + raw, decodeErr := hex.DecodeString(disk.Hash) + if decodeErr != nil { + return verification, fmt.Errorf( + "audit line %d: decode record hash: %w", + lineNumber, + decodeErr, + ) + } + tree.Append(HashLeaf(raw)) + verification.TreeSize = tree.Size() + updateCoverage() + } + + switch disk.RecordType { + case "manifest": + if legacy || lineNumber != 1 { + return verification, fmt.Errorf("audit line %d: misplaced manifest", lineNumber) + } + if signatureRequired && headVerified { + if err := releasePending(); err != nil { + return verification, err + } + } + continue + case "checkpoint": + if disk.Version < 3 { + return verification, fmt.Errorf( + "audit line %d: checkpoint requires format version 3", + lineNumber, + ) + } + if disk.EventCount != verification.EventCount { + return verification, fmt.Errorf( + "audit line %d: checkpoint count %d does not match %d events", + lineNumber, + disk.EventCount, + verification.EventCount, + ) + } + verification.Checkpoints++ + if signatureRequired && headVerified { + if err := releasePending(); err != nil { + return verification, err + } + } + continue + case "footer": + if disk.EventCount != verification.EventCount { + return verification, fmt.Errorf( "audit line %d: footer count %d does not match %d events", - line, - record.EventCount, - len(result), + lineNumber, + disk.EventCount, + verification.EventCount, ) } footer = true + verification.Complete = true + if signatureRequired && headVerified { + if err := releasePending(); err != nil { + return verification, err + } + } continue + case "event": + default: + return verification, fmt.Errorf( + "audit line %d: unknown record type %q", + lineNumber, + disk.RecordType, + ) } - if record.RecordType != "event" { - return nil, fmt.Errorf("audit line %d: unknown record type %q", line, record.RecordType) - } - result = append(result, Record{ - Version: record.Version, - RecordedAt: record.RecordedAt, - Kind: record.Kind, - Header: record.Header, - Payload: append(json.RawMessage(nil), record.Payload...), - PreviousHash: record.PreviousHash, - Hash: record.Hash, - }) + if disk.Kind == "" { + return verification, fmt.Errorf("audit line %d: event kind is empty", lineNumber) + } + + header, headerErr := eventHeader(disk) + if headerErr != nil { + return verification, fmt.Errorf("audit line %d: %w", lineNumber, headerErr) + } + if disk.Version >= 3 { + if !headSessionSet { + return verification, fmt.Errorf( + "%w: audit line 1 does not bind a controller session", + ErrSessionRequired, + ) + } + if header.ID.Session != headSession { + return verification, fmt.Errorf( + "audit line %d: event session does not match manifest", + lineNumber, + ) + } + } + if session == nil { + value := header.ID.Session + session = &value + if verification.Session == (teleop.SessionID{}) { + verification.Session = value + } + } else if header.ID.Session != *session { + return verification, fmt.Errorf("audit line %d: session changed within stream", lineNumber) + } + if header.ID.Stream == "" || header.ID.Sequence == 0 { + return verification, fmt.Errorf("audit line %d: invalid event ID", lineNumber) + } + if header.ID.Sequence != sequences[header.ID.Stream]+1 { + return verification, fmt.Errorf( + "audit line %d: stream %q sequence %d follows %d", + lineNumber, + header.ID.Stream, + header.ID.Sequence, + sequences[header.ID.Stream], + ) + } + for _, cause := range header.Causes { + if _, ok := seen[cause]; !ok { + return verification, fmt.Errorf( + "audit line %d: cause %v does not precede event", + lineNumber, + cause, + ) + } + } + sequences[header.ID.Stream] = header.ID.Sequence + seen[header.ID] = struct{}{} + verification.EventCount++ + encodingError := "" + if disk.Version >= 2 { + encodingError = disk.EncodingError + } + record := Record{ + Version: disk.Version, + RecordedAt: disk.RecordedAt, + Kind: disk.Kind, + Header: header, + Payload: append(json.RawMessage(nil), disk.Payload...), + PreviousHash: disk.PreviousHash, + Hash: disk.Hash, + EncodingError: encodingError, + } + if signatureRequired { + if consume != nil { + recordBytes := uint64(len(line)) + if recordBytes > maxPendingBytes || + pendingBytes > maxPendingBytes-recordBytes { + return verification, fmt.Errorf( + "%w: more than %d bytes await a signed tree head", + ErrPendingLimit, + maxPendingBytes, + ) + } + pendingBytes += recordBytes + pending = append(pending, record) + } + } else if consume != nil { + if consumeErr := consume(record); consumeErr != nil { + return verification, consumeErr + } + } + } + if verification.Version == 0 { + return verification, errors.New("audit log is empty") } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read audit log: %w", err) + if options.RequireFooter && !footer { + return verification, fmt.Errorf("%w: footer is missing", ErrIncomplete) } - if requireFooter && !footer { - return nil, errors.New("audit log is incomplete: footer is missing") + verification.Integrity = verification.Chain == chainSHA256 || + verification.Chain == chainHMAC + verification.Authenticated = verification.Chain == chainHMAC + verification.TreeSize = tree.Size() + verification.TreeRoot = tree.Root() + updateCoverage() + if signatureRequired && !verification.Signed { + return verification, fmt.Errorf( + "%w: %d trailing record(s) are not covered by a signed tree head", + ErrSignatureRequired, + tree.Size()-signedThrough, + ) + } + return verification, nil +} + +func nextLine(reader *bufio.Reader) ([]byte, bool, error) { + line := make([]byte, 0, 64*1024) + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > MaxRecordSize { + return nil, false, ErrRecordTooLarge + } + line = append(line, fragment...) + switch { + case err == nil: + return line[:len(line)-1], true, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + return line, false, io.EOF + default: + return line, false, err + } + } +} + +func eventHeader(record diskRecord) (teleop.Header, error) { + if record.Version == 1 && record.Header != nil { + return record.Header.Clone(), nil + } + var envelope struct { + Header teleop.Header `json:"header"` + } + if err := json.Unmarshal(record.Payload, &envelope); err != nil { + return teleop.Header{}, fmt.Errorf("decode event header: %w", err) + } + return envelope.Header, nil +} + +func recordHashV2(record diskRecord, chain string, key []byte) (string, error) { + var digest hash.Hash + switch chain { + case chainSHA256: + digest = sha256.New() + case chainHMAC: + if len(key) == 0 { + return "", ErrAuthenticationRequired + } + digest = hmac.New(sha256.New, key) + default: + return "", fmt.Errorf("teleop/audit: unsupported chain %q", chain) + } + writeHashField(digest, []byte("teleop-audit-v2")) + writeHashField(digest, []byte(fmt.Sprintf("%d", record.Version))) + writeHashField(digest, []byte(record.RecordType)) + writeHashField(digest, []byte(record.RecordedAt.UTC().Format(time.RFC3339Nano))) + writeHashField(digest, []byte(record.Kind)) + writeHashField(digest, record.Payload) + writeHashField(digest, []byte(fmt.Sprintf("%d", record.EventCount))) + writeHashField(digest, []byte(record.PreviousHash)) + writeHashField(digest, []byte(record.Chain)) + writeHashField(digest, []byte(record.ControlSchema)) + writeHashField(digest, []byte(record.Durability)) + writeHashField(digest, []byte(record.EncodingError)) + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func newChainDigest(chain string, key []byte) (hash.Hash, error) { + switch chain { + case chainSHA256: + return sha256.New(), nil + case chainHMAC: + if len(key) == 0 { + return nil, ErrAuthenticationRequired + } + return hmac.New(sha256.New, key), nil + default: + return nil, fmt.Errorf("teleop/audit: unsupported chain %q", chain) + } +} + +// recordHashV3 covers every field except Hash and Signature. Signature is +// excluded because it is computed over the resulting hash; it is bound to the +// record through that hash rather than through the chain. +func recordHashV3(record diskRecord, chain string, key []byte) (string, error) { + digest, err := newChainDigest(chain, key) + if err != nil { + return "", err + } + writeHashField(digest, []byte("teleop-audit-v3")) + writeHashField(digest, []byte(strconv.Itoa(record.Version))) + writeHashField(digest, []byte(record.RecordType)) + writeHashField(digest, []byte(record.RecordedAt.UTC().Format(time.RFC3339Nano))) + writeHashField(digest, []byte(record.Kind)) + writeHashField(digest, record.Payload) + writeHashField(digest, []byte(strconv.FormatUint(record.EventCount, 10))) + writeHashField(digest, []byte(record.PreviousHash)) + writeHashField(digest, []byte(record.Chain)) + writeHashField(digest, []byte(record.ControlSchema)) + writeHashField(digest, []byte(record.Durability)) + writeHashField(digest, []byte(record.EncodingError)) + + session := "" + if record.Session != nil { + session = record.Session.String() + } + writeHashField(digest, []byte(session)) + writeHashField(digest, []byte(strconv.FormatUint(record.TreeSize, 10))) + writeHashField(digest, []byte(record.TreeRoot)) + writeHashField(digest, []byte(record.KeyID)) + writeHashField(digest, []byte(record.PublicKey)) + writeHashField(digest, []byte(record.Reason)) + + var provenance []byte + if record.provenanceRaw != nil { + provenance = record.provenanceRaw + } else if record.Provenance != nil { + // encoding/json sorts map keys, so this encoding is deterministic. + encoded, marshalErr := json.Marshal(record.Provenance) + if marshalErr != nil { + return "", fmt.Errorf("marshal provenance for hashing: %w", marshalErr) + } + provenance = encoded + } + writeHashField(digest, provenance) + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func writeHashField(digest hash.Hash, value []byte) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + _, _ = digest.Write(length[:]) + _, _ = digest.Write(value) +} + +type v1Record struct { + Version int `json:"version"` + RecordType string `json:"record_type"` + RecordedAt time.Time `json:"recorded_at"` + Kind teleop.EventKind `json:"kind,omitempty"` + Header teleop.Header `json:"header,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + EventCount uint64 `json:"event_count,omitempty"` + PreviousHash string `json:"previous_hash,omitempty"` + Hash string `json:"hash,omitempty"` +} + +func recordHashV1(record diskRecord) (string, error) { + legacy := v1Record{ + Version: record.Version, + RecordType: record.RecordType, + RecordedAt: record.RecordedAt, + Kind: record.Kind, + Payload: record.Payload, + EventCount: record.EventCount, + PreviousHash: record.PreviousHash, + } + if record.Header != nil { + legacy.Header = *record.Header + } + encoded, err := json.Marshal(legacy) + if err != nil { + return "", fmt.Errorf("marshal v1 audit hash input: %w", err) } - return result, nil + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil } diff --git a/audit/recorder_test.go b/audit/recorder_test.go index ebdc199..105d586 100644 --- a/audit/recorder_test.go +++ b/audit/recorder_test.go @@ -3,11 +3,17 @@ package audit_test import ( "bytes" "context" + "encoding/json" + "errors" + "io" + "math" "testing" "time" "github.com/open-ships/teleop" + "github.com/open-ships/teleop/action" "github.com/open-ships/teleop/audit" + "github.com/open-ships/teleop/gesture" ) func TestHashChainDetectsTampering(t *testing.T) { @@ -15,10 +21,16 @@ func TestHashChainDetectsTampering(t *testing.T) { var output bytes.Buffer recorder := audit.NewRecorder(&output) + var session teleop.SessionID + session[0] = 1 for sequence := uint64(1); sequence <= 2; sequence++ { err := recorder.Record(context.Background(), teleop.ButtonEvent{ Meta: teleop.Header{ - ID: teleop.EventID{Stream: "input", Sequence: sequence}, + ID: teleop.EventID{ + Session: session, + Stream: "input", + Sequence: sequence, + }, ObservedAt: time.Unix(int64(sequence), 0), }, Button: teleop.ButtonFaceSouth, @@ -55,9 +67,15 @@ func TestObservationsExtractReplayableState(t *testing.T) { t.Parallel() state := teleop.State{LeftTrigger: 0.75} + var session teleop.SessionID + session[0] = 1 event := teleop.ObservationEvent{ Meta: teleop.Header{ - ID: teleop.EventID{Stream: "input", Sequence: 1}, + ID: teleop.EventID{ + Session: session, + Stream: "input", + Sequence: 1, + }, ObservedAt: time.Unix(20, 0), }, Current: state, @@ -83,3 +101,359 @@ func TestObservationsExtractReplayableState(t *testing.T) { t.Fatalf("observations = %#v", observations) } } + +func TestReadRejectsMissingHashesAndRequiresUnverifiedOptIn(t *testing.T) { + t.Parallel() + + var chained bytes.Buffer + recorder := audit.NewRecorder(&chained) + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + lines := bytes.Split(bytes.TrimSpace(chained.Bytes()), []byte{'\n'}) + var eventLine map[string]any + if err := json.Unmarshal(lines[1], &eventLine); err != nil { + t.Fatal(err) + } + delete(eventLine, "hash") + lines[1], _ = json.Marshal(eventLine) + dehashed := append(bytes.Join(lines, []byte{'\n'}), '\n') + if _, err := audit.ReadAll(bytes.NewReader(dehashed)); err == nil { + t.Fatal("chained stream with a missing record hash was accepted") + } + + var unchained bytes.Buffer + recorder = audit.NewRecorder(&unchained, audit.WithHashChain(false)) + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if _, err := audit.ReadAll(bytes.NewReader(unchained.Bytes())); !errors.Is(err, audit.ErrUnverified) { + t.Fatalf("ReadAll unchained error = %v, want ErrUnverified", err) + } + records, verification, err := audit.Read( + bytes.NewReader(unchained.Bytes()), + audit.VerifyOptions{RequireFooter: true, AllowUnverified: true}, + ) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || verification.Integrity || !verification.Complete { + t.Fatalf("unverified read = %#v, %#v", records, verification) + } +} + +func TestHMACRequiresAndAuthenticatesKey(t *testing.T) { + t.Parallel() + + key := []byte("controller audit secret") + var output bytes.Buffer + recorder := audit.NewRecorder(&output, audit.WithHMAC(key)) + key[0] ^= 0xff // Recorder owns a defensive copy. + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + if _, err := audit.ReadAll(bytes.NewReader(output.Bytes())); !errors.Is( + err, + audit.ErrAuthenticationRequired, + ) { + t.Fatalf("ReadAll HMAC error = %v", err) + } + var integrityOnly bytes.Buffer + integrityRecorder := audit.NewRecorder(&integrityOnly) + if err := integrityRecorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := integrityRecorder.Close(); err != nil { + t.Fatal(err) + } + if _, _, err := audit.ReadAuthenticated( + bytes.NewReader(integrityOnly.Bytes()), + []byte("controller audit secret"), + ); !errors.Is(err, audit.ErrUnauthenticated) { + t.Fatalf("SHA stream authenticated error = %v", err) + } + if _, _, err := audit.ReadAuthenticated( + bytes.NewReader(output.Bytes()), + []byte("wrong key"), + ); err == nil { + t.Fatal("wrong HMAC key authenticated") + } + records, verification, err := audit.ReadAuthenticated( + bytes.NewReader(output.Bytes()), + []byte("controller audit secret"), + ) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || !verification.Integrity || !verification.Authenticated { + t.Fatalf("authenticated read = %#v, %#v", records, verification) + } + + var invalid bytes.Buffer + emptyKey := audit.NewRecorder(&invalid, audit.WithHMAC(nil)) + if err := emptyKey.Record(context.Background(), testButtonEvent(1)); !errors.Is( + err, + audit.ErrAuthenticationRequired, + ) { + t.Fatalf("empty HMAC key error = %v", err) + } +} + +func TestRecorderRequiresOneNonZeroSession(t *testing.T) { + t.Parallel() + + t.Run("zero session", func(t *testing.T) { + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + event := testButtonEvent(1) + event.Meta.ID.Session = teleop.SessionID{} + + if err := recorder.Record(context.Background(), event); !errors.Is( + err, + audit.ErrSessionRequired, + ) { + t.Fatalf("zero-session Record error = %v, want ErrSessionRequired", err) + } + }) + + t.Run("session change", func(t *testing.T) { + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + event := testButtonEvent(2) + event.Meta.ID.Session[0] = 2 + + if err := recorder.Record(context.Background(), event); err == nil { + t.Fatal("Record accepted an event from a different session") + } + }) + + t.Run("checkpoint before session", func(t *testing.T) { + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + if err := recorder.Checkpoint("arming"); !errors.Is( + err, + audit.ErrSessionRequired, + ) { + t.Fatalf("pre-session Checkpoint error = %v, want ErrSessionRequired", err) + } + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Checkpoint("arming"); err != nil { + t.Fatalf("bound Checkpoint: %v", err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + }) +} + +func TestRecorderRetainsPublicKeyAfterClose(t *testing.T) { + t.Parallel() + + public, private, err := audit.GenerateKey() + if err != nil { + t.Fatal(err) + } + hmacKey := bytes.Repeat([]byte{0x42}, 32) + var output bytes.Buffer + recorder := audit.NewRecorder( + &output, + audit.WithHMAC(hmacKey), + audit.WithSigner(private), + ) + if err := recorder.Record(context.Background(), testButtonEvent(1)); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + + // Public identity remains available after Close, while the caller continues + // to own its original HMAC key. + if got := recorder.PublicKey(); !got.Equal(public) { + t.Fatalf("PublicKey after Close = %x, want %x", got, public) + } + if hmacKey[0] != 0x42 { + t.Fatal("Close modified the caller-owned HMAC key") + } +} + +func TestReadPartialReturnsVerifiedPrefixOfTornStream(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + for sequence := uint64(1); sequence <= 2; sequence++ { + if err := recorder.Record(context.Background(), testButtonEvent(sequence)); err != nil { + t.Fatal(err) + } + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte{'\n'}) + torn := append(bytes.Join(lines[:2], []byte{'\n'}), '\n') + torn = append(torn, lines[2][:len(lines[2])/2]...) + + records, err := audit.ReadPartial(bytes.NewReader(torn)) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Header.ID.Sequence != 1 { + t.Fatalf("partial records = %#v", records) + } + records, err = audit.ReadAll(bytes.NewReader(torn)) + if !errors.Is(err, audit.ErrIncomplete) || len(records) != 1 { + t.Fatalf("complete read = %d records, %v", len(records), err) + } +} + +func TestRecorderFailureIsSticky(t *testing.T) { + t.Parallel() + + writer := &failAfterWrites{allowed: 1} + recorder := audit.NewRecorder(writer) + err := recorder.Record(context.Background(), testButtonEvent(1)) + if !errors.Is(err, audit.ErrFailed) || !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("first failure = %v", err) + } + calls := writer.calls + if err := recorder.Record(context.Background(), testButtonEvent(2)); !errors.Is( + err, + io.ErrClosedPipe, + ) { + t.Fatalf("sticky Record error = %v", err) + } + if err := recorder.Close(); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("sticky Close error = %v", err) + } + if writer.calls != calls { + t.Fatalf("failed recorder wrote again: calls %d -> %d", calls, writer.calls) + } +} + +func TestDecodeEventPreservesEncodingFailureAndDerivedTypes(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + unrepresentable := badEvent{ + Meta: testHeader("bad", 1), + Value: math.NaN(), + } + if err := recorder.Record(context.Background(), unrepresentable); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + records, err := audit.ReadAll(bytes.NewReader(output.Bytes())) + if err != nil { + t.Fatal(err) + } + decoded, err := audit.DecodeEvent(records[0]) + if err != nil { + t.Fatal(err) + } + encodingFailure, ok := decoded.(audit.EncodingErrorEvent) + if !ok || encodingFailure.Kind() != unrepresentable.Kind() || encodingFailure.Message == "" { + t.Fatalf("encoding failure = %#v", decoded) + } + + gestureEvent := gesture.Event{ + Meta: testHeader("gesture", 1), + Type: gesture.Chord, + Phase: teleop.PhaseStarted, + Controls: []teleop.ControlID{teleop.ButtonFaceSouth, teleop.ButtonBumperLeft}, + Region: "arm", + } + actionEvent := action.Event{ + Meta: testHeader("action", 1), + Action: "arm", + Phase: teleop.PhaseStarted, + Controls: append([]teleop.ControlID(nil), gestureEvent.Controls...), + } + for _, event := range []teleop.Event{gestureEvent, actionEvent} { + payload, marshalErr := json.Marshal(event) + if marshalErr != nil { + t.Fatal(marshalErr) + } + value, decodeErr := audit.DecodeEvent(audit.Record{ + Kind: event.Kind(), + Header: event.Header(), + Payload: payload, + }) + if decodeErr != nil || value.Kind() != event.Kind() { + t.Fatalf("decode %s = %#v, %v", event.Kind(), value, decodeErr) + } + } + + opaque, err := audit.DecodeEvent(audit.Record{ + Kind: "third-party", + Header: testHeader("third-party", 1), + Payload: json.RawMessage(`{"custom":true}`), + }) + if err != nil { + t.Fatal(err) + } + if _, ok := opaque.(audit.OpaqueEvent); !ok { + t.Fatalf("unknown event = %T", opaque) + } +} + +func testButtonEvent(sequence uint64) teleop.ButtonEvent { + return teleop.ButtonEvent{ + Meta: testHeader("input", sequence), + Button: teleop.ButtonFaceSouth, + Pressed: true, + Phase: teleop.PhasePressed, + } +} + +func testHeader(stream string, sequence uint64) teleop.Header { + var session teleop.SessionID + session[0] = 1 + return teleop.Header{ + ID: teleop.EventID{ + Session: session, + Stream: stream, + Sequence: sequence, + }, + DeviceID: "test:0", + ObservedAt: time.Unix(int64(sequence), 0), + } +} + +type failAfterWrites struct { + allowed int + calls int +} + +func (writer *failAfterWrites) Write(value []byte) (int, error) { + writer.calls++ + if writer.calls > writer.allowed { + return 0, io.ErrClosedPipe + } + return len(value), nil +} + +type badEvent struct { + Meta teleop.Header `json:"header"` + Value float64 `json:"value"` +} + +func (event badEvent) Header() teleop.Header { return event.Meta.Clone() } +func (badEvent) Kind() teleop.EventKind { return "bad.event" } diff --git a/audit/replay.go b/audit/replay.go index 01ad8b7..9de10a8 100644 --- a/audit/replay.go +++ b/audit/replay.go @@ -2,12 +2,121 @@ package audit import ( "encoding/json" + "errors" "fmt" "strings" "github.com/open-ships/teleop" + "github.com/open-ships/teleop/action" + "github.com/open-ships/teleop/gesture" ) +// OpaqueEvent retains an unknown third-party kind without pretending it was +// decoded. +type OpaqueEvent struct { + Meta teleop.Header + Type teleop.EventKind + Payload json.RawMessage +} + +// Header implements teleop.Event. +func (event OpaqueEvent) Header() teleop.Header { return event.Meta.Clone() } + +// Kind implements teleop.Event. +func (event OpaqueEvent) Kind() teleop.EventKind { return event.Type } + +// CloneEvent implements teleop.EventCloner. +func (event OpaqueEvent) CloneEvent() teleop.Event { + event.Meta = event.Meta.Clone() + event.Payload = append(json.RawMessage(nil), event.Payload...) + return event +} + +// EncodingErrorEvent preserves the identity and original kind of an event +// whose concrete payload could not be represented as JSON. It prevents replay +// from silently manufacturing a zero-valued event of the original type. +type EncodingErrorEvent struct { + Meta teleop.Header + OriginalKind teleop.EventKind + Message string +} + +// Header implements teleop.Event. +func (event EncodingErrorEvent) Header() teleop.Header { return event.Meta.Clone() } + +// Kind implements teleop.Event. +func (event EncodingErrorEvent) Kind() teleop.EventKind { return event.OriginalKind } + +// CloneEvent implements teleop.EventCloner. +func (event EncodingErrorEvent) CloneEvent() teleop.Event { + event.Meta = event.Meta.Clone() + return event +} + +// DecodeEvent reconstructs every event kind shipped by this module. Unknown +// kinds are returned as OpaqueEvent. +func DecodeEvent(record Record) (teleop.Event, error) { + if record.EncodingError != "" { + return EncodingErrorEvent{ + Meta: record.Header.Clone(), + OriginalKind: record.Kind, + Message: record.EncodingError, + }, nil + } + var target teleop.Event + switch record.Kind { + case teleop.EventObservation: + target = &teleop.ObservationEvent{} + case teleop.EventButton: + target = &teleop.ButtonEvent{} + case teleop.EventStick: + target = &teleop.StickEvent{} + case teleop.EventTrigger: + target = &teleop.TriggerEvent{} + case teleop.EventConnection: + target = &teleop.ConnectionEvent{} + case teleop.EventCapabilities: + target = &teleop.CapabilitiesEvent{} + case teleop.EventLiveness: + target = &teleop.LivenessEvent{} + case teleop.EventGap: + target = &teleop.GapEvent{} + case teleop.EventError: + target = &teleop.ErrorEvent{} + case teleop.EventClock: + target = &teleop.ClockEvent{} + case teleop.EventCommand: + target = &teleop.CommandEvent{} + case gesture.EventKind: + target = &gesture.Event{} + case action.EventKind: + target = &action.Event{} + default: + return OpaqueEvent{ + Meta: record.Header.Clone(), + Type: record.Kind, + Payload: append(json.RawMessage(nil), record.Payload...), + }, nil + } + if err := json.Unmarshal(record.Payload, target); err != nil { + return nil, fmt.Errorf("decode %s event: %w", record.Kind, err) + } + return target, nil +} + +// Events decodes all verified records in order. +func Events(records []Record) ([]teleop.Event, error) { + result := make([]teleop.Event, 0, len(records)) + for _, record := range records { + event, err := DecodeEvent(record) + if err != nil { + return result, err + } + result = append(result, event) + } + return result, nil +} + // Descriptor returns the first controller descriptor recorded in a connection // event. func Descriptor(records []Record) (teleop.Descriptor, bool, error) { @@ -15,30 +124,45 @@ func Descriptor(records []Record) (teleop.Descriptor, bool, error) { if record.Kind != teleop.EventConnection { continue } - var event teleop.ConnectionEvent - if err := json.Unmarshal(record.Payload, &event); err != nil { - return teleop.Descriptor{}, false, fmt.Errorf("decode connection event: %w", err) + event, err := DecodeEvent(record) + if err != nil { + return teleop.Descriptor{}, false, err } - if event.Descriptor.ID != "" { - return event.Descriptor.Clone(), true, nil + connection := event.(*teleop.ConnectionEvent) + if connection.Descriptor.ID != "" { + return connection.Descriptor.Clone(), true, nil } } return teleop.Descriptor{}, false, nil } -// Observations extracts replayable source observations from an audit log. -// Known gaps are attached to the next observation. +// ReplayFaultError reports that observations were recovered from a session +// which ended with loss or an unexpected disconnect. +type ReplayFaultError struct { + Reason string +} + +func (err *ReplayFaultError) Error() string { return "audit replay contains a fault: " + err.Reason } + +// Observations extracts replayable source observations. It returns the intact +// prefix together with ReplayFaultError when a gap has no following +// observation or a session ended unexpectedly. func Observations(records []Record) ([]teleop.Observation, error) { var ( result []teleop.Observation pendingGap *teleop.SourceGap + faults []string ) for _, record := range records { switch record.Kind { case teleop.EventGap: var event teleop.GapEvent if err := json.Unmarshal(record.Payload, &event); err != nil { - return nil, fmt.Errorf("decode gap event: %w", err) + return result, fmt.Errorf("decode gap event: %w", err) + } + if event.Source == "subscription" { + faults = append(faults, event.Reason) + continue } if pendingGap == nil { pendingGap = &teleop.SourceGap{} @@ -47,12 +171,12 @@ func Observations(records []Record) ([]teleop.Observation, error) { if pendingGap.Reason == "" { pendingGap.Reason = event.Reason } else { - pendingGap.Reason = strings.Join([]string{pendingGap.Reason, event.Reason}, "; ") + pendingGap.Reason += "; " + event.Reason } case teleop.EventObservation: var event teleop.ObservationEvent if err := json.Unmarshal(record.Payload, &event); err != nil { - return nil, fmt.Errorf("decode observation event: %w", err) + return result, fmt.Errorf("decode observation event: %w", err) } result = append(result, teleop.Observation{ State: event.Current.Clone(), @@ -62,7 +186,33 @@ func Observations(records []Record) ([]teleop.Observation, error) { Gap: pendingGap, }) pendingGap = nil + case teleop.EventError: + var event teleop.ErrorEvent + if err := json.Unmarshal(record.Payload, &event); err == nil { + faults = append(faults, event.Message) + } + case teleop.EventConnection: + var event teleop.ConnectionEvent + if err := json.Unmarshal(record.Payload, &event); err == nil && + event.State == teleop.Disconnected && + event.Reason != teleop.ErrClosed.Error() && + !errors.Is(classifyReason(event.Reason), teleop.ErrClosed) { + faults = append(faults, event.Reason) + } } } + if pendingGap != nil { + faults = append(faults, "trailing input gap: "+pendingGap.Reason) + } + if len(faults) > 0 { + return result, &ReplayFaultError{Reason: strings.Join(faults, "; ")} + } return result, nil } + +func classifyReason(reason string) error { + if strings.Contains(reason, teleop.ErrClosed.Error()) { + return teleop.ErrClosed + } + return errors.New(reason) +} diff --git a/audit/sign.go b/audit/sign.go new file mode 100644 index 0000000..464f27e --- /dev/null +++ b/audit/sign.go @@ -0,0 +1,189 @@ +package audit + +import ( + "bytes" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "strconv" + "time" + + "github.com/open-ships/teleop" +) + +// A hash chain establishes integrity and an HMAC chain establishes +// authenticity, but an HMAC cannot provide third-party attribution: its +// verifier holds the same key that could have produced it. An asymmetric +// signature separates the writing and verification roles. When the public key +// is bound to a device through trusted provisioning and the private key's +// lifecycle is controlled, an Ed25519-capable hardware or remote signer can +// support evidence about that device rather than merely about possession of an +// exportable key. + +const ( + signatureDomain = "teleop.audit.sth.v1" + // SignatureAlgorithmEd25519 identifies Ed25519 signatures in published + // checkpoints. + SignatureAlgorithmEd25519 = "ed25519" +) + +var ( + // ErrSignature reports a missing, malformed, or invalid signature. + ErrSignature = errors.New("teleop/audit: signature is invalid") + // ErrSignatureRequired reports that verification demanded a signed stream. + ErrSignatureRequired = errors.New("teleop/audit: signature required") + // ErrUntrustedKey reports a stream signed by a key other than the one the + // verifier was told to trust. + ErrUntrustedKey = errors.New("teleop/audit: stream signed by an untrusted key") +) + +// GenerateKey creates an Ed25519 signing key. Production deployments should +// prefer an Ed25519-capable hardware or remote crypto.Signer so the private key +// cannot be exported; this exists for tests and for development logs. +func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate audit signing key: %w", err) + } + return public, private, nil +} + +// KeyID returns the stable short display identifier recorded alongside a +// signature. It is not a trust anchor; security decisions must compare the +// complete public key obtained through a trusted channel. +func KeyID(public ed25519.PublicKey) string { + sum := sha256.Sum256(public) + return hex.EncodeToString(sum[:8]) +} + +// signingKey binds a crypto.Signer to the Ed25519 public key it represents. +type signingKey struct { + signer crypto.Signer + public ed25519.PublicKey + id string +} + +func newSigningKey(signer crypto.Signer) (*signingKey, error) { + if signer == nil { + return nil, fmt.Errorf("%w: nil signer", ErrSignature) + } + public, ok := signer.Public().(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf( + "%w: signer public key is %T, want ed25519.PublicKey", + ErrSignature, + signer.Public(), + ) + } + if len(public) != ed25519.PublicKeySize { + return nil, fmt.Errorf( + "%w: signer public key is %d bytes", + ErrSignature, + len(public), + ) + } + public = append(ed25519.PublicKey(nil), public...) + return &signingKey{signer: signer, public: public, id: KeyID(public)}, nil +} + +// sign produces a detached Ed25519 signature. Ed25519 hashes internally, so +// crypto.Hash(0) instructs the signer to consume the message directly. +func (k *signingKey) sign(message []byte) (string, error) { + signature, err := k.signer.Sign(rand.Reader, message, crypto.Hash(0)) + if err != nil { + return "", fmt.Errorf("sign audit record: %w", err) + } + if len(signature) != ed25519.SignatureSize { + return "", fmt.Errorf( + "%w: signer returned a %d-byte signature", + ErrSignature, + len(signature), + ) + } + return hex.EncodeToString(signature), nil +} + +// TreeHead is the statement a recorder signs. Binding the record hash, the +// Merkle root, and the tree size together means a signature cannot be lifted +// from one record and replayed onto another, and that a signed head commits to +// every record beneath it. +type TreeHead struct { + // Version and RecordType select the signed statement format. + Version int + RecordType string + // Session binds the statement to one controller session. + Session teleop.SessionID + // Size and Root are the Merkle head over preceding records. + Size uint64 + Root []byte + // ChainHead is the hash of the head record itself. + ChainHead string + // EventCount is the number of events committed by the statement. + EventCount uint64 + // RecordedAt is the head record's timestamp. + RecordedAt time.Time +} + +// signingBytes returns an unambiguous encoding of the head. Every field is +// length prefixed so no combination of values can be reinterpreted as a +// different head. +func (h TreeHead) signingBytes() []byte { + buffer := &bytes.Buffer{} + writeSigned(buffer, []byte(signatureDomain)) + writeSigned(buffer, []byte(strconv.Itoa(h.Version))) + writeSigned(buffer, []byte(h.RecordType)) + writeSigned(buffer, h.Session[:]) + writeSignedUint(buffer, h.Size) + writeSigned(buffer, h.Root) + writeSigned(buffer, []byte(h.ChainHead)) + writeSignedUint(buffer, h.EventCount) + writeSigned(buffer, []byte(h.RecordedAt.UTC().Format(time.RFC3339Nano))) + return buffer.Bytes() +} + +func writeSigned(buffer *bytes.Buffer, value []byte) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + buffer.Write(length[:]) + buffer.Write(value) +} + +func writeSignedUint(buffer *bytes.Buffer, value uint64) { + var encoded [8]byte + binary.BigEndian.PutUint64(encoded[:], value) + writeSigned(buffer, encoded[:]) +} + +// VerifyTreeHead checks a hex-encoded signature over head against public. +func VerifyTreeHead(public ed25519.PublicKey, head TreeHead, signature string) error { + if len(public) != ed25519.PublicKeySize { + return fmt.Errorf("%w: public key is %d bytes", ErrSignature, len(public)) + } + raw, err := hex.DecodeString(signature) + if err != nil { + return fmt.Errorf("%w: %v", ErrSignature, err) + } + if len(raw) != ed25519.SignatureSize { + return fmt.Errorf("%w: signature is %d bytes", ErrSignature, len(raw)) + } + if !ed25519.Verify(public, head.signingBytes(), raw) { + return fmt.Errorf("%w: tree head signature does not verify", ErrSignature) + } + return nil +} + +func decodePublicKey(encoded string) (ed25519.PublicKey, error) { + raw, err := hex.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("%w: decode public key: %v", ErrSignature, err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%w: public key is %d bytes", ErrSignature, len(raw)) + } + return ed25519.PublicKey(raw), nil +} diff --git a/audit/wire.go b/audit/wire.go new file mode 100644 index 0000000..4b07dcc --- /dev/null +++ b/audit/wire.go @@ -0,0 +1,273 @@ +package audit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "unicode/utf8" +) + +// decodeDiskRecord applies the wire schema before decoding into diskRecord. +// Cryptographic verification must never accept a field that the selected +// format version does not authenticate. +func decodeDiskRecord(encoded []byte) (diskRecord, error) { + var record diskRecord + if !utf8.Valid(encoded) { + return record, errors.New("audit record is not valid UTF-8") + } + if err := rejectDuplicateJSONFields(encoded); err != nil { + return record, err + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + return record, err + } + rawVersion, ok := fields["version"] + if !ok { + return record, errors.New(`audit record field "version" is required`) + } + var version int + if err := json.Unmarshal(rawVersion, &version); err != nil { + return record, fmt.Errorf(`decode audit record field "version": %w`, err) + } + + if version >= 1 && version <= FormatVersion { + for name := range fields { + if !diskFieldAllowed(version, name) { + return record, fmt.Errorf( + "audit record field %q is not valid in format version %d", + name, + version, + ) + } + } + } + if err := json.Unmarshal(encoded, &record); err != nil { + return record, err + } + if version == FormatVersion { + if err := validateCurrentRecordShape(record, fields); err != nil { + return diskRecord{}, err + } + } + if raw, ok := fields["provenance"]; ok { + record.provenanceRaw = append(json.RawMessage(nil), raw...) + if !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var provenance Provenance + if err := decoder.Decode(&provenance); err != nil { + return diskRecord{}, fmt.Errorf("decode audit provenance: %w", err) + } + } + } + return record, nil +} + +func diskFieldAllowed(version int, name string) bool { + switch name { + case "version", + "record_type", + "recorded_at", + "kind", + "payload", + "event_count", + "previous_hash", + "hash": + return true + case "header": + return version == 1 + case "chain", "control_schema", "durability", "encoding_error": + return version >= 2 + case "session", + "tree_size", + "tree_root", + "signature", + "key_id", + "public_key", + "provenance", + "reason": + return version >= 3 + default: + return false + } +} + +func validateCurrentRecordShape(record diskRecord, fields map[string]json.RawMessage) error { + for _, required := range []string{"version", "record_type", "recorded_at", "tree_size"} { + if _, ok := fields[required]; !ok { + return fmt.Errorf("audit record field %q is required in format version 3", required) + } + } + if err := rejectPresentZeroFields(record, fields); err != nil { + return err + } + + allowedForType := func(name string) bool { + switch record.RecordType { + case "manifest": + switch name { + case "kind", "payload", "event_count", "encoding_error", "reason": + return false + } + case "event": + switch name { + case "event_count", + "chain", + "control_schema", + "durability", + "session", + "tree_root", + "signature", + "key_id", + "public_key", + "provenance", + "reason": + return false + } + case "checkpoint": + switch name { + case "kind", + "payload", + "chain", + "control_schema", + "durability", + "encoding_error", + "session", + "key_id", + "public_key", + "provenance": + return false + } + case "footer": + switch name { + case "kind", + "payload", + "chain", + "control_schema", + "durability", + "encoding_error", + "session", + "key_id", + "public_key", + "provenance", + "reason": + return false + } + } + return true + } + for name := range fields { + if !allowedForType(name) { + return fmt.Errorf( + "audit record field %q is not valid on record type %q", + name, + record.RecordType, + ) + } + } + return nil +} + +func rejectPresentZeroFields( + record diskRecord, + fields map[string]json.RawMessage, +) error { + zero := map[string]bool{ + "kind": record.Kind == "", + "payload": len(record.Payload) == 0, + "event_count": record.EventCount == 0, + "previous_hash": record.PreviousHash == "", + "hash": record.Hash == "", + "chain": record.Chain == "", + "control_schema": record.ControlSchema == "", + "durability": record.Durability == "", + "encoding_error": record.EncodingError == "", + "session": record.Session == nil, + "tree_root": record.TreeRoot == "", + "signature": record.Signature == "", + "key_id": record.KeyID == "", + "public_key": record.PublicKey == "", + "provenance": record.Provenance == nil, + "reason": record.Reason == "", + } + for name, isZero := range zero { + if _, present := fields[name]; present && isZero { + return fmt.Errorf("audit record field %q must be omitted when empty", name) + } + } + return nil +} + +func rejectDuplicateJSONFields(encoded []byte) error { + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := scanJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("audit record contains more than one JSON value") + } + return err + } + return nil +} + +func scanJSONValue(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() { + token, err := decoder.Token() + if err != nil { + return err + } + name, ok := token.(string) + if !ok { + return errors.New("audit JSON object contains a non-string field name") + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("audit JSON object contains duplicate field %q", name) + } + seen[name] = struct{}{} + if err := scanJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim('}') { + return errors.New("audit JSON object is not terminated") + } + case '[': + for decoder.More() { + if err := scanJSONValue(decoder); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil { + return err + } + if closing != json.Delim(']') { + return errors.New("audit JSON array is not terminated") + } + default: + return errors.New("audit JSON contains an unexpected delimiter") + } + return nil +} diff --git a/clock.go b/clock.go new file mode 100644 index 0000000..076962d --- /dev/null +++ b/clock.go @@ -0,0 +1,83 @@ +package teleop + +import "time" + +// DefaultClockStepThreshold is the wall-versus-monotonic divergence a +// controller treats as a clock step rather than ordinary drift. NTP slewing +// stays well below it; a step adjustment or manual clock change exceeds it. +const DefaultClockStepThreshold = 250 * time.Millisecond + +// clockSample is one reading of both time bases taken by the controller run +// loop. Monotonic is relative to the controller session origin, so it is +// meaningful only within a single session. +type clockSample struct { + Wall time.Time + Monotonic time.Duration + Step time.Duration + Stepped bool +} + +// clockMonitor detects wall-clock steps by comparing wall-clock movement +// against monotonic movement between consecutive readings. +// +// A Clock implementation whose times carry no monotonic reading, such as a +// deterministic test clock, degrades safely: both deltas are then computed +// from the same base and no step is ever reported. +type clockMonitor struct { + base time.Time + lastWall time.Time + lastMono time.Duration + threshold time.Duration + steps uint64 + started bool +} + +func newClockMonitor(base time.Time, threshold time.Duration) *clockMonitor { + if threshold <= 0 { + threshold = DefaultClockStepThreshold + } + return &clockMonitor{base: base, threshold: threshold} +} + +// since returns the session-relative monotonic reading for now. Go subtracts +// monotonic readings whenever both operands carry one, so the result is +// unaffected by wall-clock adjustments. It reads only immutable state and is +// safe to call from any goroutine. +func (m *clockMonitor) since(now time.Time) time.Duration { + return now.Sub(m.base) +} + +// observe records a reading and reports whether the wall clock stepped +// relative to the monotonic clock since the previous reading. It mutates +// monitor state and must be called only from the controller run loop. +func (m *clockMonitor) observe(now time.Time) clockSample { + monotonic := m.since(now) + sample := clockSample{Wall: now, Monotonic: monotonic} + if !m.started { + m.started = true + m.lastWall = now + m.lastMono = monotonic + return sample + } + + // Round(0) strips the monotonic reading, so this difference reflects the + // wall clock alone. The unrounded difference uses the monotonic reading. + wallDelta := now.Round(0).Sub(m.lastWall.Round(0)) + monoDelta := monotonic - m.lastMono + divergence := wallDelta - monoDelta + magnitude := divergence + if magnitude < 0 { + magnitude = -magnitude + } + if magnitude >= m.threshold { + m.steps++ + sample.Step = divergence + sample.Stepped = true + } + + m.lastWall = now + m.lastMono = monotonic + return sample +} + +func (m *clockMonitor) stepCount() uint64 { return m.steps } diff --git a/clock_test.go b/clock_test.go new file mode 100644 index 0000000..c88bdd6 --- /dev/null +++ b/clock_test.go @@ -0,0 +1,92 @@ +package teleop + +import ( + "testing" + "time" +) + +func TestClockMonitorDetectsWallClockDivergence(t *testing.T) { + monitor := newClockMonitor(time.Now(), 100*time.Millisecond) + monitor.observe(time.Now()) + + // Simulate a wall-clock correction after the first sample. The saved + // monotonic reading is deliberately left unchanged, as it would be by an + // NTP step or a manual wall-clock adjustment. + monitor.lastWall = monitor.lastWall.Add(-time.Second) + sample := monitor.observe(time.Now()) + if !sample.Stepped || sample.Step < 900*time.Millisecond || monitor.stepCount() != 1 { + t.Fatalf("clock sample = %#v, steps = %d", sample, monitor.stepCount()) + } +} + +// TestClockMonitorDetectsBackwardStep covers the direction that corrupts +// forensic ordering: a wall clock moved backwards mid-session can make a +// release appear to precede the press that caused it. +func TestClockMonitorDetectsBackwardStep(t *testing.T) { + monitor := newClockMonitor(time.Now(), 100*time.Millisecond) + monitor.observe(time.Now()) + + monitor.lastWall = monitor.lastWall.Add(time.Second) + sample := monitor.observe(time.Now()) + if !sample.Stepped { + t.Fatalf("a backward step must be detected: %#v", sample) + } + if sample.Step > -900*time.Millisecond { + t.Fatalf("step = %s, want a large negative adjustment", sample.Step) + } +} + +func TestClockMonitorReportsNoStepUnderUniformAdvance(t *testing.T) { + base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + monitor := newClockMonitor(base, 250*time.Millisecond) + + // A clock with no monotonic reading, such as a deterministic test clock, + // must degrade safely: both deltas come from the same base, so no step is + // ever reported. + for tick := 1; tick <= 20; tick++ { + sample := monitor.observe(base.Add(time.Duration(tick) * time.Hour)) + if sample.Stepped { + t.Fatalf("tick %d reported a step under uniform advance", tick) + } + } + if monitor.stepCount() != 0 { + t.Fatalf("step count = %d, want 0", monitor.stepCount()) + } +} + +func TestClockMonitorFirstObservationNeverSteps(t *testing.T) { + base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + monitor := newClockMonitor(base, time.Millisecond) + + if sample := monitor.observe(base.Add(time.Hour)); sample.Stepped { + t.Fatal("the first observation has no predecessor to compare against") + } +} + +// TestClockMonitorSinceIsSessionRelative checks the reading that every event +// header carries. +func TestClockMonitorSinceIsSessionRelative(t *testing.T) { + base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + monitor := newClockMonitor(base, 0) + + if got := monitor.since(base); got != 0 { + t.Fatalf("since at origin = %s, want 0", got) + } + if got := monitor.since(base.Add(1500 * time.Millisecond)); got != 1500*time.Millisecond { + t.Fatalf("since = %s, want 1.5s", got) + } +} + +func TestClockMonitorDefaultsThreshold(t *testing.T) { + for _, threshold := range []time.Duration{0, -time.Second} { + monitor := newClockMonitor(time.Now(), threshold) + if monitor.threshold != DefaultClockStepThreshold { + t.Fatalf( + "threshold %s produced %s, want %s", + threshold, + monitor.threshold, + DefaultClockStepThreshold, + ) + } + } +} diff --git a/cmd/teleop-monitor/main.go b/cmd/teleop-monitor/main.go index 2299e94..25ef6b2 100644 --- a/cmd/teleop-monitor/main.go +++ b/cmd/teleop-monitor/main.go @@ -11,7 +11,6 @@ import ( "os" "os/signal" "runtime" - "syscall" "github.com/open-ships/teleop" "github.com/open-ships/teleop/audit" @@ -32,12 +31,12 @@ func main() { defer runtime.UnlockOSThread() if err := run(); err != nil { - fmt.Fprintln(os.Stderr, "teleop-monitor:", err) + fmt.Fprintln(os.Stderr, "teleop-monitor:", terminalText(err.Error())) os.Exit(1) } } -func run() error { +func run() (err error) { var config configuration flag.StringVar(&config.deviceID, "device", "", "device ID to open (defaults to the first controller)") flag.BoolVar(&config.list, "list", false, "list connected Xbox controllers and exit") @@ -45,7 +44,7 @@ func run() error { flag.StringVar(&config.audit, "audit", "", "write a lossless, hash-chained audit log to this file") flag.Parse() - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, cancel := signal.NotifyContext(context.Background(), monitoredSignals()...) defer cancel() provider := xbox.NewProvider() @@ -73,13 +72,19 @@ func run() error { auditFile *os.File ) if config.audit != "" { - auditFile, err = os.OpenFile(config.audit, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + auditFile, err = os.OpenFile(config.audit, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return fmt.Errorf("open audit log: %w", err) } - defer auditFile.Close() recorder = audit.NewRecorder(auditFile, audit.WithFlushEveryEvent(true)) - defer recorder.Close() + defer func() { + if closeErr := recorder.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close audit recorder: %w", closeErr)) + } + if closeErr := auditFile.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close audit file: %w", closeErr)) + } + }() openOptions = append(openOptions, teleop.WithAuditSink(recorder)) } @@ -87,18 +92,31 @@ func run() error { if err != nil { return err } - defer controller.Close() + defer func() { + if closeErr := controller.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close controller: %w", closeErr)) + } + }() + streaming := config.json || !terminalOutput() + delivery := teleop.DeliveryLatest + if streaming { + delivery = teleop.DeliveryLossless + } subscription, err := controller.Subscribe(teleop.SubscriptionOptions{ - Delivery: teleop.DeliveryLossless, + Delivery: delivery, Buffer: 8192, }) if err != nil { return err } - defer subscription.Close() + defer func() { + if closeErr := subscription.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close subscription: %w", closeErr)) + } + }() - if config.json || !terminalOutput() { + if streaming { return streamJSON(ctx, subscription) } return runTUI(ctx, controller, subscription, config.audit) @@ -110,17 +128,21 @@ func printDevices(devices []teleop.Descriptor) { return } for _, device := range devices { - fmt.Printf( - "%s\t%s\tbackend=%s transport=%s audit=%s\n", - device.ID, - device.Name, - device.Backend, - device.Transport, - device.Capability.AuditGrade, - ) + fmt.Println(deviceLine(device)) } } +func deviceLine(device teleop.Descriptor) string { + return fmt.Sprintf( + "%s\t%s\tbackend=%s transport=%s audit=%s", + terminalText(string(device.ID)), + terminalText(device.Name), + terminalText(device.Backend), + terminalText(string(device.Transport)), + terminalText(string(device.Capability.AuditGrade)), + ) +} + func selectDevice(devices []teleop.Descriptor, id teleop.DeviceID) (teleop.Descriptor, error) { if id == "" { return devices[0], nil diff --git a/cmd/teleop-monitor/signals_other.go b/cmd/teleop-monitor/signals_other.go new file mode 100644 index 0000000..97c4973 --- /dev/null +++ b/cmd/teleop-monitor/signals_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris + +package main + +import "os" + +func monitoredSignals() []os.Signal { return []os.Signal{os.Interrupt} } diff --git a/cmd/teleop-monitor/signals_unix.go b/cmd/teleop-monitor/signals_unix.go new file mode 100644 index 0000000..9d25f05 --- /dev/null +++ b/cmd/teleop-monitor/signals_unix.go @@ -0,0 +1,12 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package main + +import ( + "os" + "syscall" +) + +func monitoredSignals() []os.Signal { + return []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP} +} diff --git a/cmd/teleop-monitor/signals_windows.go b/cmd/teleop-monitor/signals_windows.go new file mode 100644 index 0000000..3df98c3 --- /dev/null +++ b/cmd/teleop-monitor/signals_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +import "os" + +func monitoredSignals() []os.Signal { return []os.Signal{os.Interrupt} } diff --git a/cmd/teleop-monitor/terminal.go b/cmd/teleop-monitor/terminal.go index e299d3a..973e3bd 100644 --- a/cmd/teleop-monitor/terminal.go +++ b/cmd/teleop-monitor/terminal.go @@ -1,8 +1,26 @@ package main -import "os" +import ( + "os" + "strings" + "unicode" + + "golang.org/x/term" +) func terminalOutput() bool { - info, err := os.Stdout.Stat() - return err == nil && info.Mode()&os.ModeCharDevice != 0 + return isTerminal(int(os.Stdout.Fd())) +} + +func isTerminal(fd int) bool { return term.IsTerminal(fd) } + +// terminalText strips terminal-control code points from device- and +// backend-supplied text before it is rendered by the interactive monitor. +func terminalText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) } diff --git a/cmd/teleop-monitor/terminal_test.go b/cmd/teleop-monitor/terminal_test.go new file mode 100644 index 0000000..e400619 --- /dev/null +++ b/cmd/teleop-monitor/terminal_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/open-ships/teleop" +) + +func TestTerminalTextRemovesControlSequences(t *testing.T) { + value := terminalText("Xbox\x1b]8;;https://example.test\x07 controller\n") + if strings.ContainsRune(value, '\x1b') || strings.ContainsRune(value, '\x07') || + strings.ContainsRune(value, '\n') { + t.Fatalf("terminal text retained a control character: %q", value) + } + if value != "Xbox]8;;https://example.test controller" { + t.Fatalf("terminal text = %q", value) + } +} + +func TestDeviceLineUsesDescriptorNameAndSanitizesIt(t *testing.T) { + line := deviceLine(teleop.Descriptor{ + ID: "xbox:\x1b0", + Name: "Field controller\n", + Backend: "xinput", + Transport: teleop.TransportUSB, + Capability: teleop.Capabilities{ + AuditGrade: teleop.AuditSampledState, + }, + }) + if strings.ContainsAny(line, "\x1b\n") { + t.Fatalf("device line retained a terminal control character: %q", line) + } + for _, fragment := range []string{ + "xbox:0", "Field controller", "backend=xinput", "transport=usb", + "audit=sampled-state", + } { + if !strings.Contains(line, fragment) { + t.Fatalf("device line %q does not contain %q", line, fragment) + } + } +} + +func TestDevNullIsNotATerminal(t *testing.T) { + file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("close %s: %v", os.DevNull, err) + } + }) + if isTerminal(int(file.Fd())) { + t.Fatal("os.DevNull was classified as a terminal") + } +} diff --git a/cmd/teleop-monitor/tui.go b/cmd/teleop-monitor/tui.go index 47fe0e9..3fe1726 100644 --- a/cmd/teleop-monitor/tui.go +++ b/cmd/teleop-monitor/tui.go @@ -149,25 +149,27 @@ func (m *monitorModel) addEvent(event teleop.Event, state teleop.State) { if observation, ok := event.(teleop.ObservationEvent); ok { m.observations++ m.state = observation.Current + m.lastGap = "" return } if observation, ok := event.(*teleop.ObservationEvent); ok { m.observations++ m.state = observation.Current + m.lastGap = "" return } if gap, ok := event.(teleop.GapEvent); ok { - m.lastGap = gap.Reason + m.lastGap = terminalText(gap.Reason) } if gap, ok := event.(*teleop.GapEvent); ok { - m.lastGap = gap.Reason + m.lastGap = terminalText(gap.Reason) } m.recent = append(m.recent, recentEvent{ number: m.eventCount, kind: event.Kind(), - summary: eventSummary(event), + summary: terminalText(eventSummary(event)), }) if len(m.recent) > maxRecentEvents { m.recent = m.recent[len(m.recent)-maxRecentEvents:] @@ -270,19 +272,19 @@ func (m *monitorModel) renderHeader(width int) string { } title := accentStyle.Render("TELEOP MONITOR") - deviceName := m.descriptor.Name + deviceName := terminalText(m.descriptor.Name) if deviceName == "" { deviceName = "Game controller" } - metadata := fmt.Sprintf( + metadata := terminalText(fmt.Sprintf( "%s · %s · %s · audit %s", m.descriptor.Type, m.descriptor.Backend, m.descriptor.Transport, m.descriptor.Capability.AuditGrade, - ) + )) if m.auditPath != "" { - metadata += " · " + filepath.Base(m.auditPath) + metadata += " · " + terminalText(filepath.Base(m.auditPath)) } return lipgloss.JoinVertical( @@ -471,8 +473,6 @@ func eventKindLabel(kind teleop.EventKind) string { return "stick" case teleop.EventTrigger: return "trigger" - case teleop.EventDPad: - return "d-pad" case teleop.EventConnection: return "device" case teleop.EventCapabilities: @@ -517,10 +517,6 @@ func eventSummary(event teleop.Event) string { return fmt.Sprintf("%s %.3f", value.Trigger, value.Position) case *teleop.TriggerEvent: return fmt.Sprintf("%s %.3f", value.Trigger, value.Position) - case teleop.DPadEvent: - return dpad(value.Current) - case *teleop.DPadEvent: - return dpad(value.Current) case teleop.ConnectionEvent: return string(value.State) case *teleop.ConnectionEvent: @@ -539,23 +535,3 @@ func eventSummary(event teleop.Event) string { return string(event.Kind()) } } - -func dpad(value teleop.DPad) string { - var directions []string - if value.Up { - directions = append(directions, "up") - } - if value.Down { - directions = append(directions, "down") - } - if value.Left { - directions = append(directions, "left") - } - if value.Right { - directions = append(directions, "right") - } - if len(directions) == 0 { - return "center" - } - return strings.Join(directions, "+") -} diff --git a/cmd/teleop-monitor/tui_test.go b/cmd/teleop-monitor/tui_test.go index d730414..3b8c4e9 100644 --- a/cmd/teleop-monitor/tui_test.go +++ b/cmd/teleop-monitor/tui_test.go @@ -14,6 +14,7 @@ import ( type stubController struct { descriptor teleop.Descriptor state teleop.State + done chan struct{} } func (c *stubController) Descriptor() teleop.Descriptor { @@ -28,6 +29,29 @@ func (c *stubController) Snapshot() teleop.State { return c.state } +func (c *stubController) SnapshotWithMeta() (teleop.State, teleop.StateMeta) { + return c.state, teleop.StateMeta{Connected: true} +} + +func (*stubController) Session() teleop.SessionID { + return teleop.SessionID{} +} + +func (c *stubController) Done() <-chan struct{} { + if c.done == nil { + c.done = make(chan struct{}) + } + return c.done +} + +func (*stubController) Err() error { + return nil +} + +func (*stubController) RecordCommand(context.Context, teleop.Command) error { + return nil +} + func (*stubController) Subscribe( teleop.SubscriptionOptions, ) (teleop.Subscription, error) { @@ -52,6 +76,10 @@ func (s *stubSubscription) Next(context.Context) (teleop.Event, error) { return event, nil } +func (*stubSubscription) Stats() teleop.SubscriptionStats { + return teleop.SubscriptionStats{} +} + func (*stubSubscription) Close() error { return nil } @@ -159,6 +187,26 @@ func TestMonitorModelTracksObservationsAndGaps(t *testing.T) { } } +func TestMonitorModelClearsGapAlertOnNextObservation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + model := newMonitorModel( + ctx, + cancel, + &stubController{descriptor: teleop.Descriptor{Name: "Test Controller"}}, + &stubSubscription{}, + "", + ) + model.addEvent(teleop.GapEvent{Reason: "kernel overflow\x1b[2J"}, teleop.State{}) + if model.lastGap != "kernel overflow[2J" { + t.Fatalf("sanitized gap = %q", model.lastGap) + } + model.addEvent(teleop.ObservationEvent{Current: teleop.State{}}, teleop.State{}) + if model.lastGap != "" { + t.Fatalf("gap alert remained after a complete observation: %q", model.lastGap) + } +} + func TestMonitorModelRespondsToResizeAndQuit(t *testing.T) { eventContext, cancelEvents := context.WithCancel(context.Background()) controller := &stubController{ diff --git a/command.go b/command.go new file mode 100644 index 0000000..b255110 --- /dev/null +++ b/command.go @@ -0,0 +1,136 @@ +package teleop + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// Command describes a command an application issued to the system under +// control, so it can be recorded alongside the input that caused it. +// +// A log of input alone leaves the causal chain incomplete. Reconstructing an +// incident requires knowing what the machine was told to do, which is a +// function of the application's own mapping from input to actuation, not of +// the controller state teleop observes. +type Command struct { + // Name identifies the command in an application-defined vocabulary. + Name string + // Payload is marshaled to JSON and recorded verbatim. A nil payload + // records the name alone. + Payload any + // Authorized reports whether a safety gate permitted this command at the + // instant it was issued. Recording an unauthorized command is meaningful: + // it shows the application computed one and the gate inhibited it. + Authorized bool + // Reason explains an unauthorized or degraded command. + Reason string + // Causes links the command to the input events that produced it. Every + // cause must already have been published. + Causes []EventID +} + +// commandRequest carries a command from an application goroutine to the run +// loop. Event identity is allocated during publication rather than here, so +// concurrent callers cannot produce out-of-order sequence numbers. +type commandRequest struct { + command Command + payload json.RawMessage + issuedAt time.Time +} + +// RecordCommand publishes a CommandEvent to every subscription and audit sink. +// +// It never blocks the caller. A full queue returns ErrPipelineOverflow +// immediately rather than stalling a control loop; a safety-critical caller +// should treat that error as a fault and inhibit output, because the command +// it just issued is not in the record. +func (c *Controller) RecordCommand(ctx context.Context, command Command) error { + if err := ctx.Err(); err != nil { + return err + } + if command.Name == "" { + return fmt.Errorf("%w: command name is empty", ErrInvalidState) + } + if err := c.validateCommandCauses(command.Causes); err != nil { + return err + } + var payload json.RawMessage + if command.Payload != nil { + encoded, err := json.Marshal(command.Payload) + if err != nil { + return fmt.Errorf("marshal command payload: %w", err) + } + payload = encoded + } + request := commandRequest{ + command: command, + payload: payload, + issuedAt: c.clock.Now(), + } + request.command.Causes = append([]EventID(nil), command.Causes...) + request.command.Payload = nil + + // A caller may use a deferred controller solely for audit-backed command + // recording. Starting here preserves the method's promise to publish rather + // than leaving an accepted request stranded until some unrelated Snapshot or + // Subscribe call. + c.start() + select { + case <-c.done: + if err := c.Err(); err != nil { + return err + } + return ErrClosed + default: + } + select { + case c.external <- request: + return nil + case <-c.done: + if err := c.Err(); err != nil { + return err + } + return ErrClosed + default: + return fmt.Errorf("%w: command queue is full", ErrPipelineOverflow) + } +} + +func (c *Controller) validateCommandCauses(causes []EventID) error { + if len(causes) == 0 { + return nil + } + c.knownMu.RLock() + defer c.knownMu.RUnlock() + for _, cause := range causes { + if _, ok := c.known[cause]; !ok { + return fmt.Errorf( + "%w: command cause %s/%s/%d has not been published", + ErrInvalidState, + cause.Session, + cause.Stream, + cause.Sequence, + ) + } + } + return nil +} + +func (c *Controller) handleCommand(request commandRequest) error { + return c.publish(CommandEvent{ + Meta: c.nextHeaderAt( + "command", + request.issuedAt, + request.issuedAt, + 0, + request.command.Causes, + false, + ), + Command: request.command.Name, + Payload: request.payload, + Authorized: request.command.Authorized, + Reason: request.command.Reason, + }) +} diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..b6bb9db --- /dev/null +++ b/command_test.go @@ -0,0 +1,131 @@ +package teleop_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/open-ships/teleop" + "github.com/open-ships/teleop/audit" + "github.com/open-ships/teleop/testkit" +) + +func TestRecordCommandPublishesCausalAuditableEvent(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "command"}, 4) + var output bytes.Buffer + recorder := audit.NewRecorder(&output) + controller, err := teleop.NewController(source, teleop.WithAuditSink(recorder)) + if err != nil { + t.Fatal(err) + } + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + state := teleop.State{} + state.SetButton(teleop.ButtonFaceSouth, true) + if err := source.Push(ctx, state); err != nil { + t.Fatal(err) + } + + var cause teleop.EventID + for cause == (teleop.EventID{}) { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + if button, ok := event.(teleop.ButtonEvent); ok && + button.Button == teleop.ButtonFaceSouth && + button.Phase == teleop.PhasePressed { + cause = button.Meta.ID + } + } + if err := controller.RecordCommand(ctx, teleop.Command{ + Name: "drive.set", + Payload: map[string]any{"forward": 0.5}, + Authorized: true, + Causes: []teleop.EventID{cause}, + }); err != nil { + t.Fatal(err) + } + + var command teleop.CommandEvent + for command.Command == "" { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + if value, ok := event.(teleop.CommandEvent); ok { + command = value + } + } + if command.Command != "drive.set" || !command.Authorized || + len(command.Meta.Causes) != 1 || command.Meta.Causes[0] != cause { + t.Fatalf("command event = %#v", command) + } + var payload map[string]float64 + if err := json.Unmarshal(command.Payload, &payload); err != nil || payload["forward"] != 0.5 { + t.Fatalf("command payload = %q, %v", command.Payload, err) + } + + if err := controller.Close(); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + records, err := audit.ReadAll(bytes.NewReader(output.Bytes())) + if err != nil { + t.Fatalf("verify command audit: %v", err) + } + var decoded bool + for _, record := range records { + if record.Kind != teleop.EventCommand { + continue + } + event, err := audit.DecodeEvent(record) + if err != nil { + t.Fatal(err) + } + value, ok := event.(*teleop.CommandEvent) + if !ok || value.Command != "drive.set" { + t.Fatalf("decoded command = %#v", event) + } + decoded = true + } + if !decoded { + t.Fatal("command event missing from audit log") + } +} + +func TestRecordCommandRejectsInvalidAndTerminalRequests(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "command-terminal"}, 1) + controller, err := teleop.NewController(source) + if err != nil { + t.Fatal(err) + } + if err := controller.RecordCommand(context.Background(), teleop.Command{}); !errors.Is(err, teleop.ErrInvalidState) { + t.Fatalf("empty command error = %v, want ErrInvalidState", err) + } + if err := controller.RecordCommand(context.Background(), teleop.Command{ + Name: "unknown.cause", + Causes: []teleop.EventID{{ + Stream: "input", + Sequence: 1, + }}, + }); !errors.Is(err, teleop.ErrInvalidState) { + t.Fatalf("unknown cause error = %v, want ErrInvalidState", err) + } + if err := controller.Close(); err != nil { + t.Fatal(err) + } + if err := controller.RecordCommand(context.Background(), teleop.Command{Name: "after.close"}); !errors.Is(err, teleop.ErrClosed) { + t.Fatalf("terminal command error = %v, want ErrClosed", err) + } +} diff --git a/control.go b/control.go index a9ba5a5..ccdda72 100644 --- a/control.go +++ b/control.go @@ -5,6 +5,7 @@ package teleop type ControllerType string const ( + // ControllerXbox identifies the built-in Xbox-compatible provider family. ControllerXbox ControllerType = "xbox" ) @@ -13,17 +14,24 @@ const ( type Transport string const ( - TransportUnknown Transport = "unknown" - TransportBluetooth Transport = "bluetooth" - TransportUSB Transport = "usb" + // TransportUnknown means the backend cannot determine the connection. + TransportUnknown Transport = "unknown" + // TransportBluetooth identifies a Bluetooth connection. + TransportBluetooth Transport = "bluetooth" + // TransportUSB identifies a USB connection. + TransportUSB Transport = "usb" + // TransportXboxWireless identifies Microsoft's proprietary wireless link. TransportXboxWireless Transport = "xbox-wireless" - TransportVirtual Transport = "virtual" + // TransportVirtual identifies a synthetic or replay device. + TransportVirtual Transport = "virtual" ) // ControlID names a physical control by position rather than by the label // printed by a particular controller manufacturer. type ControlID string +// Standard control identifiers name physical positions independently of the +// labels printed by a controller manufacturer. const ( ButtonFaceSouth ControlID = "button.face.south" ButtonFaceEast ControlID = "button.face.east" @@ -84,26 +92,38 @@ func StandardButtonIDs() []ControlID { return append([]ControlID(nil), standardButtons...) } +// StickID selects one of the two standard analog sticks. type StickID string const ( - LeftStick StickID = "left" + // LeftStick selects the left analog stick. + LeftStick StickID = "left" + // RightStick selects the right analog stick. RightStick StickID = "right" ) +// TriggerID selects one of the two standard analog triggers. type TriggerID string const ( - LeftTrigger TriggerID = "left" + // LeftTrigger selects the left analog trigger. + LeftTrigger TriggerID = "left" + // RightTrigger selects the right analog trigger. RightTrigger TriggerID = "right" ) +// Phase describes an event edge or lifecycle transition. type Phase string const ( - PhasePressed Phase = "pressed" + // PhasePressed reports a digital press edge. + PhasePressed Phase = "pressed" + // PhaseReleased reports a digital release edge. PhaseReleased Phase = "released" - PhaseChanged Phase = "changed" - PhaseStarted Phase = "started" - PhaseEnded Phase = "ended" + // PhaseChanged reports an analog value change. + PhaseChanged Phase = "changed" + // PhaseStarted reports the start of a stateful derived event. + PhaseStarted Phase = "started" + // PhaseEnded reports the end of a stateful derived event. + PhaseEnded Phase = "ended" ) diff --git a/controller.go b/controller.go index c20fa88..6e9536e 100644 --- a/controller.go +++ b/controller.go @@ -6,120 +6,281 @@ import ( "errors" "fmt" "io" + "math" "sync" "time" ) +// DeliveryPolicy controls what happens when a subscription cannot keep up. type DeliveryPolicy uint8 const ( // DeliveryLossless terminates the subscription with // ErrSubscriptionOverflow instead of silently losing an event. DeliveryLossless DeliveryPolicy = iota - // DeliveryLatest discards the oldest queued event when its buffer is full. - // It is intended for monitors and other snapshot-oriented consumers. + // DeliveryLatest coalesces a full queue to the newest event and accounts for + // every discarded event in SubscriptionStats and the authoritative stream. DeliveryLatest ) +// SubscriptionOptions configures a bounded event subscription. type SubscriptionOptions struct { Delivery DeliveryPolicy Buffer int } +// SubscriptionStats is a snapshot of subscription delivery and loss. +type SubscriptionStats struct { + Enqueued uint64 + Delivered uint64 + Dropped uint64 + Buffered int + Closed bool + Err error +} + +// Subscription is a bounded, context-aware controller event stream. type Subscription interface { Next(context.Context) (Event, error) + Stats() SubscriptionStats Close() error } +// StateMeta describes the freshness and lifecycle of Snapshot. +type StateMeta struct { + ObservedAt time.Time + ReceivedAt time.Time + PublishedAt time.Time + + // ReceivedMonotonic is the session-relative monotonic reading taken when + // the latest observation arrived. Enforce a command timeout against this + // and Controller.Monotonic rather than against ReceivedAt: a wall-clock + // step can make stale input appear fresh. + ReceivedMonotonic time.Duration + + Sequence uint64 + Connected bool + Stale bool + Synthetic bool +} + +// GameController is an open controller session. Implementations are created by +// a Provider; the zero value is not usable. type GameController interface { Descriptor() Descriptor Capabilities() Capabilities Snapshot() State + SnapshotWithMeta() (State, StateMeta) Subscribe(SubscriptionOptions) (Subscription, error) + RecordCommand(context.Context, Command) error + Session() SessionID + Done() <-chan struct{} + Err() error Close() error } +const ( + defaultIngestBuffer = 512 + defaultSinkBuffer = 2048 + defaultCallbackTimeout = 2 * time.Second + defaultShutdownTimeout = time.Second + advanceInterval = 25 * time.Millisecond +) + +// Controller implements one open, normalized, loss-accounted controller +// session. type Controller struct { source InputSource descriptor Descriptor options controllerOptions session SessionID + clock Clock + + sourceCtx context.Context + cancelSource context.CancelFunc + pipelineCtx context.Context + cancelPipe context.CancelFunc - ctx context.Context - cancel context.CancelFunc + startOnce sync.Once + closeOnce sync.Once + sourceCloseOnce sync.Once + done chan struct{} + ingest chan sourceResult + external chan commandRequest + fatal chan error - startOnce sync.Once - closeOnce sync.Once - done chan struct{} + // clocks detects wall-clock steps. Only the run loop may call observe; + // since is read-only and safe from any goroutine. + clocks *clockMonitor stateMu sync.RWMutex state State + meta StateMeta - pipelineMu sync.Mutex - eventMu sync.Mutex - sequence uint64 + eventMu sync.Mutex + sequences map[string]uint64 + knownMu sync.RWMutex + known map[EventID]struct{} - subsMu sync.Mutex - subscribers map[*eventSubscription]struct{} - terminalErr error + subsMu sync.Mutex + subscribers map[*eventSubscription]struct{} + terminalErr error + connection Event + capabilities Event + observation Event - backgroundMu sync.Mutex - backgroundErr error + sourceCloseMu sync.Mutex + sourceCloseErr error + sourceClosed chan struct{} + + sinks []*sinkRunner + + processorDisabled []bool + lastLiveness time.Time + startedAt time.Time +} + +type sourceResult struct { + observation Observation + receivedAt time.Time + err error +} + +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now() } +func (systemClock) NewTicker(interval time.Duration) Ticker { + return systemTicker{Ticker: time.NewTicker(interval)} } +type systemTicker struct{ *time.Ticker } + +func (ticker systemTicker) C() <-chan time.Time { return ticker.Ticker.C } + +// NewController opens the lifecycle around source and starts ingest +// immediately. An attached audit sink therefore records the session even when +// the caller never takes a snapshot or subscribes. func NewController(source InputSource, options ...OpenOption) (*Controller, error) { if source == nil { return nil, fmt.Errorf("%w: nil input source", ErrUnavailable) } - var configured controllerOptions + configured := controllerOptions{ + context: context.Background(), + clock: systemClock{}, + ingestBuffer: defaultIngestBuffer, + sinkBuffer: defaultSinkBuffer, + callbackTimeout: defaultCallbackTimeout, + shutdownTimeout: defaultShutdownTimeout, + } for _, option := range options { if option != nil { option(&configured) } } - ctx, cancel := context.WithCancel(context.Background()) + if configured.context == nil { + configured.context = context.Background() + } + if configured.clock == nil { + configured.clock = systemClock{} + } + sourceCtx, cancelSource := context.WithCancel(configured.context) + pipelineCtx, cancelPipe := context.WithCancel(context.Background()) controller := &Controller{ - source: source, - descriptor: source.Descriptor().Clone(), - options: configured, - ctx: ctx, - cancel: cancel, - done: make(chan struct{}), - subscribers: make(map[*eventSubscription]struct{}), + source: source, + descriptor: source.Descriptor().Clone(), + options: configured, + clock: configured.clock, + sourceCtx: sourceCtx, + cancelSource: cancelSource, + pipelineCtx: pipelineCtx, + cancelPipe: cancelPipe, + done: make(chan struct{}), + ingest: make(chan sourceResult, configured.ingestBuffer), + external: make(chan commandRequest, configured.ingestBuffer), + fatal: make(chan error, len(configured.sinks)+2), + clocks: newClockMonitor( + configured.clock.Now(), + configured.clockStepThreshold, + ), + subscribers: make(map[*eventSubscription]struct{}), + sequences: make(map[string]uint64), + known: make(map[EventID]struct{}), + sourceClosed: make(chan struct{}), + processorDisabled: make( + []bool, + len(configured.processors), + ), } if _, err := rand.Read(controller.session[:]); err != nil { - cancel() + cancelSource() + cancelPipe() _ = source.Close() return nil, fmt.Errorf("create controller session: %w", err) } + for _, sink := range configured.sinks { + runner := newSinkRunner(controller, sink, configured.sinkBuffer) + controller.sinks = append(controller.sinks, runner) + go runner.run() + } + if !configured.deferredStart { + controller.start() + } return controller, nil } -func (c *Controller) Descriptor() Descriptor { - return c.descriptor.Clone() +func (c *Controller) start() { + c.startOnce.Do(func() { go c.run() }) } +// Descriptor returns an isolated copy of the open device descriptor. +func (c *Controller) Descriptor() Descriptor { return c.descriptor.Clone() } + +// Capabilities returns an isolated copy of the device capabilities. func (c *Controller) Capabilities() Capabilities { return c.descriptor.Capability.Clone() } +// Session returns the cryptographically random identity of this open session. +func (c *Controller) Session() SessionID { return c.session } + +// Done closes after terminal disconnect and bounded sink/source cleanup has +// either completed or produced a terminal timeout error. +func (c *Controller) Done() <-chan struct{} { return c.done } + +// Err returns the terminal controller error after Done closes, or nil while +// the controller is active. +func (c *Controller) Err() error { + c.subsMu.Lock() + defer c.subsMu.Unlock() + return c.terminalErr +} + +// Snapshot returns the latest state. On stale input or disconnect it returns a +// synthesized neutral state. func (c *Controller) Snapshot() State { + state, _ := c.SnapshotWithMeta() + return state +} + +// SnapshotWithMeta returns the state and enough timing information to enforce +// a dead-man policy without relying on event delivery. +func (c *Controller) SnapshotWithMeta() (State, StateMeta) { c.start() c.stateMu.RLock() defer c.stateMu.RUnlock() - return c.state.Clone() + return c.state.Clone(), c.meta } +// Subscribe attaches a bounded stream and immediately replays the latched +// connection, capabilities, and latest observation events. func (c *Controller) Subscribe(options SubscriptionOptions) (Subscription, error) { if options.Buffer <= 0 { options.Buffer = 1024 } - subscription := &eventSubscription{ - controller: c, - policy: options.Delivery, - limit: options.Buffer, - notify: make(chan struct{}, 1), + if options.Delivery != DeliveryLossless && options.Delivery != DeliveryLatest { + return nil, fmt.Errorf("%w: unknown delivery policy %d", ErrUnsupported, options.Delivery) } + subscription := newEventSubscription(c, options.Delivery, options.Buffer) c.subsMu.Lock() if c.terminalErr != nil { @@ -128,255 +289,805 @@ func (c *Controller) Subscribe(options SubscriptionOptions) (Subscription, error return nil, err } c.subscribers[subscription] = struct{}{} + latched := []Event{c.connection, c.capabilities, c.observation} + for _, event := range latched { + if event == nil { + continue + } + _, overflow := subscription.enqueue(cloneEvent(event)) + if overflow { + delete(c.subscribers, subscription) + break + } + } c.subsMu.Unlock() c.start() return subscription, nil } -func (c *Controller) start() { - c.startOnce.Do(func() { - go c.run() - }) -} - func (c *Controller) run() { defer close(c.done) - defer func() { _ = c.source.Close() }() - advanceDone := make(chan struct{}) - go c.advanceLoop(advanceDone) + defer c.cancelPipe() defer func() { - c.cancel() - <-advanceDone + if recovered := recover(); recovered != nil { + c.terminate(fmt.Errorf("%w: controller pipeline: %v", ErrCallbackPanic, recovered)) + } }() + go c.readLoop() - if c.ctx.Err() != nil { - c.finish(ErrClosed) - return - } + now := c.clock.Now() + c.startedAt = now + c.stateMu.Lock() + c.meta.Connected = true + c.meta.PublishedAt = now + c.stateMu.Unlock() if err := c.publish(ConnectionEvent{ - Meta: c.nextHeader(time.Now(), 0, nil), + Meta: c.nextHeaderAt("input", now, now, 0, nil, false), State: Connected, Descriptor: c.Descriptor(), }); err != nil { - c.finish(err) + c.terminate(err) return } if err := c.publish(CapabilitiesEvent{ - Meta: c.nextHeader(time.Now(), 0, nil), + Meta: c.nextHeaderAt("input", now, now, 0, nil, false), Capabilities: c.Capabilities(), }); err != nil { - c.finish(err) + c.terminate(err) return } + var ticker Ticker + tickInterval := c.tickInterval() + if tickInterval > 0 { + ticker = c.clock.NewTicker(tickInterval) + defer ticker.Stop() + } + var ticks <-chan time.Time + if ticker != nil { + ticks = ticker.C() + } for { - observation, err := c.source.Read(c.ctx) - if err != nil { - if c.ctx.Err() != nil || errors.Is(err, ErrClosed) { - terminalErr := ErrClosed - reason := "controller closed" - if backgroundErr := c.getBackgroundError(); backgroundErr != nil { - terminalErr = backgroundErr - reason = backgroundErr.Error() - } - _ = c.publish(ConnectionEvent{ - Meta: c.nextHeader(time.Now(), 0, nil), - State: Disconnected, - Reason: reason, - Descriptor: c.Descriptor(), - }) - c.finish(terminalErr) + select { + case result := <-c.ingest: + if result.err != nil { + c.terminate(normalizeSourceError(result.err, c.sourceCtx.Err() != nil)) return } - reason := err.Error() - if publishErr := c.publish(ConnectionEvent{ - Meta: c.nextHeader(time.Now(), 0, nil), - State: Disconnected, - Reason: reason, - Descriptor: c.Descriptor(), - }); publishErr != nil { - c.finish(publishErr) + if err := c.handleObservation(result.observation, result.receivedAt); err != nil { + c.terminate(err) return } - if !errors.Is(err, io.EOF) && !errors.Is(err, ErrDisconnected) { - if publishErr := c.publish(ErrorEvent{ - Meta: c.nextHeader(time.Now(), 0, nil), - Message: reason, - Err: err, - }); publishErr != nil { - c.finish(publishErr) - return - } + case request := <-c.external: + if err := c.handleCommand(request); err != nil { + c.terminate(err) + return } - c.finish(err) + case err := <-c.fatal: + c.terminate(err) + return + case now := <-ticks: + if err := c.onTick(now); err != nil { + c.terminate(err) + return + } + case <-c.sourceCtx.Done(): + c.terminate(ErrClosed) return } + } +} - if observation.ObservedAt.IsZero() { - observation.ObservedAt = time.Now() +func (c *Controller) readLoop() { + defer func() { + if recovered := recover(); recovered != nil { + c.requestFatal(fmt.Errorf("%w: input source read: %v", ErrCallbackPanic, recovered)) } - if observation.Gap != nil { - if err := c.publish(GapEvent{ - Meta: c.nextHeader(observation.ObservedAt, observation.DeviceTimestamp, nil), - Source: c.descriptor.Backend, - Dropped: observation.Gap.Dropped, - Reason: observation.Gap.Reason, - }); err != nil { - c.finish(err) - return - } + }() + for { + observation, err := c.source.Read(c.sourceCtx) + result := sourceResult{ + observation: observation, + receivedAt: c.clock.Now(), + err: err, + } + select { + case c.ingest <- result: + case <-c.sourceCtx.Done(): + return + default: + c.requestFatal(ErrPipelineOverflow) + return + } + if err != nil { + return + } + } +} + +func (c *Controller) handleObservation(observation Observation, receivedAt time.Time) error { + if err := c.checkClock(receivedAt); err != nil { + return err + } + if observation.ObservedAt.IsZero() { + observation.ObservedAt = receivedAt + } + state, changed := sanitizeState(observation.State) + if changed { + invalid := ErrorEvent{ + Meta: c.nextHeaderAt( + "input", + observation.ObservedAt, + receivedAt, + observation.DeviceTimestamp, + nil, + true, + ), + Message: ErrInvalidState.Error() + ": non-finite or out-of-range values were neutralized", + Err: ErrInvalidState, + } + if err := c.publish(invalid); err != nil { + return err + } + } + if observation.Gap != nil { + if err := c.publish(GapEvent{ + Meta: c.nextHeaderAt( + "input", + observation.ObservedAt, + receivedAt, + observation.DeviceTimestamp, + nil, + false, + ), + Source: c.descriptor.Backend, + Dropped: observation.Gap.Dropped, + Reason: observation.Gap.Reason, + }); err != nil { + return err + } + } + + c.stateMu.Lock() + previous := c.state.Clone() + c.stateMu.Unlock() + + observationEvent := ObservationEvent{ + Meta: c.nextHeaderAt( + "input", + observation.ObservedAt, + receivedAt, + observation.DeviceTimestamp, + nil, + false, + ), + Native: observation.Native.clone(), + Previous: previous, + Current: state, + } + + // Commit the state and its metadata together, before publishing. Updating + // state first and metadata after the publish loop leaves a window in which + // a snapshot returns new state alongside stale freshness metadata, which a + // consumer enforcing a command timeout reads as "no input has ever + // arrived". That fails safe, but it trips a gate for no reason, and a gate + // that trips spuriously is one operators learn to work around. + c.stateMu.Lock() + c.state = state.Clone() + c.meta = StateMeta{ + ObservedAt: observation.ObservedAt, + ReceivedAt: receivedAt, + PublishedAt: observationEvent.Meta.PublishedAt, + ReceivedMonotonic: observationEvent.Meta.ReceivedMonotonic, + Sequence: observationEvent.Meta.ID.Sequence, + Connected: true, + } + c.stateMu.Unlock() + + if err := c.publish(observationEvent); err != nil { + return err + } + cause := []EventID{observationEvent.Meta.ID} + for _, event := range diffEvents(previous, state, func() Header { + return c.nextHeaderAt( + "input", + observation.ObservedAt, + receivedAt, + observation.DeviceTimestamp, + cause, + false, + ) + }) { + if err := c.publish(event); err != nil { + return err + } + } + + return nil +} + +func (c *Controller) onTick(now time.Time) error { + if err := c.checkClock(now); err != nil { + return err + } + c.stateMu.RLock() + meta := c.meta + c.stateMu.RUnlock() + if meta.ReceivedAt.IsZero() { + meta.ReceivedAt = c.startedAt + } + age := now.Sub(meta.ReceivedAt) + if age < 0 { + age = 0 + } + stale := c.options.staleAfter > 0 && age >= c.options.staleAfter + if stale && !meta.Stale && c.options.neutralizeOnStale { + if err := c.neutralize(now, "input stale"); err != nil { + return err + } + } + if c.options.livenessInterval > 0 && + (c.lastLiveness.IsZero() || now.Sub(c.lastLiveness) >= c.options.livenessInterval) { + status := LivenessHealthy + if stale { + status = LivenessStale + } + if err := c.publish(LivenessEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + State: status, + LastObserved: meta.ObservedAt, + LastReceived: meta.ReceivedAt, + Age: age, + }); err != nil { + return err + } + c.lastLiveness = now + } + c.stateMu.Lock() + c.meta.Stale = stale + c.stateMu.Unlock() + + for index, processor := range c.options.processors { + if err := c.advanceProcessor(index, processor, now); err != nil { + return err } + } + return nil +} - c.stateMu.Lock() - previous := c.state.Clone() - current := observation.State.Clone() - c.state = current.Clone() - c.stateMu.Unlock() +func (c *Controller) neutralize(now time.Time, reason string) error { + c.stateMu.Lock() + previous := c.state.Clone() + c.state = State{} + c.meta.Stale = true + c.meta.Synthetic = true + c.stateMu.Unlock() - observationEvent := ObservationEvent{ - Meta: c.nextHeader(observation.ObservedAt, observation.DeviceTimestamp, nil), - Native: observation.Native.clone(), - Previous: previous, - Current: current, + observation := ObservationEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + Native: NativeInput{ + Format: "teleop.synthetic." + reason, + }, + Previous: previous, + Current: State{}, + } + if err := c.publish(observation); err != nil { + return err + } + cause := []EventID{observation.Meta.ID} + for _, event := range diffEvents(previous, State{}, func() Header { + return c.nextHeaderAt("input", now, now, 0, cause, true) + }) { + if err := c.publish(event); err != nil { + return err } - if err := c.publish(observationEvent); err != nil { - c.finish(err) + } + c.stateMu.Lock() + c.meta.PublishedAt = observation.Meta.PublishedAt + c.meta.Sequence = observation.Meta.ID.Sequence + c.stateMu.Unlock() + return nil +} + +// neutralizeTerminal guarantees canonical safety events first. Terminal +// processing skips failed stages, lets healthy stages close active state, and +// never lets one failed sink suppress subscriber delivery. +func (c *Controller) neutralizeTerminal(now time.Time, reason string) { + c.stateMu.Lock() + previous := c.state.Clone() + c.state = State{} + c.meta.Stale = true + c.meta.Synthetic = true + c.stateMu.Unlock() + + observation := ObservationEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + Native: NativeInput{ + Format: "teleop.synthetic." + reason, + }, + Previous: previous, + Current: State{}, + } + c.publishTerminal(observation) + cause := []EventID{observation.Meta.ID} + for _, event := range diffEvents(previous, State{}, func() Header { + return c.nextHeaderAt("input", now, now, 0, cause, true) + }) { + c.publishTerminal(event) + } + c.stateMu.Lock() + c.meta.PublishedAt = observation.Meta.PublishedAt + c.meta.Sequence = observation.Meta.ID.Sequence + c.stateMu.Unlock() +} + +func (c *Controller) terminate(err error) { + if err == nil { + err = ErrDisconnected + } + // Publish commands the application already handed over. RecordCommand + // reports success once a command is queued, so discarding the queue here + // would silently drop a command from the record after telling the caller + // it was accepted. They are published before the terminal neutral state so + // the log keeps them in the order they were issued. + c.drainCommands() + + now := c.clock.Now() + c.neutralizeTerminal(now, "disconnect") + + if !errors.Is(err, ErrClosed) && !errors.Is(err, ErrDisconnected) && !errors.Is(err, io.EOF) { + c.publishTerminal(ErrorEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + Message: err.Error(), + Err: err, + }) + } + reason := err.Error() + c.stateMu.Lock() + c.meta.Connected = false + c.meta.Stale = true + c.meta.Synthetic = true + c.meta.PublishedAt = now + c.stateMu.Unlock() + c.publishTerminal(ConnectionEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + State: Disconnected, + Reason: reason, + Descriptor: c.Descriptor(), + }) + + c.cancelSource() + go c.closeSource() + c.finishSubscriptions(err) + deadline := time.Now().Add(c.options.shutdownTimeout) + c.addTerminalError(c.stopSinks(deadline)) + c.addTerminalError(c.waitSourceClose(deadline)) +} + +// drainCommands publishes every command already accepted into the queue. It +// uses terminal dispatch because the pipeline is shutting down and a failed +// sink must not suppress delivery of the remainder. +func (c *Controller) drainCommands() { + for { + select { + case request := <-c.external: + c.publishTerminal(CommandEvent{ + Meta: c.nextHeaderAt( + "command", + request.issuedAt, + request.issuedAt, + 0, + request.command.Causes, + false, + ), + Command: request.command.Name, + Payload: request.payload, + Authorized: request.command.Authorized, + Reason: request.command.Reason, + }) + default: return } - cause := []EventID{observationEvent.Meta.ID} - for _, event := range diffEvents( - previous, - current, - func() Header { - return c.nextHeader(observation.ObservedAt, observation.DeviceTimestamp, cause) - }, - ) { - if err := c.publish(event); err != nil { - c.finish(err) - return - } + } +} + +func normalizeSourceError(err error, closing bool) error { + if closing || errors.Is(err, context.Canceled) || errors.Is(err, ErrClosed) { + return ErrClosed + } + if errors.Is(err, io.EOF) || errors.Is(err, ErrDisconnected) { + return ErrDisconnected + } + return errors.Join(ErrDisconnected, err) +} + +func (c *Controller) tickInterval() time.Duration { + interval := time.Duration(0) + if c.options.livenessInterval > 0 { + interval = c.options.livenessInterval + } + if c.options.staleAfter > 0 && + (interval == 0 || c.options.staleAfter < interval) { + interval = c.options.staleAfter + } + for _, processor := range c.options.processors { + _, contextual := processor.(ContextAdvancingProcessor) + _, legacy := processor.(AdvancingProcessor) + if (contextual || legacy) && (interval == 0 || advanceInterval < interval) { + interval = advanceInterval } } + return interval } -func (c *Controller) nextHeader(observedAt time.Time, deviceTimestamp int64, causes []EventID) Header { +func (c *Controller) nextHeaderAt( + stream string, + observedAt time.Time, + receivedAt time.Time, + deviceTimestamp int64, + causes []EventID, + synthetic bool, +) Header { c.eventMu.Lock() - defer c.eventMu.Unlock() - c.sequence++ + c.sequences[stream]++ + sequence := c.sequences[stream] + c.eventMu.Unlock() + if receivedAt.IsZero() { + receivedAt = c.clock.Now() + } + publishedAt := c.clock.Now() return Header{ ID: EventID{ Session: c.session, - Stream: "input", - Sequence: c.sequence, + Stream: stream, + Sequence: sequence, }, - DeviceID: c.descriptor.ID, - ObservedAt: observedAt, - DeviceTimestamp: deviceTimestamp, - Causes: append([]EventID(nil), causes...), + DeviceID: c.descriptor.ID, + ObservedAt: observedAt, + ReceivedAt: receivedAt, + PublishedAt: publishedAt, + Monotonic: c.clocks.since(publishedAt), + ReceivedMonotonic: c.clocks.since(receivedAt), + DeviceTimestamp: deviceTimestamp, + Causes: append([]EventID(nil), causes...), + Synthetic: synthetic, } } +// checkClock publishes a ClockEvent when the host wall clock has stepped +// relative to the monotonic clock. Callers must be the run loop: clockMonitor +// state is not concurrency safe. +func (c *Controller) checkClock(now time.Time) error { + sample := c.clocks.observe(now) + if !sample.Stepped { + return nil + } + return c.publish(ClockEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, nil, true), + Step: sample.Step, + Steps: c.clocks.stepCount(), + Reason: "host wall clock stepped relative to monotonic clock", + }) +} + +// NewHeader implements ProcessingContext. +func (c *Controller) NewHeader( + stream string, + observedAt time.Time, + deviceTimestamp int64, + causes ...EventID, +) Header { + if stream == "" { + stream = "derived" + } + return c.nextHeaderAt( + stream, + observedAt, + c.clock.Now(), + deviceTimestamp, + causes, + false, + ) +} + +// Now implements ProcessingContext. +func (c *Controller) Now() time.Time { return c.clock.Now() } + +// Monotonic returns the controller's current session-relative monotonic +// reading. Subtracting StateMeta.ReceivedMonotonic from it yields an input age +// that a wall-clock adjustment cannot falsify, which is the measurement a +// command timeout must be built on. +func (c *Controller) Monotonic() time.Duration { + return c.clocks.since(c.clock.Now()) +} + func (c *Controller) publish(event Event) error { - c.pipelineMu.Lock() - defer c.pipelineMu.Unlock() - if err := c.dispatch(event); err != nil { + if event == nil { + return nil + } + if err := c.dispatch(event, true); err != nil { return err } return c.processStages([]Event{event}, 0) } -func (c *Controller) processStages(inputs []Event, start int) error { - for _, processor := range c.options.processors[start:] { +func (c *Controller) publishTerminal(event Event) { + if event == nil || !c.dispatchTerminal(event) { + return + } + visible := []Event{event} + for index, processor := range c.options.processors { + if c.processorDisabled[index] { + continue + } + stageInputs := append([]Event(nil), visible...) var derived []Event - for _, input := range inputs { - derived = append(derived, processor.Process(input)...) + failed := false + for _, input := range stageInputs { + output, err := c.callProcessor(processor, input) + if err != nil { + c.processorDisabled[index] = true + failed = true + break + } + derived = append(derived, output...) + } + if failed { + continue } for _, output := range derived { - if err := c.dispatch(output); err != nil { - return err + if output == nil { + continue + } + if !c.dispatchTerminal(output) { + c.processorDisabled[index] = true + break } + visible = append(visible, output) } - inputs = append(inputs, derived...) } - return nil } -func (c *Controller) advanceLoop(done chan<- struct{}) { - defer close(done) - ticker := time.NewTicker(25 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-c.ctx.Done(): - return - case now := <-ticker.C: - for index, processor := range c.options.processors { - advancing, ok := processor.(AdvancingProcessor) - if !ok { - continue - } - c.pipelineMu.Lock() - derived := advancing.Advance(now) - if len(derived) == 0 { - c.pipelineMu.Unlock() - continue - } - var err error - for _, event := range derived { - if dispatchErr := c.dispatch(event); dispatchErr != nil { - err = dispatchErr - break - } - } - if err == nil { - err = c.processStages(derived, index+1) - } - c.pipelineMu.Unlock() - if err != nil { - c.setBackgroundError(err) - c.cancel() - _ = c.source.Close() - return +func (c *Controller) dispatchTerminal(event Event) (completed bool) { + defer func() { + if recover() != nil { + completed = false + } + }() + _ = c.dispatch(event, false) + return true +} + +func (c *Controller) processStages(inputs []Event, start int) error { + visible := append([]Event(nil), inputs...) + for index := start; index < len(c.options.processors); index++ { + if c.processorDisabled[index] { + continue + } + processor := c.options.processors[index] + stageInputs := append([]Event(nil), visible...) + derived := make([]Event, 0, len(stageInputs)) + for _, input := range stageInputs { + output, err := c.callProcessor(processor, input) + if err != nil { + c.processorDisabled[index] = true + return err + } + derived = append(derived, output...) + } + for _, output := range derived { + if output == nil { + continue + } + if panicked, err := c.dispatchProcessorOutput(output, true); err != nil { + if panicked { + c.processorDisabled[index] = true } + return err } + visible = append(visible, output) } } + return nil } -func (c *Controller) setBackgroundError(err error) { - c.backgroundMu.Lock() - if c.backgroundErr == nil { - c.backgroundErr = err +func (c *Controller) advanceProcessor(index int, processor Processor, now time.Time) error { + if c.processorDisabled[index] { + return nil + } + var ( + derived []Event + err error + ) + if contextual, ok := processor.(ContextAdvancingProcessor); ok { + derived, err = callGuarded(c, func(ctx context.Context) ([]Event, error) { + return contextual.AdvanceContext(ctx, c, now) + }) + } else if advancing, ok := processor.(AdvancingProcessor); ok { + derived, err = callGuarded(c, func(context.Context) ([]Event, error) { + return advancing.Advance(now), nil + }) + } else { + return nil + } + if err != nil { + c.processorDisabled[index] = true + return err } - c.backgroundMu.Unlock() + for _, event := range derived { + if event == nil { + continue + } + if panicked, err := c.dispatchProcessorOutput(event, true); err != nil { + if panicked { + c.processorDisabled[index] = true + } + return err + } + } + return c.processStages(derived, index+1) } -func (c *Controller) getBackgroundError() error { - c.backgroundMu.Lock() - defer c.backgroundMu.Unlock() - return c.backgroundErr +func (c *Controller) dispatchProcessorOutput( + event Event, + reportLoss bool, +) (panicked bool, err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("%w: processor event: %v", ErrCallbackPanic, recovered) + panicked = true + } + }() + return false, c.dispatch(event, reportLoss) } -func (c *Controller) dispatch(event Event) error { - for _, sink := range c.options.sinks { - if err := sink.Record(context.WithoutCancel(c.ctx), event); err != nil { - return fmt.Errorf("record controller event: %w", err) +func (c *Controller) callProcessor(processor Processor, event Event) ([]Event, error) { + return callGuarded(c, func(ctx context.Context) ([]Event, error) { + if contextual, ok := processor.(ContextProcessor); ok { + return contextual.ProcessContext(ctx, c, cloneEvent(event)) } + return processor.Process(cloneEvent(event)), nil + }) +} + +func callGuarded( + c *Controller, + callback func(context.Context) ([]Event, error), +) (events []Event, err error) { + ctx, cancel := context.WithTimeout(c.pipelineCtx, c.options.callbackTimeout) + defer cancel() + result := make(chan struct { + events []Event + err error + }, 1) + go func() { + var outcome struct { + events []Event + err error + } + defer func() { + if recovered := recover(); recovered != nil { + outcome.err = fmt.Errorf("%w: %v", ErrCallbackPanic, recovered) + } + result <- outcome + }() + outcome.events, outcome.err = callback(ctx) + }() + select { + case outcome := <-result: + return outcome.events, outcome.err + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, ErrCallbackTimeout + } + return nil, ErrClosed + } +} + +func (c *Controller) dispatch(event Event, reportLoss bool) error { + dispatchErr := c.enqueueSinks(event) + if dispatchErr == nil { + // Record identity before subscribers can observe the event. This makes a + // command causally valid exactly when its caller can have received the + // event ID, without retaining mutable event payloads in the controller. + c.knownMu.Lock() + c.known[event.Header().ID] = struct{}{} + c.knownMu.Unlock() } c.subsMu.Lock() + switch event.Kind() { + case EventConnection: + c.connection = cloneEvent(event) + case EventCapabilities: + c.capabilities = cloneEvent(event) + case EventObservation: + c.observation = cloneEvent(event) + } subscribers := make([]*eventSubscription, 0, len(c.subscribers)) for subscriber := range c.subscribers { subscribers = append(subscribers, subscriber) } c.subsMu.Unlock() + + var latestDropped, losslessDropped uint64 + overflowed := make([]*eventSubscription, 0) for _, subscriber := range subscribers { - subscriber.enqueue(event) + lost, overflow := subscriber.enqueue(cloneEvent(event)) + if overflow { + losslessDropped += lost + overflowed = append(overflowed, subscriber) + } else { + latestDropped += lost + } } - return nil + if len(overflowed) > 0 { + c.subsMu.Lock() + for _, subscriber := range overflowed { + delete(c.subscribers, subscriber) + } + c.subsMu.Unlock() + } + if reportLoss { + if latestDropped > 0 { + dispatchErr = errors.Join( + dispatchErr, + c.recordSubscriptionGap( + event, + latestDropped, + "DeliveryLatest coalesced a full queue", + ), + ) + } + if losslessDropped > 0 { + dispatchErr = errors.Join( + dispatchErr, + c.recordSubscriptionGap( + event, + losslessDropped, + "DeliveryLossless subscription overflowed", + ), + ) + } + } + return dispatchErr } -func (c *Controller) finish(err error) { +func (c *Controller) enqueueSinks(event Event) error { + var result error + for _, sink := range c.sinks { + if err := sink.enqueue(cloneEvent(event)); err != nil { + result = errors.Join(result, err) + } + } + return result +} + +func (c *Controller) recordSubscriptionGap( + cause Event, + dropped uint64, + reason string, +) error { + now := c.clock.Now() + return c.enqueueSinks(GapEvent{ + Meta: c.nextHeaderAt("input", now, now, 0, []EventID{cause.Header().ID}, true), + Source: "subscription", + Dropped: dropped, + Reason: reason, + }) +} + +func (c *Controller) requestFatal(err error) { + if err == nil { + return + } + select { + case c.fatal <- err: + default: + } +} + +func (c *Controller) finishSubscriptions(err error) { c.subsMu.Lock() if c.terminalErr == nil { c.terminalErr = err @@ -392,72 +1103,272 @@ func (c *Controller) finish(err error) { } } +func (c *Controller) addTerminalError(err error) { + if err == nil { + return + } + c.subsMu.Lock() + c.terminalErr = errors.Join(c.terminalErr, err) + c.subsMu.Unlock() +} + func (c *Controller) removeSubscription(subscription *eventSubscription) { c.subsMu.Lock() delete(c.subscribers, subscription) c.subsMu.Unlock() } +func (c *Controller) closeSource() { + c.sourceCloseOnce.Do(func() { + err := func() (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("%w: input source close: %v", ErrCallbackPanic, recovered) + } + }() + return c.source.Close() + }() + c.sourceCloseMu.Lock() + c.sourceCloseErr = err + c.sourceCloseMu.Unlock() + close(c.sourceClosed) + }) +} + +func (c *Controller) stopSinks(deadline time.Time) error { + for _, sink := range c.sinks { + sink.stop() + } + var result error + for _, sink := range c.sinks { + remaining := time.Until(deadline) + if remaining <= 0 { + result = errors.Join(result, fmt.Errorf("%w: drain event sinks", ErrCallbackTimeout)) + break + } + timer := time.NewTimer(remaining) + select { + case <-sink.done: + case <-timer.C: + result = errors.Join(result, fmt.Errorf("%w: drain event sinks", ErrCallbackTimeout)) + return result + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + result = errors.Join(result, sink.Err()) + } + return result +} + +func (c *Controller) waitSourceClose(deadline time.Time) error { + remaining := time.Until(deadline) + if remaining <= 0 { + return fmt.Errorf("%w: close input source", ErrCallbackTimeout) + } + timer := time.NewTimer(remaining) + defer timer.Stop() + select { + case <-c.sourceClosed: + c.sourceCloseMu.Lock() + defer c.sourceCloseMu.Unlock() + return c.sourceCloseErr + case <-timer.C: + return fmt.Errorf("%w: close input source", ErrCallbackTimeout) + } +} + +// Close requests a neutralizing disconnect and waits for bounded cleanup. It +// returns the terminal pipeline or source-close error when one occurred. func (c *Controller) Close() error { c.closeOnce.Do(func() { - c.cancel() - _ = c.source.Close() c.start() - <-c.done + c.cancelSource() + go c.closeSource() }) + timer := time.NewTimer(c.options.callbackTimeout + c.options.shutdownTimeout) + defer timer.Stop() + select { + case <-c.done: + case <-timer.C: + return ErrCallbackTimeout + } + + err := c.Err() + if err != nil && err != ErrClosed { + return err + } return nil } +type sinkRunner struct { + controller *Controller + sink EventSink + queue chan Event + done chan struct{} + stopOnce sync.Once + + mu sync.Mutex + err error +} + +func newSinkRunner(controller *Controller, sink EventSink, buffer int) *sinkRunner { + return &sinkRunner{ + controller: controller, + sink: sink, + queue: make(chan Event, buffer), + done: make(chan struct{}), + } +} + +func (runner *sinkRunner) run() { + defer close(runner.done) + for event := range runner.queue { + if err := runner.record(event); err != nil { + runner.mu.Lock() + runner.err = err + runner.mu.Unlock() + runner.controller.requestFatal(fmt.Errorf("record controller event: %w", err)) + return + } + } +} + +func (runner *sinkRunner) record(event Event) error { + ctx, cancel := context.WithTimeout( + runner.controller.pipelineCtx, + runner.controller.options.callbackTimeout, + ) + defer cancel() + result := make(chan error, 1) + go func() { + var err error + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("%w: %v", ErrCallbackPanic, recovered) + } + result <- err + }() + err = runner.sink.Record(ctx, event) + }() + select { + case err := <-result: + return err + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return ErrCallbackTimeout + } + return ErrClosed + } +} + +func (runner *sinkRunner) enqueue(event Event) error { + runner.mu.Lock() + err := runner.err + runner.mu.Unlock() + if err != nil { + return err + } + select { + case runner.queue <- event: + return nil + default: + return ErrPipelineOverflow + } +} + +func (runner *sinkRunner) stop() { + runner.stopOnce.Do(func() { close(runner.queue) }) +} + +func (runner *sinkRunner) Err() error { + runner.mu.Lock() + defer runner.mu.Unlock() + return runner.err +} + type eventSubscription struct { controller *Controller policy DeliveryPolicy limit int - notify chan struct{} - mu sync.Mutex - queue []Event - closed bool - err error + mu sync.Mutex + queue []Event + head int + size int + notify chan struct{} + closed bool + err error + enqueued uint64 + delivered uint64 + dropped uint64 +} + +func newEventSubscription( + controller *Controller, + policy DeliveryPolicy, + limit int, +) *eventSubscription { + return &eventSubscription{ + controller: controller, + policy: policy, + limit: limit, + queue: make([]Event, limit), + notify: make(chan struct{}), + } } -func (s *eventSubscription) enqueue(event Event) { +func (s *eventSubscription) enqueue(event Event) (dropped uint64, fatal bool) { s.mu.Lock() defer s.mu.Unlock() if s.closed { - return + return 0, false } - if len(s.queue) >= s.limit { + if s.size == s.limit { if s.policy == DeliveryLatest { - copy(s.queue, s.queue[1:]) - s.queue[len(s.queue)-1] = event - s.signal() - return + dropped = uint64(s.size) + s.dropped += dropped + for index := range s.queue { + s.queue[index] = nil + } + s.head = 0 + s.size = 0 + } else { + s.closed = true + s.err = ErrSubscriptionOverflow + s.dropped++ + s.broadcastLocked() + return 1, true } - s.closed = true - s.err = ErrSubscriptionOverflow - s.queue = nil - s.signal() - go s.controller.removeSubscription(s) - return } - s.queue = append(s.queue, event) - s.signal() + index := (s.head + s.size) % s.limit + s.queue[index] = event + s.size++ + s.enqueued++ + s.broadcastLocked() + return dropped, false } -func (s *eventSubscription) signal() { - select { - case s.notify <- struct{}{}: - default: - } +func (s *eventSubscription) broadcastLocked() { + close(s.notify) + s.notify = make(chan struct{}) } func (s *eventSubscription) Next(ctx context.Context) (Event, error) { for { + if err := ctx.Err(); err != nil { + return nil, err + } s.mu.Lock() - if len(s.queue) > 0 { - event := s.queue[0] - copy(s.queue, s.queue[1:]) - s.queue = s.queue[:len(s.queue)-1] + if s.size > 0 { + event := s.queue[s.head] + s.queue[s.head] = nil + s.head = (s.head + 1) % s.limit + s.size-- + s.delivered++ s.mu.Unlock() return event, nil } @@ -469,24 +1380,38 @@ func (s *eventSubscription) Next(ctx context.Context) (Event, error) { s.mu.Unlock() return nil, err } + notify := s.notify s.mu.Unlock() select { case <-ctx.Done(): return nil, ctx.Err() - case <-s.notify: + case <-notify: } } } +func (s *eventSubscription) Stats() SubscriptionStats { + s.mu.Lock() + defer s.mu.Unlock() + return SubscriptionStats{ + Enqueued: s.enqueued, + Delivered: s.delivered, + Dropped: s.dropped, + Buffered: s.size, + Closed: s.closed, + Err: s.err, + } +} + func (s *eventSubscription) terminate(err error) { s.mu.Lock() if !s.closed { s.closed = true s.err = err } + s.broadcastLocked() s.mu.Unlock() - s.signal() } func (s *eventSubscription) Close() error { @@ -494,3 +1419,32 @@ func (s *eventSubscription) Close() error { s.controller.removeSubscription(s) return nil } + +func sanitizeState(state State) (State, bool) { + state = state.Clone() + changed := false + sanitize := func(value, minimum, maximum float32) float32 { + if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { + changed = true + return 0 + } + clamped := Clamp(value, minimum, maximum) + if clamped != value { + changed = true + } + return clamped + } + state.LeftStick.X = sanitize(state.LeftStick.X, -1, 1) + state.LeftStick.Y = sanitize(state.LeftStick.Y, -1, 1) + state.RightStick.X = sanitize(state.RightStick.X, -1, 1) + state.RightStick.Y = sanitize(state.RightStick.Y, -1, 1) + state.LeftTrigger = sanitize(state.LeftTrigger, 0, 1) + state.RightTrigger = sanitize(state.RightTrigger, 0, 1) + for id, pressed := range state.Buttons.Extensions { + if !pressed { + delete(state.Buttons.Extensions, id) + changed = true + } + } + return state, changed +} diff --git a/controller_remediation_test.go b/controller_remediation_test.go new file mode 100644 index 0000000..28dcfe6 --- /dev/null +++ b/controller_remediation_test.go @@ -0,0 +1,892 @@ +package teleop_test + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/open-ships/teleop" + "github.com/open-ships/teleop/gesture" + "github.com/open-ships/teleop/testkit" +) + +func TestDisconnectNeutralizesStateAndClosesControlEdges(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "disconnect"}, 8) + controller, err := teleop.NewController(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + + active := teleop.State{ + LeftStick: teleop.Stick{Y: 1}, + RightTrigger: 1, + } + active.SetButton(teleop.ButtonFaceSouth, true) + if err := source.Push(context.Background(), active); err != nil { + t.Fatal(err) + } + waitForSnapshot(t, controller, func(state teleop.State, _ teleop.StateMeta) bool { + return state.LeftStick.Y == 1 && + state.RightTrigger == 1 && + state.Button(teleop.ButtonFaceSouth) + }) + if err := source.Close(); err != nil { + t.Fatal(err) + } + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not terminate after disconnect") + } + + state, meta := controller.SnapshotWithMeta() + if !reflect.DeepEqual(state, teleop.State{}) { + t.Fatalf("snapshot after disconnect = %#v, want neutral", state) + } + if meta.Connected || !meta.Stale || !meta.Synthetic { + t.Fatalf("metadata after disconnect = %#v", meta) + } + if !errors.Is(controller.Err(), teleop.ErrDisconnected) { + t.Fatalf("terminal error = %v, want ErrDisconnected", controller.Err()) + } + + var ( + releasedButton bool + centeredStick bool + releasedTrigger bool + disconnected bool + ) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, nextErr := subscription.Next(ctx) + if nextErr != nil { + if !errors.Is(nextErr, teleop.ErrDisconnected) { + t.Fatalf("subscription error = %v", nextErr) + } + break + } + switch value := event.(type) { + case teleop.ButtonEvent: + releasedButton = releasedButton || + value.Button == teleop.ButtonFaceSouth && + value.Phase == teleop.PhaseReleased + case teleop.StickEvent: + centeredStick = centeredStick || + value.Stick == teleop.LeftStick && + value.Position == (teleop.Stick{}) + case teleop.TriggerEvent: + releasedTrigger = releasedTrigger || + value.Trigger == teleop.RightTrigger && + value.Position == 0 + case teleop.ConnectionEvent: + disconnected = disconnected || value.State == teleop.Disconnected + } + } + if !releasedButton || !centeredStick || !releasedTrigger || !disconnected { + t.Fatalf( + "closing events: button=%t stick=%t trigger=%t disconnected=%t", + releasedButton, + centeredStick, + releasedTrigger, + disconnected, + ) + } +} + +func TestControllerContextOwnsSessionLifetime(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + source := testkit.NewFakeSource(teleop.Descriptor{ID: "context-lifetime"}, 4) + controller, err := teleop.NewController(source, teleop.WithContext(ctx)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + cancel() + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller remained open after its context was canceled") + } + if !errors.Is(controller.Err(), teleop.ErrClosed) { + t.Fatalf("terminal error = %v, want ErrClosed", controller.Err()) + } +} + +func TestDeferredStartAttachesBeforeFiniteReplayBegins(t *testing.T) { + source := testkit.NewReplaySource( + teleop.Descriptor{ID: "replay-deferred"}, + []teleop.Observation{ + {State: teleop.State{LeftTrigger: 0.25}, ObservedAt: time.Unix(1, 0)}, + {State: teleop.State{LeftTrigger: 0.75}, ObservedAt: time.Unix(2, 0)}, + }, + ) + controller, err := teleop.NewController(source, teleop.WithDeferredStart()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + select { + case <-controller.Done(): + t.Fatal("deferred controller started before a consumer attached") + case <-time.After(15 * time.Millisecond): + } + + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 32}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + observations := 0 + for { + event, err := subscription.Next(ctx) + if err != nil { + if !errors.Is(err, teleop.ErrDisconnected) { + t.Fatal(err) + } + break + } + if observation, ok := event.(teleop.ObservationEvent); ok && + !observation.Header().Synthetic { + observations++ + } + } + if observations != 2 { + t.Fatalf("replayed observations = %d, want 2", observations) + } +} + +func TestDisconnectLetsHealthyProcessorsCloseActiveGestures(t *testing.T) { + config := gesture.DefaultConfig() + config.TapMaximum = 10 * time.Millisecond + config.HoldMinimum = 10 * time.Millisecond + source := testkit.NewFakeSource(teleop.Descriptor{ID: "gesture-disconnect"}, 8) + controller, err := teleop.NewController( + source, + teleop.WithProcessor(gesture.New(config)), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + state := teleop.State{} + state.SetButton(teleop.ButtonFaceSouth, true) + if err := source.Push(context.Background(), state); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + recognized, ok := event.(gesture.Event) + if ok && recognized.Type == gesture.Hold && + recognized.Phase == teleop.PhaseStarted { + break + } + } + if err := source.Close(); err != nil { + t.Fatal(err) + } + + foundEnded := false + for { + event, err := subscription.Next(ctx) + if err != nil { + if !errors.Is(err, teleop.ErrDisconnected) { + t.Fatal(err) + } + break + } + recognized, ok := event.(gesture.Event) + foundEnded = foundEnded || ok && + recognized.Type == gesture.Hold && + recognized.Phase == teleop.PhaseEnded + } + if !foundEnded { + t.Fatal("active hold did not end during disconnect neutralization") + } +} + +func TestSubscriptionCloseBroadcastsToAllBlockedReaders(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "broadcast"}, 4) + controller, err := teleop.NewController(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + waitForSnapshot(t, controller, func(_ teleop.State, meta teleop.StateMeta) bool { + return meta.Connected + }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 16}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for index := 0; index < 2; index++ { + if _, err := subscription.Next(ctx); err != nil { + t.Fatal(err) + } + } + + started := make(chan struct{}, 4) + finished := make(chan error, 4) + for index := 0; index < 4; index++ { + go func() { + started <- struct{}{} + _, err := subscription.Next(context.Background()) + finished <- err + }() + } + for index := 0; index < 4; index++ { + <-started + } + time.Sleep(10 * time.Millisecond) + if err := subscription.Close(); err != nil { + t.Fatal(err) + } + for index := 0; index < 4; index++ { + select { + case err := <-finished: + if !errors.Is(err, teleop.ErrClosed) { + t.Fatalf("reader error = %v, want ErrClosed", err) + } + case <-time.After(250 * time.Millisecond): + t.Fatalf("reader %d remained blocked after subscription close", index) + } + } +} + +func TestLosslessOverflowPreservesBufferedPrefix(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "overflow-prefix"}, 4) + controller, err := teleop.NewController(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + waitForSnapshot(t, controller, func(_ teleop.State, meta teleop.StateMeta) bool { + return meta.Connected + }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{ + Delivery: teleop.DeliveryLossless, + Buffer: 3, + }) + if err != nil { + t.Fatal(err) + } + state := teleop.State{} + state.SetButton(teleop.ButtonFaceSouth, true) + if err := source.Push(context.Background(), state); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for !subscription.Stats().Closed && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !subscription.Stats().Closed { + t.Fatal("lossless subscription did not close on overflow") + } + select { + case <-controller.Done(): + t.Fatalf("subscription overflow terminated controller: %v", controller.Err()) + default: + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + kinds := make([]teleop.EventKind, 0, 3) + for { + event, nextErr := subscription.Next(ctx) + if nextErr != nil { + if !errors.Is(nextErr, teleop.ErrSubscriptionOverflow) { + t.Fatalf("subscription error = %v", nextErr) + } + break + } + kinds = append(kinds, event.Kind()) + } + want := []teleop.EventKind{ + teleop.EventConnection, + teleop.EventCapabilities, + teleop.EventObservation, + } + if len(kinds) != len(want) { + t.Fatalf("buffered kinds = %v, want %v", kinds, want) + } + for index := range want { + if kinds[index] != want[index] { + t.Fatalf("buffered kinds = %v, want %v", kinds, want) + } + } +} + +func TestLatestOverflowRetainsRealLatestEventAndAuditsLoss(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "latest-overflow"}, 4) + audited := make(chan teleop.Event, 32) + controller, err := teleop.NewController( + source, + teleop.WithAuditSink(channelSink{events: audited}), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + waitForSnapshot(t, controller, func(_ teleop.State, meta teleop.StateMeta) bool { + return meta.Connected + }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{ + Delivery: teleop.DeliveryLatest, + Buffer: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := source.Push(context.Background(), teleop.State{LeftTrigger: 1}); err != nil { + t.Fatal(err) + } + waitForSnapshot(t, controller, func(state teleop.State, _ teleop.StateMeta) bool { + return state.LeftTrigger == 1 + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := event.(teleop.GapEvent); ok { + t.Fatalf("subscription diagnostic displaced the real latest event: %#v", event) + } + trigger, ok := event.(teleop.TriggerEvent) + if ok && trigger.Trigger == teleop.LeftTrigger && trigger.Position == 1 { + break + } + } + if subscription.Stats().Dropped == 0 { + t.Fatal("latest subscription did not account for coalesced events") + } + + for { + select { + case event := <-audited: + if gap, ok := event.(teleop.GapEvent); ok && + gap.Source == "subscription" && + gap.Dropped > 0 { + return + } + case <-ctx.Done(): + t.Fatal("subscription loss was not recorded to the audit sink") + } + } +} + +type panicOnObservation struct{} + +func (panicOnObservation) Process(event teleop.Event) []teleop.Event { + if event.Kind() == teleop.EventObservation { + panic("processor bug") + } + return nil +} + +type blockingObservationProcessor struct { + started chan struct{} + release chan struct{} + once sync.Once + + mu sync.Mutex + observationCalls int +} + +func (processor *blockingObservationProcessor) Process(event teleop.Event) []teleop.Event { + if event.Kind() != teleop.EventObservation { + return nil + } + processor.mu.Lock() + processor.observationCalls++ + processor.mu.Unlock() + processor.once.Do(func() { close(processor.started) }) + <-processor.release + return nil +} + +func (processor *blockingObservationProcessor) calls() int { + processor.mu.Lock() + defer processor.mu.Unlock() + return processor.observationCalls +} + +type panicSource struct{} + +func (panicSource) Descriptor() teleop.Descriptor { + return teleop.Descriptor{ID: "source-panic"} +} + +func (panicSource) Read(context.Context) (teleop.Observation, error) { + panic("backend bug") +} + +func (panicSource) Close() error { return nil } + +var errSourceClose = errors.New("source close failed") + +type closeErrorSource struct{} + +func (closeErrorSource) Descriptor() teleop.Descriptor { + return teleop.Descriptor{ID: "source-close-error"} +} + +func (closeErrorSource) Read(ctx context.Context) (teleop.Observation, error) { + <-ctx.Done() + return teleop.Observation{}, ctx.Err() +} + +func (closeErrorSource) Close() error { return errSourceClose } + +type readErrorSource struct{ err error } + +func (readErrorSource) Descriptor() teleop.Descriptor { + return teleop.Descriptor{ID: "source-read-error"} +} + +func (source readErrorSource) Read(context.Context) (teleop.Observation, error) { + return teleop.Observation{}, source.err +} + +func (readErrorSource) Close() error { return nil } + +func TestInputSourcePanicBecomesObservableTerminalError(t *testing.T) { + controller, err := teleop.NewController(panicSource{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not contain input source panic") + } + if !errors.Is(controller.Err(), teleop.ErrCallbackPanic) { + t.Fatalf("terminal error = %v, want ErrCallbackPanic", controller.Err()) + } +} + +func TestCloseReportsInputSourceCloseError(t *testing.T) { + controller, err := teleop.NewController( + closeErrorSource{}, + teleop.WithShutdownTimeout(100*time.Millisecond), + ) + if err != nil { + t.Fatal(err) + } + if err := controller.Close(); !errors.Is(err, errSourceClose) { + t.Fatalf("Close error = %v, want source close error", err) + } + if !errors.Is(controller.Err(), errSourceClose) { + t.Fatalf("terminal error = %v, want source close error", controller.Err()) + } +} + +func TestReadErrorPreservesTypedCause(t *testing.T) { + controller, err := teleop.NewController(readErrorSource{err: teleop.ErrPermission}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not terminate after source error") + } + if !errors.Is(controller.Err(), teleop.ErrDisconnected) || + !errors.Is(controller.Err(), teleop.ErrPermission) { + t.Fatalf( + "terminal error = %v, want ErrDisconnected and ErrPermission", + controller.Err(), + ) + } +} + +func TestProcessorPanicBecomesObservableTerminalError(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "processor-panic"}, 4) + controller, err := teleop.NewController( + source, + teleop.WithProcessor(panicOnObservation{}), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + if err := source.Push(context.Background(), teleop.State{RightTrigger: 1}); err != nil { + t.Fatal(err) + } + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not contain processor panic") + } + if !errors.Is(controller.Err(), teleop.ErrCallbackPanic) { + t.Fatalf("terminal error = %v, want ErrCallbackPanic", controller.Err()) + } + + var foundError, foundDisconnect, foundNeutralTrigger bool + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, nextErr := subscription.Next(ctx) + if nextErr != nil { + break + } + switch value := event.(type) { + case teleop.ErrorEvent: + foundError = foundError || errors.Is(value.Err, teleop.ErrCallbackPanic) + case teleop.ConnectionEvent: + foundDisconnect = foundDisconnect || value.State == teleop.Disconnected + case teleop.TriggerEvent: + foundNeutralTrigger = foundNeutralTrigger || + value.Trigger == teleop.RightTrigger && value.Position == 0 + } + } + if !foundError || !foundDisconnect || !foundNeutralTrigger { + t.Fatalf( + "terminal events: error=%t disconnect=%t neutral=%t", + foundError, + foundDisconnect, + foundNeutralTrigger, + ) + } +} + +func TestTimedOutProcessorIsNotReenteredDuringTermination(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "processor-timeout"}, 4) + processor := &blockingObservationProcessor{ + started: make(chan struct{}), + release: make(chan struct{}), + } + defer close(processor.release) + controller, err := teleop.NewController( + source, + teleop.WithProcessor(processor), + teleop.WithCallbackTimeout(20*time.Millisecond), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + if err := source.Push(context.Background(), teleop.State{}); err != nil { + t.Fatal(err) + } + select { + case <-processor.started: + case <-time.After(time.Second): + t.Fatal("processor callback did not start") + } + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not terminate after processor timeout") + } + if !errors.Is(controller.Err(), teleop.ErrCallbackTimeout) { + t.Fatalf("terminal error = %v, want ErrCallbackTimeout", controller.Err()) + } + if calls := processor.calls(); calls != 1 { + t.Fatalf("timed-out processor observation calls = %d, want 1", calls) + } +} + +type delayedSink struct { + delay time.Duration + + mu sync.Mutex + count int +} + +func (sink *delayedSink) Record(context.Context, teleop.Event) error { + time.Sleep(sink.delay) + sink.mu.Lock() + sink.count++ + sink.mu.Unlock() + return nil +} + +func TestSlowSinkDoesNotDelayCanonicalSnapshot(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "async-sink"}, 16) + sink := &delayedSink{delay: 20 * time.Millisecond} + controller, err := teleop.NewController(source, teleop.WithAuditSink(sink)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + waitForSnapshot(t, controller, func(_ teleop.State, meta teleop.StateMeta) bool { + return meta.Connected + }) + for index := 0; index < 8; index++ { + if err := source.Push(context.Background(), teleop.State{ + LeftTrigger: float32(index) / 7, + }); err != nil { + t.Fatal(err) + } + } + deadline := time.Now().Add(150 * time.Millisecond) + for time.Now().Before(deadline) { + if controller.Snapshot().LeftTrigger == 1 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("snapshot remained at %f while a slow sink drained", controller.Snapshot().LeftTrigger) +} + +type channelSink struct { + events chan teleop.Event +} + +func (sink channelSink) Record(ctx context.Context, event teleop.Event) error { + select { + case sink.events <- event: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestControllerStartsAttachedSinkWithoutSnapshotOrSubscription(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "eager-audit"}, 4) + events := make(chan teleop.Event, 8) + controller, err := teleop.NewController( + source, + teleop.WithAuditSink(channelSink{events: events}), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + for _, want := range []teleop.EventKind{ + teleop.EventConnection, + teleop.EventCapabilities, + } { + select { + case event := <-events: + if event.Kind() != want { + t.Fatalf("sink event kind = %s, want %s", event.Kind(), want) + } + case <-time.After(time.Second): + t.Fatalf("sink did not receive %s without an explicit start operation", want) + } + } +} + +func TestLivenessMarksAndNeutralizesStaleInput(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "liveness"}, 4) + controller, err := teleop.NewController( + source, + teleop.WithLiveness(5*time.Millisecond, 20*time.Millisecond), + teleop.WithNeutralizeOnStale(true), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + liveness, ok := event.(teleop.LivenessEvent) + if ok && liveness.State == teleop.LivenessStale { + break + } + } + state, meta := controller.SnapshotWithMeta() + if !reflect.DeepEqual(state, teleop.State{}) || !meta.Stale || !meta.Synthetic { + t.Fatalf("stale snapshot = %#v, %#v", state, meta) + } + time.Sleep(15 * time.Millisecond) + _, meta = controller.SnapshotWithMeta() + if !meta.Stale { + t.Fatal("synthetic neutralization refreshed physical input freshness") + } +} + +func TestObservationAgeDoesNotNeutralizeWithoutExplicitOptIn(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "held-input"}, 4) + controller, err := teleop.NewController( + source, + teleop.WithLiveness(0, 20*time.Millisecond), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + state := teleop.State{} + state.SetButton(teleop.ButtonFaceSouth, true) + if err := source.Push(context.Background(), state); err != nil { + t.Fatal(err) + } + waitForSnapshot(t, controller, func(got teleop.State, _ teleop.StateMeta) bool { + return got.Button(teleop.ButtonFaceSouth) + }) + deadline := time.Now().Add(100 * time.Millisecond) + var ( + got teleop.State + meta teleop.StateMeta + ) + for time.Now().Before(deadline) { + got, meta = controller.SnapshotWithMeta() + if meta.Stale { + break + } + time.Sleep(time.Millisecond) + } + if !got.Button(teleop.ButtonFaceSouth) { + t.Fatal("observation age neutralized a healthy held control without opt-in") + } + if !meta.Stale || meta.Synthetic { + t.Fatalf("aged snapshot metadata = %#v", meta) + } +} + +type failAfterSink struct { + mu sync.Mutex + remaining int +} + +func (sink *failAfterSink) Record(context.Context, teleop.Event) error { + sink.mu.Lock() + defer sink.mu.Unlock() + if sink.remaining == 0 { + return errors.New("audit disk full") + } + sink.remaining-- + return nil +} + +func TestSinkFailureStillDeliversTerminalSafetyEvents(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "sink-failure"}, 4) + sink := &failAfterSink{remaining: 2} + controller, err := teleop.NewController(source, teleop.WithAuditSink(sink)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 64}) + if err != nil { + t.Fatal(err) + } + if err := source.Push(context.Background(), teleop.State{LeftTrigger: 1}); err != nil { + t.Fatal(err) + } + select { + case <-controller.Done(): + case <-time.After(time.Second): + t.Fatal("controller did not terminate after sink failure") + } + + var foundError, foundDisconnect, foundNeutralTrigger bool + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for { + event, nextErr := subscription.Next(ctx) + if nextErr != nil { + break + } + switch value := event.(type) { + case teleop.ErrorEvent: + foundError = foundError || value.Message != "" + case teleop.ConnectionEvent: + foundDisconnect = foundDisconnect || value.State == teleop.Disconnected + case teleop.TriggerEvent: + foundNeutralTrigger = foundNeutralTrigger || + value.Trigger == teleop.LeftTrigger && value.Position == 0 + } + } + if !foundError || !foundDisconnect || !foundNeutralTrigger { + t.Fatalf( + "sink failure terminal events: error=%t disconnect=%t neutral=%t terminal=%v", + foundError, + foundDisconnect, + foundNeutralTrigger, + controller.Err(), + ) + } +} + +func TestLateSubscriberReceivesLatchedSessionState(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{ID: "late"}, 4) + controller, err := teleop.NewController(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Close() }) + if err := source.Push(context.Background(), teleop.State{RightTrigger: 0.75}); err != nil { + t.Fatal(err) + } + waitForSnapshot(t, controller, func(state teleop.State, _ teleop.StateMeta) bool { + return state.RightTrigger == 0.75 + }) + subscription, err := controller.Subscribe(teleop.SubscriptionOptions{Buffer: 8}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for _, want := range []teleop.EventKind{ + teleop.EventConnection, + teleop.EventCapabilities, + teleop.EventObservation, + } { + event, err := subscription.Next(ctx) + if err != nil { + t.Fatal(err) + } + if event.Kind() != want { + t.Fatalf("latched event kind = %s, want %s", event.Kind(), want) + } + } +} + +func waitForSnapshot( + t *testing.T, + controller *teleop.Controller, + ready func(teleop.State, teleop.StateMeta) bool, +) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + state, meta := controller.SnapshotWithMeta() + if ready(state, meta) { + return + } + time.Sleep(time.Millisecond) + } + state, meta := controller.SnapshotWithMeta() + t.Fatalf("snapshot condition not reached: state=%#v meta=%#v", state, meta) +} diff --git a/controller_test.go b/controller_test.go index 10421bb..2f06222 100644 --- a/controller_test.go +++ b/controller_test.go @@ -67,7 +67,7 @@ func TestControllerPublishesCanonicalEventsAndAudit(t *testing.T) { button teleop.ButtonEvent stick teleop.StickEvent ) - for !(button.Button != "" && stick.Stick != "") { + for button.Button == "" || stick.Stick == "" { event, err := subscription.Next(ctx) if err != nil { t.Fatal(err) @@ -95,6 +95,9 @@ func TestControllerPublishesCanonicalEventsAndAudit(t *testing.T) { t.Fatalf("snapshot = %#v", got) } + if err := controller.Close(); err != nil { + t.Fatal(err) + } if err := recorder.Close(); err != nil { t.Fatal(err) } @@ -129,6 +132,9 @@ func TestLosslessSubscriptionFailsExplicitlyOnOverflow(t *testing.T) { // Connection and capabilities are published immediately, overflowing a // deliberately undersized subscriber before it consumes either event. + if _, err = subscription.Next(ctx); err != nil { + t.Fatalf("buffered event error = %v", err) + } _, err = subscription.Next(ctx) if !errors.Is(err, teleop.ErrSubscriptionOverflow) { t.Fatalf("Next error = %v, want %v", err, teleop.ErrSubscriptionOverflow) @@ -264,8 +270,8 @@ func TestAdvancingProcessorPublishesHoldWhileControllerIsIdle(t *testing.T) { func TestNormalization(t *testing.T) { t.Parallel() - if got := teleop.NormalizeAxis(0, -32768, 32767); got < 0 || got > 0.001 { - t.Fatalf("centered axis = %f", got) + if got := teleop.NormalizeAxis(0, -32768, 32767); got != 0 { + t.Fatalf("centered axis = %f, want exact neutral", got) } if got := teleop.NormalizeAxis(-32768, -32768, 32767); got != -1 { t.Fatalf("minimum axis = %f", got) @@ -273,6 +279,12 @@ func TestNormalization(t *testing.T) { if got := teleop.NormalizeTrigger(255, 0, 255); got != 1 { t.Fatalf("maximum trigger = %f", got) } + if got := teleop.NormalizeAxis(32767, -32768, 32767); got != 1 { + t.Fatalf("maximum signed axis = %f", got) + } + if got := teleop.NormalizeTrigger(2_147_483_647, -2_147_483_648, 2_147_483_647); got != 1 { + t.Fatalf("wide trigger maximum = %f", got) + } if got := teleop.ApplyRadialDeadZone(teleop.Stick{X: 0.05}, 0.1); got != (teleop.Stick{}) { t.Fatalf("dead-zone result = %#v", got) } diff --git a/device.go b/device.go index 4a34f41..f517e92 100644 --- a/device.go +++ b/device.go @@ -5,33 +5,57 @@ import ( "time" ) +// DeviceID is the provider-stable identity used to open a discovered device. type DeviceID string // AuditGrade states what the selected OS backend can honestly guarantee. type AuditGrade string const ( + // AuditExactBackendStream means the backend reports each exposed input + // transition rather than reconstructing changes from sampled snapshots. AuditExactBackendStream AuditGrade = "exact-backend-stream" - AuditSampledState AuditGrade = "sampled-state" - AuditUnavailable AuditGrade = "unavailable" + // AuditSampledState means the backend derives events from periodic state. + AuditSampledState AuditGrade = "sampled-state" + // AuditUnavailable means the backend makes no audit-delivery guarantee. + AuditUnavailable AuditGrade = "unavailable" ) +// Valid reports whether grade is a guarantee understood by this format. +func (grade AuditGrade) Valid() bool { + switch grade { + case AuditExactBackendStream, AuditSampledState, AuditUnavailable: + return true + default: + return false + } +} + +// ControlKind classifies a discovered physical control. type ControlKind string const ( - ControlButton ControlKind = "button" - ControlStick ControlKind = "stick" + // ControlButton identifies a digital button. + ControlButton ControlKind = "button" + // ControlStick identifies a two-axis stick. + ControlStick ControlKind = "stick" + // ControlTrigger identifies a normalized analog trigger. ControlTrigger ControlKind = "trigger" - ControlDPad ControlKind = "dpad" - ControlOther ControlKind = "other" + // ControlDPad identifies one digital D-pad direction. + ControlDPad ControlKind = "dpad" + // ControlOther identifies a provider-specific control. + ControlOther ControlKind = "other" ) +// ControlDescriptor identifies one control exposed by a device. type ControlDescriptor struct { ID ControlID `json:"id"` Kind ControlKind `json:"kind"` Label string `json:"label,omitempty"` } +// Capabilities describes the controls and optional output features a device +// exposes through its selected backend. type Capabilities struct { Controls []ControlDescriptor `json:"controls"` AuditGrade AuditGrade `json:"audit_grade"` @@ -40,11 +64,16 @@ type Capabilities struct { LEDs bool `json:"leds"` } +// Clone returns an isolated copy with an explicit valid audit grade. func (c Capabilities) Clone() Capabilities { + if !c.AuditGrade.Valid() { + c.AuditGrade = AuditUnavailable + } c.Controls = append([]ControlDescriptor(nil), c.Controls...) return c } +// Supports reports whether id appears in the discovered control list. func (c Capabilities) Supports(id ControlID) bool { for _, control := range c.Controls { if control.ID == id { @@ -54,6 +83,8 @@ func (c Capabilities) Supports(id ControlID) bool { return false } +// Descriptor contains the stable metadata and capabilities of one discovered +// controller. type Descriptor struct { ID DeviceID `json:"id"` Type ControllerType `json:"type"` @@ -66,6 +97,7 @@ type Descriptor struct { Capability Capabilities `json:"capabilities"` } +// Clone returns an isolated copy of the device descriptor. func (d Descriptor) Clone() Descriptor { d.Capability = d.Capability.Clone() if d.Properties != nil { @@ -84,15 +116,21 @@ type Provider interface { Open(context.Context, DeviceID, ...OpenOption) (GameController, error) } +// DeviceEventKind identifies a hotplug discovery change. type DeviceEventKind string const ( - DeviceAdded DeviceEventKind = "added" + // DeviceAdded reports a newly discovered controller. + DeviceAdded DeviceEventKind = "added" + // DeviceRemoved reports a controller no longer present. DeviceRemoved DeviceEventKind = "removed" + // DeviceUpdated reports changed metadata or capabilities. DeviceUpdated DeviceEventKind = "updated" - DeviceError DeviceEventKind = "error" + // DeviceError reports a failed discovery poll. + DeviceError DeviceEventKind = "error" ) +// DeviceEvent is one hotplug discovery update. type DeviceEvent struct { Kind DeviceEventKind `json:"kind"` Descriptor Descriptor `json:"descriptor"` diff --git a/device_test.go b/device_test.go new file mode 100644 index 0000000..3fb395f --- /dev/null +++ b/device_test.go @@ -0,0 +1,18 @@ +package teleop + +import "testing" + +func TestCapabilitiesDefaultUnknownAuditGradeToUnavailable(t *testing.T) { + capability := (Capabilities{}).Clone() + if capability.AuditGrade != AuditUnavailable { + t.Fatalf("zero audit grade = %q, want %q", capability.AuditGrade, AuditUnavailable) + } + capability = (Capabilities{AuditGrade: "future-grade"}).Clone() + if capability.AuditGrade != AuditUnavailable { + t.Fatalf("unknown audit grade = %q, want %q", capability.AuditGrade, AuditUnavailable) + } + if !AuditExactBackendStream.Valid() || !AuditSampledState.Valid() || + !AuditUnavailable.Valid() { + t.Fatal("known audit grades are invalid") + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 056926c..439c0d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ teleop.Controller - `xbox` supplies Xbox labels and selects the platform backend. - `gesture` derives temporal patterns without hiding canonical input. - `action` maps physical or gesture events to application-defined identifiers. -- `audit` stores the event and causality chain. +- `audit` stores the event, command, and causality chain. - `testkit` supplies deterministic fake and replay sources. Attach recognizers and mappers with `teleop.WithProcessor`. Processors run in @@ -37,15 +37,34 @@ connections are managed by the host operating system. ## Event guarantee -Every observation delivered by an `InputSource` is recorded to authoritative -event sinks before it is published to subscriptions. A lossless subscription -never silently drops an event: it terminates with -`teleop.ErrSubscriptionOverflow` if its configured queue is exhausted. +Every observation delivered by an `InputSource` is accepted into each bounded +authoritative-sink queue before it is published to subscriptions. Sink I/O is +isolated from device ingest, so a durable recorder cannot add filesystem or +`fsync` latency to the control path. If a sink fails, times out, or exhausts +its queue, the controller terminates, publishes canonical neutral/release +events to subscribers, and exposes the error through `Done`, `Err`, and +`Close`. Acceptance does not mean the record is already durable when a +subscriber receives the event. + +A lossless subscription never silently drops an event: only that subscription +terminates with `teleop.ErrSubscriptionOverflow` if its configured queue is +exhausted. Latest-value subscriptions account for coalescing in +`Subscription.Stats`; subscription loss is also sent to authoritative sinks as +a `GapEvent`. This guarantee begins at the OS API boundary. Linux evdev and macOS Game Controller provide event-oriented streams. Windows XInput exposes sampled state, so its descriptors advertise `teleop.AuditSampledState`. +These backends are change-driven: a control held steadily may produce no new +observation. `SnapshotWithMeta` therefore exposes observation age without +claiming that age proves transport failure. Confirmed disconnect always +neutralizes state; age-based neutralization is an explicit application option. +Headers include a session-relative monotonic reading, and the controller emits +`ClockEvent` if wall time steps materially relative to that reading. Application +commands are injected through `GameController.RecordCommand` and receive a +controller-owned event ID, preserving their causal place in the same stream. + ## Extending teleop A third-party provider implements `teleop.Provider` and returns an diff --git a/docs/audit.md b/docs/audit.md index 4445ece..2c49bf2 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -3,13 +3,17 @@ Attach a recorder when opening a controller: ```go -file, err := os.OpenFile("controller.jsonl", os.O_CREATE|os.O_WRONLY, 0o600) +file, err := os.OpenFile( + "controller.jsonl", + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + 0o600, +) if err != nil { return err } defer file.Close() -recorder := audit.NewRecorder(file, audit.WithFlushEveryEvent(true)) +recorder := audit.NewRecorder(file) defer recorder.Close() controller, err := provider.Open( @@ -21,21 +25,256 @@ controller, err := provider.Open( The audit log is versioned newline-delimited JSON. Each record contains its event kind, full event payload, controller session and sequence, observation -time, causes, and a SHA-256 hash linked to the preceding record. +time, causes, and a cryptographic hash linked to the preceding record. + +`audit.ReadAll` rejects missing hashes, broken links, reordering, deletion, and +truncation of a completed stream. Its default unkeyed SHA-256 chain provides +integrity against corruption, not authenticity: an attacker able to rewrite +the file can recompute the entire chain. For adversarial tamper-evidence, create +the recorder with `audit.WithHMAC(key)` and verify it with +`audit.ReadAuthenticated(reader, key)`. Generate at least 32 random key bytes +with a cryptographically secure source and keep them outside the log. + +Use `audit.ReadPartial` only when inspecting an interrupted or currently open +log; it returns the integrity-checked prefix and does not require signature +coverage. Unchained streams require the explicit unverified read option. -Use `audit.ReadAll` to decode and verify a completed stream. Modification, -reordering, deletion, or truncation causes verification to fail. Use -`audit.ReadPartial` only when inspecting an interrupted or currently open log. +Audit files contain a single header/footer-delimited stream. Create a unique +file per session; `O_EXCL` prevents an accidental overwrite. Appending a new +stream to an old one is not supported. + +Logs are intentionally append-only and are not auto-rotated: splitting a +stream changes its verification boundary. For long-running deployments, rotate +at a supervisory/session boundary, close and verify each file, and enforce a +filesystem quota outside the controller process. `audit.Descriptor` and `audit.Observations` extract the controller metadata and canonical observations. Pass those observations to `testkit.NewReplaySource` -to reproduce the input stream without controller hardware. +to exercise code against canonical input without controller hardware. Use +`audit.Events` when exact forensic playback is required: it decodes the +recorded canonical, gesture, action, command, clock, and lifecycle events +directly. Re-running processors from `Observations` is a new derivation and is +not a substitute for the recorded derived-event stream. + +Input alone is not a record of what the application commanded. At the actuator +decision boundary call `controller.RecordCommand` with an application-defined +name, JSON-serializable payload, authorization result, and the already +published event IDs that caused it. A command queue overflow means that command +was not admitted to the audit pipeline and must be handled as an application +safety fault. + +Finite `testkit.ReplaySource` instances should be opened with +`teleop.WithDeferredStart()`; the first subscription then attaches before the +replay source starts returning observations. + +## What each mechanism actually proves + +The mechanisms below are not interchangeable. Each closes a different gap, and +adding a stronger one does not make a weaker one redundant. + +| Property | Question it answers | Mechanism | +| --- | --- | --- | +| Integrity | Was the log edited? | hash chain | +| Authenticity | Did a holder of the key write it? | `WithHMAC` | +| Origin authentication | Did the provisioned signing key write it? | `WithSigner` | +| Existence | Was a log destroyed or truncated? | `WithAnchor` | +| Disclosure | Can one record be proved without the rest? | Merkle inclusion proof | +| Reconstruction | What code and configuration interpreted this input? | `WithProvenance` | + +## Signing and origin authentication + +An HMAC chain proves that someone holding the key wrote the log. It cannot +establish who, because the verifier holds the same key that could have produced +it. `audit.WithSigner` signs the manifest, every checkpoint, and the footer +with Ed25519, so writing and verification are separate capabilities: + +```go +recorder := audit.NewRecorder(file, audit.WithSigner(signer)) +``` + +`signer` is any `crypto.Signer` with an Ed25519 key. Prefer an +Ed25519-capable hardware or remote signer whose private key is non-exportable. +Hardware non-exportability does not by itself establish device identity: bind +the public key to the device through trusted provisioning and retain the +key-lifecycle records needed by future verifiers. + +Individual events are not signed. The hash chain and Merkle tree already bind +every event to the nearest signed head, so per-event signatures would add cost +without adding evidence. + +For a completed signed log, prefer the fail-closed helper: + +```go +records, verification, err := audit.ReadTrusted(reader, trustedKey) +``` + +`trustedKey` must be obtained outside the log. The equivalent configurable +call is: + +```go +records, verification, err := audit.Read(reader, audit.VerifyOptions{ + RequireFooter: true, + PublicKey: trustedKey, // obtained out of band +}) +``` + +- `verification.Signed` means every record read is covered by a tree head + verified against the key the log declares about itself. That is internal + consistency only: whoever can rewrite the whole log can also replace the + declared key. +- `verification.Trusted` means every record read is covered by a tree head + verified against `PublicKey`, supplied from outside the log. Only this claim + carries evidentiary weight. +- `SignedTreeSize` and `TrustedTreeSize` expose the exact prefix covered by the + latest verified head. + +Setting `PublicKey` makes `RequireSignature` implicit, requires format version +3, and withholds events from a streaming verifier until a signed checkpoint or +footer covers them. A mismatch between the declared key and the trusted key +fails with `ErrUntrustedKey` rather than being ignored. + +`KeyID` is only a short display/index label. Never use it alone as a trust +decision; compare the complete public key or a certificate/attestation bound to +that key. + +The withheld event buffer defaults to 64 MiB so a replayed manifest followed +by an attacker-controlled unsigned tail cannot cause unbounded memory growth. +If intentionally large events can exceed that between signed heads, set +`VerifyOptions.MaxPendingBytes` to a deployment-appropriate bound. + +The package uses keys but does not manage their lifecycle. Hardware +provisioning, trusted public-key distribution, access control, rotation, +revocation, archival verification, and destruction belong to the deployment's +key-management system. + +If a deployment deliberately combines `WithSigner` and `WithHMAC`, verification +needs both trust inputs. Use `audit.Read` with `RequireFooter`, `PublicKey`, and +`HMACKey`; `ReadTrusted` accepts only the public-key input. + +## Checkpoints and external anchoring + +A hash chain proves a log was not edited. It proves nothing about a log that +was deleted, truncated before its final records, or never written at all — the +failure most likely to matter when a session ends badly, and the one an +append-only structure inherently cannot see. + +The recorder periodically emits a signed checkpoint carrying the Merkle head +over every record before it. `audit.WithAnchor` publishes each checkpoint to +storage this process does not control: + +```go +recorder := audit.NewRecorder( + file, + audit.WithSigner(signer), + audit.WithCheckpoints(10*time.Second, 10000), + audit.WithAnchor(audit.NewFileAnchor(remote)), +) +``` + +Anchor to a different failure and custody domain than the log itself: object +storage under a WORM or legal-hold policy, a transparency log, or a host under +separate control. Anchoring beside the log proves very little. + +A published checkpoint identifies its format version, record type, and +signature algorithm. Verify it against an independently trusted key: + +```go +err := audit.VerifyCheckpoint(trustedKey, checkpoint) +``` + +Checkpoint publication is asynchronous and never blocks the input path. +Because a later head supersedes an earlier one, a saturated anchor queue drops +the oldest pending checkpoint rather than the freshest. Drops and failures are +counted, never hidden: + +```go +if stats := recorder.AnchorStats(); stats.Failed > 0 || stats.Dropped > 0 { + // Recent heads were never witnessed outside this process. Those records + // are integrity protected but not protected against destruction. +} +``` + +An anchor failure does not stop recording. A network problem should not stop a +vessel; it should be visible. Call `recorder.Checkpoint(reason)` directly at +moments worth being able to prove later, such as arming, an emergency stop, or +an operator handover. The first recorded controller event binds the log's +session; a checkpoint attempted before that returns `audit.ErrSessionRequired`. + +## Selective disclosure with Merkle proofs + +Records form an RFC 6962 Merkle tree in addition to the linear chain. This +matters in discovery: proving a record authentic through a hash chain alone +requires handing over the entire log, which may contain other operators' +activity or telemetry that is not discoverable. + +An inclusion proof establishes that one record belongs to a signed head in +O(log n) hashes without revealing any other record: + +```go +proof, err := audit.InclusionProof(index, leaves) +err = audit.VerifyInclusion(index, size, leaves[index], root, proof) +``` + +A consistency proof establishes that a later head extends an earlier one, so a +log cannot be quietly rewritten between two disclosures: + +```go +err := audit.VerifyConsistency(oldSize, newSize, oldRoot, newRoot, proof) +``` + +## Provenance + +A record that an operator pressed a control is not by itself evidence: the same +input produces different actuation under a different build, dead zone, or +action binding. `audit.WithProvenance` records what interpreted the input. + +```go +provenance := audit.CaptureProvenance() // build, VCS revision, host, Go version +provenance.Application = "harbor-tug" +provenance.ApplicationVersion = "2.4.0" +provenance.Operator = "operator-7" +provenance.Authorization = "work-order-8812" +provenance.Config = map[string]any{"dead_zone": 0.12, "rate_limit_hz": 50} + +recorder := audit.NewRecorder(file, audit.WithProvenance(provenance)) +``` + +`CaptureProvenance` fills what the process can determine for itself, including +`vcs.revision` and whether the build came from a dirty tree. A `Modified` build +means the revision does not fully describe the running code. + +`Config` is the field most often omitted and most often needed. Without the +parameters that turn input into actuation, a replay reproduces the operator's +thumb but not the machine's behavior. + +Recording `Operator` is workplace surveillance and is treated as personal data +under the GDPR and comparable regimes. Decide that deliberately, with a +retention policy, rather than enabling it by default. + +## Time + +Wall-clock timestamps come from a clock the recording host controls and can +step. Every header therefore also carries `Monotonic`, a session-relative +reading that no clock adjustment can move. Use it, not `ObservedAt` or +`ReceivedAt`, for any duration or ordering question, including how long a +control was held and how stale an observation was. + +When the wall clock steps relative to the monotonic clock by more than +`teleop.DefaultClockStepThreshold`, the controller publishes a `ClockEvent`. +Wall-clock times recorded on either side of one are not comparable; monotonic +readings remain valid across it. ## Guarantee boundary The recorder stores every event received from the selected backend. It cannot store input that the operating system or transport did not expose. +The controller places an event into the recorder's bounded queue before +publishing it to subscribers, but recording and `fsync` happen asynchronously. +The recorder flushes every event by default. A sink failure, timeout, or queue +overflow terminates the controller and is observable through `Done`, `Err`, +and `Close`; it cannot retract events a subscriber already received. + - `AuditExactBackendStream` means the backend consumes an OS event stream. - `AuditSampledState` means intermediate transitions can occur between polls. - `GapEvent` means the backend or a queue reported known input loss. @@ -44,3 +283,19 @@ An application that requires an uninterrupted trail should treat a `GapEvent`, subscription overflow, recorder error, or sampled backend as a safety event. Teleop reports these conditions; the application decides the appropriate safe state. + +### What none of this provides + +An audit log is evidence and post-incident learning. It is not a safety +function, and no amount of cryptography in this package makes a system safer. + +Every mechanism here detects **errors of transcription**: records altered, +substituted, removed, or attributed to the wrong key. None detects an **error +of omission** — input the operating system, driver, radio, or polling API never +delivered. A log cannot record what was never observed, and a perfectly signed, +anchored, provable log of nothing is exactly what a silent transport failure +produces. + +Omission is the failure most likely to injure someone. It is addressed by +command timeout, liveness, and a dead-man switch, not by recording. See +[the safety guide](safety.md). diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..b6e5dad --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,162 @@ +# Command timeout and dead-man switch + +`teleop` transports operator input. It does not decide whether acting on that +input is safe. The `safety` package makes that decision explicit and fails +closed. + +> [!IMPORTANT] +> A `safety.Guard` is not a certified safety controller. It does not replace a +> hardware emergency stop, a safety-rated interlock, or an actuator that reaches +> a safe state on its own when commands stop arriving. It is the software half +> of a dead-man policy, and it is only as good as the actuation path that +> honors its decisions. + +## The problem it solves + +The dangerous failure in a teleoperation system is not a corrupted input value. +It is the absence of input: the operator releases a control and the release +never arrives, or the operator becomes unable to act and the last command keeps +being applied. A backend cannot report what the operating system, driver, or +radio never delivered, so the input stream alone can never distinguish "the +operator is holding steady" from "the link died three seconds ago." + +Only elapsed time distinguishes them, and only an output gate can act on it. + +## Setup + +```go +guard := safety.New( + safety.WithCommandTimeout(150*time.Millisecond), + safety.WithDeadMan(xbox.ButtonBumperRight), + safety.WithDeadManReactuation(30*time.Second), + safety.WithLoopWatchdog(100*time.Millisecond), +) + +controller, err := provider.Open(ctx, deviceID, + teleop.WithProcessor(guard), + teleop.WithLiveness(20*time.Millisecond, 100*time.Millisecond), + teleop.WithAuditSink(recorder), +) +if err != nil { + return err +} +guard.Bind(controller) +``` + +Attaching the guard with `teleop.WithProcessor` gives it lossless edge +detection on the dead-man control and publishes every transition into the +event stream and audit sinks. `Bind` is separate because the guard must exist +before `Open` and the controller does not exist until after it. + +Pair a short command timeout with `teleop.WithLiveness`. Change-driven backends +emit nothing while a control is held steady, so without periodic observations a +held control will trip the timeout. + +## The control loop + +```go +for range ticker.C { + guard.Heartbeat() + + decision := guard.Evaluate() + drive(decision.Command) // neutral whenever inhibited + + _ = controller.RecordCommand(ctx, teleop.Command{ + Name: "thrust.set", + Payload: thrust, + Authorized: decision.Permit, + Reason: firstReason(decision), + }) +} +``` + +Three properties of this loop matter: + +`Evaluate` is authoritative and must be called immediately before acting, every +time. A decision describes the instant it was made and nothing after it. + +`Decision.Command` is the observed state when permitted and the neutral zero +state when inhibited. Using it instead of a separate snapshot removes the +possibility of acting on live input after an inhibiting decision. + +`Evaluate` is not read-only. A failed condition latches the guard, so authority +stays revoked until an operator re-arms. + +## Conditions + +A guard authorizes output only when it can affirmatively establish all of: + +| Condition | Inhibits when | Reason | +| --- | --- | --- | +| Bound | `Bind` was never called | `ReasonUnbound` | +| Armed | never armed, or latched by a trip | `ReasonNotArmed` | +| Input exists | no observation has ever arrived | `ReasonNoInput` | +| Input fresh | newest observation older than the timeout | `ReasonCommandTimeout` | +| Connected | controller reports disconnected | `ReasonDisconnected` | +| Observed | state was synthesized, not observed | `ReasonSynthetic` | +| Pipeline healthy | controller terminated | `ReasonControllerFault` | +| Loop alive | no `Heartbeat` within the watchdog | `ReasonLoopStalled` | +| Operator engaged | dead-man control not held | `ReasonDeadManReleased` | +| Engagement observed | held, but the press was never seen | `ReasonDeadManUnconfirmed` | +| Switch not defeated | held past the re-actuation deadline | `ReasonDeadManStale` | +| No stop | emergency stop active | `ReasonEmergencyStop` | + +Anything the guard cannot prove inhibits output. A guard that has never been +bound, never been armed, or never seen an observation denies. + +## Design decisions worth knowing + +**Freshness is measured on the monotonic clock.** `StateMeta.ReceivedMonotonic` +and `Controller.Monotonic` come from a clock no NTP step or manual adjustment +can move. Enforcing a timeout against wall-clock fields would let a backward +clock step make stale input appear fresh. + +**A never-observed input path is treated as failed, not quiet.** Silence that +has never been broken is not evidence that anything works. + +**Trips latch by default.** Automatic resumption after a fault is how a +transient dropout becomes an unexpected movement. Clearing a trip requires +`Arm`. `WithLatching(false)` is available and is rarely the right choice. + +**Releasing the dead-man control does not latch.** Letting go is normal +operation, so re-engaging restores authority without a re-arm. Every other +condition is a fault and latches. The distinction is what keeps the switch +usable without making faults self-clearing. + +**An indefinitely held dead-man control is treated as defeated.** A control +held past `WithDeadManReactuation` is indistinguishable from one taped down, +wedged, or held by an incapacitated operator. The default deadline is one +minute. A guard bound while the control was already down cannot bound the hold +and reports `ReasonDeadManStale` until it observes a fresh press. + +**An emergency stop cannot be cleared by `Arm`.** It requires `Reset` first, so +a stop is not undone by reflex. + +**The guard trips on its own timer.** As a `teleop.AdvancingProcessor` it is +re-evaluated on the controller's internal ticker, so a command timeout or a +stalled control loop revokes authority and records the transition even if the +application stops calling `Evaluate`. Ticker granularity is roughly 25 ms, so +the application's own `Evaluate` call remains the precise enforcement point. + +## Recording + +Guard transitions are published as `safety.Event` values into the same +subscriptions and audit sinks as input, so the record shows not only what the +operator did but what the gate permitted. Transitions are emitted on change +only, so a steady state costs nothing. + +Record inhibited commands as well as authorized ones. A recorded unauthorized +command shows that the application computed one and the gate stopped it, which +is exactly the evidence that the gate worked. + +`RecordCommand` never blocks and returns `ErrPipelineOverflow` when its queue +is full. Treat that as a fault and inhibit: the command just issued is not in +the record. + +## What this does not cover + +The guard gates commands leaving the application. It cannot ensure the actuator +honors them, cannot detect a control surface that fails in place, and cannot +substitute for an actuator-side timeout. A remote system should reach a safe +state on its own when commands stop arriving, independently of anything decided +here. diff --git a/event.go b/event.go index 072a426..66a3d40 100644 --- a/event.go +++ b/event.go @@ -2,34 +2,53 @@ package teleop import ( "encoding/hex" + "encoding/json" "fmt" "time" ) +// EventKind is the stable persisted discriminator for an event payload. type EventKind string const ( - EventObservation EventKind = "input.observation" - EventButton EventKind = "input.button" - EventStick EventKind = "input.stick" - EventTrigger EventKind = "input.trigger" - EventDPad EventKind = "input.dpad" - EventConnection EventKind = "device.connection" + // EventObservation records a complete canonical controller state. + EventObservation EventKind = "input.observation" + // EventButton records one digital-control edge. + EventButton EventKind = "input.button" + // EventStick records one normalized stick change. + EventStick EventKind = "input.stick" + // EventTrigger records one normalized trigger change. + EventTrigger EventKind = "input.trigger" + // EventConnection records a controller lifecycle transition. + EventConnection EventKind = "device.connection" + // EventCapabilities records the controls exposed by a controller. EventCapabilities EventKind = "device.capabilities" - EventGap EventKind = "stream.gap" - EventError EventKind = "stream.error" + // EventLiveness records observation-age state. + EventLiveness EventKind = "stream.liveness" + // EventGap records known or suspected input loss. + EventGap EventKind = "stream.gap" + // EventError records a pipeline or source error. + EventError EventKind = "stream.error" + // EventClock records a detected host wall-clock adjustment. + EventClock EventKind = "stream.clock" + // EventCommand records an application command at the actuation boundary. + EventCommand EventKind = "command.issued" ) +// SessionID is the random 128-bit identity of one open controller session. type SessionID [16]byte +// String returns the lowercase hexadecimal session identity. func (id SessionID) String() string { return hex.EncodeToString(id[:]) } +// MarshalText implements encoding.TextMarshaler. func (id SessionID) MarshalText() ([]byte, error) { return []byte(id.String()), nil } +// UnmarshalText implements encoding.TextUnmarshaler. func (id *SessionID) UnmarshalText(value []byte) error { decoded, err := hex.DecodeString(string(value)) if err != nil { @@ -42,20 +61,37 @@ func (id *SessionID) UnmarshalText(value []byte) error { return nil } +// EventID uniquely identifies an event within a controller session. type EventID struct { Session SessionID `json:"session"` Stream string `json:"stream"` Sequence uint64 `json:"sequence"` } +// Header carries identity, timing, device, and causal metadata shared by every +// event. type Header struct { - ID EventID `json:"id"` - DeviceID DeviceID `json:"device_id"` - ObservedAt time.Time `json:"observed_at"` + ID EventID `json:"id"` + DeviceID DeviceID `json:"device_id"` + ObservedAt time.Time `json:"observed_at"` + ReceivedAt time.Time `json:"received_at,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + + // Monotonic is the session-relative monotonic reading taken when the event + // was published. Unlike the wall-clock fields it survives clock steps, so + // it is the authoritative source for event ordering and for any duration + // measured during forensic reconstruction. + Monotonic time.Duration `json:"monotonic"` + // ReceivedMonotonic is the session-relative monotonic reading taken when + // the originating observation reached the controller. + ReceivedMonotonic time.Duration `json:"received_monotonic,omitempty"` + DeviceTimestamp int64 `json:"device_timestamp,omitempty"` Causes []EventID `json:"causes,omitempty"` + Synthetic bool `json:"synthetic,omitempty"` } +// Clone returns an isolated copy of the header. func (h Header) Clone() Header { h.Causes = append([]EventID(nil), h.Causes...) return h @@ -68,6 +104,13 @@ type Event interface { Kind() EventKind } +// EventCloner lets derived and third-party events provide isolation when an +// event contains reference-backed mutable data. +type EventCloner interface { + CloneEvent() Event +} + +// NativeInput retains backend-specific source data for forensic replay. type NativeInput struct { Format string `json:"format"` Data []byte `json:"data,omitempty"` @@ -85,6 +128,7 @@ func (n NativeInput) clone() NativeInput { return n } +// ObservationEvent records one complete canonical state transition. type ObservationEvent struct { Meta Header `json:"header"` Native NativeInput `json:"native"` @@ -92,9 +136,22 @@ type ObservationEvent struct { Current State `json:"current"` } +// Header implements Event. func (e ObservationEvent) Header() Header { return e.Meta.Clone() } -func (ObservationEvent) Kind() EventKind { return EventObservation } +// Kind implements Event. +func (ObservationEvent) Kind() EventKind { return EventObservation } + +// CloneEvent implements EventCloner. +func (e ObservationEvent) CloneEvent() Event { + e.Meta = e.Meta.Clone() + e.Native = e.Native.clone() + e.Previous = e.Previous.Clone() + e.Current = e.Current.Clone() + return e +} + +// ButtonEvent records one digital button or D-pad transition. type ButtonEvent struct { Meta Header `json:"header"` Button ControlID `json:"button"` @@ -102,9 +159,13 @@ type ButtonEvent struct { Pressed bool `json:"pressed"` } +// Header implements Event. func (e ButtonEvent) Header() Header { return e.Meta.Clone() } -func (ButtonEvent) Kind() EventKind { return EventButton } +// Kind implements Event. +func (ButtonEvent) Kind() EventKind { return EventButton } + +// StickEvent records a normalized stick position and delta. type StickEvent struct { Meta Header `json:"header"` Stick StickID `json:"stick"` @@ -112,9 +173,13 @@ type StickEvent struct { Delta Stick `json:"delta"` } +// Header implements Event. func (e StickEvent) Header() Header { return e.Meta.Clone() } -func (StickEvent) Kind() EventKind { return EventStick } +// Kind implements Event. +func (StickEvent) Kind() EventKind { return EventStick } + +// TriggerEvent records a normalized trigger position and delta. type TriggerEvent struct { Meta Header `json:"header"` Trigger TriggerID `json:"trigger"` @@ -122,25 +187,23 @@ type TriggerEvent struct { Delta float32 `json:"delta"` } +// Header implements Event. func (e TriggerEvent) Header() Header { return e.Meta.Clone() } -func (TriggerEvent) Kind() EventKind { return EventTrigger } - -type DPadEvent struct { - Meta Header `json:"header"` - Previous DPad `json:"previous"` - Current DPad `json:"current"` -} -func (e DPadEvent) Header() Header { return e.Meta.Clone() } -func (DPadEvent) Kind() EventKind { return EventDPad } +// Kind implements Event. +func (TriggerEvent) Kind() EventKind { return EventTrigger } +// ConnectionState is the lifecycle state carried by ConnectionEvent. type ConnectionState string const ( - Connected ConnectionState = "connected" + // Connected reports an opened controller session. + Connected ConnectionState = "connected" + // Disconnected reports a terminal device or session transition. Disconnected ConnectionState = "disconnected" ) +// ConnectionEvent records a controller lifecycle transition and descriptor. type ConnectionEvent struct { Meta Header `json:"header"` State ConnectionState `json:"state"` @@ -148,17 +211,55 @@ type ConnectionEvent struct { Descriptor Descriptor `json:"descriptor"` } +// Header implements Event. func (e ConnectionEvent) Header() Header { return e.Meta.Clone() } -func (ConnectionEvent) Kind() EventKind { return EventConnection } +// Kind implements Event. +func (ConnectionEvent) Kind() EventKind { return EventConnection } + +// CapabilitiesEvent records the controls and output features exposed by the +// open controller. type CapabilitiesEvent struct { Meta Header `json:"header"` Capabilities Capabilities `json:"capabilities"` } +// Header implements Event. func (e CapabilitiesEvent) Header() Header { return e.Meta.Clone() } -func (CapabilitiesEvent) Kind() EventKind { return EventCapabilities } +// Kind implements Event. +func (CapabilitiesEvent) Kind() EventKind { return EventCapabilities } + +// LivenessState distinguishes recent input from input older than the +// configured observation-age threshold. It represents transport health only +// for backends known to emit periodic observations. +type LivenessState string + +const ( + // LivenessHealthy reports input newer than the configured threshold. + LivenessHealthy LivenessState = "healthy" + // LivenessStale reports input older than the configured threshold. + LivenessStale LivenessState = "stale" +) + +// LivenessEvent periodically reports the age of the most recently received +// complete observation. +type LivenessEvent struct { + Meta Header `json:"header"` + State LivenessState `json:"state"` + LastObserved time.Time `json:"last_observed,omitempty"` + LastReceived time.Time `json:"last_received,omitempty"` + Age time.Duration `json:"age"` +} + +// Header implements Event. +func (e LivenessEvent) Header() Header { return e.Meta.Clone() } + +// Kind implements Event. +func (LivenessEvent) Kind() EventKind { return EventLiveness } + +// GapEvent records known or suspected loss in a source, pipeline, or +// subscription stream. type GapEvent struct { Meta Header `json:"header"` Source string `json:"source"` @@ -166,17 +267,154 @@ type GapEvent struct { Reason string `json:"reason"` } +// Header implements Event. func (e GapEvent) Header() Header { return e.Meta.Clone() } -func (GapEvent) Kind() EventKind { return EventGap } +// Kind implements Event. +func (GapEvent) Kind() EventKind { return EventGap } + +// ErrorEvent records a source or pipeline error while retaining its Go error +// for in-process consumers. type ErrorEvent struct { Meta Header `json:"header"` Message string `json:"message"` Err error `json:"-"` } +// Header implements Event. func (e ErrorEvent) Header() Header { return e.Meta.Clone() } -func (ErrorEvent) Kind() EventKind { return EventError } + +// Kind implements Event. +func (ErrorEvent) Kind() EventKind { return EventError } + +// ClockEvent reports that the host wall clock moved relative to the monotonic +// clock by more than the configured step threshold. Wall-clock timestamps +// recorded before and after a step are not directly comparable; the Monotonic +// header field remains authoritative across one. +type ClockEvent struct { + Meta Header `json:"header"` + // Step is the signed wall-clock adjustment: positive when the wall clock + // jumped forward relative to elapsed monotonic time. + Step time.Duration `json:"step"` + // Steps counts adjustments detected so far in this session. + Steps uint64 `json:"steps"` + Reason string `json:"reason,omitempty"` +} + +// Header implements Event. +func (e ClockEvent) Header() Header { return e.Meta.Clone() } + +// Kind implements Event. +func (ClockEvent) Kind() EventKind { return EventClock } + +// CommandEvent records a command an application issued to the system under +// control. Recording input alone leaves the causal chain incomplete: the +// liability question is usually what the machine was told to do, not what the +// operator's thumb did. Causes in the header link the command to the input +// events that produced it. +type CommandEvent struct { + Meta Header `json:"header"` + Command string `json:"command"` + // Payload is the application's own encoding of the command. It is written + // to audit sinks verbatim. + Payload json.RawMessage `json:"payload,omitempty"` + // Authorized reports whether a safety gate permitted the command at the + // instant it was issued. A recorded unauthorized command means the + // application computed one and the gate inhibited it. + Authorized bool `json:"authorized"` + // Reason explains an unauthorized or degraded command. + Reason string `json:"reason,omitempty"` +} + +// Header implements Event. +func (e CommandEvent) Header() Header { return e.Meta.Clone() } + +// Kind implements Event. +func (CommandEvent) Kind() EventKind { return EventCommand } + +// CloneEvent implements EventCloner. +func (e CommandEvent) CloneEvent() Event { + e.Meta = e.Meta.Clone() + e.Payload = append(json.RawMessage(nil), e.Payload...) + return e +} + +func cloneEvent(event Event) Event { + if cloner, ok := event.(EventCloner); ok { + return cloner.CloneEvent() + } + switch value := event.(type) { + case ButtonEvent: + value.Meta = value.Meta.Clone() + return value + case *ButtonEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case StickEvent: + value.Meta = value.Meta.Clone() + return value + case *StickEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case TriggerEvent: + value.Meta = value.Meta.Clone() + return value + case *TriggerEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case ConnectionEvent: + value.Meta = value.Meta.Clone() + value.Descriptor = value.Descriptor.Clone() + return value + case *ConnectionEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + cloned.Descriptor = cloned.Descriptor.Clone() + return &cloned + case CapabilitiesEvent: + value.Meta = value.Meta.Clone() + value.Capabilities = value.Capabilities.Clone() + return value + case *CapabilitiesEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + cloned.Capabilities = cloned.Capabilities.Clone() + return &cloned + case LivenessEvent: + value.Meta = value.Meta.Clone() + return value + case *LivenessEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case GapEvent: + value.Meta = value.Meta.Clone() + return value + case *GapEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case ErrorEvent: + value.Meta = value.Meta.Clone() + return value + case *ErrorEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + case ClockEvent: + value.Meta = value.Meta.Clone() + return value + case *ClockEvent: + cloned := *value + cloned.Meta = cloned.Meta.Clone() + return &cloned + default: + return event + } +} // ControlOf returns the physical control associated with a standard input // event. Not every event has one. @@ -190,22 +428,34 @@ func ControlOf(event Event) (ControlID, bool) { if value.Stick == LeftStick { return StickLeft, true } - return StickRight, true + if value.Stick == RightStick { + return StickRight, true + } + return "", false case *StickEvent: if value.Stick == LeftStick { return StickLeft, true } - return StickRight, true + if value.Stick == RightStick { + return StickRight, true + } + return "", false case TriggerEvent: if value.Trigger == LeftTrigger { return TriggerLeft, true } - return TriggerRight, true + if value.Trigger == RightTrigger { + return TriggerRight, true + } + return "", false case *TriggerEvent: if value.Trigger == LeftTrigger { return TriggerLeft, true } - return TriggerRight, true + if value.Trigger == RightTrigger { + return TriggerRight, true + } + return "", false default: return "", false } diff --git a/examples/basic/main.go b/examples/basic/main.go index 6209eef..1edeeb5 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -1,3 +1,5 @@ +// Package main demonstrates discovering an Xbox controller and consuming its +// lossless normalized event stream. package main import ( @@ -13,39 +15,54 @@ import ( ) func main() { + if err := run(); err != nil { + log.Print(err) + os.Exit(1) + } +} + +func run() (err error) { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() provider := xbox.NewProvider() devices, err := provider.Discover(ctx) if err != nil { - log.Fatal(err) + return err } if len(devices) == 0 { - log.Fatal("connect an Xbox controller first") + return errors.New("connect an Xbox controller first") } controller, err := provider.Open(ctx, devices[0].ID) if err != nil { - log.Fatal(err) + return err } - defer controller.Close() + defer func() { + if closeErr := controller.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close controller: %w", closeErr)) + } + }() events, err := controller.Subscribe(teleop.SubscriptionOptions{ Delivery: teleop.DeliveryLossless, }) if err != nil { - log.Fatal(err) + return err } - defer events.Close() + defer func() { + if closeErr := events.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close event subscription: %w", closeErr)) + } + }() for { event, err := events.Next(ctx) if err != nil { if errors.Is(err, context.Canceled) { - return + return nil } - log.Fatal(err) + return err } switch event := event.(type) { case teleop.ButtonEvent: diff --git a/gesture/gesture.go b/gesture/gesture.go index 5c768af..a2be50e 100644 --- a/gesture/gesture.go +++ b/gesture/gesture.go @@ -3,26 +3,39 @@ package gesture import ( + "context" + "fmt" "math" + "sort" "sync" + "sync/atomic" "time" "github.com/open-ships/teleop" ) +// Type identifies a recognized gesture. type Type string const ( - Tap Type = "tap" - DoubleTap Type = "double-tap" - Hold Type = "hold" - Chord Type = "chord" - StickRegion Type = "stick-region" + // Tap is a short press followed by release. + Tap Type = "tap" + // DoubleTap is two taps within Config.DoubleTapWindow. + DoubleTap Type = "double-tap" + // Hold is a press sustained for Config.HoldMinimum. + Hold Type = "hold" + // Chord is an exact configured set of simultaneous buttons. + Chord Type = "chord" + // StickRegion is entry into or exit from a directional stick region. + StickRegion Type = "stick-region" + // TriggerThreshold is a hysteretic trigger threshold crossing. TriggerThreshold Type = "trigger-threshold" ) +// EventKind is the persisted kind for gesture events. const EventKind teleop.EventKind = "gesture" +// Event is a derived temporal or directional input. type Event struct { Meta teleop.Header `json:"header"` Type Type `json:"type"` @@ -33,30 +46,48 @@ type Event struct { Value float32 `json:"value,omitempty"` } +// Header implements teleop.Event. func (e Event) Header() teleop.Header { return e.Meta.Clone() } -func (Event) Kind() teleop.EventKind { return EventKind } +// Kind implements teleop.Event. +func (Event) Kind() teleop.EventKind { return EventKind } + +// CloneEvent implements teleop.EventCloner. +func (e Event) CloneEvent() teleop.Event { + e.Meta = e.Meta.Clone() + e.Controls = append([]teleop.ControlID(nil), e.Controls...) + return e +} + +// ChordSpec names an exact set of simultaneously pressed buttons. type ChordSpec struct { Name string Buttons []teleop.ControlID } +// Config controls gesture timing and analog hysteresis. type Config struct { - TapMaximum time.Duration - DoubleTapWindow time.Duration - HoldMinimum time.Duration - StickThreshold float32 - TriggerThreshold float32 - Chords []ChordSpec + TapMaximum time.Duration + DoubleTapWindow time.Duration + HoldMinimum time.Duration + StickThreshold float32 + StickHysteresis float32 + TriggerThreshold float32 + TriggerHysteresis float32 + Chords []ChordSpec } +// DefaultConfig returns conservative gesture defaults with no duration gap +// between a tap and a hold. func DefaultConfig() Config { return Config{ - TapMaximum: 250 * time.Millisecond, - DoubleTapWindow: 350 * time.Millisecond, - HoldMinimum: 600 * time.Millisecond, - StickThreshold: 0.65, - TriggerThreshold: 0.5, + TapMaximum: 600 * time.Millisecond, + DoubleTapWindow: 350 * time.Millisecond, + HoldMinimum: 600 * time.Millisecond, + StickThreshold: 0.65, + StickHysteresis: 0.08, + TriggerThreshold: 0.5, + TriggerHysteresis: 0.08, } } @@ -64,6 +95,8 @@ type press struct { at time.Time header teleop.Header holdSent bool + holdID teleop.EventID + consumed bool } type tap struct { @@ -71,47 +104,110 @@ type tap struct { eventID teleop.EventID } -// Recognizer is safe for concurrent use, though ordered calls from one event -// stream are recommended. -type Recognizer struct { - mu sync.Mutex - config Config - stream uint64 +type streamKey struct { + session teleop.SessionID + device teleop.DeviceID +} +type streamState struct { pressed map[teleop.ControlID]press lastTap map[teleop.ControlID]tap - activeChords map[int]bool + activeChords map[int]teleop.EventID stickRegions map[teleop.StickID]string triggerDown map[teleop.TriggerID]bool } +var recognizerInstances atomic.Uint64 + +// Recognizer keeps independent state per controller session and is safe for +// concurrent use. Calls within each session must retain event order. +type Recognizer struct { + mu sync.Mutex + config Config + states map[streamKey]*streamState + fallbackStream string + fallbackSeq map[teleop.SessionID]uint64 + processing teleop.ProcessingContext +} + +// New validates config and constructs a recognizer. Invalid configuration +// panics so a contradictory safety policy cannot be silently accepted. func New(config Config) *Recognizer { defaults := DefaultConfig() - if config.TapMaximum <= 0 { + if config.TapMaximum == 0 { config.TapMaximum = defaults.TapMaximum } - if config.DoubleTapWindow <= 0 { + if config.DoubleTapWindow == 0 { config.DoubleTapWindow = defaults.DoubleTapWindow } - if config.HoldMinimum <= 0 { + if config.HoldMinimum == 0 { config.HoldMinimum = defaults.HoldMinimum } - if config.StickThreshold <= 0 { + if config.StickThreshold == 0 { config.StickThreshold = defaults.StickThreshold } - if config.TriggerThreshold <= 0 { + if config.StickHysteresis == 0 { + config.StickHysteresis = defaults.StickHysteresis + } + if config.TriggerThreshold == 0 { config.TriggerThreshold = defaults.TriggerThreshold } + if config.TriggerHysteresis == 0 { + config.TriggerHysteresis = defaults.TriggerHysteresis + } + config.Chords = append([]ChordSpec(nil), config.Chords...) + for index := range config.Chords { + config.Chords[index].Buttons = canonicalControls(config.Chords[index].Buttons) + } + if err := validateConfig(config); err != nil { + panic(err) + } + instance := recognizerInstances.Add(1) return &Recognizer{ - config: config, - pressed: make(map[teleop.ControlID]press), - lastTap: make(map[teleop.ControlID]tap), - activeChords: make(map[int]bool), - stickRegions: make(map[teleop.StickID]string), - triggerDown: make(map[teleop.TriggerID]bool), + config: config, + states: make(map[streamKey]*streamState), + fallbackStream: fmt.Sprintf("gesture/%d", instance), + fallbackSeq: make(map[teleop.SessionID]uint64), } } +func validateConfig(config Config) error { + if config.TapMaximum < 0 || + config.DoubleTapWindow < 0 || + config.HoldMinimum <= 0 { + return fmt.Errorf("gesture: durations must be non-negative and hold minimum positive") + } + if config.TapMaximum > config.HoldMinimum { + return fmt.Errorf("gesture: tap maximum %s exceeds hold minimum %s", config.TapMaximum, config.HoldMinimum) + } + if config.StickThreshold <= 0 || config.StickThreshold > 1 || + config.StickHysteresis < 0 || config.StickHysteresis >= config.StickThreshold { + return fmt.Errorf("gesture: invalid stick threshold/hysteresis") + } + if config.TriggerThreshold <= 0 || config.TriggerThreshold > 1 || + config.TriggerHysteresis < 0 || config.TriggerHysteresis >= config.TriggerThreshold { + return fmt.Errorf("gesture: invalid trigger threshold/hysteresis") + } + chordNames := make(map[string]struct{}, len(config.Chords)) + chordControls := make([][]teleop.ControlID, 0, len(config.Chords)) + for _, chord := range config.Chords { + if chord.Name == "" || len(canonicalControls(chord.Buttons)) < 2 { + return fmt.Errorf("gesture: chord must have a name and at least two distinct buttons") + } + if _, duplicate := chordNames[chord.Name]; duplicate { + return fmt.Errorf("gesture: duplicate chord name %q", chord.Name) + } + chordNames[chord.Name] = struct{}{} + for _, controls := range chordControls { + if equalControls(controls, chord.Buttons) { + return fmt.Errorf("gesture: duplicate chord controls %v", chord.Buttons) + } + } + chordControls = append(chordControls, chord.Buttons) + } + return nil +} + // Process implements teleop.Processor. func (r *Recognizer) Process(input teleop.Event) []teleop.Event { recognized := r.Recognize(input) @@ -122,6 +218,24 @@ func (r *Recognizer) Process(input teleop.Event) []teleop.Event { return result } +// ProcessContext implements teleop.ContextProcessor. +func (r *Recognizer) ProcessContext( + _ context.Context, + processing teleop.ProcessingContext, + input teleop.Event, +) ([]teleop.Event, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.processing = processing + defer func() { r.processing = nil }() + recognized := r.recognizeLocked(input) + result := make([]teleop.Event, len(recognized)) + for index := range recognized { + result[index] = recognized[index] + } + return result, nil +} + // Advance implements teleop.AdvancingProcessor. func (r *Recognizer) Advance(now time.Time) []teleop.Event { recognized := r.AdvanceGestures(now) @@ -132,100 +246,142 @@ func (r *Recognizer) Advance(now time.Time) []teleop.Event { return result } -// Recognize accepts one canonical input event and returns zero or more -// strongly typed gestures. Returned events cite the input event that caused -// them. +// AdvanceContext implements teleop.ContextAdvancingProcessor. +func (r *Recognizer) AdvanceContext( + _ context.Context, + processing teleop.ProcessingContext, + now time.Time, +) ([]teleop.Event, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.processing = processing + defer func() { r.processing = nil }() + recognized := r.advanceLocked(now) + result := make([]teleop.Event, len(recognized)) + for index := range recognized { + result[index] = recognized[index] + } + return result, nil +} + +// Recognize consumes one canonical input event. func (r *Recognizer) Recognize(input teleop.Event) []Event { r.mu.Lock() defer r.mu.Unlock() + return r.recognizeLocked(input) +} +func (r *Recognizer) recognizeLocked(input teleop.Event) []Event { + header := input.Header() + key := streamKey{session: header.ID.Session, device: header.DeviceID} + state := r.state(key) + r.expireTaps(state, header.ObservedAt) switch event := input.(type) { case teleop.ButtonEvent: - return r.processButton(event) + return r.processButton(state, event) case *teleop.ButtonEvent: - return r.processButton(*event) + return r.processButton(state, *event) case teleop.StickEvent: - return r.processStick(event) + return r.processStick(state, event) case *teleop.StickEvent: - return r.processStick(*event) + return r.processStick(state, *event) case teleop.TriggerEvent: - return r.processTrigger(event) + return r.processTrigger(state, event) case *teleop.TriggerEvent: - return r.processTrigger(*event) - default: - return nil + return r.processTrigger(state, *event) + case teleop.ConnectionEvent: + if event.State == teleop.Disconnected { + return r.reset(key, state, event.Meta) + } + case *teleop.ConnectionEvent: + if event.State == teleop.Disconnected { + return r.reset(key, state, event.Meta) + } } + return nil } -// AdvanceGestures emits strongly typed holds whose duration has elapsed even -// when no new input event arrives. +// AdvanceGestures emits elapsed holds using the supplied event-timeline time. func (r *Recognizer) AdvanceGestures(now time.Time) []Event { r.mu.Lock() defer r.mu.Unlock() + return r.advanceLocked(now) +} +func (r *Recognizer) advanceLocked(now time.Time) []Event { + keys := make([]streamKey, 0, len(r.states)) + for key := range r.states { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + left := keys[i].session.String() + string(keys[i].device) + right := keys[j].session.String() + string(keys[j].device) + return left < right + }) var result []Event - for button, state := range r.pressed { - if state.holdSent || now.Sub(state.at) < r.config.HoldMinimum { - continue - } - state.holdSent = true - r.pressed[button] = state - header := teleop.Header{ - ID: r.nextID(state.header.ID.Session), - DeviceID: state.header.DeviceID, - ObservedAt: now, - Causes: []teleop.EventID{state.header.ID}, + for _, key := range keys { + state := r.states[key] + r.expireTaps(state, now) + buttons := sortedPressed(state.pressed) + for _, button := range buttons { + pressState := state.pressed[button] + duration := nonNegative(now.Sub(pressState.at)) + if pressState.consumed || pressState.holdSent || duration < r.config.HoldMinimum { + continue + } + pressState.holdSent = true + startedAt := pressState.at.Add(r.config.HoldMinimum) + event := r.event( + pressState.header, + startedAt, + Hold, + teleop.PhaseStarted, + []teleop.ControlID{button}, + r.config.HoldMinimum, + "", + 0, + ) + event.Meta.Causes = []teleop.EventID{pressState.header.ID} + pressState.holdID = event.Meta.ID + state.pressed[button] = pressState + result = append(result, event) } - result = append(result, Event{ - Meta: header, - Type: Hold, - Phase: teleop.PhaseStarted, - Controls: []teleop.ControlID{button}, - Duration: now.Sub(state.at), - }) } return result } -func (r *Recognizer) processButton(event teleop.ButtonEvent) []Event { +func (r *Recognizer) processButton(state *streamState, event teleop.ButtonEvent) []Event { now := event.Meta.ObservedAt if now.IsZero() { - now = time.Now() + if r.processing != nil { + now = r.processing.Now() + } else { + now = time.Now() + } + event.Meta.ObservedAt = now } if event.Pressed { - r.pressed[event.Button] = press{at: now, header: event.Meta} - return r.startedChords(event) + if _, repeated := state.pressed[event.Button]; repeated { + return nil + } + state.pressed[event.Button] = press{at: now, header: event.Meta} + return r.startedChords(state, event) } - state, found := r.pressed[event.Button] - delete(r.pressed, event.Button) - var result []Event - for index, active := range r.activeChords { - chord := r.config.Chords[index] - if !active || !contains(chord.Buttons, event.Button) { - continue - } - r.activeChords[index] = false - ended := r.event( - event.Meta, - Chord, - teleop.PhaseEnded, - append([]teleop.ControlID(nil), chord.Buttons...), - 0, - chord.Name, - 0, - ) - ended.Meta.Causes = appendChordCauses(ended.Meta.Causes, r.pressed, chord.Buttons) - result = append(result, ended) + pressState, found := state.pressed[event.Button] + delete(state.pressed, event.Button) + result := r.endedChords(state, event) + if !found || pressState.consumed { + return result } - if !found { + duration := now.Sub(pressState.at) + if duration < 0 { return result } - - duration := now.Sub(state.at) - if state.holdSent { + if pressState.holdSent { ended := r.event( event.Meta, + now, Hold, teleop.PhaseEnded, []teleop.ControlID{event.Button}, @@ -233,23 +389,24 @@ func (r *Recognizer) processButton(event teleop.ButtonEvent) []Event { "", 0, ) - ended.Meta.Causes = []teleop.EventID{state.header.ID, event.Meta.ID} - result = append(result, ended) - return result + ended.Meta.Causes = []teleop.EventID{pressState.holdID, event.Meta.ID} + return append(result, ended) } if duration >= r.config.HoldMinimum { started := r.event( - event.Meta, + pressState.header, + pressState.at.Add(r.config.HoldMinimum), Hold, teleop.PhaseStarted, []teleop.ControlID{event.Button}, - duration, + r.config.HoldMinimum, "", 0, ) - started.Meta.Causes = []teleop.EventID{state.header.ID, event.Meta.ID} + started.Meta.Causes = []teleop.EventID{pressState.header.ID} ended := r.event( event.Meta, + now, Hold, teleop.PhaseEnded, []teleop.ControlID{event.Button}, @@ -257,15 +414,35 @@ func (r *Recognizer) processButton(event teleop.ButtonEvent) []Event { "", 0, ) - ended.Meta.Causes = []teleop.EventID{started.Meta.ID} - result = append(result, started, ended) - return result + ended.Meta.Causes = []teleop.EventID{started.Meta.ID, event.Meta.ID} + return append(result, started, ended) } - if duration > r.config.TapMaximum { - return result + + if last, ok := state.lastTap[event.Button]; ok && !now.Before(last.at) { + since := now.Sub(last.at) + if since <= r.config.DoubleTapWindow { + doubleTapped := r.event( + event.Meta, + now, + DoubleTap, + teleop.PhaseEnded, + []teleop.ControlID{event.Button}, + since, + "", + 0, + ) + doubleTapped.Meta.Causes = []teleop.EventID{ + last.eventID, + pressState.header.ID, + event.Meta.ID, + } + delete(state.lastTap, event.Button) + return append(result, doubleTapped) + } } tapped := r.event( event.Meta, + now, Tap, teleop.PhaseEnded, []teleop.ControlID{event.Button}, @@ -273,122 +450,209 @@ func (r *Recognizer) processButton(event teleop.ButtonEvent) []Event { "", 0, ) - tapped.Meta.Causes = []teleop.EventID{state.header.ID, event.Meta.ID} - result = append(result, tapped) - if last := r.lastTap[event.Button]; !last.at.IsZero() && now.Sub(last.at) <= r.config.DoubleTapWindow { - doubleTapped := r.event( - event.Meta, - DoubleTap, - teleop.PhaseEnded, - []teleop.ControlID{event.Button}, - now.Sub(last.at), - "", - 0, - ) - doubleTapped.Meta.Causes = []teleop.EventID{last.eventID, tapped.Meta.ID} - result = append(result, doubleTapped) - delete(r.lastTap, event.Button) - } else { - r.lastTap[event.Button] = tap{at: now, eventID: tapped.Meta.ID} - } - return result + tapped.Meta.Causes = []teleop.EventID{pressState.header.ID, event.Meta.ID} + state.lastTap[event.Button] = tap{at: now, eventID: tapped.Meta.ID} + return append(result, tapped) } -func (r *Recognizer) startedChords(event teleop.ButtonEvent) []Event { +func (r *Recognizer) startedChords( + state *streamState, + event teleop.ButtonEvent, +) []Event { var result []Event for index, chord := range r.config.Chords { - if r.activeChords[index] || !contains(chord.Buttons, event.Button) { + if _, active := state.activeChords[index]; active || + !contains(chord.Buttons, event.Button) { continue } allPressed := true for _, button := range chord.Buttons { - if _, pressed := r.pressed[button]; !pressed { + if _, pressed := state.pressed[button]; !pressed { allPressed = false break } } - if allPressed { - r.activeChords[index] = true - started := r.event( - event.Meta, - Chord, - teleop.PhaseStarted, - append([]teleop.ControlID(nil), chord.Buttons...), - 0, - chord.Name, - 0, - ) - started.Meta.Causes = appendChordCauses(nil, r.pressed, chord.Buttons) - result = append(result, started) + if !allPressed { + continue } + started := r.event( + event.Meta, + event.Meta.ObservedAt, + Chord, + teleop.PhaseStarted, + append([]teleop.ControlID(nil), chord.Buttons...), + 0, + chord.Name, + 0, + ) + started.Meta.Causes = appendChordCauses(nil, state.pressed, chord.Buttons) + state.activeChords[index] = started.Meta.ID + for _, button := range chord.Buttons { + value := state.pressed[button] + value.consumed = true + state.pressed[button] = value + delete(state.lastTap, button) + } + result = append(result, started) } return result } -func (r *Recognizer) processStick(event teleop.StickEvent) []Event { - region := stickRegion(event.Position, r.config.StickThreshold) - previous := r.stickRegions[event.Stick] - if region == previous { +func (r *Recognizer) endedChords( + state *streamState, + event teleop.ButtonEvent, +) []Event { + var result []Event + for index, chord := range r.config.Chords { + startedID, active := state.activeChords[index] + if !active || !contains(chord.Buttons, event.Button) { + continue + } + delete(state.activeChords, index) + ended := r.event( + event.Meta, + event.Meta.ObservedAt, + Chord, + teleop.PhaseEnded, + append([]teleop.ControlID(nil), chord.Buttons...), + 0, + chord.Name, + 0, + ) + ended.Meta.Causes = []teleop.EventID{startedID, event.Meta.ID} + result = append(result, ended) + } + return result +} + +func (r *Recognizer) processStick(state *streamState, event teleop.StickEvent) []Event { + control, ok := stickControl(event.Stick) + if !ok { return nil } - r.stickRegions[event.Stick] = region - control := teleop.StickRight - if event.Stick == teleop.LeftStick { - control = teleop.StickLeft + previous := state.stickRegions[event.Stick] + threshold := r.config.StickThreshold + if previous != "" { + threshold -= r.config.StickHysteresis + } + region := stickRegion(event.Position, threshold) + if region == previous { + return nil } + state.stickRegions[event.Stick] = region var result []Event if previous != "" { result = append(result, r.event( - event.Meta, - StickRegion, - teleop.PhaseEnded, - []teleop.ControlID{control}, - 0, - previous, - 0, + event.Meta, event.Meta.ObservedAt, StickRegion, teleop.PhaseEnded, + []teleop.ControlID{control}, 0, previous, 0, )) } if region != "" { result = append(result, r.event( - event.Meta, - StickRegion, - teleop.PhaseStarted, - []teleop.ControlID{control}, - 0, - region, - 0, + event.Meta, event.Meta.ObservedAt, StickRegion, teleop.PhaseStarted, + []teleop.ControlID{control}, 0, region, 0, )) } return result } -func (r *Recognizer) processTrigger(event teleop.TriggerEvent) []Event { - down := event.Position >= r.config.TriggerThreshold - if r.triggerDown[event.Trigger] == down { +func (r *Recognizer) processTrigger(state *streamState, event teleop.TriggerEvent) []Event { + wasDown := state.triggerDown[event.Trigger] + threshold := r.config.TriggerThreshold + if wasDown { + threshold -= r.config.TriggerHysteresis + } + down := event.Position >= threshold + if wasDown == down { return nil } - r.triggerDown[event.Trigger] = down - control := teleop.TriggerRight - if event.Trigger == teleop.LeftTrigger { - control = teleop.TriggerLeft + control, ok := triggerControl(event.Trigger) + if !ok { + return nil } + state.triggerDown[event.Trigger] = down phase := teleop.PhaseEnded if down { phase = teleop.PhaseStarted } return []Event{r.event( - event.Meta, - TriggerThreshold, - phase, - []teleop.ControlID{control}, - 0, - "", - event.Position, + event.Meta, event.Meta.ObservedAt, TriggerThreshold, phase, + []teleop.ControlID{control}, 0, "", event.Position, )} } +func (r *Recognizer) reset( + key streamKey, + state *streamState, + cause teleop.Header, +) []Event { + var result []Event + for index, chord := range r.config.Chords { + startedID, active := state.activeChords[index] + if !active { + continue + } + ended := r.event( + cause, cause.ObservedAt, Chord, teleop.PhaseEnded, + append([]teleop.ControlID(nil), chord.Buttons...), 0, chord.Name, 0, + ) + ended.Meta.Causes = []teleop.EventID{startedID, cause.ID} + result = append(result, ended) + } + for _, button := range sortedPressed(state.pressed) { + value := state.pressed[button] + if value.consumed || !value.holdSent { + continue + } + duration := nonNegative(cause.ObservedAt.Sub(value.at)) + ended := r.event( + cause, cause.ObservedAt, Hold, teleop.PhaseEnded, + []teleop.ControlID{button}, duration, "", 0, + ) + ended.Meta.Causes = []teleop.EventID{value.holdID, cause.ID} + result = append(result, ended) + } + sticks := make([]string, 0, len(state.stickRegions)) + for stick, region := range state.stickRegions { + if region != "" { + sticks = append(sticks, string(stick)) + } + } + sort.Strings(sticks) + for _, raw := range sticks { + stick := teleop.StickID(raw) + control, ok := stickControl(stick) + if ok { + result = append(result, r.event( + cause, cause.ObservedAt, StickRegion, teleop.PhaseEnded, + []teleop.ControlID{control}, 0, state.stickRegions[stick], 0, + )) + } + } + triggers := make([]string, 0, len(state.triggerDown)) + for trigger, down := range state.triggerDown { + if down { + triggers = append(triggers, string(trigger)) + } + } + sort.Strings(triggers) + for _, raw := range triggers { + trigger := teleop.TriggerID(raw) + control, ok := triggerControl(trigger) + if ok { + result = append(result, r.event( + cause, cause.ObservedAt, TriggerThreshold, teleop.PhaseEnded, + []teleop.ControlID{control}, 0, "", 0, + )) + } + } + delete(r.states, key) + return result +} + func (r *Recognizer) event( cause teleop.Header, + observedAt time.Time, kind Type, phase teleop.Phase, controls []teleop.ControlID, @@ -396,38 +660,110 @@ func (r *Recognizer) event( region string, value float32, ) Event { + var header teleop.Header + if r.processing != nil { + header = r.processing.NewHeader( + "gesture", + observedAt, + cause.DeviceTimestamp, + cause.ID, + ) + header.Synthetic = cause.Synthetic + } else { + r.fallbackSeq[cause.ID.Session]++ + header = teleop.Header{ + ID: teleop.EventID{ + Session: cause.ID.Session, + Stream: r.fallbackStream, + Sequence: r.fallbackSeq[cause.ID.Session], + }, + DeviceID: cause.DeviceID, + ObservedAt: observedAt, + ReceivedAt: cause.ReceivedAt, + PublishedAt: cause.PublishedAt, + DeviceTimestamp: cause.DeviceTimestamp, + Causes: []teleop.EventID{cause.ID}, + Synthetic: cause.Synthetic, + } + } return Event{ - Meta: teleop.Header{ - ID: r.nextID(cause.ID.Session), - DeviceID: cause.DeviceID, - ObservedAt: cause.ObservedAt, - Causes: []teleop.EventID{cause.ID}, - }, + Meta: header, Type: kind, Phase: phase, - Controls: controls, + Controls: append([]teleop.ControlID(nil), controls...), Duration: duration, Region: region, Value: value, } } -func (r *Recognizer) nextID(session teleop.SessionID) teleop.EventID { - r.stream++ - return teleop.EventID{ - Session: session, - Stream: "gesture", - Sequence: r.stream, +func (r *Recognizer) state(key streamKey) *streamState { + state := r.states[key] + if state == nil { + state = &streamState{ + pressed: make(map[teleop.ControlID]press), + lastTap: make(map[teleop.ControlID]tap), + activeChords: make(map[int]teleop.EventID), + stickRegions: make(map[teleop.StickID]string), + triggerDown: make(map[teleop.TriggerID]bool), + } + r.states[key] = state } + return state } -func contains(values []teleop.ControlID, value teleop.ControlID) bool { - for _, candidate := range values { - if candidate == value { - return true +func (r *Recognizer) expireTaps(state *streamState, now time.Time) { + if now.IsZero() { + return + } + for control, last := range state.lastTap { + if now.Sub(last.at) > r.config.DoubleTapWindow { + delete(state.lastTap, control) + } + } +} + +func sortedPressed(values map[teleop.ControlID]press) []teleop.ControlID { + result := make([]teleop.ControlID, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func canonicalControls(values []teleop.ControlID) []teleop.ControlID { + seen := make(map[teleop.ControlID]struct{}, len(values)) + result := make([]teleop.ControlID, 0, len(values)) + for _, value := range values { + if value == "" { + continue } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) } - return false + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func equalControls(left, right []teleop.ControlID) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func contains(values []teleop.ControlID, value teleop.ControlID) bool { + index := sort.Search(len(values), func(index int) bool { return values[index] >= value }) + return index < len(values) && values[index] == value } func appendChordCauses( @@ -448,6 +784,35 @@ func appendChordCauses( return causes } +func stickControl(stick teleop.StickID) (teleop.ControlID, bool) { + switch stick { + case teleop.LeftStick: + return teleop.StickLeft, true + case teleop.RightStick: + return teleop.StickRight, true + default: + return "", false + } +} + +func triggerControl(trigger teleop.TriggerID) (teleop.ControlID, bool) { + switch trigger { + case teleop.LeftTrigger: + return teleop.TriggerLeft, true + case teleop.RightTrigger: + return teleop.TriggerRight, true + default: + return "", false + } +} + +func nonNegative(duration time.Duration) time.Duration { + if duration < 0 { + return 0 + } + return duration +} + func stickRegion(stick teleop.Stick, threshold float32) string { magnitude := math.Hypot(float64(stick.X), float64(stick.Y)) if magnitude < float64(threshold) { diff --git a/gesture/gesture_test.go b/gesture/gesture_test.go index d6ab4c9..e5667ee 100644 --- a/gesture/gesture_test.go +++ b/gesture/gesture_test.go @@ -31,7 +31,7 @@ func TestTapDoubleTapAndHold(t *testing.T) { recognizer.Recognize(buttonEvent(3, start.Add(200*time.Millisecond), true)) result = recognizer.Recognize(buttonEvent(4, start.Add(275*time.Millisecond), false)) - if len(result) != 2 || result[1].Type != gesture.DoubleTap { + if len(result) != 1 || result[0].Type != gesture.DoubleTap { t.Fatalf("second release emitted %#v", result) } @@ -73,6 +73,219 @@ func TestStickRegionAndTriggerThreshold(t *testing.T) { } } +func TestRecognizerIsolatesControllerSessions(t *testing.T) { + t.Parallel() + + recognizer := gesture.New(gesture.Config{ + Chords: []gesture.ChordSpec{{ + Name: "arm", + Buttons: []teleop.ControlID{ + teleop.ButtonBumperLeft, + teleop.ButtonFaceSouth, + }, + }}, + }) + at := time.Unix(100, 0) + if got := recognizer.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonBumperLeft, true, + )); len(got) != 0 { + t.Fatalf("first session press = %#v", got) + } + if got := recognizer.Recognize(controllerButtonEvent( + 2, 1, at, teleop.ButtonFaceSouth, true, + )); len(got) != 0 { + t.Fatalf("cross-session chord = %#v", got) + } + got := recognizer.Recognize(controllerButtonEvent( + 1, 2, at.Add(time.Millisecond), teleop.ButtonFaceSouth, true, + )) + if len(got) != 1 || got[0].Type != gesture.Chord || got[0].Region != "arm" { + t.Fatalf("same-session chord = %#v", got) + } +} + +func TestDisconnectResetsAndTerminatesActiveGestures(t *testing.T) { + t.Parallel() + + recognizer := gesture.New(gesture.Config{ + Chords: []gesture.ChordSpec{{ + Name: "arm", + Buttons: []teleop.ControlID{ + teleop.ButtonBumperLeft, + teleop.ButtonFaceSouth, + }, + }}, + }) + at := time.Unix(200, 0) + recognizer.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonBumperLeft, true, + )) + chord := recognizer.Recognize(controllerButtonEvent( + 1, 2, at.Add(time.Millisecond), teleop.ButtonFaceSouth, true, + )) + if len(chord) != 1 || chord[0].Phase != teleop.PhaseStarted { + t.Fatalf("chord start = %#v", chord) + } + recognizer.Recognize(teleop.StickEvent{ + Meta: gestureHeader(1, 3, at.Add(2*time.Millisecond)), + Stick: teleop.LeftStick, + Position: teleop.Stick{Y: 1}, + }) + recognizer.Recognize(teleop.TriggerEvent{ + Meta: gestureHeader(1, 4, at.Add(3*time.Millisecond)), + Trigger: teleop.LeftTrigger, + Position: 1, + }) + ended := recognizer.Recognize(teleop.ConnectionEvent{ + Meta: gestureHeader(1, 5, at.Add(time.Second)), + State: teleop.Disconnected, + }) + if len(ended) != 3 || + ended[0].Type != gesture.Chord || + ended[1].Type != gesture.StickRegion || + ended[2].Type != gesture.TriggerThreshold { + t.Fatalf("disconnect endings = %#v", ended) + } + for _, event := range ended { + if event.Phase != teleop.PhaseEnded { + t.Fatalf("disconnect phase = %#v", event) + } + } + if later := recognizer.AdvanceGestures(at.Add(24 * time.Hour)); len(later) != 0 { + t.Fatalf("state remained after disconnect = %#v", later) + } +} + +func TestDisconnectEndsOnlyAnAlreadyStartedHold(t *testing.T) { + t.Parallel() + + config := gesture.DefaultConfig() + recognizer := gesture.New(config) + at := time.Unix(300, 0) + press := controllerButtonEvent(1, 1, at, teleop.ButtonFaceSouth, true) + recognizer.Recognize(press) + started := recognizer.AdvanceGestures(at.Add(config.HoldMinimum)) + if len(started) != 1 || started[0].Phase != teleop.PhaseStarted { + t.Fatalf("hold start = %#v", started) + } + disconnect := teleop.ConnectionEvent{ + Meta: gestureHeader(1, 2, at.Add(config.HoldMinimum+time.Second)), + State: teleop.Disconnected, + } + result := recognizer.Recognize(disconnect) + if len(result) != 1 || + result[0].Type != gesture.Hold || + result[0].Phase != teleop.PhaseEnded { + t.Fatalf("disconnect hold = %#v", result) + } + if len(result[0].Meta.Causes) != 2 || + result[0].Meta.Causes[0] != started[0].Meta.ID || + result[0].Meta.Causes[1] != disconnect.Meta.ID { + t.Fatalf("hold end causes = %#v", result[0].Meta.Causes) + } +} + +func TestHoldAdvanceUsesEventTimelineNotLogAge(t *testing.T) { + t.Parallel() + + config := gesture.DefaultConfig() + recognizer := gesture.New(config) + recordedAt := time.Unix(400, 0) + recognizer.Recognize(controllerButtonEvent( + 1, 1, recordedAt, teleop.ButtonFaceSouth, true, + )) + replayedNow := recordedAt.Add(24 * time.Hour) + result := recognizer.AdvanceGestures(replayedNow) + if len(result) != 1 { + t.Fatalf("replayed hold = %#v", result) + } + if result[0].Meta.ObservedAt != recordedAt.Add(config.HoldMinimum) || + result[0].Duration != config.HoldMinimum { + t.Fatalf("replayed hold timeline = %#v", result[0]) + } +} + +func TestGestureOrderingAndFallbackIDsAreDeterministicPerSession(t *testing.T) { + t.Parallel() + + config := gesture.DefaultConfig() + recognizer := gesture.New(config) + at := time.Unix(500, 0) + recognizer.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonFaceSouth, true, + )) + recognizer.Recognize(controllerButtonEvent( + 1, 2, at, teleop.ButtonFaceEast, true, + )) + result := recognizer.AdvanceGestures(at.Add(config.HoldMinimum)) + if len(result) != 2 { + t.Fatalf("holds = %#v", result) + } + if result[0].Controls[0] >= result[1].Controls[0] || + result[0].Meta.ID.Sequence != 1 || + result[1].Meta.ID.Sequence != 2 { + t.Fatalf("hold order/IDs = %#v", result) + } + + other := gesture.New(config) + other.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonFaceSouth, true, + )) + first := other.Recognize(controllerButtonEvent( + 1, 2, at.Add(time.Millisecond), teleop.ButtonFaceSouth, false, + )) + other.Recognize(controllerButtonEvent( + 2, 1, at, teleop.ButtonFaceSouth, true, + )) + second := other.Recognize(controllerButtonEvent( + 2, 2, at.Add(time.Millisecond), teleop.ButtonFaceSouth, false, + )) + if len(first) != 1 || len(second) != 1 || + first[0].Meta.ID.Sequence != 1 || + second[0].Meta.ID.Sequence != 1 { + t.Fatalf("per-session gesture IDs = %#v, %#v", first, second) + } +} + +func TestOutOfOrderReleaseDoesNotCreateTap(t *testing.T) { + t.Parallel() + + recognizer := gesture.New(gesture.DefaultConfig()) + at := time.Unix(600, 0) + recognizer.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonFaceSouth, true, + )) + if result := recognizer.Recognize(controllerButtonEvent( + 1, 2, at.Add(-time.Second), teleop.ButtonFaceSouth, false, + )); len(result) != 0 { + t.Fatalf("out-of-order release = %#v", result) + } +} + +func TestRecognizerCopiesChordConfiguration(t *testing.T) { + t.Parallel() + + buttons := []teleop.ControlID{ + teleop.ButtonBumperLeft, + teleop.ButtonFaceSouth, + } + chords := []gesture.ChordSpec{{Name: "arm", Buttons: buttons}} + recognizer := gesture.New(gesture.Config{Chords: chords}) + buttons[0] = teleop.ButtonFaceEast + chords[0].Name = "mutated" + + at := time.Unix(700, 0) + recognizer.Recognize(controllerButtonEvent( + 1, 1, at, teleop.ButtonBumperLeft, true, + )) + result := recognizer.Recognize(controllerButtonEvent( + 1, 2, at.Add(time.Millisecond), teleop.ButtonFaceSouth, true, + )) + if len(result) != 1 || result[0].Region != "arm" { + t.Fatalf("copied chord config = %#v", result) + } +} + func buttonEvent(sequence uint64, at time.Time, pressed bool) teleop.ButtonEvent { phase := teleop.PhaseReleased if pressed { @@ -88,3 +301,36 @@ func buttonEvent(sequence uint64, at time.Time, pressed bool) teleop.ButtonEvent Pressed: pressed, } } + +func controllerButtonEvent( + sessionByte byte, + sequence uint64, + at time.Time, + button teleop.ControlID, + pressed bool, +) teleop.ButtonEvent { + phase := teleop.PhaseReleased + if pressed { + phase = teleop.PhasePressed + } + return teleop.ButtonEvent{ + Meta: gestureHeader(sessionByte, sequence, at), + Button: button, + Phase: phase, + Pressed: pressed, + } +} + +func gestureHeader(sessionByte byte, sequence uint64, at time.Time) teleop.Header { + var session teleop.SessionID + session[0] = sessionByte + return teleop.Header{ + ID: teleop.EventID{ + Session: session, + Stream: "input", + Sequence: sequence, + }, + DeviceID: "controller", + ObservedAt: at, + } +} diff --git a/go.mod b/go.mod index 11f1d9f..b531ce8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.0 require ( charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 ) require ( @@ -22,5 +24,4 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index 8298683..ebabde3 100644 --- a/go.sum +++ b/go.sum @@ -36,5 +36,7 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/normalize.go b/normalize.go index 9742bbf..7c389cf 100644 --- a/normalize.go +++ b/normalize.go @@ -2,7 +2,14 @@ package teleop import "math" +// Clamp limits value to the inclusive range from minimum to maximum. func Clamp(value, minimum, maximum float32) float32 { + if math.IsNaN(float64(value)) { + if minimum <= 0 && maximum >= 0 { + return 0 + } + return minimum + } if value < minimum { return minimum } @@ -17,6 +24,19 @@ func NormalizeAxis(value, minimum, maximum int32) float32 { if maximum <= minimum { return 0 } + // Linux gamepad axes commonly expose -32768..32767. Treat zero as the + // electrical center and scale each signed side independently so a centered + // stick is exactly neutral rather than a small persistent deflection. + if minimum < 0 && maximum > 0 { + switch { + case value == 0: + return 0 + case value < 0: + return Clamp(float32(float64(value)/-float64(minimum)), -1, 0) + default: + return Clamp(float32(float64(value)/float64(maximum)), 0, 1) + } + } center := (float64(minimum) + float64(maximum)) / 2 halfRange := (float64(maximum) - float64(minimum)) / 2 return Clamp(float32((float64(value)-center)/halfRange), -1, 1) @@ -27,13 +47,19 @@ func NormalizeTrigger(value, minimum, maximum int32) float32 { if maximum <= minimum { return 0 } - result := float32(float64(value-minimum) / float64(maximum-minimum)) + result := float32((float64(value) - float64(minimum)) / (float64(maximum) - float64(minimum))) return Clamp(result, 0, 1) } // ApplyRadialDeadZone is an operational helper. The controller's canonical // audit stream never applies it automatically. func ApplyRadialDeadZone(stick Stick, deadZone float32) Stick { + if math.IsNaN(float64(stick.X)) || + math.IsNaN(float64(stick.Y)) || + math.IsInf(float64(stick.X), 0) || + math.IsInf(float64(stick.Y), 0) { + return Stick{} + } deadZone = Clamp(deadZone, 0, 0.99) magnitude := float32(math.Hypot(float64(stick.X), float64(stick.Y))) if magnitude <= deadZone || magnitude == 0 { diff --git a/registry.go b/registry.go index 883989b..6fd5f36 100644 --- a/registry.go +++ b/registry.go @@ -2,17 +2,20 @@ package teleop import ( "context" + "errors" "fmt" "sort" "sync" ) -// Registry is an explicit, dependency-injected collection of providers. +// Registry is an explicit, dependency-injected collection of providers. Its +// zero value is ready for Register. type Registry struct { mu sync.RWMutex providers map[ControllerType]Provider } +// NewRegistry constructs an explicit provider registry. func NewRegistry(providers ...Provider) *Registry { registry := &Registry{providers: make(map[ControllerType]Provider)} for _, provider := range providers { @@ -23,15 +26,21 @@ func NewRegistry(providers ...Provider) *Registry { return registry } +// Register adds or replaces the provider for its controller type. func (r *Registry) Register(provider Provider) { if provider == nil { return } r.mu.Lock() defer r.mu.Unlock() + if r.providers == nil { + r.providers = make(map[ControllerType]Provider) + } r.providers[provider.Type()] = provider } +// Discover queries every provider, retaining successful results while joining +// provider-specific errors. func (r *Registry) Discover(ctx context.Context) ([]Descriptor, error) { r.mu.RLock() types := make([]ControllerType, 0, len(r.providers)) @@ -45,17 +54,29 @@ func (r *Registry) Discover(ctx context.Context) ([]Descriptor, error) { } r.mu.RUnlock() - var devices []Descriptor + var ( + devices []Descriptor + resultErr error + ) for _, provider := range providers { found, err := provider.Discover(ctx) + for _, descriptor := range found { + devices = append(devices, descriptor.Clone()) + } if err != nil { - return nil, fmt.Errorf("discover %s controllers: %w", provider.Type(), err) + resultErr = errors.Join( + resultErr, + fmt.Errorf("discover %s controllers: %w", provider.Type(), err), + ) + if ctx.Err() != nil { + break + } } - devices = append(devices, found...) } - return devices, nil + return devices, resultErr } +// Open delegates to the registered provider for controllerType. func (r *Registry) Open( ctx context.Context, controllerType ControllerType, diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..db40975 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,64 @@ +package teleop + +import ( + "context" + "errors" + "testing" +) + +var errDiscovery = errors.New("discovery failed") + +type registryTestProvider struct { + controllerType ControllerType + devices []Descriptor + err error +} + +func (provider registryTestProvider) Type() ControllerType { + return provider.controllerType +} + +func (provider registryTestProvider) Discover(context.Context) ([]Descriptor, error) { + return provider.devices, provider.err +} + +func (registryTestProvider) Open( + context.Context, + DeviceID, + ...OpenOption, +) (GameController, error) { + return nil, ErrUnsupported +} + +func TestRegistryDiscoverRetainsDevicesWhenAnotherProviderFails(t *testing.T) { + registry := NewRegistry( + registryTestProvider{ + controllerType: "broken", + err: errDiscovery, + }, + registryTestProvider{ + controllerType: "working", + devices: []Descriptor{{ + ID: "working:0", + Name: "Working controller", + }}, + }, + ) + + devices, err := registry.Discover(context.Background()) + if !errors.Is(err, errDiscovery) { + t.Fatalf("Discover error = %v, want provider error", err) + } + if len(devices) != 1 || devices[0].ID != "working:0" { + t.Fatalf("Discover devices = %#v, want working provider result", devices) + } +} + +func TestZeroRegistryCanRegisterAndOpen(t *testing.T) { + var registry Registry + provider := registryTestProvider{controllerType: "working"} + registry.Register(provider) + if _, err := registry.Open(context.Background(), "working", "working:0"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Open error = %v, want provider error", err) + } +} diff --git a/safety/event.go b/safety/event.go new file mode 100644 index 0000000..a025c0e --- /dev/null +++ b/safety/event.go @@ -0,0 +1,125 @@ +package safety + +import ( + "time" + + "github.com/open-ships/teleop" +) + +// EventDecision is the kind of every safety event. Decisions are published +// into the controller's ordinary event stream and audit sinks, so the record +// shows not only what the operator did but what the gate permitted. +const EventDecision teleop.EventKind = "safety.decision" + +// State is the gate's authorization state. +type State string + +const ( + // StateSafe inhibits output. It is the state of a Guard that has not been + // armed, has been tripped, or cannot prove its conditions are met. + StateSafe State = "safe" + // StateArmed means every automatic condition holds but the operator is not + // currently engaging the dead-man control. + StateArmed State = "armed" + // StateLive permits output. + StateLive State = "live" +) + +// Reason identifies why output is inhibited. A decision may carry several. +type Reason string + +const ( + // ReasonUnbound reports a Guard that was never bound to a controller. + ReasonUnbound Reason = "unbound" + // ReasonNotArmed reports that no operator has armed the gate, or that a + // previous trip latched it and it has not been re-armed. + ReasonNotArmed Reason = "not_armed" + // ReasonEmergencyStop reports a latched emergency stop. + ReasonEmergencyStop Reason = "emergency_stop" + // ReasonCommandTimeout reports that the newest observation is older than + // the configured command timeout, measured on the monotonic clock. + ReasonCommandTimeout Reason = "command_timeout" + // ReasonNoInput reports that no observation has ever arrived, so the input + // path has never been proven to work. + ReasonNoInput Reason = "no_input" + // ReasonDeadManReleased reports that the dead-man control is not held. + ReasonDeadManReleased Reason = "dead_man_released" + // ReasonDeadManUnconfirmed reports a control the snapshot shows as held + // whose press the Guard never observed, so it cannot bound how long the + // hold has lasted. This is ordinary at startup and while a press is still + // propagating through the pipeline, so it inhibits without latching and + // clears on the next observed press. + ReasonDeadManUnconfirmed Reason = "dead_man_unconfirmed" + // ReasonDeadManStale reports a dead-man control held continuously past the + // re-actuation deadline, which is the signature of a defeated switch. + ReasonDeadManStale Reason = "dead_man_stale" + // ReasonLoopStalled reports that the application control loop stopped + // calling Heartbeat within its watchdog interval. + ReasonLoopStalled Reason = "loop_stalled" + // ReasonDisconnected reports that the controller is not connected. + ReasonDisconnected Reason = "disconnected" + // ReasonSynthetic reports state the controller synthesized rather than + // observed, such as the neutral state published after a disconnect. + ReasonSynthetic Reason = "synthetic_state" + // ReasonControllerFault reports a terminated controller pipeline. + ReasonControllerFault Reason = "controller_fault" + // ReasonOperator reports an explicit Disarm. + ReasonOperator Reason = "operator_disarmed" +) + +// Event records a change in authorization. Guards publish one on every +// transition, never on every evaluation, so a steady state does not flood the +// log. +type Event struct { + Meta teleop.Header `json:"header"` + State State `json:"state"` + Permit bool `json:"permit"` + // Reasons lists every unmet condition, in a stable order. + Reasons []Reason `json:"reasons,omitempty"` + // InputAge is the age of the newest observation on the monotonic clock. + InputAge time.Duration `json:"input_age"` + // Detail carries operator-supplied context for a manual transition. + Detail string `json:"detail,omitempty"` +} + +// Header implements teleop.Event. +func (e Event) Header() teleop.Header { return e.Meta.Clone() } + +// Kind implements teleop.Event. +func (Event) Kind() teleop.EventKind { return EventDecision } + +// CloneEvent implements teleop.EventCloner. +func (e Event) CloneEvent() teleop.Event { + e.Meta = e.Meta.Clone() + e.Reasons = append([]Reason(nil), e.Reasons...) + return e +} + +// Decision is the authorization answer for one instant. Treat a Decision as +// valid only at the moment it was produced: re-evaluate before every command. +type Decision struct { + // Permit reports whether output is authorized. It is false whenever the + // Guard could not affirmatively establish every condition. + Permit bool + State State + // Reasons is empty when Permit is true. + Reasons []Reason + // Command is the controller state to act on: the observed state when + // permitted, and the neutral zero state when inhibited. Using it removes + // the chance of acting on live input after an inhibiting decision. + Command teleop.State + // InputAge is the age of the newest observation on the monotonic clock. + InputAge time.Duration + // EvaluatedAt is the monotonic reading at which this decision was made. + EvaluatedAt time.Duration +} + +// Has reports whether the decision carries a specific reason. +func (d Decision) Has(reason Reason) bool { + for _, candidate := range d.Reasons { + if candidate == reason { + return true + } + } + return false +} diff --git a/safety/guard.go b/safety/guard.go new file mode 100644 index 0000000..d8f9471 --- /dev/null +++ b/safety/guard.go @@ -0,0 +1,508 @@ +// Package safety gates operator input behind a command timeout and a dead-man +// switch. +// +// teleop transports input; it does not decide whether acting on that input is +// safe. A Guard makes that decision explicit and fails closed: it authorizes +// output only when it can affirmatively establish that input is fresh, that an +// operator is present and engaged, that the application's own control loop is +// running, and that nothing has tripped since the last arming. Any condition +// it cannot prove inhibits output. +// +// A Guard is not a certified safety controller and does not replace a +// hardware emergency stop or an independent safety-rated interlock. It is the +// software half of a dead-man policy, and it is only as good as the actuation +// path that honors its decisions. +// +// Typical use: +// +// guard := safety.New( +// safety.WithCommandTimeout(150*time.Millisecond), +// safety.WithDeadMan(xbox.ButtonBumperRight), +// safety.WithDeadManReactuation(30*time.Second), +// safety.WithLoopWatchdog(100*time.Millisecond), +// ) +// controller, err := provider.Open(ctx, id, teleop.WithProcessor(guard)) +// if err != nil { +// return err +// } +// guard.Bind(controller) +// +// for range ticker.C { +// guard.Heartbeat() +// decision := guard.Evaluate() +// drive(decision.Command) // neutral whenever inhibited +// } +package safety + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/open-ships/teleop" +) + +const ( + // DefaultCommandTimeout is the maximum age of the newest observation for + // which output stays authorized. + DefaultCommandTimeout = 250 * time.Millisecond + // DefaultDeadManReactuation requires the operator to release and re-engage + // the dead-man control periodically. A control that is held indefinitely + // is indistinguishable from one that has been taped, wedged, or left in + // the hand of an incapacitated operator. + DefaultDeadManReactuation = 60 * time.Second +) + +// ErrUnbound reports an operation on a Guard that has no controller. +var ErrUnbound = errors.New("teleop/safety: guard is not bound to a controller") + +// Source is the controller surface a Guard depends on. *teleop.Controller +// satisfies it. +type Source interface { + SnapshotWithMeta() (teleop.State, teleop.StateMeta) + Monotonic() time.Duration + Done() <-chan struct{} +} + +type options struct { + commandTimeout time.Duration + deadMan teleop.ControlID + reactuation time.Duration + loopTimeout time.Duration + latching bool +} + +// Option configures a Guard. +type Option func(*options) + +// WithCommandTimeout sets the maximum age of the newest observation for which +// output stays authorized, measured on the monotonic clock. It cannot be +// disabled; a non-positive value leaves the default in place. +// +// Choose it from the worst tolerable actuation overrun, not from the expected +// input rate. Note that a change-driven backend legitimately falls silent +// while a control is held steady, so pair a short timeout with +// teleop.WithLiveness so held input keeps producing observations. +func WithCommandTimeout(timeout time.Duration) Option { + return func(o *options) { + if timeout > 0 { + o.commandTimeout = timeout + } + } +} + +// WithDeadMan requires that a control be held for output to be authorized. +// Releasing it inhibits output immediately. +func WithDeadMan(control teleop.ControlID) Option { + return func(o *options) { o.deadMan = control } +} + +// WithDeadManReactuation requires the dead-man control to be released and +// pressed again within the given interval. Set it to zero to allow an +// indefinite hold, which defeats the purpose of the switch. +func WithDeadManReactuation(interval time.Duration) Option { + return func(o *options) { o.reactuation = interval } +} + +// WithLoopWatchdog inhibits output when the application has not called +// Heartbeat within the interval. It detects a stalled or deadlocked control +// loop, which fresh controller input alone cannot reveal. +func WithLoopWatchdog(interval time.Duration) Option { + return func(o *options) { + if interval > 0 { + o.loopTimeout = interval + } + } +} + +// WithLatching controls whether a trip requires an explicit Arm to clear. It +// defaults to true: automatic resumption after a fault is how a transient +// dropout becomes an unexpected movement. +func WithLatching(latching bool) Option { + return func(o *options) { o.latching = latching } +} + +// Guard authorizes or inhibits operator output. It is safe for concurrent use. +// +// A Guard is also a teleop processor. Attaching it with teleop.WithProcessor +// gives it lossless edge detection on the dead-man control and publishes every +// transition into the controller's event stream and audit sinks. +type Guard struct { + options options + + mu sync.Mutex + source Source + + armed bool + latched bool + estop bool + + // heartbeat and deadManSince are monotonic readings from the bound + // controller's session clock. Zero is a legitimate reading at the start of + // a session, so each carries a separate flag rather than treating zero as + // "never set". + heartbeat time.Duration + heartbeatSeen bool + deadManSince time.Duration + deadManHeld bool + + state State + reasons []Reason + detail string + + // lastPublished is the decision behind the most recent published event, + // so transitions can be emitted without republishing a steady state. + lastPublished Decision + published bool +} + +// New returns a Guard that inhibits output until it is bound and armed. +func New(opts ...Option) *Guard { + configured := options{ + commandTimeout: DefaultCommandTimeout, + reactuation: DefaultDeadManReactuation, + latching: true, + } + for _, option := range opts { + if option != nil { + option(&configured) + } + } + return &Guard{ + options: configured, + state: StateSafe, + reasons: []Reason{ReasonUnbound}, + } +} + +// Bind attaches the Guard to a controller. Call it after Open. Until it is +// called, every decision inhibits output. +func (g *Guard) Bind(source Source) { + g.mu.Lock() + defer g.mu.Unlock() + g.source = source +} + +// Arm authorizes output subject to the configured conditions, and clears a +// latched trip. It fails while an emergency stop is active: an operator must +// Reset first, which keeps a stop from being cleared by reflex. +func (g *Guard) Arm() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.source == nil { + return ErrUnbound + } + if g.estop { + return errors.New("teleop/safety: cannot arm during an emergency stop") + } + g.armed = true + g.latched = false + g.detail = "" + return nil +} + +// Disarm inhibits output until the next Arm. +func (g *Guard) Disarm(detail string) { + g.mu.Lock() + defer g.mu.Unlock() + g.armed = false + g.latched = true + g.detail = detail +} + +// EmergencyStop latches an inhibit that only Reset can clear. +func (g *Guard) EmergencyStop(detail string) { + g.mu.Lock() + defer g.mu.Unlock() + g.estop = true + g.armed = false + g.latched = true + g.detail = detail +} + +// Reset clears an emergency stop. Output stays inhibited until Arm. +func (g *Guard) Reset(detail string) { + g.mu.Lock() + defer g.mu.Unlock() + g.estop = false + g.detail = detail +} + +// Heartbeat records that the application control loop is alive. Call it once +// per control-loop iteration when a loop watchdog is configured. +func (g *Guard) Heartbeat() { + g.mu.Lock() + defer g.mu.Unlock() + if g.source == nil { + return + } + g.heartbeat = g.source.Monotonic() + g.heartbeatSeen = true +} + +// Evaluate returns the authorization decision for this instant and is the only +// safe way to gate output. Call it immediately before acting, every time: a +// decision describes the moment it was made and nothing after it. +// +// Evaluate is not read-only. A condition that fails here latches the Guard, so +// output stays inhibited until an operator re-arms. +func (g *Guard) Evaluate() Decision { + g.mu.Lock() + defer g.mu.Unlock() + return g.evaluateLocked() +} + +// State returns the current authorization state without re-evaluating. +func (g *Guard) State() State { + g.mu.Lock() + defer g.mu.Unlock() + return g.state +} + +func (g *Guard) evaluateLocked() Decision { + if g.source == nil { + g.state = StateSafe + g.reasons = []Reason{ReasonUnbound} + return Decision{State: StateSafe, Reasons: g.copyReasons()} + } + + now := g.source.Monotonic() + state, meta := g.source.SnapshotWithMeta() + reasons := make([]Reason, 0, 4) + + select { + case <-g.source.Done(): + reasons = append(reasons, ReasonControllerFault) + default: + } + + // Input freshness. An input path that has never delivered an observation + // has never been shown to work, so it is treated as failed rather than as + // merely quiet. + var age time.Duration + switch meta.Sequence { + case 0: + reasons = append(reasons, ReasonNoInput) + age = now + default: + age = now - meta.ReceivedMonotonic + if age < 0 { + age = 0 + } + if age >= g.options.commandTimeout { + reasons = append(reasons, ReasonCommandTimeout) + } + } + if !meta.Connected { + reasons = append(reasons, ReasonDisconnected) + } + if meta.Synthetic { + reasons = append(reasons, ReasonSynthetic) + } + + if g.options.loopTimeout > 0 { + if !g.heartbeatSeen || now-g.heartbeat >= g.options.loopTimeout { + reasons = append(reasons, ReasonLoopStalled) + } + } + + if g.options.deadMan != "" { + switch { + case !state.Button(g.options.deadMan): + reasons = append(reasons, ReasonDeadManReleased) + case !g.deadManHeld: + // Held, but the Guard never saw the press that started the hold, + // so it cannot bound how long the control has been down. This is + // ordinary while a press propagates through the pipeline, so it + // inhibits without latching. + reasons = append(reasons, ReasonDeadManUnconfirmed) + case g.options.reactuation > 0 && now-g.deadManSince >= g.options.reactuation: + reasons = append(reasons, ReasonDeadManStale) + } + } + + if g.estop { + reasons = append(reasons, ReasonEmergencyStop) + } + if !g.armed { + reasons = append(reasons, ReasonNotArmed) + } + + // Any unmet condition latches, so a transient fault cannot silently + // restore authority when it clears. + if len(reasons) > 0 && g.options.latching && g.armed { + if !onlyDeadManEngagement(reasons) { + g.armed = false + g.latched = true + reasons = appendUnique(reasons, ReasonNotArmed) + } + } + + decision := Decision{ + Reasons: reasons, + InputAge: age, + EvaluatedAt: now, + } + switch { + case len(reasons) == 0: + decision.Permit = true + decision.State = StateLive + decision.Command = state + case onlyDeadManEngagement(reasons): + // Every automatic condition holds; the operator has simply let go. + decision.State = StateArmed + default: + decision.State = StateSafe + } + + g.state = decision.State + g.reasons = append([]Reason(nil), reasons...) + return decision +} + +// onlyDeadManEngagement reports a decision blocked solely because the operator +// is not currently engaging the dead-man control, or because an engagement has +// not yet been observed. Both are normal operation rather than faults, so +// neither may latch: a gate that demands a re-arm every time an operator lets +// go, or every time a press is still in flight, is one operators defeat. +func onlyDeadManEngagement(reasons []Reason) bool { + if len(reasons) == 0 { + return false + } + for _, reason := range reasons { + if reason != ReasonDeadManReleased && reason != ReasonDeadManUnconfirmed { + return false + } + } + return true +} + +func appendUnique(reasons []Reason, reason Reason) []Reason { + for _, candidate := range reasons { + if candidate == reason { + return reasons + } + } + return append(reasons, reason) +} + +func (g *Guard) copyReasons() []Reason { + return append([]Reason(nil), g.reasons...) +} + +// Process implements teleop.Processor for callers that cannot supply a +// ProcessingContext. It tracks dead-man edges but publishes no events. +func (g *Guard) Process(event teleop.Event) []teleop.Event { + g.mu.Lock() + defer g.mu.Unlock() + g.observeLocked(event) + return nil +} + +// ProcessContext implements teleop.ContextProcessor. It observes dead-man +// edges losslessly and publishes a decision event whenever authorization +// changes. +func (g *Guard) ProcessContext( + _ context.Context, + pc teleop.ProcessingContext, + event teleop.Event, +) ([]teleop.Event, error) { + g.mu.Lock() + defer g.mu.Unlock() + g.observeLocked(event) + decision := g.evaluateLocked() + return g.transitionLocked(pc, decision, event.Header().ID), nil +} + +// Advance implements teleop.AdvancingProcessor. +func (g *Guard) Advance(time.Time) []teleop.Event { return nil } + +// AdvanceContext implements teleop.ContextAdvancingProcessor. Periodic +// re-evaluation is what lets a command timeout or a stalled control loop trip +// the Guard even when no input is arriving and the application never calls +// Evaluate. +func (g *Guard) AdvanceContext( + _ context.Context, + pc teleop.ProcessingContext, + _ time.Time, +) ([]teleop.Event, error) { + g.mu.Lock() + defer g.mu.Unlock() + decision := g.evaluateLocked() + return g.transitionLocked(pc, decision, teleop.EventID{}), nil +} + +// observeLocked tracks dead-man press and release edges from the event stream. +// Sampling the snapshot would miss a press and release that fall between two +// evaluations, which is exactly the case a re-actuation deadline exists to +// detect. +func (g *Guard) observeLocked(event teleop.Event) { + if g.options.deadMan == "" { + return + } + button, ok := event.(teleop.ButtonEvent) + if !ok { + pointer, isPointer := event.(*teleop.ButtonEvent) + if !isPointer { + return + } + button = *pointer + } + if button.Button != g.options.deadMan { + return + } + if button.Pressed { + // Only a rising edge restarts the re-actuation deadline; a repeated + // press report for a control already down must not extend it. + if !g.deadManHeld { + g.deadManHeld = true + g.deadManSince = button.Meta.Monotonic + } + return + } + g.deadManHeld = false + g.deadManSince = 0 +} + +// transitionLocked emits an event only when authorization actually changed, so +// a steady state costs nothing in the log. +func (g *Guard) transitionLocked( + pc teleop.ProcessingContext, + decision Decision, + cause teleop.EventID, +) []teleop.Event { + if g.published && !g.changedLocked(decision) { + return nil + } + g.published = true + g.lastPublished = decision + + var causes []teleop.EventID + if cause != (teleop.EventID{}) { + causes = []teleop.EventID{cause} + } + header := pc.NewHeader("safety", pc.Now(), 0, causes...) + return []teleop.Event{Event{ + Meta: header, + State: decision.State, + Permit: decision.Permit, + Reasons: append([]Reason(nil), decision.Reasons...), + InputAge: decision.InputAge, + Detail: g.detail, + }} +} + +func (g *Guard) changedLocked(decision Decision) bool { + if decision.State != g.lastPublished.State || + decision.Permit != g.lastPublished.Permit || + len(decision.Reasons) != len(g.lastPublished.Reasons) { + return true + } + for index, reason := range decision.Reasons { + if g.lastPublished.Reasons[index] != reason { + return true + } + } + return false +} diff --git a/safety/guard_test.go b/safety/guard_test.go new file mode 100644 index 0000000..8a32ce5 --- /dev/null +++ b/safety/guard_test.go @@ -0,0 +1,475 @@ +package safety + +import ( + "sync" + "testing" + "time" + + "github.com/open-ships/teleop" +) + +// fakeSource drives a Guard from an explicit monotonic clock, so timeout +// behavior is tested deterministically rather than by sleeping. +type fakeSource struct { + mu sync.Mutex + state teleop.State + meta teleop.StateMeta + mono time.Duration + done chan struct{} +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + meta: teleop.StateMeta{Connected: true, Sequence: 1}, + done: make(chan struct{}), + } +} + +func (f *fakeSource) SnapshotWithMeta() (teleop.State, teleop.StateMeta) { + f.mu.Lock() + defer f.mu.Unlock() + return f.state, f.meta +} + +func (f *fakeSource) Monotonic() time.Duration { + f.mu.Lock() + defer f.mu.Unlock() + return f.mono +} + +func (f *fakeSource) Done() <-chan struct{} { return f.done } + +// advance moves the monotonic clock without delivering input, which is how a +// command timeout is provoked. +func (f *fakeSource) advance(delta time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.mono += delta +} + +// observe delivers a fresh observation at the current monotonic reading. +func (f *fakeSource) observe(state teleop.State) { + f.mu.Lock() + defer f.mu.Unlock() + f.state = state + f.meta.ReceivedMonotonic = f.mono + f.meta.Sequence++ + f.meta.Connected = true + f.meta.Synthetic = false +} + +func (f *fakeSource) setMeta(mutate func(*teleop.StateMeta)) { + f.mu.Lock() + defer f.mu.Unlock() + mutate(&f.meta) +} + +const deadMan = teleop.ButtonBumperRight + +func heldState() teleop.State { + var state teleop.State + state.SetButton(deadMan, true) + return state +} + +// liveGuard returns an armed, engaged Guard permitting output, which is the +// baseline each inhibiting condition is tested against. +func liveGuard(t *testing.T, opts ...Option) (*Guard, *fakeSource) { + t.Helper() + source := newFakeSource() + guard := New(append([]Option{ + WithCommandTimeout(100 * time.Millisecond), + WithDeadMan(deadMan), + }, opts...)...) + guard.Bind(source) + + // The Guard must witness the press that begins the hold. + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Pressed: true, + Phase: teleop.PhasePressed, + }) + source.observe(heldState()) + if err := guard.Arm(); err != nil { + t.Fatalf("arm: %v", err) + } + decision := guard.Evaluate() + if !decision.Permit { + t.Fatalf("baseline must permit, got %v", decision.Reasons) + } + return guard, source +} + +func TestUnboundGuardDeniesOutput(t *testing.T) { + guard := New() + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("an unbound guard must never permit output") + } + if !decision.Has(ReasonUnbound) { + t.Fatalf("reasons = %v", decision.Reasons) + } + if guard.Arm() == nil { + t.Fatal("arming an unbound guard must fail") + } +} + +func TestUnarmedGuardDeniesOutput(t *testing.T) { + source := newFakeSource() + guard := New(WithDeadMan(deadMan)) + guard.Bind(source) + source.observe(heldState()) + + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("an unarmed guard must not permit output") + } + if !decision.Has(ReasonNotArmed) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +// TestNeverObservedInputDeniesOutput covers a controller that opened but from +// which no observation ever arrived: silence that has never been broken is not +// evidence of a working input path. +func TestNeverObservedInputDeniesOutput(t *testing.T) { + source := newFakeSource() + source.setMeta(func(meta *teleop.StateMeta) { meta.Sequence = 0 }) + guard := New() + guard.Bind(source) + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("output must be denied before any observation arrives") + } + if !decision.Has(ReasonNoInput) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +func TestCommandTimeoutInhibitsAndNeutralizesCommand(t *testing.T) { + guard, source := liveGuard(t) + + source.advance(99 * time.Millisecond) + if decision := guard.Evaluate(); !decision.Permit { + t.Fatalf("input inside the timeout must stay authorized: %v", decision.Reasons) + } + + source.advance(2 * time.Millisecond) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("input older than the command timeout must inhibit output") + } + if !decision.Has(ReasonCommandTimeout) { + t.Fatalf("reasons = %v", decision.Reasons) + } + if decision.Command.Button(deadMan) || decision.Command.LeftStick != (teleop.Stick{}) { + t.Fatal("an inhibited decision must carry the neutral command state") + } + if decision.State != StateSafe { + t.Fatalf("state = %q, want %q", decision.State, StateSafe) + } +} + +// TestCommandTimeoutLatches is the property that keeps a transient radio +// dropout from becoming an unexpected movement when the link returns. +func TestCommandTimeoutLatches(t *testing.T) { + guard, source := liveGuard(t) + + source.advance(200 * time.Millisecond) + if guard.Evaluate().Permit { + t.Fatal("timeout must inhibit") + } + + source.observe(heldState()) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("fresh input must not silently restore authority after a trip") + } + if !decision.Has(ReasonNotArmed) { + t.Fatalf("reasons = %v", decision.Reasons) + } + + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("an explicit re-arm must restore authority") + } +} + +func TestLatchingCanBeDisabled(t *testing.T) { + guard, source := liveGuard(t, WithLatching(false)) + + source.advance(200 * time.Millisecond) + if guard.Evaluate().Permit { + t.Fatal("timeout must inhibit") + } + source.observe(heldState()) + if !guard.Evaluate().Permit { + t.Fatal("a non-latching guard must resume when the condition clears") + } +} + +// TestDeadManReleaseDoesNotLatch distinguishes normal operation from a fault: +// letting go is expected and must not require re-arming. +func TestDeadManReleaseDoesNotLatch(t *testing.T) { + guard, source := liveGuard(t) + + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Phase: teleop.PhaseReleased, + }) + source.observe(teleop.State{}) + + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("releasing the dead-man control must inhibit output") + } + if decision.State != StateArmed { + t.Fatalf("state = %q, want %q", decision.State, StateArmed) + } + if !decision.Has(ReasonDeadManReleased) { + t.Fatalf("reasons = %v", decision.Reasons) + } + + source.advance(10 * time.Millisecond) + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Pressed: true, + Phase: teleop.PhasePressed, + }) + source.observe(heldState()) + if !guard.Evaluate().Permit { + t.Fatal("re-engaging must restore authority without a re-arm") + } +} + +// TestDeadManReactuationDeadline detects a control held down indefinitely, +// which is what a taped or wedged switch looks like. +func TestDeadManReactuationDeadline(t *testing.T) { + guard, source := liveGuard(t, WithDeadManReactuation(500*time.Millisecond)) + + for elapsed := time.Duration(0); elapsed < 400*time.Millisecond; elapsed += 50 * time.Millisecond { + source.advance(50 * time.Millisecond) + source.observe(heldState()) + if !guard.Evaluate().Permit { + t.Fatalf("a held control must stay authorized before the deadline at %s", elapsed) + } + } + + source.advance(150 * time.Millisecond) + source.observe(heldState()) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("a control held past the re-actuation deadline must inhibit output") + } + if !decision.Has(ReasonDeadManStale) { + t.Fatalf("reasons = %v", decision.Reasons) + } + + // Releasing and pressing again restarts the deadline, but the trip + // latched, so an explicit re-arm is still required. + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Phase: teleop.PhaseReleased, + }) + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Pressed: true, + Phase: teleop.PhasePressed, + }) + source.observe(heldState()) + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("re-actuation followed by a re-arm must restore authority") + } +} + +// TestDeadManHeldWithoutObservedPressIsStale covers a Guard bound while the +// control was already down: it cannot bound the hold, so it must not trust it. +func TestDeadManHeldWithoutObservedPressIsStale(t *testing.T) { + source := newFakeSource() + guard := New(WithDeadMan(deadMan)) + guard.Bind(source) + source.observe(heldState()) + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("an unobserved hold must not authorize output") + } + if !decision.Has(ReasonDeadManUnconfirmed) { + t.Fatalf("reasons = %v", decision.Reasons) + } + // An unobserved hold is ordinary rather than a fault, so it must not + // demand a re-arm once the press is seen. + if decision.State != StateArmed { + t.Fatalf("state = %q, want %q", decision.State, StateArmed) + } + guard.Process(teleop.ButtonEvent{ + Meta: teleop.Header{Monotonic: source.Monotonic()}, + Button: deadMan, + Pressed: true, + Phase: teleop.PhasePressed, + }) + if !guard.Evaluate().Permit { + t.Fatal("observing the press must restore authority without a re-arm") + } +} + +func TestLoopWatchdogDetectsStalledControlLoop(t *testing.T) { + source := newFakeSource() + guard := New( + WithCommandTimeout(time.Second), + WithLoopWatchdog(50*time.Millisecond), + ) + guard.Bind(source) + source.observe(teleop.State{}) + guard.Heartbeat() + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("a heartbeat inside the watchdog interval must permit output") + } + + source.advance(60 * time.Millisecond) + source.observe(teleop.State{}) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("a stalled control loop must inhibit output even with fresh input") + } + if !decision.Has(ReasonLoopStalled) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +func TestEmergencyStopLatchesUntilReset(t *testing.T) { + guard, source := liveGuard(t) + + guard.EmergencyStop("obstacle in the work area") + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("an emergency stop must inhibit output") + } + if !decision.Has(ReasonEmergencyStop) { + t.Fatalf("reasons = %v", decision.Reasons) + } + + if guard.Arm() == nil { + t.Fatal("arming during an emergency stop must fail") + } + + guard.Reset("area cleared") + if guard.Evaluate().Permit { + t.Fatal("reset alone must not restore authority") + } + source.observe(heldState()) + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("reset followed by arm must restore authority") + } +} + +func TestDisconnectAndSyntheticStateInhibitOutput(t *testing.T) { + guard, source := liveGuard(t) + + source.setMeta(func(meta *teleop.StateMeta) { meta.Connected = false }) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("a disconnected controller must inhibit output") + } + if !decision.Has(ReasonDisconnected) { + t.Fatalf("reasons = %v", decision.Reasons) + } + + guard2, source2 := liveGuard(t) + source2.setMeta(func(meta *teleop.StateMeta) { meta.Synthetic = true }) + decision = guard2.Evaluate() + if decision.Permit { + t.Fatal("synthesized state must inhibit output") + } + if !decision.Has(ReasonSynthetic) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +func TestControllerFaultInhibitsOutput(t *testing.T) { + guard, source := liveGuard(t) + + close(source.done) + decision := guard.Evaluate() + if decision.Permit { + t.Fatal("a terminated controller must inhibit output") + } + if !decision.Has(ReasonControllerFault) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +// TestCommandTimeoutIgnoresWallClock is the reason the timeout is measured on +// the monotonic clock: a wall-clock step must not make stale input look fresh +// or fresh input look stale. +func TestCommandTimeoutIgnoresWallClock(t *testing.T) { + guard, source := liveGuard(t) + + // A wall clock jumping an hour backwards leaves monotonic readings alone. + source.setMeta(func(meta *teleop.StateMeta) { + meta.ReceivedAt = time.Now().Add(-time.Hour) + }) + if !guard.Evaluate().Permit { + t.Fatal("a wall-clock step must not affect the command timeout") + } +} + +func TestDisarmInhibitsUntilRearmed(t *testing.T) { + guard, source := liveGuard(t) + + guard.Disarm("shift handover") + if guard.Evaluate().Permit { + t.Fatal("a disarmed guard must inhibit output") + } + source.observe(heldState()) + if guard.Evaluate().Permit { + t.Fatal("fresh input must not undo a disarm") + } + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("re-arming must restore authority") + } +} + +func TestConcurrentEvaluationIsRaceFree(t *testing.T) { + guard, source := liveGuard(t) + + var waiting sync.WaitGroup + for range 8 { + waiting.Add(1) + go func() { + defer waiting.Done() + for range 200 { + guard.Heartbeat() + guard.Evaluate() + source.observe(heldState()) + } + }() + } + waiting.Wait() +} diff --git a/safety/integration_test.go b/safety/integration_test.go new file mode 100644 index 0000000..4ddf490 --- /dev/null +++ b/safety/integration_test.go @@ -0,0 +1,307 @@ +package safety_test + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/open-ships/teleop" + "github.com/open-ships/teleop/audit" + "github.com/open-ships/teleop/safety" + "github.com/open-ships/teleop/testkit" +) + +const deadMan = teleop.ButtonBumperRight + +func descriptor() teleop.Descriptor { + return teleop.Descriptor{ + ID: "fake:0", + Backend: "testkit", + Capability: teleop.Capabilities{ + Controls: []teleop.ControlDescriptor{ + {ID: deadMan, Kind: teleop.ControlButton}, + }, + }, + } +} + +// waitForState blocks until the controller's snapshot satisfies want. The +// input stream carries connection and capability events too, so a sequence +// number is not a reliable signal that a specific observation was processed. +func waitForState( + t *testing.T, + controller *teleop.Controller, + description string, + want func(teleop.State) bool, +) teleop.StateMeta { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + state, meta := controller.SnapshotWithMeta() + if want(state) { + return meta + } + time.Sleep(time.Millisecond) + } + t.Fatalf("controller never reached state: %s", description) + return teleop.StateMeta{} +} + +func deadManHeld(state teleop.State) bool { return state.Button(deadMan) } +func deadManClear(state teleop.State) bool { return !state.Button(deadMan) } + +// waitForPermit polls until the guard authorizes output. A snapshot showing +// the dead-man control held does not by itself mean the guard has observed the +// press that started the hold: the snapshot is committed when the observation +// is handled, while the guard learns of the press when the resulting button +// event reaches it. Polling the guard's own decision is the only correct +// synchronization point. +func waitForPermit(t *testing.T, guard *safety.Guard) safety.Decision { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var decision safety.Decision + for time.Now().Before(deadline) { + guard.Heartbeat() + decision = guard.Evaluate() + if decision.Permit { + return decision + } + time.Sleep(time.Millisecond) + } + t.Fatalf("guard never permitted output: %v", decision.Reasons) + return decision +} + +// TestGuardedSessionIsFullyRecorded exercises the whole chain: a guard gates +// output, the application records the command it issued, and the signed audit +// log reproduces the decision, the command, and the causal link between them. +func TestGuardedSessionIsFullyRecorded(t *testing.T) { + public, private, err := audit.GenerateKey() + if err != nil { + t.Fatal(err) + } + + provenance := audit.CaptureProvenance() + provenance.Application = "integration-test" + provenance.Operator = "operator-1" + provenance.Config = map[string]any{"command_timeout_ms": 500} + + log := &bytes.Buffer{} + recorder := audit.NewRecorder( + log, + audit.WithSigner(private), + audit.WithProvenance(provenance), + audit.WithCheckpoints(0, 5), + ) + + source := testkit.NewFakeSource(descriptor(), 16) + guard := safety.New( + safety.WithCommandTimeout(500*time.Millisecond), + safety.WithDeadMan(deadMan), + safety.WithLoopWatchdog(time.Second), + ) + controller, err := teleop.NewController( + source, + teleop.WithProcessor(guard), + teleop.WithAuditSink(recorder), + ) + if err != nil { + t.Fatal(err) + } + guard.Bind(controller) + + var held teleop.State + held.SetButton(deadMan, true) + if err := source.Push(t.Context(), held); err != nil { + t.Fatal(err) + } + waitForState(t, controller, "dead-man held", deadManHeld) + + guard.Heartbeat() + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + decision := waitForPermit(t, guard) + + _, meta := controller.SnapshotWithMeta() + err = controller.RecordCommand(t.Context(), teleop.Command{ + Name: "thrust.set", + Payload: map[string]any{"newtons": 120}, + Authorized: decision.Permit, + Causes: []teleop.EventID{{ + Session: controller.Session(), + Stream: "input", + Sequence: meta.Sequence, + }}, + }) + if err != nil { + t.Fatal(err) + } + + // Release the dead-man control and record the inhibited command that the + // application still computed, which is what shows the gate did its job. + if err := source.Push(t.Context(), teleop.State{}); err != nil { + t.Fatal(err) + } + waitForState(t, controller, "dead-man released", deadManClear) + guard.Heartbeat() + inhibited := guard.Evaluate() + if inhibited.Permit { + t.Fatal("releasing the dead-man control must inhibit output") + } + err = controller.RecordCommand(t.Context(), teleop.Command{ + Name: "thrust.set", + Payload: map[string]any{"newtons": 0}, + Authorized: false, + Reason: string(safety.ReasonDeadManReleased), + }) + if err != nil { + t.Fatal(err) + } + + if err := controller.Close(); err != nil { + t.Fatal(err) + } + if err := recorder.Close(); err != nil { + t.Fatal(err) + } + + records, verification, err := audit.Read(log, audit.VerifyOptions{ + RequireFooter: true, + RequireSignature: true, + PublicKey: public, + }) + if err != nil { + t.Fatalf("verify recorded session: %v", err) + } + if !verification.Trusted { + t.Fatal("session must verify against the trusted key") + } + if verification.Provenance == nil || + verification.Provenance.Application != "integration-test" { + t.Fatalf("provenance = %+v", verification.Provenance) + } + if verification.Session != controller.Session() { + t.Fatal("manifest session must match the controller session") + } + + var ( + decisions []safety.Event + commands []teleop.CommandEvent + monotonic bool + known = make(map[teleop.EventID]struct{}) + ) + for _, record := range records { + known[record.Header.ID] = struct{}{} + if record.Header.Monotonic > 0 { + monotonic = true + } + switch record.Kind { + case safety.EventDecision: + var event safety.Event + if err := json.Unmarshal(record.Payload, &event); err != nil { + t.Fatal(err) + } + decisions = append(decisions, event) + case teleop.EventCommand: + var event teleop.CommandEvent + if err := json.Unmarshal(record.Payload, &event); err != nil { + t.Fatal(err) + } + commands = append(commands, event) + } + } + + if !monotonic { + t.Fatal("recorded headers must carry monotonic readings") + } + if len(decisions) < 2 { + t.Fatalf("expected safety transitions in the log, got %d", len(decisions)) + } + if len(commands) != 2 { + t.Fatalf("expected 2 recorded commands, got %d", len(commands)) + } + if !commands[0].Authorized { + t.Fatal("the first command must be recorded as authorized") + } + if commands[1].Authorized { + t.Fatal("the second command must be recorded as inhibited") + } + if commands[1].Reason != string(safety.ReasonDeadManReleased) { + t.Fatalf("inhibit reason = %q", commands[1].Reason) + } + + // The authorized command must be traceable back to the observation that + // produced it; a command with no cause is not reconstructable. + if len(commands[0].Meta.Causes) == 0 { + t.Fatal("the authorized command must record its cause") + } + for _, cause := range commands[0].Meta.Causes { + if _, ok := known[cause]; !ok { + t.Fatalf("cause %v is not present in the log", cause) + } + } + + // A transition to inhibited must be recorded, not merely implied by the + // absence of an authorization. + inhibitedRecorded := false + for _, event := range decisions { + if !event.Permit { + inhibitedRecorded = true + } + } + if !inhibitedRecorded { + t.Fatal("an inhibiting transition must appear in the log") + } +} + +// TestGuardTripsWithoutApplicationInvolvement proves the guard inhibits on its +// own timer: an application that stops calling Evaluate still has its stale +// authorization revoked and recorded. +func TestGuardTripsWithoutApplicationInvolvement(t *testing.T) { + log := &bytes.Buffer{} + recorder := audit.NewRecorder(log) + source := testkit.NewFakeSource(descriptor(), 16) + guard := safety.New(safety.WithCommandTimeout(30 * time.Millisecond)) + + controller, err := teleop.NewController( + source, + teleop.WithProcessor(guard), + teleop.WithAuditSink(recorder), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := controller.Close(); err != nil { + t.Errorf("close controller: %v", err) + } + }) + guard.Bind(controller) + + if err := source.Push(t.Context(), teleop.State{}); err != nil { + t.Fatal(err) + } + waitForState(t, controller, "first observation", func(teleop.State) bool { + _, meta := controller.SnapshotWithMeta() + return meta.Sequence > 0 && !meta.Synthetic + }) + if err := guard.Arm(); err != nil { + t.Fatal(err) + } + if !guard.Evaluate().Permit { + t.Fatal("guard must permit immediately after arming on fresh input") + } + + // Deliver nothing further and never call Evaluate again. The controller's + // advance ticker must drive the guard to a safe state on its own. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if guard.State() == safety.StateSafe { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("guard did not trip on its own timer") +} diff --git a/source.go b/source.go index cfd8c6a..e810676 100644 --- a/source.go +++ b/source.go @@ -3,19 +3,41 @@ package teleop import ( "context" "errors" + "fmt" "time" ) var ( - ErrClosed = errors.New("teleop: controller closed") - ErrDisconnected = errors.New("teleop: controller disconnected") - ErrUnsupported = errors.New("teleop: unsupported") - ErrPermission = errors.New("teleop: permission denied") - ErrUnavailable = errors.New("teleop: unavailable") + // ErrClosed reports an explicitly closed controller or subscription. + ErrClosed = errors.New("teleop: controller closed") + // ErrDisconnected reports that the device transport was lost. + ErrDisconnected = errors.New("teleop: controller disconnected") + // ErrUnsupported reports an operation unavailable on this platform or device. + ErrUnsupported = errors.New("teleop: unsupported") + // ErrPermission reports insufficient permission to access a device. + ErrPermission = errors.New("teleop: permission denied") + // ErrUnavailable reports that a requested device or resource is unavailable. + ErrUnavailable = errors.New("teleop: unavailable") + // ErrSubscriptionOverflow reports loss on a lossless subscription. ErrSubscriptionOverflow = errors.New("teleop: subscription overflow") + // ErrPipelineOverflow reports that a bounded controller pipeline could not + // keep pace with the device stream. + ErrPipelineOverflow = errors.New("teleop: pipeline overflow") + // ErrCallbackPanic reports a panic recovered from an InputSource, EventSink, + // Processor, or third-party event implementation. + ErrCallbackPanic = errors.New("teleop: callback panic") + // ErrCallbackTimeout reports a callback that did not return before its + // configured deadline. + ErrCallbackTimeout = errors.New("teleop: callback timeout") + // ErrInvalidState reports a non-finite or out-of-range backend state. + ErrInvalidState = errors.New("teleop: invalid controller state") ) +// SourceGap describes input known or suspected to be missing before an +// observation. type SourceGap struct { + // Dropped is the known count, or a lower bound when Reason identifies a + // loss signal that cannot report its exact magnitude. Zero means unknown. Dropped uint64 Reason string } @@ -37,10 +59,19 @@ type InputSource interface { Close() error } +// EventSink receives the authoritative ordered controller event stream. type EventSink interface { Record(context.Context, Event) error } +// ProcessingContext supplies controller-owned identity and timing to a +// processor. Using NewHeader prevents derived event ID collisions when +// multiple processor instances are composed. +type ProcessingContext interface { + NewHeader(stream string, observedAt time.Time, deviceTimestamp int64, causes ...EventID) Header + Now() time.Time +} + // Processor derives events from events earlier in a controller pipeline. // Processors are applied in option order; a later processor sees canonical // events and output from all earlier processors. @@ -48,6 +79,14 @@ type Processor interface { Process(Event) []Event } +// ContextProcessor is the preferred processor contract. Legacy Processor +// implementations remain supported, but cannot use the controller's identity +// allocator or deterministic clock. +type ContextProcessor interface { + Processor + ProcessContext(context.Context, ProcessingContext, Event) ([]Event, error) +} + // AdvancingProcessor emits time-based events even when the controller is // otherwise idle. Controller invokes it on a short internal ticker. type AdvancingProcessor interface { @@ -55,15 +94,48 @@ type AdvancingProcessor interface { Advance(time.Time) []Event } +// ContextAdvancingProcessor is the deterministic, cancellable form of +// AdvancingProcessor. +type ContextAdvancingProcessor interface { + ContextProcessor + AdvanceContext(context.Context, ProcessingContext, time.Time) ([]Event, error) +} + +// Ticker is the clock seam used by Controller for liveness and advancing +// processors. +type Ticker interface { + C() <-chan time.Time + Stop() +} + +// Clock provides deterministic controller time in tests and replay. +type Clock interface { + Now() time.Time + NewTicker(time.Duration) Ticker +} + type controllerOptions struct { - sinks []EventSink - processors []Processor + sinks []EventSink + processors []Processor + context context.Context + clock Clock + ingestBuffer int + sinkBuffer int + livenessInterval time.Duration + staleAfter time.Duration + callbackTimeout time.Duration + shutdownTimeout time.Duration + clockStepThreshold time.Duration + neutralizeOnStale bool + deferredStart bool } +// OpenOption configures a controller session. type OpenOption func(*controllerOptions) -// WithAuditSink attaches an authoritative ingress sink. The controller records -// each event to all sinks before publishing it to subscriptions. +// WithAuditSink attaches an authoritative ingress sink. The controller accepts +// each event into every bounded sink queue before publishing it to +// subscriptions; sink I/O runs independently of device ingest. func WithAuditSink(sink EventSink) OpenOption { return func(options *controllerOptions) { if sink != nil { @@ -81,3 +153,102 @@ func WithProcessor(processor Processor) OpenOption { } } } + +// WithContext binds the controller lifetime to ctx. +func WithContext(ctx context.Context) OpenOption { + return func(options *controllerOptions) { + if ctx != nil { + options.context = ctx + } + } +} + +// WithDeferredStart waits to start device ingest until the first Snapshot or +// Subscribe call. It is useful for finite replay sources, whose complete event +// history must not finish before a subscriber is attached. Audit-only sessions +// should use the default eager start. +func WithDeferredStart() OpenOption { + return func(options *controllerOptions) { + options.deferredStart = true + } +} + +// WithClock replaces wall-clock time and tickers. It is primarily intended for +// deterministic replay and tests. +func WithClock(clock Clock) OpenOption { + return func(options *controllerOptions) { + if clock != nil { + options.clock = clock + } + } +} + +// WithPipelineBuffers sets the bounded source-ingest and per-sink queue sizes. +func WithPipelineBuffers(ingest, sink int) OpenOption { + return func(options *controllerOptions) { + if ingest > 0 { + options.ingestBuffer = ingest + } + if sink > 0 { + options.sinkBuffer = sink + } + } +} + +// WithLiveness configures observation-age events and the age at which metadata +// is marked stale. Set interval to zero to disable heartbeat events; freshness +// metadata is still checked at staleAfter. Change-driven backends emit nothing +// while a control is held steady, so age alone does not prove a transport +// failure and does not neutralize state unless WithNeutralizeOnStale(true) is +// also supplied. +func WithLiveness(interval, staleAfter time.Duration) OpenOption { + return func(options *controllerOptions) { + if interval < 0 || staleAfter < 0 { + panic(fmt.Sprintf("teleop: negative liveness duration: %s, %s", interval, staleAfter)) + } + options.livenessInterval = interval + options.staleAfter = staleAfter + } +} + +// WithNeutralizeOnStale opts into synthesizing a neutral observation when the +// configured observation-age threshold is exceeded. Applications should use +// this only when silence is known to indicate transport failure for their +// source. Disconnects are always neutralized. +func WithNeutralizeOnStale(enabled bool) OpenOption { + return func(options *controllerOptions) { + options.neutralizeOnStale = enabled + } +} + +// WithCallbackTimeout bounds Processor and EventSink calls. +func WithCallbackTimeout(timeout time.Duration) OpenOption { + return func(options *controllerOptions) { + if timeout > 0 { + options.callbackTimeout = timeout + } + } +} + +// WithClockStepThreshold sets the wall-versus-monotonic divergence reported as +// a ClockEvent. It defaults to DefaultClockStepThreshold. Wall-clock +// timestamps spanning a step are not comparable; header monotonic readings +// remain valid across one. +func WithClockStepThreshold(threshold time.Duration) OpenOption { + return func(options *controllerOptions) { + if threshold > 0 { + options.clockStepThreshold = threshold + } + } +} + +// WithShutdownTimeout bounds terminal sink draining and source closure after +// any in-flight callback has reached its separately configured callback +// timeout. +func WithShutdownTimeout(timeout time.Duration) OpenOption { + return func(options *controllerOptions) { + if timeout > 0 { + options.shutdownTimeout = timeout + } + } +} diff --git a/state.go b/state.go index 23cb06a..9f53bd4 100644 --- a/state.go +++ b/state.go @@ -150,11 +150,13 @@ type State struct { DPad DPad `json:"dpad"` } +// Clone returns an isolated copy of the controller state. func (s State) Clone() State { s.Buttons = s.Buttons.clone() return s } +// Button reports the current digital state of a button or D-pad direction. func (s State) Button(id ControlID) bool { switch id { case DPadUp: @@ -170,6 +172,7 @@ func (s State) Button(id ControlID) bool { } } +// SetButton updates a button or D-pad direction. func (s *State) SetButton(id ControlID, pressed bool) { switch id { case DPadUp: diff --git a/testkit/fake.go b/testkit/fake.go index 3df4235..a77ef16 100644 --- a/testkit/fake.go +++ b/testkit/fake.go @@ -11,6 +11,7 @@ import ( "github.com/open-ships/teleop" ) +// FakeSource is a bounded, push-driven teleop.InputSource for tests. type FakeSource struct { descriptor teleop.Descriptor observations chan teleop.Observation @@ -18,6 +19,7 @@ type FakeSource struct { closeOnce sync.Once } +// NewFakeSource returns a fake source with sensible virtual-device defaults. func NewFakeSource(descriptor teleop.Descriptor, buffer int) *FakeSource { if buffer <= 0 { buffer = 64 @@ -41,6 +43,7 @@ func NewFakeSource(descriptor teleop.Descriptor, buffer int) *FakeSource { } } +// Descriptor implements teleop.InputSource. func (f *FakeSource) Descriptor() teleop.Descriptor { return f.descriptor.Clone() } @@ -56,6 +59,7 @@ func (f *FakeSource) Read(ctx context.Context) (teleop.Observation, error) { } } +// Push queues a canonical state observed at the current wall-clock time. func (f *FakeSource) Push(ctx context.Context, state teleop.State) error { return f.PushObservation(ctx, teleop.Observation{ State: state, @@ -66,7 +70,13 @@ func (f *FakeSource) Push(ctx context.Context, state teleop.State) error { }) } +// PushObservation queues an exact observation for the controller ingest loop. func (f *FakeSource) PushObservation(ctx context.Context, observation teleop.Observation) error { + select { + case <-f.done: + return teleop.ErrClosed + default: + } select { case <-ctx.Done(): return ctx.Err() @@ -77,11 +87,13 @@ func (f *FakeSource) PushObservation(ctx context.Context, observation teleop.Obs } } +// Close implements teleop.InputSource. func (f *FakeSource) Close() error { f.closeOnce.Do(func() { close(f.done) }) return nil } +// ReplaySource is a finite teleop.InputSource over recorded observations. type ReplaySource struct { descriptor teleop.Descriptor observations []teleop.Observation @@ -90,6 +102,7 @@ type ReplaySource struct { closed bool } +// NewReplaySource copies descriptor and observations into a finite replay. func NewReplaySource(descriptor teleop.Descriptor, observations []teleop.Observation) *ReplaySource { return &ReplaySource{ descriptor: descriptor.Clone(), @@ -97,6 +110,7 @@ func NewReplaySource(descriptor teleop.Descriptor, observations []teleop.Observa } } +// Descriptor implements teleop.InputSource. func (r *ReplaySource) Descriptor() teleop.Descriptor { return r.descriptor.Clone() } @@ -115,6 +129,7 @@ func (r *ReplaySource) Read(ctx context.Context) (teleop.Observation, error) { return observation, nil } +// Close implements teleop.InputSource. func (r *ReplaySource) Close() error { r.mu.Lock() r.closed = true diff --git a/testkit/fake_test.go b/testkit/fake_test.go new file mode 100644 index 0000000..72ce753 --- /dev/null +++ b/testkit/fake_test.go @@ -0,0 +1,67 @@ +package testkit_test + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/open-ships/teleop" + "github.com/open-ships/teleop/testkit" +) + +func TestFakeSourcePushReadAndClose(t *testing.T) { + source := testkit.NewFakeSource(teleop.Descriptor{}, 1) + state := teleop.State{LeftTrigger: 0.5} + if err := source.Push(context.Background(), state); err != nil { + t.Fatal(err) + } + observation, err := source.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + if observation.State.LeftTrigger != 0.5 || + observation.Native.Format != "testkit" || + observation.ObservedAt.IsZero() { + t.Fatalf("observation = %#v", observation) + } + if err := source.Close(); err != nil { + t.Fatal(err) + } + if _, err := source.Read(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("Read after Close error = %v, want EOF", err) + } + if err := source.Push(context.Background(), state); !errors.Is(err, teleop.ErrClosed) { + t.Fatalf("Push after Close error = %v, want ErrClosed", err) + } +} + +func TestReplaySourcePreservesOrderAndHonorsClose(t *testing.T) { + observedAt := time.Unix(100, 0) + source := testkit.NewReplaySource( + teleop.Descriptor{ID: "replay:0"}, + []teleop.Observation{ + {ObservedAt: observedAt, State: teleop.State{LeftTrigger: 0.25}}, + {ObservedAt: observedAt.Add(time.Second), State: teleop.State{LeftTrigger: 0.75}}, + }, + ) + for _, want := range []float32{0.25, 0.75} { + observation, err := source.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + if observation.State.LeftTrigger != want { + t.Fatalf("trigger = %v, want %v", observation.State.LeftTrigger, want) + } + } + if _, err := source.Read(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("exhausted replay error = %v, want EOF", err) + } + if err := source.Close(); err != nil { + t.Fatal(err) + } + if _, err := source.Read(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("closed replay error = %v, want EOF", err) + } +} diff --git a/tracker.go b/tracker.go index d887c76..02bc525 100644 --- a/tracker.go +++ b/tracker.go @@ -4,31 +4,11 @@ import "sort" func diffEvents(previous, current State, header func() Header) []Event { var events []Event - buttons := StandardButtonIDs() - seen := make(map[ControlID]bool) - for _, id := range buttons { - seen[id] = true - } - var extensions []ControlID - for id := range previous.Buttons.Extensions { - if !seen[id] { - extensions = append(extensions, id) - seen[id] = true - } - } - for id := range current.Buttons.Extensions { - if !seen[id] { - extensions = append(extensions, id) - seen[id] = true - } - } - sort.Slice(extensions, func(i, j int) bool { return extensions[i] < extensions[j] }) - buttons = append(buttons, extensions...) - for _, id := range buttons { + appendButton := func(id ControlID) { before := previous.Button(id) after := current.Button(id) if before == after { - continue + return } phase := PhaseReleased if after { @@ -41,6 +21,30 @@ func diffEvents(previous, current State, header func() Header) []Event { Pressed: after, }) } + for _, id := range standardButtons { + appendButton(id) + } + + var extensions []ControlID + for id := range previous.Buttons.Extensions { + if !isStandardButton(id) { + extensions = append(extensions, id) + } + } + for id := range current.Buttons.Extensions { + if !isStandardButton(id) { + extensions = append(extensions, id) + } + } + sort.Slice(extensions, func(i, j int) bool { return extensions[i] < extensions[j] }) + var previousID ControlID + for index, id := range extensions { + if index > 0 && id == previousID { + continue + } + appendButton(id) + previousID = id + } if previous.LeftStick != current.LeftStick { events = append(events, StickEvent{ @@ -82,3 +86,12 @@ func diffEvents(previous, current State, header func() Header) []Event { } return events } + +func isStandardButton(id ControlID) bool { + for _, standard := range standardButtons { + if id == standard { + return true + } + } + return false +} diff --git a/tracker_test.go b/tracker_test.go index 3fc4336..484652f 100644 --- a/tracker_test.go +++ b/tracker_test.go @@ -53,6 +53,47 @@ func TestDPadIDsUseButtonNamespace(t *testing.T) { } } +func TestDiffEventsCoversEveryStateShapeInDeterministicOrder(t *testing.T) { + var sequence uint64 + header := func() Header { + sequence++ + return Header{ID: EventID{Sequence: sequence}} + } + current := State{ + LeftStick: Stick{X: -0.5, Y: 0.75}, + RightStick: Stick{X: 0.25, Y: -1}, + LeftTrigger: 0.4, + RightTrigger: 1, + } + current.SetButton(ButtonFaceNorth, true) + current.SetButton("button.extension.z", true) + current.SetButton("button.extension.a", true) + + events := diffEvents(State{}, current, header) + if len(events) != 7 { + t.Fatalf("events = %#v, want 7 state changes", events) + } + assertButtonEvent(t, events[0], ButtonFaceNorth, PhasePressed, true) + assertButtonEvent(t, events[1], "button.extension.a", PhasePressed, true) + assertButtonEvent(t, events[2], "button.extension.z", PhasePressed, true) + if event, ok := events[3].(StickEvent); !ok || + event.Stick != LeftStick || event.Position != current.LeftStick { + t.Fatalf("left-stick event = %#v", events[3]) + } + if event, ok := events[4].(StickEvent); !ok || + event.Stick != RightStick || event.Position != current.RightStick { + t.Fatalf("right-stick event = %#v", events[4]) + } + if event, ok := events[5].(TriggerEvent); !ok || + event.Trigger != LeftTrigger || event.Position != current.LeftTrigger { + t.Fatalf("left-trigger event = %#v", events[5]) + } + if event, ok := events[6].(TriggerEvent); !ok || + event.Trigger != RightTrigger || event.Position != current.RightTrigger { + t.Fatalf("right-trigger event = %#v", events[6]) + } +} + func assertButtonEvent( t *testing.T, event Event, diff --git a/xbox/aliases.go b/xbox/aliases.go index 361a543..b31ceea 100644 --- a/xbox/aliases.go +++ b/xbox/aliases.go @@ -4,6 +4,8 @@ package xbox import "github.com/open-ships/teleop" +// Xbox button aliases map familiar printed labels to controller-neutral +// control positions. const ( ButtonA = teleop.ButtonFaceSouth ButtonB = teleop.ButtonFaceEast diff --git a/xbox/ioctl_linux_13bit.go b/xbox/ioctl_linux_13bit.go new file mode 100644 index 0000000..1826fe3 --- /dev/null +++ b/xbox/ioctl_linux_13bit.go @@ -0,0 +1,15 @@ +//go:build linux && (ppc || ppc64 || ppc64le || mips || mipsle || mips64 || mips64le) + +package xbox + +// linuxIOR constructs an _IOR request for Linux architectures whose kernel +// ABI uses a 13-bit size field and puts direction at bit 29. +func linuxIOR(kind, number, size uintptr) uintptr { + const ( + read = 2 + directionShift = 29 + sizeShift = 16 + typeShift = 8 + ) + return uintptr(read)< #import +#import #include #include #include @@ -10,10 +11,11 @@ #define TELEOP_QUEUE_SIZE 4096 -typedef struct { +typedef struct teleop_gc_handle { GCController *controller; id disconnect_observer; dispatch_queue_t handler_queue; + dispatch_queue_t previous_handler_queue; pthread_mutex_t mutex; pthread_cond_t condition; teleop_gc_state queue[TELEOP_QUEUE_SIZE]; @@ -23,10 +25,20 @@ int dropped; int disconnected; int closed; + uint64_t generation; + size_t active_callbacks; + struct teleop_gc_handle *next; } teleop_gc_handle; static pthread_once_t teleop_gc_discovery_once = PTHREAD_ONCE_INIT; static char teleop_gc_handler_queue_key; +static char teleop_gc_identifier_key; +static pthread_mutex_t teleop_gc_identifier_mutex = PTHREAD_MUTEX_INITIALIZER; +static uint64_t teleop_gc_next_identifier; +static pthread_mutex_t teleop_gc_registry_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t teleop_gc_registry_condition = PTHREAD_COND_INITIALIZER; +static teleop_gc_handle *teleop_gc_active_handles; +static uint64_t teleop_gc_next_generation; static void teleop_gc_start_discovery(void) { [GCController startWirelessControllerDiscoveryWithCompletionHandler:^{}]; @@ -63,7 +75,59 @@ static void teleop_gc_copy_string(NSString *value, char *destination, size_t siz destination[size - 1] = '\0'; } -static teleop_gc_state teleop_gc_capture(teleop_gc_handle *handle, GCExtendedGamepad *gamepad) { +static uint64_t teleop_gc_identifier(GCController *controller) { + pthread_mutex_lock(&teleop_gc_identifier_mutex); + NSNumber *stored = objc_getAssociatedObject( + controller, + &teleop_gc_identifier_key + ); + if (stored == nil) { + stored = [NSNumber numberWithUnsignedLongLong:++teleop_gc_next_identifier]; + objc_setAssociatedObject( + controller, + &teleop_gc_identifier_key, + stored, + OBJC_ASSOCIATION_RETAIN_NONATOMIC + ); + } + uint64_t identifier = [stored unsignedLongLongValue]; + pthread_mutex_unlock(&teleop_gc_identifier_mutex); + return identifier; +} + +static GCController *teleop_gc_controller_by_id(uint64_t identifier) { + for (GCController *controller in [GCController controllers]) { + if (teleop_gc_identifier(controller) == identifier) { + return controller; + } + } + return nil; +} + +static int teleop_gc_acquire_callback(teleop_gc_handle *handle, uint64_t generation) { + int found = 0; + pthread_mutex_lock(&teleop_gc_registry_mutex); + for (teleop_gc_handle *candidate = teleop_gc_active_handles; + candidate != NULL; + candidate = candidate->next) { + if (candidate == handle && candidate->generation == generation) { + candidate->active_callbacks++; + found = 1; + break; + } + } + pthread_mutex_unlock(&teleop_gc_registry_mutex); + return found; +} + +static void teleop_gc_release_callback(teleop_gc_handle *handle) { + pthread_mutex_lock(&teleop_gc_registry_mutex); + handle->active_callbacks--; + pthread_cond_broadcast(&teleop_gc_registry_condition); + pthread_mutex_unlock(&teleop_gc_registry_mutex); +} + +static teleop_gc_state teleop_gc_capture(GCExtendedGamepad *gamepad) { teleop_gc_state state; memset(&state, 0, sizeof(state)); state.left_x = gamepad.leftThumbstick.xAxis.value; @@ -102,28 +166,32 @@ static teleop_gc_state teleop_gc_capture(teleop_gc_handle *handle, GCExtendedGam state.dpad_down = gamepad.dpad.down.isPressed; state.dpad_left = gamepad.dpad.left.isPressed; state.dpad_right = gamepad.dpad.right.isPressed; - state.sequence = ++handle->sequence; - struct timespec now; - clock_gettime(CLOCK_REALTIME, &now); - state.timestamp = (double)now.tv_sec + ((double)now.tv_nsec / 1000000000.0); + // GameController's timestamp is the input profile's monotonic event time, + // rather than the wall clock time at which this callback happened to run. + state.timestamp = gamepad.lastEventTimestamp; return state; } static void teleop_gc_enqueue(teleop_gc_handle *handle, GCExtendedGamepad *gamepad) { - pthread_mutex_lock(&handle->mutex); - if (!handle->closed) { - teleop_gc_state state = teleop_gc_capture(handle, gamepad); - if (handle->count == TELEOP_QUEUE_SIZE) { - handle->head = (handle->head + 1) % TELEOP_QUEUE_SIZE; - handle->count--; - handle->dropped = 1; + @autoreleasepool { + // Capture Objective-C properties before taking the queue lock. The + // consumer only contends with the bounded ring-buffer mutation. + teleop_gc_state state = teleop_gc_capture(gamepad); + pthread_mutex_lock(&handle->mutex); + if (!handle->closed) { + state.sequence = ++handle->sequence; + if (handle->count == TELEOP_QUEUE_SIZE) { + handle->head = (handle->head + 1) % TELEOP_QUEUE_SIZE; + handle->count--; + handle->dropped = 1; + } + size_t tail = (handle->head + handle->count) % TELEOP_QUEUE_SIZE; + handle->queue[tail] = state; + handle->count++; + pthread_cond_signal(&handle->condition); } - size_t tail = (handle->head + handle->count) % TELEOP_QUEUE_SIZE; - handle->queue[tail] = state; - handle->count++; - pthread_cond_signal(&handle->condition); + pthread_mutex_unlock(&handle->mutex); } - pthread_mutex_unlock(&handle->mutex); } int teleop_gc_count(void) { @@ -134,6 +202,7 @@ int teleop_gc_count(void) { int teleop_gc_info( int index, + uint64_t *identifier, char *name, size_t name_size, char *product_category, @@ -150,6 +219,9 @@ int teleop_gc_info( if (gamepad == nil) { return 0; } + if (identifier != NULL) { + *identifier = teleop_gc_identifier(controller); + } NSString *vendor = controller.vendorName ?: @"Game controller"; teleop_gc_copy_string(vendor, name, name_size); teleop_gc_copy_string( @@ -170,13 +242,47 @@ int teleop_gc_info( } } -void *teleop_gc_open(int index) { +int teleop_gc_info_by_id( + uint64_t identifier, + char *name, + size_t name_size, + char *product_category, + size_t product_category_size, + uint32_t *features +) { @autoreleasepool { - NSArray *controllers = [GCController controllers]; - if (index < 0 || index >= (int)[controllers count]) { - return NULL; + GCController *controller = teleop_gc_controller_by_id(identifier); + if (controller == nil) { + return 0; } - GCController *controller = controllers[(NSUInteger)index]; + GCExtendedGamepad *gamepad = controller.extendedGamepad; + if (gamepad == nil) { + return 0; + } + NSString *vendor = controller.vendorName ?: @"Game controller"; + teleop_gc_copy_string(vendor, name, name_size); + teleop_gc_copy_string( + controller.productCategory, + product_category, + product_category_size + ); + uint32_t result = 0; + if (gamepad.buttonMenu != nil) result |= 1; + if (gamepad.buttonOptions != nil) result |= 2; + if (gamepad.buttonHome != nil) result |= 4; + SEL share_selector = NSSelectorFromString(@"buttonShare"); + if ([gamepad respondsToSelector:share_selector] && [gamepad valueForKey:@"buttonShare"] != nil) result |= 8; + if (gamepad.leftThumbstickButton != nil) result |= 16; + if (gamepad.rightThumbstickButton != nil) result |= 32; + if (features != NULL) *features = result; + return 1; + } +} + +void *teleop_gc_open(uint64_t identifier) { + @autoreleasepool { + GCController *controller = teleop_gc_controller_by_id(identifier); + if (controller == nil) return NULL; GCExtendedGamepad *gamepad = controller.extendedGamepad; if (gamepad == nil) { return NULL; @@ -192,29 +298,68 @@ int teleop_gc_info( "ai.openships.teleop.gamecontroller-input", DISPATCH_QUEUE_SERIAL ); + if (handle->handler_queue == nil) { + pthread_cond_destroy(&handle->condition); + pthread_mutex_destroy(&handle->mutex); + [handle->controller release]; + free(handle); + return NULL; + } + + // GameController exposes one callback per controller. Reject a second + // open instead of silently replacing and permanently silencing the + // first handle. + pthread_mutex_lock(&teleop_gc_registry_mutex); + for (teleop_gc_handle *candidate = teleop_gc_active_handles; + candidate != NULL; + candidate = candidate->next) { + if (candidate->controller == controller) { + pthread_mutex_unlock(&teleop_gc_registry_mutex); + dispatch_release(handle->handler_queue); + pthread_cond_destroy(&handle->condition); + pthread_mutex_destroy(&handle->mutex); + [handle->controller release]; + free(handle); + return NULL; + } + } + handle->generation = ++teleop_gc_next_generation; + handle->next = teleop_gc_active_handles; + teleop_gc_active_handles = handle; + pthread_mutex_unlock(&teleop_gc_registry_mutex); + dispatch_queue_set_specific( handle->handler_queue, &teleop_gc_handler_queue_key, handle, NULL ); + handle->previous_handler_queue = controller.handlerQueue; + if (handle->previous_handler_queue != nil) { + dispatch_retain(handle->previous_handler_queue); + } controller.handlerQueue = handle->handler_queue; + uint64_t generation = handle->generation; gamepad.valueChangedHandler = ^(GCExtendedGamepad *changed, GCControllerElement *element) { (void)element; + if (!teleop_gc_acquire_callback(handle, generation)) return; teleop_gc_enqueue(handle, changed); + teleop_gc_release_callback(handle); }; - handle->disconnect_observer = [[NSNotificationCenter defaultCenter] + handle->disconnect_observer = [[[NSNotificationCenter defaultCenter] addObserverForName:GCControllerDidDisconnectNotification object:controller queue:nil usingBlock:^(NSNotification *note) { (void)note; + if (!teleop_gc_acquire_callback(handle, generation)) return; pthread_mutex_lock(&handle->mutex); handle->disconnected = 1; pthread_cond_broadcast(&handle->condition); pthread_mutex_unlock(&handle->mutex); - }]; + teleop_gc_release_callback(handle); + }] retain]; teleop_gc_enqueue(handle, gamepad); return handle; } @@ -272,17 +417,40 @@ void teleop_gc_close(void *opaque) { handle->controller.extendedGamepad.valueChangedHandler = nil; if (handle->disconnect_observer != nil) { [[NSNotificationCenter defaultCenter] removeObserver:handle->disconnect_observer]; + [handle->disconnect_observer release]; handle->disconnect_observer = nil; } if (dispatch_get_specific(&teleop_gc_handler_queue_key) != handle) { dispatch_sync(handle->handler_queue, ^{}); } - handle->controller.handlerQueue = dispatch_get_main_queue(); + + pthread_mutex_lock(&teleop_gc_registry_mutex); + teleop_gc_handle **candidate = &teleop_gc_active_handles; + while (*candidate != NULL && *candidate != handle) { + candidate = &(*candidate)->next; + } + if (*candidate == handle) { + *candidate = handle->next; + } + while (handle->active_callbacks != 0) { + pthread_cond_wait( + &teleop_gc_registry_condition, + &teleop_gc_registry_mutex + ); + } + pthread_mutex_unlock(&teleop_gc_registry_mutex); + + handle->controller.handlerQueue = handle->previous_handler_queue; + if (handle->previous_handler_queue != nil) { + dispatch_release(handle->previous_handler_queue); + handle->previous_handler_queue = nil; + } dispatch_release(handle->handler_queue); handle->handler_queue = nil; [handle->controller release]; handle->controller = nil; - // The handle remains allocated so a concurrent timed wait can safely - // observe closure. Controller handles are few and process-scoped. + pthread_cond_destroy(&handle->condition); + pthread_mutex_destroy(&handle->mutex); + free(handle); } } diff --git a/xbox/provider.go b/xbox/provider.go index 36a690f..69615dd 100644 --- a/xbox/provider.go +++ b/xbox/provider.go @@ -8,20 +8,26 @@ import ( "github.com/open-ships/teleop" ) +// Provider discovers and opens Xbox-compatible controllers using the current +// platform backend. type Provider struct{} +// NewProvider returns an Xbox controller provider. func NewProvider() *Provider { return &Provider{} } +// Type implements teleop.Provider. func (*Provider) Type() teleop.ControllerType { return teleop.ControllerXbox } +// Discover implements teleop.Provider. func (*Provider) Discover(ctx context.Context) ([]teleop.Descriptor, error) { return discoverPlatform(ctx) } +// Open implements teleop.Provider. func (*Provider) Open( ctx context.Context, id teleop.DeviceID, @@ -31,7 +37,11 @@ func (*Provider) Open( if err != nil { return nil, err } - return teleop.NewController(source, options...) + // Provider.Open's context owns both discovery/opening and the resulting + // session. A caller may still override it explicitly with a later + // teleop.WithContext option. + openOptions := append([]teleop.OpenOption{teleop.WithContext(ctx)}, options...) + return teleop.NewController(source, openOptions...) } // Watch polls the platform's controller registry and publishes hotplug changes. diff --git a/xbox/provider_darwin.go b/xbox/provider_darwin.go index b791f32..03946ca 100644 --- a/xbox/provider_darwin.go +++ b/xbox/provider_darwin.go @@ -43,30 +43,33 @@ func openPlatform(ctx context.Context, id teleop.DeviceID) (teleop.InputSource, if err := ctx.Err(); err != nil { return nil, err } - index, err := parseDarwinID(id) + identifier, err := parseDarwinID(id) if err != nil { return nil, err } - descriptor, ok := darwinDescriptor(index) + descriptor, ok := darwinDescriptorByID(identifier) if !ok { return nil, fmt.Errorf("%w: %s", teleop.ErrUnavailable, id) } - handle := C.teleop_gc_open(C.int(index)) + handle := C.teleop_gc_open(C.uint64_t(identifier)) if handle == nil { return nil, fmt.Errorf("%w: open %s", teleop.ErrUnavailable, id) } return &darwinSource{ handle: handle, descriptor: descriptor, + closeDone: make(chan struct{}), }, nil } func darwinDescriptor(index int) (teleop.Descriptor, bool) { name := make([]byte, 256) productCategory := make([]byte, 256) + var identifier C.uint64_t var features C.uint32_t ok := C.teleop_gc_info( C.int(index), + &identifier, (*C.char)(unsafe.Pointer(&name[0])), C.size_t(len(name)), (*C.char)(unsafe.Pointer(&productCategory[0])), @@ -76,8 +79,46 @@ func darwinDescriptor(index int) (teleop.Descriptor, bool) { if ok == 0 { return teleop.Descriptor{}, false } - vendorName := nullTerminatedString(name) - category := nullTerminatedString(productCategory) + return newDarwinDescriptor( + uint64(identifier), + index, + nullTerminatedString(name), + nullTerminatedString(productCategory), + uint32(features), + ) +} + +func darwinDescriptorByID(identifier uint64) (teleop.Descriptor, bool) { + name := make([]byte, 256) + productCategory := make([]byte, 256) + var features C.uint32_t + ok := C.teleop_gc_info_by_id( + C.uint64_t(identifier), + (*C.char)(unsafe.Pointer(&name[0])), + C.size_t(len(name)), + (*C.char)(unsafe.Pointer(&productCategory[0])), + C.size_t(len(productCategory)), + &features, + ) + if ok == 0 { + return teleop.Descriptor{}, false + } + return newDarwinDescriptor( + identifier, + -1, + nullTerminatedString(name), + nullTerminatedString(productCategory), + uint32(features), + ) +} + +func newDarwinDescriptor( + identifier uint64, + index int, + vendorName string, + category string, + features uint32, +) (teleop.Descriptor, bool) { if !isXboxIdentity(vendorName, category) { return teleop.Descriptor{}, false } @@ -89,24 +130,28 @@ func darwinDescriptor(index int) (teleop.Descriptor, bool) { teleop.StickLeft: true, teleop.StickRight: true, teleop.TriggerLeft: true, teleop.TriggerRight: true, } - mask := uint32(features) + mask := features supported[Menu] = mask&1 != 0 supported[View] = mask&2 != 0 supported[Xbox] = mask&4 != 0 supported[Share] = mask&8 != 0 supported[LeftStick] = mask&16 != 0 supported[RightStick] = mask&32 != 0 + properties := map[string]string{ + "gamecontroller_identifier": fmt.Sprintf("%016x", identifier), + "gamecontroller_product_category": category, + } + if index >= 0 { + properties["gamecontroller_index"] = strconv.Itoa(index) + } return teleop.Descriptor{ - ID: teleop.DeviceID(fmt.Sprintf("gamecontroller:%d", index)), + ID: formatDarwinID(identifier), Type: teleop.ControllerXbox, Name: darwinControllerName(vendorName, category), Transport: teleop.TransportUnknown, Backend: "darwin-gamecontroller", Capability: capabilities(teleop.AuditExactBackendStream, supported), - Properties: map[string]string{ - "gamecontroller_index": strconv.Itoa(index), - "gamecontroller_product_category": category, - }, + Properties: properties, }, true } @@ -139,6 +184,8 @@ type darwinSource struct { handle unsafe.Pointer descriptor teleop.Descriptor closed bool + active sync.WaitGroup + closeDone chan struct{} } func (s *darwinSource) Descriptor() teleop.Descriptor { @@ -156,10 +203,18 @@ func (s *darwinSource) Read(ctx context.Context) (teleop.Observation, error) { return teleop.Observation{}, teleop.ErrClosed } handle := s.handle + s.active.Add(1) s.mu.Unlock() var native C.teleop_gc_state result := int(C.teleop_gc_next(handle, &native, 100)) + s.active.Done() + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return teleop.Observation{}, teleop.ErrClosed + } switch result { case 0: continue @@ -238,20 +293,45 @@ func (s *darwinSource) Read(ctx context.Context) (teleop.Observation, error) { func (s *darwinSource) Close() error { s.mu.Lock() - defer s.mu.Unlock() if s.closed { + done := s.closeDone + s.mu.Unlock() + <-done return nil } s.closed = true - C.teleop_gc_close(s.handle) + handle := s.handle + s.mu.Unlock() + + // teleop_gc_next uses the native handle during its timed wait. Prevent new + // reads, wait for existing calls to return, and only then release it. + s.active.Wait() + C.teleop_gc_close(handle) + + s.mu.Lock() + s.handle = nil + close(s.closeDone) + s.mu.Unlock() return nil } -func parseDarwinID(id teleop.DeviceID) (int, error) { - value := strings.TrimPrefix(string(id), "gamecontroller:") - index, err := strconv.Atoi(value) - if err != nil || index < 0 { +func formatDarwinID(identifier uint64) teleop.DeviceID { + return teleop.DeviceID(fmt.Sprintf("gamecontroller:%016x", identifier)) +} + +func parseDarwinID(id teleop.DeviceID) (uint64, error) { + const prefix = "gamecontroller:" + raw := string(id) + if !strings.HasPrefix(raw, prefix) { + return 0, fmt.Errorf("%w: invalid Game Controller device ID %q", teleop.ErrUnavailable, id) + } + value := strings.TrimPrefix(raw, prefix) + identifier, err := strconv.ParseUint(value, 16, 64) + if err != nil || + identifier == 0 || + len(value) != 16 || + value != fmt.Sprintf("%016x", identifier) { return 0, fmt.Errorf("%w: invalid Game Controller device ID %q", teleop.ErrUnavailable, id) } - return index, nil + return identifier, nil } diff --git a/xbox/provider_darwin_test.go b/xbox/provider_darwin_test.go index f818f2c..8631695 100644 --- a/xbox/provider_darwin_test.go +++ b/xbox/provider_darwin_test.go @@ -11,12 +11,45 @@ import ( func TestParseDarwinID(t *testing.T) { t.Parallel() - index, err := parseDarwinID("gamecontroller:3") - if err != nil || index != 3 { - t.Fatalf("parse = %d, %v", index, err) + const identifier = uint64(0x1234abcd) + id := formatDarwinID(identifier) + got, err := parseDarwinID(id) + if err != nil || got != identifier { + t.Fatalf("parse %q = %#x, %v; want %#x", id, got, err, identifier) } - if _, err := parseDarwinID(teleop.DeviceID("bad")); err == nil { - t.Fatal("invalid ID parsed successfully") + for _, invalid := range []teleop.DeviceID{ + "", + "bad", + "gamecontroller:0", + "gamecontroller:0000000000000000", + "gamecontroller:000000000000000g", + "gamecontroller:00000000000000001", + } { + if _, err := parseDarwinID(invalid); err == nil { + t.Errorf("invalid ID %q parsed successfully", invalid) + } + } +} + +func TestNewDarwinDescriptorUsesOpaqueIdentifier(t *testing.T) { + t.Parallel() + + const identifier = uint64(0x1234abcd) + descriptor, ok := newDarwinDescriptor( + identifier, + 7, + "Xbox Wireless Controller", + "Xbox One", + 0, + ) + if !ok { + t.Fatal("Xbox descriptor rejected") + } + if descriptor.ID != formatDarwinID(identifier) { + t.Fatalf("descriptor ID = %q, want %q", descriptor.ID, formatDarwinID(identifier)) + } + if got := descriptor.Properties["gamecontroller_identifier"]; got != "000000001234abcd" { + t.Fatalf("identifier property = %q", got) } } diff --git a/xbox/provider_linux.go b/xbox/provider_linux.go index 4b59a89..73488c6 100644 --- a/xbox/provider_linux.go +++ b/xbox/provider_linux.go @@ -8,6 +8,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -18,6 +19,7 @@ import ( "unsafe" "github.com/open-ships/teleop" + "golang.org/x/sys/unix" ) const ( @@ -65,6 +67,11 @@ const ( busUSB = 0x03 busBluetooth = 0x05 + + linuxReadPollInterval = 100 * time.Millisecond + // maxLinuxRawBytes bounds a malformed evdev frame that never reaches + // SYN_REPORT. Native input is diagnostic data, not unbounded storage. + maxLinuxRawBytes = 1 << 20 ) type linuxInputID struct { @@ -245,6 +252,15 @@ type linuxSource struct { dropped bool initial bool closeOnce sync.Once + closeMu sync.RWMutex + closed bool + + // pending holds bytes read from the device that do not yet form a complete + // event. The kernel can split an event across reads, so a decoder that + // assumed each read yielded whole events would either lose the remainder + // or block waiting for it. + pending bytes.Buffer + scratch []byte } func (s *linuxSource) Descriptor() teleop.Descriptor { @@ -252,6 +268,12 @@ func (s *linuxSource) Descriptor() teleop.Descriptor { } func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { + if err := ctx.Err(); err != nil { + return teleop.Observation{}, err + } + if s.isClosed() { + return teleop.Observation{}, teleop.ErrClosed + } if s.initial { s.initial = false return teleop.Observation{ @@ -266,14 +288,11 @@ func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { if err := ctx.Err(); err != nil { return teleop.Observation{}, err } - var event linuxInputEvent - if err := binary.Read(s.file, binary.LittleEndian, &event); err != nil { - if errors.Is(err, os.ErrClosed) { - return teleop.Observation{}, teleop.ErrClosed - } + event, err := s.nextEvent(ctx) + if err != nil { return teleop.Observation{}, err } - _ = binary.Write(&s.raw, binary.LittleEndian, event) + s.appendRaw(event) if event.Type == evSyn && event.Code == synDropped { s.dropped = true @@ -282,19 +301,23 @@ func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { if s.dropped { if event.Type == evSyn && event.Code == synReport { if err := s.resync(); err != nil { - return teleop.Observation{}, err + return teleop.Observation{}, s.normalizeReadError(err) } raw := append([]byte(nil), s.raw.Bytes()...) s.raw.Reset() s.dropped = false return teleop.Observation{ - State: s.state.Clone(), - ObservedAt: linuxEventTime(event), + State: s.state.Clone(), + ObservedAt: linuxEventTime(event), + DeviceTimestamp: int64(event.Time.Sec)*1_000_000_000 + int64(event.Time.Usec)*1_000, Native: teleop.NativeInput{ Format: "linux-evdev", Data: raw, }, - Gap: &teleop.SourceGap{Reason: "evdev SYN_DROPPED buffer overrun"}, + Gap: &teleop.SourceGap{ + Dropped: 1, // SYN_DROPPED proves at least one event was lost. + Reason: "evdev SYN_DROPPED buffer overrun", + }, }, nil } continue @@ -317,6 +340,99 @@ func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { } } +// linuxReadBatchEvents bounds one read syscall. Reading several events at once +// keeps syscall overhead low at high report rates without letting a burst +// allocate without limit. +const linuxReadBatchEvents = 64 + +// nextEvent returns the next complete evdev event. +// +// Poll readiness guarantees that at least one byte can be read, not that a +// whole event is available. Demanding a fixed-size read after a readiness +// signal, as io.ReadFull does, blocks in a syscall that neither the context +// nor Close can interrupt until the remainder of a split event arrives — and +// for a partial event at the tail of a kernel buffer, that can be never. +// +// Reading whatever is available and decoding only complete records keeps poll +// the single blocking point, so cancellation and Close stay effective. +func (s *linuxSource) nextEvent(ctx context.Context) (linuxInputEvent, error) { + size := binary.Size(linuxInputEvent{}) + if size <= 0 { + return linuxInputEvent{}, fmt.Errorf("evdev event has no fixed wire size") + } + for { + if s.pending.Len() >= size { + var event linuxInputEvent + if _, err := binary.Decode( + s.pending.Next(size), + binary.NativeEndian, + &event, + ); err != nil { + return linuxInputEvent{}, fmt.Errorf("decode evdev event: %w", err) + } + return event, nil + } + if err := ctx.Err(); err != nil { + return linuxInputEvent{}, err + } + if err := s.waitReadable(ctx); err != nil { + return linuxInputEvent{}, err + } + if s.scratch == nil { + s.scratch = make([]byte, size*linuxReadBatchEvents) + } + // Bound the read when the descriptor supports deadlines. This is best + // effort: an evdev device does not, because the capability ioctls need + // a raw descriptor and take it out of the runtime poller. Clearing a + // stale deadline matters as much as setting one, since a deadline left + // over from an earlier canceled call would fire against a live read. + if deadline, ok := ctx.Deadline(); ok { + _ = s.file.SetReadDeadline(deadline) + } else { + _ = s.file.SetReadDeadline(time.Time{}) + } + count, err := s.file.Read(s.scratch) + if count > 0 { + s.pending.Write(s.scratch[:count]) + } + if err != nil { + // Bytes that completed an event are still usable; report the error + // only once the buffered remainder is exhausted. + if s.pending.Len() >= size { + continue + } + // The deadline was derived from ctx, so its expiry is a + // cancellation rather than a device fault. ctx.Err may not be set + // yet when both fire at once, so do not depend on it. + if errors.Is(err, os.ErrDeadlineExceeded) { + if ctxErr := ctx.Err(); ctxErr != nil { + return linuxInputEvent{}, ctxErr + } + return linuxInputEvent{}, context.DeadlineExceeded + } + // Poll reported readiness that did not survive to the read. Treat + // it as spurious and wait again rather than reporting a fault. + if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK) { + continue + } + return linuxInputEvent{}, s.normalizeReadError(err) + } + if count == 0 && s.pending.Len() < size { + // A descriptor that reports readable but yields nothing has + // reached end of file: the device detached. + return linuxInputEvent{}, s.normalizeReadError(io.EOF) + } + } +} + +func (s *linuxSource) appendRaw(event linuxInputEvent) { + _ = binary.Write(&s.raw, binary.NativeEndian, event) + if s.raw.Len() > maxLinuxRawBytes { + s.raw.Reset() + s.dropped = true + } +} + func (s *linuxSource) apply(event linuxInputEvent) { switch event.Type { case evKey: @@ -380,7 +496,82 @@ func (s *linuxSource) resync() error { func (s *linuxSource) Close() error { var err error - s.closeOnce.Do(func() { err = s.file.Close() }) + s.closeOnce.Do(func() { + s.closeMu.Lock() + s.closed = true + s.closeMu.Unlock() + err = s.file.Close() + }) + return err +} + +func (s *linuxSource) isClosed() bool { + s.closeMu.RLock() + defer s.closeMu.RUnlock() + return s.closed +} + +func (s *linuxSource) waitReadable(ctx context.Context) error { + for { + if err := ctx.Err(); err != nil { + return err + } + if s.isClosed() { + return teleop.ErrClosed + } + + timeout := linuxReadPollInterval + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return ctx.Err() + } + if remaining < timeout { + timeout = remaining + } + } + timeoutMillis := int((timeout + time.Millisecond - 1) / time.Millisecond) + pollFDs := []unix.PollFd{{ + Fd: int32(s.file.Fd()), + Events: unix.POLLIN, + }} + ready, err := unix.Poll(pollFDs, timeoutMillis) + if errors.Is(err, syscall.EINTR) { + continue + } + if err != nil { + return s.normalizeReadError(err) + } + if ready == 0 { + continue + } + revents := pollFDs[0].Revents + if revents&unix.POLLNVAL != 0 { + if s.isClosed() { + return teleop.ErrClosed + } + return fmt.Errorf("%w: evdev descriptor is invalid", teleop.ErrDisconnected) + } + if revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP) != 0 { + return nil + } + } +} + +func (s *linuxSource) normalizeReadError(err error) error { + if err == nil { + return nil + } + if s.isClosed() || errors.Is(err, os.ErrClosed) { + return teleop.ErrClosed + } + if errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) || + errors.Is(err, syscall.ENODEV) || + errors.Is(err, syscall.ENXIO) || + errors.Is(err, syscall.EIO) { + return fmt.Errorf("%w: evdev read: %v", teleop.ErrDisconnected, err) + } return err } @@ -436,32 +627,35 @@ func linuxCapabilities(file *os.File) (map[teleop.ControlID]bool, map[uint16]lin return supported, ranges, nil } -func linuxKeyControls() map[int]teleop.ControlID { - return map[int]teleop.ControlID{ - btnSouth: ButtonA, - btnEast: ButtonB, - btnWest: ButtonX, - btnNorth: ButtonY, - btnTL: LeftBumper, - btnTR: RightBumper, - btnThumbL: LeftStick, - btnThumbR: RightStick, - btnStart: Menu, - btnSelect: View, - btnMode: Xbox, - keyF12: Share, - keyRecord: Share, - btnDPadUp: teleop.DPadUp, - btnDPadDown: teleop.DPadDown, - btnDPadLeft: teleop.DPadLeft, - btnDPadRight: teleop.DPadRight, - btnGripLeft: Paddle1, - btnGripRight: Paddle2, - btnGripLeft2: Paddle3, - btnGripRight2: Paddle4, - } +var linuxControls = map[int]teleop.ControlID{ + btnSouth: ButtonA, + btnEast: ButtonB, + // Xbox's xpad driver emits the legacy BTN_X/BTN_Y aliases. Those aliases + // share numeric values with BTN_NORTH/BTN_WEST, respectively, so their + // physical labels are the inverse of the geometric names. + btnNorth: ButtonX, + btnWest: ButtonY, + btnTL: LeftBumper, + btnTR: RightBumper, + btnThumbL: LeftStick, + btnThumbR: RightStick, + btnStart: Menu, + btnSelect: View, + btnMode: Xbox, + keyF12: Share, + keyRecord: Share, + btnDPadUp: teleop.DPadUp, + btnDPadDown: teleop.DPadDown, + btnDPadLeft: teleop.DPadLeft, + btnDPadRight: teleop.DPadRight, + btnGripLeft: Paddle1, + btnGripRight: Paddle2, + btnGripLeft2: Paddle3, + btnGripRight2: Paddle4, } +func linuxKeyControls() map[int]teleop.ControlID { return linuxControls } + func linuxKeyControl(code uint16) (teleop.ControlID, bool) { control, ok := linuxKeyControls()[int(code)] return control, ok @@ -483,16 +677,6 @@ func linuxBit(buffer []byte, bit int) bool { return index >= 0 && index < len(buffer) && buffer[index]&(1< 3 { + if err != nil || slot > 3 || value != strconv.FormatUint(slot, 10) { return 0, fmt.Errorf("%w: invalid XInput device ID %q", teleop.ErrUnavailable, id) } return uint32(slot), nil diff --git a/xbox/provider_windows_test.go b/xbox/provider_windows_test.go index fd0e346..75cd5b4 100644 --- a/xbox/provider_windows_test.go +++ b/xbox/provider_windows_test.go @@ -3,25 +3,92 @@ package xbox import ( + "encoding/binary" + "errors" + "syscall" "testing" + "unsafe" "github.com/open-ships/teleop" ) +func TestXInputStructsMatchWindowsABI(t *testing.T) { + t.Parallel() + + if got := binary.Size(xinputGamepad{}); got != 12 { + t.Fatalf("encoded XINPUT_GAMEPAD size = %d, want 12", got) + } + if got := unsafe.Sizeof(xinputGamepad{}); got != 12 { + t.Fatalf("memory XINPUT_GAMEPAD size = %d, want 12", got) + } + if got := binary.Size(xinputState{}); got != 16 { + t.Fatalf("encoded XINPUT_STATE size = %d, want 16", got) + } + if got := unsafe.Sizeof(xinputState{}); got != 16 { + t.Fatalf("memory XINPUT_STATE size = %d, want 16", got) + } + if got := unsafe.Offsetof(xinputState{}.Gamepad); got != 4 { + t.Fatalf("XINPUT_STATE.Gamepad offset = %d, want 4", got) + } +} + +func TestEncodeXInputState(t *testing.T) { + t.Parallel() + + state := xinputState{ + PacketNumber: 0x01020304, + Gamepad: xinputGamepad{ + Buttons: 0x1000, + LeftTrigger: 1, + RightTrigger: 2, + ThumbLX: -32768, + ThumbLY: 32767, + ThumbRX: -1, + ThumbRY: 0, + }, + } + want := []byte{ + 0x04, 0x03, 0x02, 0x01, + 0x00, 0x10, 0x01, 0x02, + 0x00, 0x80, 0xff, 0x7f, + 0xff, 0xff, 0x00, 0x00, + } + got := encodeXInputState(state) + if string(got) != string(want) { + t.Fatalf("encoded state = % x, want % x", got, want) + } +} + func TestXInputMapping(t *testing.T) { t.Parallel() state := xinputTeleopState(xinputGamepad{ - Buttons: xinputA | xinputLeftShoulder | xinputStart | xinputDPadUp, + Buttons: xinputA | xinputB | xinputX | xinputY | + xinputLeftShoulder | xinputRightShoulder | + xinputLeftThumb | xinputRightThumb | + xinputStart | xinputBack | + xinputDPadUp | xinputDPadDown | xinputDPadLeft | xinputDPadRight, LeftTrigger: 255, RightTrigger: 128, ThumbLX: 32767, ThumbLY: -32768, + ThumbRX: -16384, + ThumbRY: 16384, }) if !state.Button(ButtonA) || + !state.Button(ButtonB) || + !state.Button(ButtonX) || + !state.Button(ButtonY) || !state.Button(LeftBumper) || + !state.Button(RightBumper) || + !state.Button(LeftStick) || + !state.Button(RightStick) || !state.Button(Menu) || - !state.Button(teleop.DPadUp) { + !state.Button(View) || + !state.Button(teleop.DPadUp) || + !state.Button(teleop.DPadDown) || + !state.Button(teleop.DPadLeft) || + !state.Button(teleop.DPadRight) { t.Fatalf("buttons = %#v, dpad = %#v", state.Buttons, state.DPad) } if state.LeftTrigger != 1 { @@ -30,4 +97,86 @@ func TestXInputMapping(t *testing.T) { if state.LeftStick.X != 1 || state.LeftStick.Y != -1 { t.Fatalf("left stick = %#v", state.LeftStick) } + if state.RightStick.X != -0.5 { + t.Fatalf("right stick X = %f, want -0.5", state.RightStick.X) + } + if state.RightStick.Y <= 0.5 || state.RightStick.Y >= 0.501 { + t.Fatalf("right stick Y = %f, want approximately 0.500015", state.RightStick.Y) + } +} + +func TestNormalizeXInputAxisEndpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + value int16 + want float32 + }{ + {value: -32768, want: -1}, + {value: 0, want: 0}, + {value: 32767, want: 1}, + } + for _, test := range tests { + if got := normalizeXInputAxis(test.value); got != test.want { + t.Errorf("normalizeXInputAxis(%d) = %f, want %f", test.value, got, test.want) + } + } +} + +func TestParseXInputID(t *testing.T) { + t.Parallel() + + for slot := uint32(0); slot < 4; slot++ { + id := teleop.DeviceID("xinput:" + string(rune('0'+slot))) + got, err := parseXInputID(id) + if err != nil || got != slot { + t.Errorf("parseXInputID(%q) = %d, %v; want %d, nil", id, got, err, slot) + } + } + for _, id := range []teleop.DeviceID{"", "0", "xinput:", "xinput:-1", "xinput:00", "xinput:4", "other:0"} { + if _, err := parseXInputID(id); !errors.Is(err, teleop.ErrUnavailable) { + t.Errorf("parseXInputID(%q) error = %v, want ErrUnavailable", id, err) + } + } +} + +func TestXInputResultError(t *testing.T) { + t.Parallel() + + if err := xinputResultError(0); err != nil { + t.Fatalf("success result error = %v", err) + } + if err := xinputResultError(errorDeviceNotConnected); !errors.Is(err, teleop.ErrDisconnected) { + t.Fatalf("disconnect result error = %v, want ErrDisconnected", err) + } + const unexpected = uintptr(87) + err := xinputResultError(unexpected) + if !errors.Is(err, syscall.Errno(unexpected)) { + t.Fatalf("unexpected result error = %v, want errno %d", err, unexpected) + } + if !errors.Is(err, teleop.ErrUnavailable) { + t.Fatalf("unexpected result error = %v, want ErrUnavailable", err) + } + if errors.Is(err, teleop.ErrDisconnected) { + t.Fatalf("unexpected result normalized as disconnected: %v", err) + } +} + +func TestXInputPacketGap(t *testing.T) { + t.Parallel() + + if gap := xinputPacketGap(^uint32(0), 10); gap != nil { + t.Fatalf("initial packet gap = %#v, want nil", gap) + } + if gap := xinputPacketGap(10, 11); gap != nil { + t.Fatalf("adjacent packet gap = %#v, want nil", gap) + } + gap := xinputPacketGap(10, 14) + if gap == nil || gap.Dropped != 3 { + t.Fatalf("packet discontinuity = %#v, want three missed updates", gap) + } + gap = xinputPacketGap(^uint32(0)-1, 1) + if gap == nil || gap.Dropped != 2 { + t.Fatalf("wrapped packet discontinuity = %#v, want two missed updates", gap) + } }