diff --git a/internal/uevent/message.go b/internal/uevent/message.go new file mode 100644 index 00000000..aecd5b8c --- /dev/null +++ b/internal/uevent/message.go @@ -0,0 +1,137 @@ +package uevent + +import ( + "bytes" + "strconv" + "strings" +) + +// A Message represents a kernel message to userspace. +type Message struct { + // alloc_uevent_skb: action_string + Action KobjectAction `json:"action"` + // alloc_uevent_skb: devpath + DevPath string `json:"devpath"` + // add_uevent_var: key value strings + Env []string `json:"env"` +} + +// String returns a multiline user-facing string representation of [Message]. +func (msg *Message) String() string { + var buf strings.Builder + buf.WriteString(msg.Action.String() + " event") + if msg.DevPath != "" { + buf.WriteString(" on " + msg.DevPath) + } + buf.WriteString(":") + for _, s := range msg.Env { + buf.WriteString("\n" + s) + } + return buf.String() +} + +var ( + // zero is a single pre-allocated NUL character. + zero = []byte{0} + + // sepHeader is the separator in a [Message] header. + sepHeader = []byte{'@'} +) + +func (msg *Message) AppendBinary(b []byte) (_ []byte, err error) { + if b, err = msg.Action.AppendText(b); err != nil { + return + } + b = append(b, sepHeader...) + b = append(b, msg.DevPath...) + b = append(b, zero...) + for _, s := range msg.Env { + b = append(b, s...) + b = append(b, zero...) + } + return b, nil +} + +func (msg *Message) MarshalBinary() ([]byte, error) { + return msg.AppendBinary(nil) +} + +// MissingHeaderError is an invalid representation of [Message] which is missing +// its header added by alloc_uevent_skb. +type MissingHeaderError string + +var _ Recoverable = MissingHeaderError("") + +func (MissingHeaderError) recoverable() {} +func (e MissingHeaderError) Error() string { + return "message " + strconv.Quote(string(e)) + " has no header" +} + +// MessageError describes a malformed representation of [Message]. +type MessageError struct { + // Full offending data. + Data string `json:"data"` + // Offending section. + Section string `json:"section"` + // Part of header in Section. + Kind int `json:"kind"` +} + +var _ Recoverable = new(MessageError) +var _ Nontrivial = new(MessageError) + +const ( + // MErrorKindHeaderSep denotes a message header missing its separator. + MErrorKindHeaderSep = iota + // MErrorKindFinalNUL denotes a message body missing its final NUL terminator. + MErrorKindFinalNUL +) + +func (*MessageError) recoverable() {} +func (*MessageError) nontrivial() {} +func (e *MessageError) Error() string { + switch e.Kind { + case MErrorKindHeaderSep: + return "header " + strconv.Quote(e.Section) + " missing separator" + case MErrorKindFinalNUL: + return "entry " + strconv.Quote(e.Section) + " missing NUL" + + default: + return "section " + strconv.Quote(e.Section) + " is invalid" + } +} + +func (msg *Message) UnmarshalBinary(data []byte) error { + header, body, ok := bytes.Cut(data, zero) + if !ok { + return MissingHeaderError(data) + } + + action_string, devpath, ok := bytes.Cut(header, sepHeader) + if !ok { + return &MessageError{string(data), string(header), MErrorKindHeaderSep} + } + + if err := msg.Action.UnmarshalText(action_string); err != nil { + return err + } + msg.DevPath = string(devpath) + + if len(body) == 0 { + msg.Env = nil + return nil + } + msg.Env = make([]string, 0, bytes.Count(body, zero)) + + var s []byte + for len(body) != 0 { + var r []byte + s, r, ok = bytes.Cut(body, zero) + if !ok { + return &MessageError{string(data), string(body), MErrorKindFinalNUL} + } + body = r + msg.Env = append(msg.Env, string(s)) + } + return nil +} diff --git a/internal/uevent/message_test.go b/internal/uevent/message_test.go new file mode 100644 index 00000000..ffc7b949 --- /dev/null +++ b/internal/uevent/message_test.go @@ -0,0 +1,126 @@ +package uevent_test + +import ( + "syscall" + "testing" + + "hakurei.app/internal/uevent" +) + +func TestMessage(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + v uevent.Message + want string + wantErr error + wantErrE error + + s string + }{ + {"sample virtio-sound-pci add", uevent.Message{ + Action: uevent.KOBJ_ADD, + DevPath: "/devices/pci0000:00/0000:00:04.0/virtio1", + Env: []string{ + "ACTION=add", + "DEVPATH=/devices/pci0000:00/0000:00:04.0/virtio1", + "SUBSYSTEM=virtio", + "MODALIAS=virtio:d00000019v00001AF4", + "SEQNUM=779", + }, + }, "add@/devices/pci0000:00/0000:00:04.0/virtio1\x00" + + "ACTION=add\x00" + + "DEVPATH=/devices/pci0000:00/0000:00:04.0/virtio1\x00" + + "SUBSYSTEM=virtio\x00" + + "MODALIAS=virtio:d00000019v00001AF4\x00" + + "SEQNUM=779\x00", + nil, nil, `add event on /devices/pci0000:00/0000:00:04.0/virtio1: +ACTION=add +DEVPATH=/devices/pci0000:00/0000:00:04.0/virtio1 +SUBSYSTEM=virtio +MODALIAS=virtio:d00000019v00001AF4 +SEQNUM=779`}, + + {"sample virtio-sound-pci bind", uevent.Message{ + Action: uevent.KOBJ_BIND, + DevPath: "/devices/pci0000:00/0000:00:04.0", + Env: []string{ + "ACTION=bind", + "DEVPATH=/devices/pci0000:00/0000:00:04.0", + "SUBSYSTEM=pci", + "DRIVER=virtio-pci", + "PCI_CLASS=40100", + "PCI_ID=1AF4:1059", + "PCI_SUBSYS_ID=1AF4:1100", + "PCI_SLOT_NAME=0000:00:04.0", + "MODALIAS=pci:v00001AF4d00001059sv00001AF4sd00001100bc04sc01i00", + "SEQNUM=780", + }, + }, "bind@/devices/pci0000:00/0000:00:04.0\x00" + + "ACTION=bind\x00" + + "DEVPATH=/devices/pci0000:00/0000:00:04.0\x00" + + "SUBSYSTEM=pci\x00" + + "DRIVER=virtio-pci\x00" + + "PCI_CLASS=40100\x00" + + "PCI_ID=1AF4:1059\x00" + + "PCI_SUBSYS_ID=1AF4:1100\x00" + + "PCI_SLOT_NAME=0000:00:04.0\x00" + + "MODALIAS=pci:v00001AF4d00001059sv00001AF4sd00001100bc04sc01i00\x00" + + "SEQNUM=780\x00", nil, nil, `bind event on /devices/pci0000:00/0000:00:04.0: +ACTION=bind +DEVPATH=/devices/pci0000:00/0000:00:04.0 +SUBSYSTEM=pci +DRIVER=virtio-pci +PCI_CLASS=40100 +PCI_ID=1AF4:1059 +PCI_SUBSYS_ID=1AF4:1100 +PCI_SLOT_NAME=0000:00:04.0 +MODALIAS=pci:v00001AF4d00001059sv00001AF4sd00001100bc04sc01i00 +SEQNUM=780`}, + + {"zero devpath env", uevent.Message{ + Action: uevent.KOBJ_MOVE, + }, "move@\x00", nil, nil, "move event:"}, + + {"d final NUL e bad action", uevent.Message{ + Action: 0xbad, + }, "move@\x00truncated", &uevent.MessageError{ + Data: "move@\x00truncated", + Section: "truncated", + Kind: uevent.MErrorKindFinalNUL, + }, syscall.EINVAL, "unsupported kobject_action 2989 event:"}, + + {"bad action", uevent.Message{ + Action: 0xbad, + }, "nonexistent@\x00", uevent.UnsupportedActionError( + "nonexistent", + ), syscall.EINVAL, "unsupported kobject_action 2989 event:"}, + + {"d header sep e bad action", uevent.Message{ + Action: 0xbad, + }, "move\x00", &uevent.MessageError{ + Data: "move\x00", + Section: "move", + Kind: uevent.MErrorKindHeaderSep, + }, syscall.EINVAL, "unsupported kobject_action 2989 event:"}, + + {"d missing header e bad action", uevent.Message{ + Action: 0xbad, + }, "move", uevent.MissingHeaderError( + "move", + ), syscall.EINVAL, "unsupported kobject_action 2989 event:"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + adeB(t, "", tc.v, tc.want, tc.wantErr, tc.wantErrE) + t.Run("string", func(t *testing.T) { + if got := tc.v.String(); got != tc.s { + t.Errorf("String: %q, want %q", got, tc.s) + } + }) + }) + } +} diff --git a/internal/uevent/uevent_test.go b/internal/uevent/uevent_test.go index 9af3dcdd..53d9bafc 100644 --- a/internal/uevent/uevent_test.go +++ b/internal/uevent/uevent_test.go @@ -2,7 +2,6 @@ package uevent_test import ( "encoding" - "fmt" "reflect" "testing" @@ -14,13 +13,14 @@ func adeT[V any, S interface { encoding.TextAppender encoding.TextMarshaler encoding.TextUnmarshaler - fmt.Stringer *V }](t *testing.T, name string, v V, want string, wantErr, wantErrE error) { t.Helper() - t.Run(name, func(t *testing.T) { - t.Parallel() + f := func(t *testing.T) { + if name != "" { + t.Parallel() + } t.Helper() t.Run("decode", func(t *testing.T) { @@ -49,15 +49,63 @@ func adeT[V any, S interface { } else if err == nil && string(got) != want { t.Errorf("MarshalText: %q, want %q", string(got), want) } - if wantErrE != nil { + }) + } + if name != "" { + t.Run(name, f) + } else { + f(t) + } +} + +// adeT sets up a binary subtest for a textual appender/decoder/encoder. +func adeB[V any, S interface { + encoding.BinaryAppender + encoding.BinaryMarshaler + encoding.BinaryUnmarshaler + + *V +}](t *testing.T, name string, v V, want string, wantErr, wantErrE error) { + t.Helper() + f := func(t *testing.T) { + if name != "" { + t.Parallel() + } + t.Helper() + + t.Run("decode", func(t *testing.T) { + t.Parallel() + t.Helper() + + var got V + if err := S(&got).UnmarshalBinary([]byte(want)); !reflect.DeepEqual(err, wantErr) { + t.Fatalf("UnmarshalBinary: error = %v, want %v", err, wantErr) + } + if wantErr != nil { return } - if got := S(&v).String(); got != want { - t.Errorf("String: %q, want %q", got, want) + if !reflect.DeepEqual(&got, &v) { + t.Errorf("UnmarshalBinary: %#v, want %#v", got, v) } }) - }) + + t.Run("encode", func(t *testing.T) { + t.Parallel() + t.Helper() + + if got, err := S(&v).MarshalBinary(); !reflect.DeepEqual(err, wantErrE) { + t.Fatalf("MarshalBinary: error = %v, want %v", err, wantErrE) + } else if err == nil && string(got) != want { + t.Errorf("MarshalBinary: %q, want %q", string(got), want) + } + }) + } + if name != "" { + t.Run(name, f) + } else { + f(t) + } } func TestErrors(t *testing.T) { @@ -70,6 +118,26 @@ func TestErrors(t *testing.T) { }{ {"UnsupportedActionError", uevent.UnsupportedActionError("explode"), `unsupported kobject_action "explode"`}, + + {"MissingHeaderError", uevent.MissingHeaderError("move"), + `message "move" has no header`}, + + {"MessageError MErrorKindHeaderSep", &uevent.MessageError{ + Data: "move\x00", + Section: "move", + Kind: uevent.MErrorKindHeaderSep, + }, `header "move" missing separator`}, + + {"MessageError MErrorKindFinalNUL", &uevent.MessageError{ + Data: "move\x00truncated", + Section: "truncated", + Kind: uevent.MErrorKindFinalNUL, + }, `entry "truncated" missing NUL`}, + + {"MessageError bad", &uevent.MessageError{ + Data: "\x00", + Kind: 0xbad, + }, `section "" is invalid`}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) {