From 740d66c9a5532ae4d280694f8b90ae33f09a635f Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Mon, 27 Jul 2026 22:37:32 -0400 Subject: [PATCH 1/3] feat: add safety gating and evidentiary audit guarantees Adds a safety package that gates operator output on conditions it can affirmatively establish, and strengthens audit logs from integrity-only to evidentiary. safety: - Command timeout measured on a monotonic clock, so a wall-clock step cannot make stale input appear fresh. - Dead-man switch with a re-actuation deadline: a control held indefinitely is treated as defeated rather than engaged. - Control-loop watchdog, latching trips requiring an explicit re-arm, and a latching emergency stop that Arm alone cannot clear. - Guards run as controller processors, so transitions are recorded and the gate trips on its own timer without application involvement. audit: - Ed25519 signing over tree heads via crypto.Signer, separating verification from authorship. Verification distinguishes signatures checked against the log's self-declared key from those checked against a caller-supplied trusted key; only the latter carries weight. - RFC 6962 Merkle tree with inclusion and consistency proofs, enabling selective disclosure and append-only verification. - Periodic signed checkpoints and an Anchor interface publishing heads outside the recording process, making destruction and truncation detectable rather than merely suspected. - Session provenance: build revision, host, configuration, operator. - Format version 3. Version 1 and 2 logs remain readable and are now covered by tests. Verify is fuzzed, since it parses untrusted input. teleop: - Monotonic readings on every event header, plus wall-clock step detection published as ClockEvent. - Controller.RecordCommand closes the causal chain at the actuator boundary. It never blocks; a full queue is an explicit fault. Also carries the pending architecture-review remediation across the xbox providers, terminal monitor, gesture, action, and testkit packages. --- .github/workflows/ci.yml | 9 + README.md | 174 +++- action/action.go | 409 ++++++-- action/action_test.go | 201 ++++ audit/anchor.go | 184 ++++ audit/compat_test.go | 160 +++ audit/fuzz_test.go | 58 ++ audit/integrity_test.go | 470 +++++++++ audit/merkle.go | 277 +++++ audit/merkle_test.go | 177 ++++ audit/provenance.go | 104 ++ audit/recorder.go | 1171 ++++++++++++++++++--- audit/recorder_test.go | 279 +++++ audit/replay.go | 160 ++- audit/sign.go | 161 +++ clock.go | 83 ++ clock_test.go | 92 ++ cmd/teleop-monitor/main.go | 60 +- cmd/teleop-monitor/signals_other.go | 7 + cmd/teleop-monitor/signals_unix.go | 12 + cmd/teleop-monitor/signals_windows.go | 7 + cmd/teleop-monitor/terminal.go | 24 +- cmd/teleop-monitor/terminal_test.go | 54 + cmd/teleop-monitor/tui.go | 22 +- cmd/teleop-monitor/tui_test.go | 48 + command.go | 136 +++ command_test.go | 131 +++ controller.go | 1368 ++++++++++++++++++++----- controller_remediation_test.go | 892 ++++++++++++++++ controller_test.go | 16 +- device.go | 13 + device_test.go | 18 + docs/architecture.md | 29 +- docs/audit.md | 231 ++++- docs/safety.md | 161 +++ event.go | 210 +++- examples/basic/main.go | 31 +- gesture/gesture.go | 730 +++++++++---- gesture/gesture_test.go | 248 ++++- go.mod | 3 +- go.sum | 6 +- normalize.go | 27 +- registry.go | 31 +- registry_test.go | 64 ++ safety/event.go | 119 +++ safety/guard.go | 504 +++++++++ safety/guard_test.go | 461 +++++++++ safety/integration_test.go | 284 +++++ source.go | 185 +++- testkit/fake.go | 5 + testkit/fake_test.go | 67 ++ tracker.go | 57 +- tracker_test.go | 41 + xbox/ioctl_linux_13bit.go | 15 + xbox/ioctl_linux_generic.go | 14 + xbox/native_darwin.h | 11 +- xbox/native_darwin.m | 224 +++- xbox/provider.go | 6 +- xbox/provider_darwin.go | 116 ++- xbox/provider_darwin_test.go | 43 +- xbox/provider_linux.go | 185 +++- xbox/provider_linux_test.go | 162 +++ xbox/provider_windows.go | 174 +++- xbox/provider_windows_test.go | 153 ++- 64 files changed, 10641 insertions(+), 933 deletions(-) create mode 100644 audit/anchor.go create mode 100644 audit/compat_test.go create mode 100644 audit/fuzz_test.go create mode 100644 audit/integrity_test.go create mode 100644 audit/merkle.go create mode 100644 audit/merkle_test.go create mode 100644 audit/provenance.go create mode 100644 audit/sign.go create mode 100644 clock.go create mode 100644 clock_test.go create mode 100644 cmd/teleop-monitor/signals_other.go create mode 100644 cmd/teleop-monitor/signals_unix.go create mode 100644 cmd/teleop-monitor/signals_windows.go create mode 100644 cmd/teleop-monitor/terminal_test.go create mode 100644 command.go create mode 100644 command_test.go create mode 100644 controller_remediation_test.go create mode 100644 device_test.go create mode 100644 docs/safety.md create mode 100644 registry_test.go create mode 100644 safety/event.go create mode 100644 safety/guard.go create mode 100644 safety/guard_test.go create mode 100644 safety/integration_test.go create mode 100644 testkit/fake_test.go create mode 100644 xbox/ioctl_linux_13bit.go create mode 100644 xbox/ioctl_linux_generic.go 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..e9e1241 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,19 @@ 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. +> `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 is pre-v1 while hardware mappings are validated across controller generations and operating systems. @@ -28,12 +32,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 +120,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 +146,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 +184,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 +253,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 +278,100 @@ 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` makes authorship +non-repudiable, since the verifier no longer holds a key that could have forged +it; `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. + +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 +396,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 +416,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..1ed0a8c 100644 --- a/action/action.go +++ b/action/action.go @@ -3,102 +3,191 @@ 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"` } func (e Event) Header() teleop.Header { return e.Meta.Clone() } func (Event) Kind() teleop.EventKind { return EventKind } +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 control, phase, and gesture type. -// Empty match fields act as wildcards. +// 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 } 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} } 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) +} + +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, } } +func OnStick(action ID, stick teleop.StickID) Binding { + control, _ := stickControl(stick) + return Binding{Action: action, EventKind: teleop.EventStick, Control: control} +} + 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 +200,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 { + 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 } - if binding.Phase != "" && binding.Phase != phase { - continue - } - if binding.GestureType != "" && binding.GestureType != gestureType { - 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 "", "", Value{}, "" + 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 "", 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..c0981d2 --- /dev/null +++ b/audit/anchor.go @@ -0,0 +1,184 @@ +package audit + +import ( + "context" + "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 signed tree head suitable for publication outside the log. +// It is 32 bytes of root plus metadata, so anchoring is cheap enough to run +// continuously. +type Checkpoint struct { + Session teleop.SessionID `json:"session"` + Size uint64 `json:"size"` + Root string `json:"root"` + ChainHead string `json:"chain_head"` + EventCount uint64 `json:"event_count"` + RecordedAt time.Time `json:"recorded_at"` + KeyID string `json:"key_id,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Signature string `json:"signature,omitempty"` +} + +// 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 uint64 + Failed uint64 + Dropped uint64 + Err error +} diff --git a/audit/compat_test.go b/audit/compat_test.go new file mode 100644 index 0000000..710b43e --- /dev/null +++ b/audit/compat_test.go @@ -0,0 +1,160 @@ +package audit + +import ( + "bytes" + "encoding/json" + "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 + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + buffer.Write(encoded) + buffer.WriteByte('\n') + } + return bytes.NewReader(buffer.Bytes()) +} + +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/fuzz_test.go b/audit/fuzz_test.go new file mode 100644 index 0000000..ecc0dd6 --- /dev/null +++ b/audit/fuzz_test.go @@ -0,0 +1,58 @@ +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) + } + 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 must never report success on anything the fuzzer + // produced: it cannot forge a signature over the trusted key. + _, verification, err := Read(bytes.NewReader(data), VerifyOptions{ + RequireFooter: true, + RequireSignature: true, + PublicKey: public, + }) + if err == nil && !bytes.Equal(data, valid.Bytes()) { + t.Fatalf("fuzzed input verified as trusted: %+v", verification) + } + }) +} diff --git a/audit/integrity_test.go b/audit/integrity_test.go new file mode 100644 index 0000000..ecff016 --- /dev/null +++ b/audit/integrity_test.go @@ -0,0 +1,470 @@ +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, + RequireSignature: 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) + } +} + +// 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 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) + } +} + +// 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)) + } + + // An anchored head must be independently verifiable, which is the whole + // point of publishing it outside the log. + last := checkpoints[len(checkpoints)-1] + root, err := hex.DecodeString(last.Root) + if err != nil { + t.Fatal(err) + } + var session teleop.SessionID + session[0] = 1 + head := TreeHead{ + Version: FormatVersion, + RecordType: "footer", + Session: session, + Size: last.Size, + Root: root, + ChainHead: last.ChainHead, + EventCount: last.EventCount, + RecordedAt: last.RecordedAt, + } + if err := VerifyTreeHead(public, head, last.Signature); err != nil { + t.Fatalf("anchored footer head must verify: %v", 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..d7741f2 --- /dev/null +++ b/audit/provenance.go @@ -0,0 +1,104 @@ +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 string `json:"go_version,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + 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..e2cead4 100644 --- a/audit/recorder.go +++ b/audit/recorder.go @@ -1,64 +1,269 @@ -// 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 writes, verifies, and replays append-only controller event +// logs. The format is newline-delimited JSON for inspection and streaming. 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 = 16 * 1024 * 1024 -var ErrClosed = errors.New("teleop/audit: recorder closed") + // DefaultCheckpointInterval bounds how long a log can run without emitting + // a signed, anchorable tree head. + 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 +) + +var ( + ErrClosed = errors.New("teleop/audit: recorder closed") + ErrFailed = errors.New("teleop/audit: recorder failed") + ErrUnverified = errors.New("teleop/audit: stream is not hash chained") + ErrUnauthenticated = errors.New("teleop/audit: stream is not HMAC authenticated") + ErrAuthenticationRequired = errors.New("teleop/audit: HMAC key required") + ErrIncomplete = errors.New("teleop/audit: stream is incomplete") + ErrRecordTooLarge = errors.New("teleop/audit: record too large") + // 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") +) + +const ( + chainNone = "none" + chainSHA256 = "sha256" + chainHMAC = "hmac-sha256" +) +// Options configures the audit writer. type Options struct { HashChain bool FlushEveryEvent bool + HMACKey []byte + Now func() time.Time + + Signer crypto.Signer + Anchor Anchor + AnchorTimeout time.Duration + AnchorQueue int + CheckpointInterval time.Duration + CheckpointEvery uint64 + Provenance *Provenance } 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. +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, giving the log non-repudiation that an HMAC chain cannot. +// 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 a crypto.Signer backed by a TPM, Secure Enclave, or HSM. 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 + } + } +} + +// 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 signed tree head is emitted, by elapsed +// time and by record count. 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"` } -// Record is the decoded, implementation-neutral audit representation. +// Record is the decoded, implementation-neutral event representation. type Record struct { - Version int - RecordedAt time.Time - Kind teleop.EventKind - Header teleop.Header - Payload json.RawMessage - PreviousHash string - Hash string + Version int + RecordedAt time.Time + Kind teleop.EventKind + Header teleop.Header + Payload json.RawMessage + PreviousHash string + Hash string + EncodingError string } +// Verification describes what was actually verified. +type Verification struct { + Version int + Chain string + Integrity bool + Authenticated bool + Complete bool + EventCount uint64 + Durability string + + // Signed reports that every signed record 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 signatures verified against a key the caller + // supplied out of band. This is the property that carries evidentiary + // weight. + Trusted bool + // 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 bool + AllowUnverified bool + RequireAuthentication bool + HMACKey []byte + + // PublicKey is the signing key the verifier trusts. When set, the log's + // declared key must match it and every signature must verify against it. + PublicKey ed25519.PublicKey + // RequireSignature rejects a log that carries no signatures. + RequireSignature bool +} + +// Recorder is a concurrency-safe, sticky-failure audit sink. type Recorder struct { mu sync.Mutex writer *bufio.Writer @@ -66,39 +271,116 @@ 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 } 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.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 r.key.public +} + +// 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 signed tree head immediately. Callers should invoke it +// at moments worth being able to prove later, such as arming, an emergency +// stop, or a handover between operators. +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 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. +// Record implements teleop.EventSink. An event that JSON cannot represent is +// retained as an encoding-error payload rather than terminating controller +// input. 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,163 +388,806 @@ 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 + // 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 + } + 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 signed 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{ + 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.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 } - footer := diskRecord{ - Version: FormatVersion, - RecordType: "footer", - RecordedAt: time.Now().UTC(), - EventCount: r.count, - PreviousHash: r.previous, - } - if r.options.HashChain { - hash, err := recordHash(footer) - if err != nil { - return err + r.closed = true + // 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() } - footer.Hash = hash + }() + if r.failure != nil { + return errors.Join(ErrFailed, r.failure) } - if err := writeRecord(r.writer, footer); err != nil { - return err + if err := r.startLocked(); err != nil { + return r.failLocked(err) } - r.closed = true - return r.flushAndSync() + footer := diskRecord{ + 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) flushAndSync() error { - if err := r.writer.Flush(); err != nil { - return fmt.Errorf("flush audit log: %w", err) +func (r *Recorder) startLocked() error { + if r.started { + return nil } - if syncer, ok := r.raw.(interface{ Sync() error }); ok { - if err := syncer.Sync(); err != nil { - return fmt.Errorf("sync audit log: %w", err) - } + 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 writeRecord(writer io.Writer, record diskRecord) error { +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 + } + record.Hash = value + } + 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 _, err := writer.Write(append(encoded, '\n')); err != nil { + 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 recordHash(record diskRecord) (string, error) { - record.Hash = "" - encoded, err := json.Marshal(record) - if err != nil { - return "", fmt.Errorf("marshal audit hash input: %w", err) +func (r *Recorder) failLocked(err error) error { + if r.failure == nil { + r.failure = err } - hash := sha256.Sum256(encoded) - return hex.EncodeToString(hash[:]), nil + return errors.Join(ErrFailed, r.failure) } -// ReadAll decodes and verifies a complete audit stream. +func (r *Recorder) flushAndSync() error { + if err := r.writer.Flush(); err != nil { + return fmt.Errorf("flush audit log: %w", err) + } + if syncer, ok := r.raw.(interface{ Sync() error }); ok { + if err := syncer.Sync(); err != nil { + return fmt.Errorf("sync audit log: %w", err) + } + } + return nil +} + +// ReadAll decodes and verifies a complete, chained stream. func ReadAll(reader io.Reader) ([]Record, error) { - return readAll(reader, true) + records, _, err := Read(reader, VerifyOptions{RequireFooter: true}) + 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. +// ReadAuthenticated verifies a complete HMAC stream with key. +func ReadAuthenticated(reader io.Reader, key []byte) ([]Record, Verification, error) { + return Read(reader, VerifyOptions{ + RequireFooter: true, + RequireAuthentication: true, + HMACKey: append([]byte(nil), key...), + }) +} + +// ReadPartial verifies all complete records and permits a missing or torn +// footer. Valid prefix records are retained when a later record is invalid. func ReadPartial(reader io.Reader) ([]Record, error) { - return readAll(reader, false) + records, _, err := Read(reader, VerifyOptions{}) + return records, 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) +// Read performs streaming verification and accumulates event records. +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 +} + +// Verify checks a stream incrementally and invokes consume for each verified +// event, avoiding whole-log memory growth. +func Verify( + reader io.Reader, + options VerifyOptions, + consume func(Record) error, +) (Verification, error) { + 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 ) - 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) + for { + line, complete, err := nextLine(buffered) + if err != nil && !errors.Is(err, io.EOF) { + return verification, fmt.Errorf("read audit log: %w", err) } - if record.Version != FormatVersion { - return nil, fmt.Errorf("audit line %d: unsupported version %d", line, record.Version) + if len(line) == 0 && errors.Is(err, io.EOF) { + break + } + lineNumber++ + if !complete { + if options.RequireFooter { + return verification, fmt.Errorf("%w: torn audit line %d", ErrIncomplete, lineNumber) + } + break + } + var disk diskRecord + if decodeErr := json.Unmarshal(line, &disk); 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 + 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 + verification.Session = headSession + } + if disk.PublicKey != "" { + public, keyErr := decodePublicKey(disk.PublicKey) + if keyErr != nil { + return verification, fmt.Errorf("audit line 1: %w", keyErr) + } + verification.PublicKey = public + verification.KeyID = disk.KeyID + } + } + 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 options.RequireSignature && 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) } - expected, err := recordHash(record) - if err != nil { - return nil, err + 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, + ) } - if record.Hash != expected { - return nil, fmt.Errorf("audit line %d: hash mismatch", line) + if hashErr != nil { + return verification, hashErr } - previous = record.Hash + if !hmac.Equal([]byte(disk.Hash), []byte(expected)) { + return verification, fmt.Errorf("audit line %d: hash mismatch", lineNumber) + } + 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" + 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) + } + verification.Signed = true + verification.Trusted = len(options.PublicKey) > 0 + } + } + 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() + } + + switch disk.RecordType { + case "manifest": + if legacy || lineNumber != 1 { + return verification, fmt.Errorf("audit line %d: misplaced manifest", lineNumber) + } + 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++ + 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 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 session == nil { + value := header.ID.Session + 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 consume != nil { + if consumeErr := consume(record); consumeErr != nil { + return verification, consumeErr + } + } + } + if verification.Version == 0 { + return verification, errors.New("audit log is empty") + } + if options.RequireFooter && !footer { + return verification, fmt.Errorf("%w: footer is missing", ErrIncomplete) } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read audit log: %w", err) + verification.Integrity = verification.Chain == chainSHA256 || + verification.Chain == chainHMAC + verification.Authenticated = verification.Chain == chainHMAC + verification.TreeSize = tree.Size() + verification.TreeRoot = tree.Root() + if options.RequireSignature && !verification.Signed { + return verification, fmt.Errorf("%w: no signed tree head", ErrSignatureRequired) } - if requireFooter && !footer { - return nil, errors.New("audit log is incomplete: footer is missing") + 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.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..89123d8 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) { @@ -83,3 +89,276 @@ 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 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..f71aed2 100644 --- a/audit/replay.go +++ b/audit/replay.go @@ -2,12 +2,111 @@ 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 +} + +func (event OpaqueEvent) Header() teleop.Header { return event.Meta.Clone() } +func (event OpaqueEvent) Kind() teleop.EventKind { return event.Type } +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 +} + +func (event EncodingErrorEvent) Header() teleop.Header { return event.Meta.Clone() } +func (event EncodingErrorEvent) Kind() teleop.EventKind { return event.OriginalKind } +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 +114,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 +161,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 +176,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..45d64aa --- /dev/null +++ b/audit/sign.go @@ -0,0 +1,161 @@ +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 neither establishes non-repudiation: the party that +// verifies an HMAC holds the same key that could have produced it, so a log +// operator cannot use one to demonstrate that they did not author its +// contents. An asymmetric signature separates those roles. Backing the private +// key with a TPM, Secure Enclave, or PKCS#11 HSM further prevents the operator +// from extracting it, which is what makes a signed log evidence about a device +// rather than about whoever held a key. + +const signatureDomain = "teleop.audit.sth.v1" + +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 a crypto.Signer backed by hardware 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 identifier recorded alongside a signature. +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(), + ) + } + 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) + } + 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 int + RecordType string + Session teleop.SessionID + Size uint64 + Root []byte + ChainHead string + EventCount uint64 + 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/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..11f2169 --- /dev/null +++ b/cmd/teleop-monitor/terminal_test.go @@ -0,0 +1,54 @@ +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) + } + defer file.Close() + 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..194a2fa 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: 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/controller.go b/controller.go index c20fa88..4c70344 100644 --- a/controller.go +++ b/controller.go @@ -6,120 +6,279 @@ 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 +) + 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 +287,763 @@ 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() - current := observation.State.Clone() - c.state = current.Clone() - c.stateMu.Unlock() + c.stateMu.Lock() + previous := c.state.Clone() + c.state = state.Clone() + c.stateMu.Unlock() - observationEvent := ObservationEvent{ - Meta: c.nextHeader(observation.ObservedAt, observation.DeviceTimestamp, nil), - Native: observation.Native.clone(), - Previous: previous, - Current: current, + observationEvent := ObservationEvent{ + Meta: c.nextHeaderAt( + "input", + observation.ObservedAt, + receivedAt, + observation.DeviceTimestamp, + nil, + false, + ), + Native: observation.Native.clone(), + Previous: previous, + Current: state, + } + 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 } - if err := c.publish(observationEvent); err != nil { - c.finish(err) - return + } + + c.stateMu.Lock() + 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() + 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 } - 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 - } + } + 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 } -func (c *Controller) nextHeader(observedAt time.Time, deviceTimestamp int64, causes []EventID) Header { +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() + + 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 + } + } + 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 + } + 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)) +} + +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) 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 err, panicked := 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 + } + for _, event := range derived { + if event == nil { + continue + } + if err, panicked := c.dispatchProcessorOutput(event, true); err != nil { + if panicked { + c.processorDisabled[index] = true + } + return err + } } - c.backgroundMu.Unlock() + 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, +) (err error, panicked bool) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("%w: processor event: %v", ErrCallbackPanic, recovered) + panicked = true + } + }() + return c.dispatch(event, reportLoss), false } -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) 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) finish(err error) { +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 +1059,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 (s *eventSubscription) enqueue(event Event) { +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) (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 +1336,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 +1375,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..2775cf8 100644 --- a/controller_test.go +++ b/controller_test.go @@ -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..8f16cb8 100644 --- a/device.go +++ b/device.go @@ -16,6 +16,16 @@ const ( 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 + } +} + type ControlKind string const ( @@ -41,6 +51,9 @@ type Capabilities struct { } func (c Capabilities) Clone() Capabilities { + if !c.AuditGrade.Valid() { + c.AuditGrade = AuditUnavailable + } c.Controls = append([]ControlDescriptor(nil), c.Controls...) return c } 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..79d8797 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,218 @@ 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)`. Keep that key outside the log. + +Use `audit.ReadPartial` only when inspecting an interrupted or currently open +log; it returns only the verified prefix. Unchained streams require the +explicit unverified read option. + +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. -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. +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` | +| Non-repudiation | Can the author deny writing 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 non-repudiation + +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 verification and authorship are separate capabilities: + +```go +recorder := audit.NewRecorder(file, audit.WithSigner(signer)) +``` + +`signer` is any `crypto.Signer` with an Ed25519 key. Prefer one backed by a +TPM, Secure Enclave, or PKCS#11 HSM: a key the operator can export is a key the +operator can be accused of having used, which is the property signing exists to +remove. + +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. + +Verification distinguishes two different claims: + +```go +records, verification, err := audit.Read(reader, audit.VerifyOptions{ + RequireFooter: true, + RequireSignature: true, + PublicKey: trustedKey, // obtained out of band +}) +``` + +- `verification.Signed` means signatures verified against the key the log + declares about itself. That is internal consistency only: whoever can rewrite + the log can also replace the declared key. +- `verification.Trusted` means they verified against `PublicKey`, supplied from + outside the log. Only this claim carries evidentiary weight. + +Omitting `PublicKey` therefore proves much less than supplying it. A mismatch +between the declared key and the trusted key fails with `ErrUntrustedKey` +rather than being ignored. + +## 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 checkpoint is 32 bytes of root plus metadata, so anchoring is cheap enough to +run continuously. 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. + +## 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 +245,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..b09d509 --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,161 @@ +# 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` | +| 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..506979a 100644 --- a/event.go +++ b/event.go @@ -2,6 +2,7 @@ package teleop import ( "encoding/hex" + "encoding/json" "fmt" "time" ) @@ -13,11 +14,13 @@ const ( EventButton EventKind = "input.button" EventStick EventKind = "input.stick" EventTrigger EventKind = "input.trigger" - EventDPad EventKind = "input.dpad" EventConnection EventKind = "device.connection" EventCapabilities EventKind = "device.capabilities" + EventLiveness EventKind = "stream.liveness" EventGap EventKind = "stream.gap" EventError EventKind = "stream.error" + EventClock EventKind = "stream.clock" + EventCommand EventKind = "command.issued" ) type SessionID [16]byte @@ -49,11 +52,24 @@ type EventID struct { } 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"` } func (h Header) Clone() Header { @@ -68,6 +84,12 @@ 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 +} + type NativeInput struct { Format string `json:"format"` Data []byte `json:"data,omitempty"` @@ -94,6 +116,13 @@ type ObservationEvent struct { func (e ObservationEvent) Header() Header { return e.Meta.Clone() } func (ObservationEvent) Kind() EventKind { return EventObservation } +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 +} type ButtonEvent struct { Meta Header `json:"header"` @@ -125,15 +154,6 @@ type TriggerEvent struct { 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 } - type ConnectionState string const ( @@ -159,6 +179,29 @@ type CapabilitiesEvent struct { func (e CapabilitiesEvent) Header() Header { return e.Meta.Clone() } 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 LivenessState = "healthy" + 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"` +} + +func (e LivenessEvent) Header() Header { return e.Meta.Clone() } +func (LivenessEvent) Kind() EventKind { return EventLiveness } + type GapEvent struct { Meta Header `json:"header"` Source string `json:"source"` @@ -178,6 +221,127 @@ type ErrorEvent struct { func (e ErrorEvent) Header() Header { return e.Meta.Clone() } 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"` +} + +func (e ClockEvent) Header() Header { return e.Meta.Clone() } +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"` +} + +func (e CommandEvent) Header() Header { return e.Meta.Clone() } +func (CommandEvent) Kind() EventKind { return EventCommand } +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. func ControlOf(event Event) (ControlID, bool) { @@ -190,22 +354,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..1a86e25 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -13,39 +13,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..d49bb98 100644 --- a/gesture/gesture.go +++ b/gesture/gesture.go @@ -3,13 +3,18 @@ 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 ( @@ -21,8 +26,10 @@ const ( 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"` @@ -35,28 +42,41 @@ type Event struct { func (e Event) Header() teleop.Header { return e.Meta.Clone() } func (Event) Kind() teleop.EventKind { return EventKind } +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 +84,8 @@ type press struct { at time.Time header teleop.Header holdSent bool + holdID teleop.EventID + consumed bool } type tap struct { @@ -71,47 +93,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 +207,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 +235,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 +378,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 +403,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 +439,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 +649,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 +773,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..6c21c53 100644 --- a/normalize.go +++ b/normalize.go @@ -3,6 +3,12 @@ package teleop import "math" 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 +23,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 +46,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..13b7d74 --- /dev/null +++ b/safety/event.go @@ -0,0 +1,119 @@ +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" + // 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..cf822fd --- /dev/null +++ b/safety/guard.go @@ -0,0 +1,504 @@ +// 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 { + case meta.Sequence == 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: + // it cannot bound how long the control has been down. + reasons = append(reasons, ReasonDeadManStale) + 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 pressing the dead-man control. That is normal operation +// rather than a fault, so it must not latch. +func onlyDeadManEngagement(reasons []Reason) bool { + if len(reasons) == 0 { + return false + } + for _, reason := range reasons { + if reason != ReasonDeadManReleased { + 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, + now 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..46f7b18 --- /dev/null +++ b/safety/guard_test.go @@ -0,0 +1,461 @@ +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(ReasonDeadManStale) { + t.Fatalf("reasons = %v", decision.Reasons) + } +} + +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..43d2d3c --- /dev/null +++ b/safety/integration_test.go @@ -0,0 +1,284 @@ +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) } + +// 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 := guard.Evaluate() + if !decision.Permit { + t.Fatalf("guard must permit output: %v", decision.Reasons) + } + + _, 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) + } + defer controller.Close() + 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..39a6864 100644 --- a/source.go +++ b/source.go @@ -3,19 +3,39 @@ 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") ) 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 } @@ -41,6 +61,14 @@ 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 +76,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 +91,47 @@ 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 } 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 +149,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/testkit/fake.go b/testkit/fake.go index 3df4235..14e243c 100644 --- a/testkit/fake.go +++ b/testkit/fake.go @@ -67,6 +67,11 @@ func (f *FakeSource) Push(ctx context.Context, state teleop.State) error { } 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() 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/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..690cdf9 100644 --- a/xbox/provider.go +++ b/xbox/provider.go @@ -31,7 +31,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..80de93d 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,8 @@ type linuxSource struct { dropped bool initial bool closeOnce sync.Once + closeMu sync.RWMutex + closed bool } func (s *linuxSource) Descriptor() teleop.Descriptor { @@ -252,6 +261,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 +281,14 @@ 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 - } + if err := s.waitReadable(ctx); err != nil { return teleop.Observation{}, err } - _ = binary.Write(&s.raw, binary.LittleEndian, event) + var event linuxInputEvent + if err := binary.Read(s.file, binary.NativeEndian, &event); err != nil { + return teleop.Observation{}, s.normalizeReadError(err) + } + s.appendRaw(event) if event.Type == evSyn && event.Code == synDropped { s.dropped = true @@ -282,19 +297,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 +336,14 @@ func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { } } +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 +407,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 +538,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 +588,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) + } } From 1108cd1509690b1fb32c896b7236d09c7fa0c2c0 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Mon, 27 Jul 2026 23:07:48 -0400 Subject: [PATCH 2/3] fix: eliminate three hangs and a dropped-record path found by CI CI on Linux hung for ten minutes on the CGO_ENABLED=0 step. Reproducing it in a container surfaced one pre-existing defect and two of my own. xbox: poll readiness is not a whole event waitReadable returns once poll reports readable, which guarantees one byte, not a 24-byte evdev event. Read then called binary.Read, which is io.ReadFull, and blocked in a syscall that neither the context nor Close can interrupt until the remainder arrived. For a partial event at the tail of a kernel buffer that is never, so the input path stops delivering with no error and no timeout. Reads now go through a buffer that decodes only complete records, leaving poll as the single blocking point. A spurious readiness is treated as spurious rather than as a fault, and a read deadline bounds the read where the descriptor supports one. TestLinuxReadCancellation also leaked its descriptor on the failure path, and because these tests run in parallel, a recycled descriptor number appeared as a spurious readiness in a sibling test. teleop: state and freshness metadata were not committed together handleObservation set c.state before publishing and c.meta after, so a snapshot taken in between returned new state with stale metadata. A consumer enforcing a command timeout reads that as "no input has ever arrived". It fails safe, but it trips a gate for no reason, and a gate that trips spuriously is one operators learn to work around. Both are now committed under one lock before publishing. teleop: accepted commands could be discarded at close RecordCommand reports success once a command is queued. Close could then discard the queue without publishing, dropping a command from the record after telling the caller it was accepted. terminate now drains pending commands before the terminal neutral state, preserving issue order. safety: an unobserved hold is not a defeated switch A control the snapshot shows as held whose press the guard has not yet seen was reported as ReasonDeadManStale, which latches. That state is ordinary while a press propagates through the pipeline, so arming during one demanded a re-arm. It is now ReasonDeadManUnconfirmed, which inhibits without latching and clears when the press is observed. Verified on Linux in a container across all four CI steps: cgo enabled, cgo disabled, and -race, each repeated five times. --- controller.go | 64 +++++++++++++++++++----- docs/safety.md | 1 + safety/event.go | 6 +++ safety/guard.go | 16 +++--- safety/guard_test.go | 16 +++++- safety/integration_test.go | 27 ++++++++-- xbox/provider_linux.go | 99 +++++++++++++++++++++++++++++++++++-- xbox/provider_linux_test.go | 8 +-- 8 files changed, 207 insertions(+), 30 deletions(-) diff --git a/controller.go b/controller.go index 4c70344..41ba3e9 100644 --- a/controller.go +++ b/controller.go @@ -448,7 +448,6 @@ func (c *Controller) handleObservation(observation Observation, receivedAt time. c.stateMu.Lock() previous := c.state.Clone() - c.state = state.Clone() c.stateMu.Unlock() observationEvent := ObservationEvent{ @@ -464,6 +463,25 @@ func (c *Controller) handleObservation(observation Observation, receivedAt time. 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 } @@ -483,16 +501,6 @@ func (c *Controller) handleObservation(observation Observation, receivedAt time. } } - c.stateMu.Lock() - 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() return nil } @@ -615,6 +623,13 @@ 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") @@ -647,6 +662,33 @@ func (c *Controller) terminate(err error) { 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 + } + } +} + func normalizeSourceError(err error, closing bool) error { if closing || errors.Is(err, context.Canceled) || errors.Is(err, ErrClosed) { return ErrClosed diff --git a/docs/safety.md b/docs/safety.md index b09d509..b6e5dad 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -97,6 +97,7 @@ A guard authorizes output only when it can affirmatively establish all of: | 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` | diff --git a/safety/event.go b/safety/event.go index 13b7d74..a025c0e 100644 --- a/safety/event.go +++ b/safety/event.go @@ -44,6 +44,12 @@ const ( 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" diff --git a/safety/guard.go b/safety/guard.go index cf822fd..bf22851 100644 --- a/safety/guard.go +++ b/safety/guard.go @@ -312,9 +312,11 @@ func (g *Guard) evaluateLocked() Decision { 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: - // it cannot bound how long the control has been down. - reasons = append(reasons, ReasonDeadManStale) + // 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) } @@ -360,14 +362,16 @@ func (g *Guard) evaluateLocked() Decision { } // onlyDeadManEngagement reports a decision blocked solely because the operator -// is not currently pressing the dead-man control. That is normal operation -// rather than a fault, so it must not latch. +// 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 { + if reason != ReasonDeadManReleased && reason != ReasonDeadManUnconfirmed { return false } } diff --git a/safety/guard_test.go b/safety/guard_test.go index 46f7b18..8a32ce5 100644 --- a/safety/guard_test.go +++ b/safety/guard_test.go @@ -311,9 +311,23 @@ func TestDeadManHeldWithoutObservedPressIsStale(t *testing.T) { if decision.Permit { t.Fatal("an unobserved hold must not authorize output") } - if !decision.Has(ReasonDeadManStale) { + 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) { diff --git a/safety/integration_test.go b/safety/integration_test.go index 43d2d3c..e5e508c 100644 --- a/safety/integration_test.go +++ b/safety/integration_test.go @@ -51,6 +51,28 @@ func waitForState( 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. @@ -100,10 +122,7 @@ func TestGuardedSessionIsFullyRecorded(t *testing.T) { if err := guard.Arm(); err != nil { t.Fatal(err) } - decision := guard.Evaluate() - if !decision.Permit { - t.Fatalf("guard must permit output: %v", decision.Reasons) - } + decision := waitForPermit(t, guard) _, meta := controller.SnapshotWithMeta() err = controller.RecordCommand(t.Context(), teleop.Command{ diff --git a/xbox/provider_linux.go b/xbox/provider_linux.go index 80de93d..73488c6 100644 --- a/xbox/provider_linux.go +++ b/xbox/provider_linux.go @@ -254,6 +254,13 @@ type linuxSource struct { 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 { @@ -281,13 +288,10 @@ func (s *linuxSource) Read(ctx context.Context) (teleop.Observation, error) { if err := ctx.Err(); err != nil { return teleop.Observation{}, err } - if err := s.waitReadable(ctx); err != nil { + event, err := s.nextEvent(ctx) + if err != nil { return teleop.Observation{}, err } - var event linuxInputEvent - if err := binary.Read(s.file, binary.NativeEndian, &event); err != nil { - return teleop.Observation{}, s.normalizeReadError(err) - } s.appendRaw(event) if event.Type == evSyn && event.Code == synDropped { @@ -336,6 +340,91 @@ 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 { diff --git a/xbox/provider_linux_test.go b/xbox/provider_linux_test.go index 54ae6dc..ef9ae67 100644 --- a/xbox/provider_linux_test.go +++ b/xbox/provider_linux_test.go @@ -112,15 +112,17 @@ func TestLinuxReadCancellation(t *testing.T) { defer writer.Close() source := &linuxSource{file: reader} + // Close on every exit path. A t.Fatal below would otherwise leak the + // descriptor until finalization, and these tests run in parallel, so a + // recycled descriptor number shows up as a spurious readiness in another + // test rather than as a failure here. + defer source.Close() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() if _, err := source.Read(ctx); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Read error = %v, want context.DeadlineExceeded", err) } - if err := source.Close(); err != nil { - t.Fatal(err) - } } func TestLinuxReadDecodesEvdevReport(t *testing.T) { From cb07ed9ed4f7e9c977a4e21ea67dac6c692ed0b6 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Tue, 28 Jul 2026 00:03:34 -0400 Subject: [PATCH 3/3] fix: harden audit verification for v1 --- README.md | 24 +- action/action.go | 12 +- audit/anchor.go | 127 +++++++- audit/compat_test.go | 126 +++++++- audit/doc.go | 30 ++ audit/example_test.go | 43 +++ audit/fuzz_test.go | 19 +- audit/integrity_test.go | 452 +++++++++++++++++++++++++-- audit/provenance.go | 6 +- audit/recorder.go | 465 ++++++++++++++++++++++------ audit/recorder_test.go | 99 +++++- audit/replay.go | 14 +- audit/sign.go | 60 +++- audit/wire.go | 273 ++++++++++++++++ cmd/teleop-monitor/terminal_test.go | 6 +- cmd/teleop-monitor/tui.go | 20 -- control.go | 40 ++- controller.go | 10 +- controller_test.go | 2 +- device.go | 41 ++- docs/audit.md | 86 +++-- event.go | 120 +++++-- examples/basic/main.go | 2 + gesture/gesture.go | 23 +- normalize.go | 1 + safety/guard.go | 6 +- safety/integration_test.go | 6 +- source.go | 4 + state.go | 3 + testkit/fake.go | 10 + xbox/aliases.go | 2 + xbox/provider.go | 6 + 32 files changed, 1875 insertions(+), 263 deletions(-) create mode 100644 audit/doc.go create mode 100644 audit/example_test.go create mode 100644 audit/wire.go diff --git a/README.md b/README.md index e9e1241..d9f987d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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. @@ -19,8 +21,9 @@ provider works on Linux, macOS, and Windows. > not here. 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. +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 @@ -302,14 +305,21 @@ recorder := audit.NewRecorder( ``` Each mechanism closes a different gap. The hash chain shows the log was not -edited; `WithHMAC` shows a key holder wrote it; `WithSigner` makes authorship -non-repudiable, since the verifier no longer holds a key that could have forged -it; `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. +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. diff --git a/action/action.go b/action/action.go index 1ed0a8c..c03330f 100644 --- a/action/action.go +++ b/action/action.go @@ -36,8 +36,13 @@ type Event struct { 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 } + +// 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...) @@ -61,10 +66,12 @@ type Binding struct { 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} } +// OnDPad binds a D-pad direction transition to action. func OnDPad(action ID, direction teleop.ControlID, phase teleop.Phase) Binding { return OnButton(action, direction, phase) } @@ -79,6 +86,7 @@ func OnGesture(action ID, gestureType gesture.Type, control teleop.ControlID) Bi return OnGesturePhase(action, gestureType, control, phase) } +// OnGesturePhase binds one phase of a recognized gesture to action. func OnGesturePhase( action ID, gestureType gesture.Type, @@ -106,11 +114,13 @@ func OnChord(action ID, name string, controls ...teleop.ControlID) Binding { } } +// 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, _ := triggerControl(trigger) return Binding{Action: action, EventKind: teleop.EventTrigger, Control: control} diff --git a/audit/anchor.go b/audit/anchor.go index c0981d2..77d16fc 100644 --- a/audit/anchor.go +++ b/audit/anchor.go @@ -2,6 +2,8 @@ package audit import ( "context" + "crypto/ed25519" + "encoding/hex" "encoding/json" "fmt" "io" @@ -19,19 +21,110 @@ import ( // 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 signed tree head suitable for publication outside the log. -// It is 32 bytes of root plus metadata, so anchoring is cheap enough to run -// continuously. +// 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 { - Session teleop.SessionID `json:"session"` - Size uint64 `json:"size"` - Root string `json:"root"` - ChainHead string `json:"chain_head"` - EventCount uint64 `json:"event_count"` - RecordedAt time.Time `json:"recorded_at"` - KeyID string `json:"key_id,omitempty"` - PublicKey string `json:"public_key,omitempty"` - Signature string `json:"signature,omitempty"` + // 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 @@ -177,8 +270,12 @@ func (r *anchorRunner) stats() AnchorStats { // 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 uint64 - Dropped uint64 - Err error + // 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 index 710b43e..76d12c2 100644 --- a/audit/compat_test.go +++ b/audit/compat_test.go @@ -3,6 +3,8 @@ package audit import ( "bytes" "encoding/json" + "errors" + "io" "testing" "time" ) @@ -27,7 +29,44 @@ func encodeLegacy(t *testing.T, records []diskRecord, hashFor func(diskRecord) ( } record.Hash = digest previous = digest - encoded, err := json.Marshal(record) + 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) } @@ -37,6 +76,91 @@ func encodeLegacy(t *testing.T, records []diskRecord, hashFor func(diskRecord) ( 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)) 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 index ecc0dd6..119a9b2 100644 --- a/audit/fuzz_test.go +++ b/audit/fuzz_test.go @@ -25,6 +25,13 @@ func FuzzVerify(f *testing.F) { 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{} @@ -44,15 +51,19 @@ func FuzzVerify(f *testing.F) { // Unverified reads must survive arbitrary input. _, _, _ = Read(bytes.NewReader(data), VerifyOptions{AllowUnverified: true}) - // A trusted-key read must never report success on anything the fuzzer - // produced: it cannot forge a signature over the trusted key. + // 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 && !bytes.Equal(data, valid.Bytes()) { - t.Fatalf("fuzzed input verified as trusted: %+v", verification) + 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 index ecff016..871dede 100644 --- a/audit/integrity_test.go +++ b/audit/integrity_test.go @@ -60,9 +60,8 @@ func TestSignedLogVerifiesAgainstTrustedKey(t *testing.T) { buffer, public := signedLog(t, 5) records, verification, err := Read(buffer, VerifyOptions{ - RequireFooter: true, - RequireSignature: true, - PublicKey: public, + RequireFooter: true, + PublicKey: public, }) if err != nil { t.Fatalf("verify: %v", err) @@ -83,6 +82,376 @@ func TestSignedLogVerifiesAgainstTrustedKey(t *testing.T) { 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: @@ -102,6 +471,26 @@ func TestVerifyRejectsUntrustedKey(t *testing.T) { } } +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) @@ -133,6 +522,30 @@ func TestVerifyRequiresSignatureWhenDemanded(t *testing.T) { } } +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. @@ -297,27 +710,18 @@ func TestAnchorReceivesSignedCheckpoints(t *testing.T) { t.Fatalf("anchored %d checkpoints, want 4", len(checkpoints)) } - // An anchored head must be independently verifiable, which is the whole - // point of publishing it outside the log. - last := checkpoints[len(checkpoints)-1] - root, err := hex.DecodeString(last.Root) - if err != nil { - t.Fatal(err) - } - var session teleop.SessionID - session[0] = 1 - head := TreeHead{ - Version: FormatVersion, - RecordType: "footer", - Session: session, - Size: last.Size, - Root: root, - ChainHead: last.ChainHead, - EventCount: last.EventCount, - RecordedAt: last.RecordedAt, - } - if err := VerifyTreeHead(public, head, last.Signature); err != nil { - t.Fatalf("anchored footer head must verify: %v", err) + // 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 { diff --git a/audit/provenance.go b/audit/provenance.go index d7741f2..8e0ba6b 100644 --- a/audit/provenance.go +++ b/audit/provenance.go @@ -26,11 +26,13 @@ type Provenance struct { // 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 string `json:"host,omitempty"` - PID int `json:"pid,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. diff --git a/audit/recorder.go b/audit/recorder.go index e2cead4..21efe7b 100644 --- a/audit/recorder.go +++ b/audit/recorder.go @@ -1,5 +1,3 @@ -// Package audit writes, verifies, and replays append-only controller event -// logs. The format is newline-delimited JSON for inspection and streaming. package audit import ( @@ -29,27 +27,46 @@ const ( // ControlSchemaVersion pins persisted ControlID meanings independently of // the record container. ControlSchemaVersion = "teleop.controls.v1" - MaxRecordSize = 16 * 1024 * 1024 + // MaxRecordSize is the largest encoded JSON Lines record accepted or + // produced by this package. + MaxRecordSize = 16 * 1024 * 1024 // DefaultCheckpointInterval bounds how long a log can run without emitting - // a signed, anchorable tree head. + // 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 = errors.New("teleop/audit: recorder closed") - ErrFailed = errors.New("teleop/audit: recorder failed") - ErrUnverified = errors.New("teleop/audit: stream is not hash chained") - ErrUnauthenticated = errors.New("teleop/audit: stream is not HMAC authenticated") + // 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 = errors.New("teleop/audit: stream is incomplete") - ErrRecordTooLarge = errors.New("teleop/audit: record too large") + // 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 ( @@ -58,22 +75,34 @@ const ( chainHMAC = "hmac-sha256" ) -// Options configures the audit writer. +// 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 []byte - Now func() time.Time - - Signer crypto.Signer - Anchor Anchor - AnchorTimeout time.Duration - AnchorQueue int + // 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 *Provenance + // 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 @@ -90,7 +119,9 @@ func WithFlushEveryEvent(enabled bool) Option { } // WithHMAC authenticates the chain with HMAC-SHA-256. The key is copied; an -// empty key is rejected on the first Record or Close. +// 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)) @@ -109,13 +140,14 @@ func WithClock(now func() time.Time) Option { } // WithSigner signs the manifest, every checkpoint, and the footer with an -// Ed25519 key, giving the log non-repudiation that an HMAC chain cannot. +// 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 a crypto.Signer backed by a TPM, Secure Enclave, or HSM. A key the -// operator can read is a key the operator can be accused of having used. +// 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 { @@ -133,6 +165,7 @@ func WithAnchor(anchor Anchor) Option { return func(options *Options) { if anchor != nil { options.Anchor = anchor + options.HashChain = true } } } @@ -146,9 +179,10 @@ func WithAnchorTimeout(timeout time.Duration) Option { } } -// WithCheckpoints sets how often a signed tree head is emitted, by elapsed -// time and by record count. Either bound may be zero to disable it. Frequent -// checkpoints narrow the window in which a truncation can go unwitnessed. +// 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 @@ -200,39 +234,64 @@ type diskRecord struct { 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 event 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 - PreviousHash string - Hash string + // 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 int - Chain string - Integrity bool + // 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 bool - EventCount uint64 - Durability string - - // Signed reports that every signed record 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. + // 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 signatures verified against a key the caller - // supplied out of band. This is the property that carries evidentiary - // weight. + // 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 @@ -251,16 +310,28 @@ type Verification struct { // VerifyOptions configures streaming verification. type VerifyOptions struct { - RequireFooter bool - AllowUnverified bool + // 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 []byte + // HMACKey authenticates an HMAC-SHA-256 chain. + HMACKey []byte - // PublicKey is the signing key the verifier trusts. When set, the log's - // declared key must match it and every signature must verify against it. + // 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 rejects a log that carries no signatures. + // 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. @@ -286,6 +357,9 @@ type Recorder struct { 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, @@ -299,6 +373,21 @@ func NewRecorder(writer io.Writer, options ...Option) *Recorder { option(&configured) } } + 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 } @@ -331,7 +420,7 @@ func (r *Recorder) PublicKey() ed25519.PublicKey { if r.key == nil { return nil } - return r.key.public + return append(ed25519.PublicKey(nil), r.key.public...) } // AnchorStats reports external anchoring health. Non-zero Failed or Dropped @@ -343,9 +432,11 @@ func (r *Recorder) AnchorStats() AnchorStats { return r.anchors.stats() } -// Checkpoint forces a signed tree head immediately. Callers should invoke it -// at moments worth being able to prove later, such as arming, an emergency -// stop, or a handover between operators. +// 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() @@ -355,6 +446,9 @@ func (r *Recorder) Checkpoint(reason string) error { 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) } @@ -366,7 +460,7 @@ func (r *Recorder) Checkpoint(reason string) error { // Record implements teleop.EventSink. An event that JSON cannot represent is // retained as an encoding-error payload rather than terminating controller -// input. +// 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 @@ -391,10 +485,15 @@ func (r *Recorder) Record(ctx context.Context, event teleop.Event) error { if r.failure != nil { return errors.Join(ErrFailed, r.failure) } + 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) @@ -431,7 +530,7 @@ func (r *Recorder) checkpointDueLocked(now time.Time) bool { return false } -// checkpointLocked writes a signed tree head covering every record before it. +// checkpointLocked writes a tree head covering every record before it. func (r *Recorder) checkpointLocked(reason string) error { checkpoint := diskRecord{ Version: FormatVersion, @@ -455,6 +554,8 @@ func (r *Recorder) publishAnchorLocked(record diskRecord) { return } checkpoint := Checkpoint{ + Version: record.Version, + RecordType: record.RecordType, Session: r.session, Size: record.TreeSize, Root: record.TreeRoot, @@ -464,6 +565,7 @@ func (r *Recorder) publishAnchorLocked(record diskRecord) { Signature: record.Signature, } if r.key != nil { + checkpoint.SignatureAlgorithm = SignatureAlgorithmEd25519 checkpoint.KeyID = r.key.id checkpoint.PublicKey = hex.EncodeToString(r.key.public) } @@ -497,6 +599,7 @@ func (r *Recorder) Close() error { 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() { @@ -646,9 +749,21 @@ 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 + } +} + func (r *Recorder) flushAndSync() error { if err := r.writer.Flush(); err != nil { return fmt.Errorf("flush audit log: %w", err) @@ -661,29 +776,62 @@ func (r *Recorder) flushAndSync() error { return nil } -// ReadAll decodes and verifies a complete, chained stream. +// 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 } -// ReadAuthenticated verifies a complete HMAC stream with key. +// 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: append([]byte(nil), key...), + 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 nil, verification, err + } + return records, verification, nil +} + // ReadPartial verifies all complete records and permits a missing or torn -// footer. Valid prefix records are retained when a later record is invalid. +// 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 } -// Read performs streaming verification and accumulates event records. +// 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 { @@ -693,27 +841,71 @@ func Read(reader io.Reader, options VerifyOptions) ([]Record, Verification, erro return records, verification, err } -// Verify checks a stream incrementally and invokes consume for each verified -// event, avoiding whole-log memory growth. +// 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 ( - 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 + 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 ) + 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) { @@ -729,8 +921,8 @@ func Verify( } break } - var disk diskRecord - if decodeErr := json.Unmarshal(line, &disk); decodeErr != nil { + 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 { @@ -743,6 +935,13 @@ func Verify( } 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 @@ -761,6 +960,13 @@ func Verify( 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 != "" { @@ -768,8 +974,19 @@ func Verify( 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 { @@ -798,7 +1015,13 @@ func Verify( } else if verification.PublicKey != nil { trusted = verification.PublicKey } - if options.RequireSignature && trusted == nil { + 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 { @@ -849,6 +1072,15 @@ func Verify( // 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( @@ -896,8 +1128,11 @@ func Verify( if sigErr := VerifyTreeHead(trusted, signed, disk.Signature); sigErr != nil { return verification, fmt.Errorf("audit line %d: %w", lineNumber, sigErr) } - verification.Signed = true - verification.Trusted = len(options.PublicKey) > 0 + headVerified = true + signedThrough = tree.Size() + 1 + if len(options.PublicKey) > 0 { + trustedThrough = tree.Size() + 1 + } } } if disk.Version >= 3 && verification.Chain != chainNone { @@ -911,6 +1146,7 @@ func Verify( } tree.Append(HashLeaf(raw)) verification.TreeSize = tree.Size() + updateCoverage() } switch disk.RecordType { @@ -918,6 +1154,11 @@ func Verify( 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 { @@ -935,6 +1176,11 @@ func Verify( ) } verification.Checkpoints++ + if signatureRequired && headVerified { + if err := releasePending(); err != nil { + return verification, err + } + } continue case "footer": if disk.EventCount != verification.EventCount { @@ -947,6 +1193,11 @@ func Verify( } footer = true verification.Complete = true + if signatureRequired && headVerified { + if err := releasePending(); err != nil { + return verification, err + } + } continue case "event": default: @@ -964,9 +1215,26 @@ func Verify( 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) } @@ -1008,7 +1276,21 @@ func Verify( Hash: disk.Hash, EncodingError: encodingError, } - if consume != nil { + 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 } @@ -1025,8 +1307,13 @@ func Verify( verification.Authenticated = verification.Chain == chainHMAC verification.TreeSize = tree.Size() verification.TreeRoot = tree.Root() - if options.RequireSignature && !verification.Signed { - return verification, fmt.Errorf("%w: no signed tree head", ErrSignatureRequired) + 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 } @@ -1140,7 +1427,9 @@ func recordHashV3(record diskRecord, chain string, key []byte) (string, error) { writeHashField(digest, []byte(record.Reason)) var provenance []byte - if record.Provenance != nil { + 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 { diff --git a/audit/recorder_test.go b/audit/recorder_test.go index 89123d8..105d586 100644 --- a/audit/recorder_test.go +++ b/audit/recorder_test.go @@ -21,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, @@ -61,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, @@ -196,6 +208,89 @@ func TestHMACRequiresAndAuthenticatesKey(t *testing.T) { } } +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() diff --git a/audit/replay.go b/audit/replay.go index f71aed2..9de10a8 100644 --- a/audit/replay.go +++ b/audit/replay.go @@ -19,8 +19,13 @@ type OpaqueEvent struct { Payload json.RawMessage } -func (event OpaqueEvent) Header() teleop.Header { return event.Meta.Clone() } +// 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...) @@ -36,8 +41,13 @@ type EncodingErrorEvent struct { Message string } -func (event EncodingErrorEvent) Header() teleop.Header { return event.Meta.Clone() } +// 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 diff --git a/audit/sign.go b/audit/sign.go index 45d64aa..464f27e 100644 --- a/audit/sign.go +++ b/audit/sign.go @@ -17,15 +17,20 @@ import ( ) // A hash chain establishes integrity and an HMAC chain establishes -// authenticity, but neither establishes non-repudiation: the party that -// verifies an HMAC holds the same key that could have produced it, so a log -// operator cannot use one to demonstrate that they did not author its -// contents. An asymmetric signature separates those roles. Backing the private -// key with a TPM, Secure Enclave, or PKCS#11 HSM further prevents the operator -// from extracting it, which is what makes a signed log evidence about a device -// rather than about whoever held a key. - -const signatureDomain = "teleop.audit.sth.v1" +// 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. @@ -38,8 +43,8 @@ var ( ) // GenerateKey creates an Ed25519 signing key. Production deployments should -// prefer a crypto.Signer backed by hardware so the private key cannot be -// exported; this exists for tests and for development logs. +// 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 { @@ -48,7 +53,9 @@ func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error) { return public, private, nil } -// KeyID returns the stable short identifier recorded alongside a signature. +// 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]) @@ -73,6 +80,14 @@ func newSigningKey(signer crypto.Signer) (*signingKey, error) { 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 } @@ -83,6 +98,13 @@ func (k *signingKey) sign(message []byte) (string, error) { 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 } @@ -91,13 +113,19 @@ func (k *signingKey) sign(message []byte) (string, error) { // 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 teleop.SessionID - Size uint64 - Root []byte - ChainHead 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 } 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/cmd/teleop-monitor/terminal_test.go b/cmd/teleop-monitor/terminal_test.go index 11f2169..e400619 100644 --- a/cmd/teleop-monitor/terminal_test.go +++ b/cmd/teleop-monitor/terminal_test.go @@ -47,7 +47,11 @@ func TestDevNullIsNotATerminal(t *testing.T) { if err != nil { t.Fatal(err) } - defer file.Close() + 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 194a2fa..3fe1726 100644 --- a/cmd/teleop-monitor/tui.go +++ b/cmd/teleop-monitor/tui.go @@ -535,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/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 41ba3e9..6e9536e 100644 --- a/controller.go +++ b/controller.go @@ -87,6 +87,8 @@ const ( advanceInterval = 25 * time.Millisecond ) +// Controller implements one open, normalized, loss-accounted controller +// session. type Controller struct { source InputSource descriptor Descriptor @@ -877,7 +879,7 @@ func (c *Controller) processStages(inputs []Event, start int) error { if output == nil { continue } - if err, panicked := c.dispatchProcessorOutput(output, true); err != nil { + if panicked, err := c.dispatchProcessorOutput(output, true); err != nil { if panicked { c.processorDisabled[index] = true } @@ -916,7 +918,7 @@ func (c *Controller) advanceProcessor(index int, processor Processor, now time.T if event == nil { continue } - if err, panicked := c.dispatchProcessorOutput(event, true); err != nil { + if panicked, err := c.dispatchProcessorOutput(event, true); err != nil { if panicked { c.processorDisabled[index] = true } @@ -929,14 +931,14 @@ func (c *Controller) advanceProcessor(index int, processor Processor, now time.T func (c *Controller) dispatchProcessorOutput( event Event, reportLoss bool, -) (err error, panicked bool) { +) (panicked bool, err error) { defer func() { if recovered := recover(); recovered != nil { err = fmt.Errorf("%w: processor event: %v", ErrCallbackPanic, recovered) panicked = true } }() - return c.dispatch(event, reportLoss), false + return false, c.dispatch(event, reportLoss) } func (c *Controller) callProcessor(processor Processor, event Event) ([]Event, error) { diff --git a/controller_test.go b/controller_test.go index 2775cf8..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) diff --git a/device.go b/device.go index 8f16cb8..f517e92 100644 --- a/device.go +++ b/device.go @@ -5,15 +5,20 @@ 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. @@ -26,22 +31,31 @@ func (grade AuditGrade) Valid() bool { } } +// 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"` @@ -50,6 +64,7 @@ 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 @@ -58,6 +73,7 @@ func (c Capabilities) Clone() Capabilities { 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 { @@ -67,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"` @@ -79,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 { @@ -97,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/docs/audit.md b/docs/audit.md index 79d8797..2c49bf2 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -32,11 +32,12 @@ 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)`. Keep that key outside the log. +`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 only the verified prefix. Unchained streams require the -explicit unverified read option. +log; it returns the integrity-checked prefix and does not require signature +coverage. Unchained streams require the explicit unverified read option. Audit files contain a single header/footer-delimited stream. Create a unique file per session; `O_EXCL` prevents an accidental overwrite. Appending a new @@ -75,50 +76,80 @@ adding a stronger one does not make a weaker one redundant. | --- | --- | --- | | Integrity | Was the log edited? | hash chain | | Authenticity | Did a holder of the key write it? | `WithHMAC` | -| Non-repudiation | Can the author deny writing it? | `WithSigner` | +| 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 non-repudiation +## 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 verification and authorship are separate capabilities: +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 one backed by a -TPM, Secure Enclave, or PKCS#11 HSM: a key the operator can export is a key the -operator can be accused of having used, which is the property signing exists to -remove. +`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. -Verification distinguishes two different claims: +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, - RequireSignature: true, PublicKey: trustedKey, // obtained out of band }) ``` -- `verification.Signed` means signatures verified against the key the log - declares about itself. That is internal consistency only: whoever can rewrite - the log can also replace the declared key. -- `verification.Trusted` means they verified against `PublicKey`, supplied from - outside the log. Only this claim carries evidentiary weight. - -Omitting `PublicKey` therefore proves much less than supplying it. A mismatch -between the declared key and the trusted key fails with `ErrUntrustedKey` -rather than being ignored. +- `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 @@ -144,8 +175,14 @@ 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 checkpoint is 32 bytes of root plus metadata, so anchoring is cheap enough to -run continuously. Publication is asynchronous and never blocks the input path. +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: @@ -160,7 +197,8 @@ if stats := recorder.AnchorStats(); stats.Failed > 0 || stats.Dropped > 0 { 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. +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 diff --git a/event.go b/event.go index 506979a..66a3d40 100644 --- a/event.go +++ b/event.go @@ -7,32 +7,48 @@ import ( "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" - 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" - EventLiveness EventKind = "stream.liveness" - EventGap EventKind = "stream.gap" - EventError EventKind = "stream.error" - EventClock EventKind = "stream.clock" - EventCommand EventKind = "command.issued" + // 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 { @@ -45,12 +61,15 @@ 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"` @@ -72,6 +91,7 @@ type Header struct { 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 @@ -90,6 +110,7 @@ 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"` @@ -107,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"` @@ -114,8 +136,13 @@ 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() @@ -124,6 +151,7 @@ func (e ObservationEvent) CloneEvent() Event { return e } +// ButtonEvent records one digital button or D-pad transition. type ButtonEvent struct { Meta Header `json:"header"` Button ControlID `json:"button"` @@ -131,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"` @@ -141,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"` @@ -151,16 +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 } +// 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"` @@ -168,16 +211,24 @@ 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 @@ -185,8 +236,10 @@ func (CapabilitiesEvent) Kind() EventKind { return EventCapabilities } type LivenessState string const ( + // LivenessHealthy reports input newer than the configured threshold. LivenessHealthy LivenessState = "healthy" - LivenessStale LivenessState = "stale" + // LivenessStale reports input older than the configured threshold. + LivenessStale LivenessState = "stale" ) // LivenessEvent periodically reports the age of the most recently received @@ -199,9 +252,14 @@ type LivenessEvent struct { Age time.Duration `json:"age"` } +// Header implements Event. func (e LivenessEvent) Header() Header { return e.Meta.Clone() } -func (LivenessEvent) Kind() EventKind { return EventLiveness } +// 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"` @@ -209,17 +267,25 @@ 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 @@ -235,8 +301,11 @@ type ClockEvent struct { Reason string `json:"reason,omitempty"` } +// Header implements Event. func (e ClockEvent) Header() Header { return e.Meta.Clone() } -func (ClockEvent) Kind() EventKind { return EventClock } + +// 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 @@ -257,8 +326,13 @@ type CommandEvent struct { Reason string `json:"reason,omitempty"` } +// Header implements Event. func (e CommandEvent) Header() Header { return e.Meta.Clone() } -func (CommandEvent) Kind() EventKind { return EventCommand } + +// 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...) diff --git a/examples/basic/main.go b/examples/basic/main.go index 1a86e25..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 ( diff --git a/gesture/gesture.go b/gesture/gesture.go index d49bb98..a2be50e 100644 --- a/gesture/gesture.go +++ b/gesture/gesture.go @@ -18,11 +18,17 @@ import ( 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" ) @@ -40,8 +46,13 @@ 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...) diff --git a/normalize.go b/normalize.go index 6c21c53..7c389cf 100644 --- a/normalize.go +++ b/normalize.go @@ -2,6 +2,7 @@ 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 { diff --git a/safety/guard.go b/safety/guard.go index bf22851..d8f9471 100644 --- a/safety/guard.go +++ b/safety/guard.go @@ -281,8 +281,8 @@ func (g *Guard) evaluateLocked() Decision { // has never been shown to work, so it is treated as failed rather than as // merely quiet. var age time.Duration - switch { - case meta.Sequence == 0: + switch meta.Sequence { + case 0: reasons = append(reasons, ReasonNoInput) age = now default: @@ -425,7 +425,7 @@ func (g *Guard) Advance(time.Time) []teleop.Event { return nil } func (g *Guard) AdvanceContext( _ context.Context, pc teleop.ProcessingContext, - now time.Time, + _ time.Time, ) ([]teleop.Event, error) { g.mu.Lock() defer g.mu.Unlock() diff --git a/safety/integration_test.go b/safety/integration_test.go index e5e508c..4ddf490 100644 --- a/safety/integration_test.go +++ b/safety/integration_test.go @@ -273,7 +273,11 @@ func TestGuardTripsWithoutApplicationInvolvement(t *testing.T) { if err != nil { t.Fatal(err) } - defer controller.Close() + 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 { diff --git a/source.go b/source.go index 39a6864..e810676 100644 --- a/source.go +++ b/source.go @@ -33,6 +33,8 @@ var ( 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. @@ -57,6 +59,7 @@ type InputSource interface { Close() error } +// EventSink receives the authoritative ordered controller event stream. type EventSink interface { Record(context.Context, Event) error } @@ -127,6 +130,7 @@ type controllerOptions struct { deferredStart bool } +// OpenOption configures a controller session. type OpenOption func(*controllerOptions) // WithAuditSink attaches an authoritative ingress sink. The controller accepts 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 14e243c..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,6 +70,7 @@ 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: @@ -82,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 @@ -95,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(), @@ -102,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() } @@ -120,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/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/provider.go b/xbox/provider.go index 690cdf9..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,