Add testsink package, and unit tests for Conn / entries

This commit is contained in:
Laurence Withers 2026-01-18 12:49:25 +00:00
commit ecd852eede
3 changed files with 305 additions and 160 deletions

126
testsink/message.go Normal file
View file

@ -0,0 +1,126 @@
/*
Package testsink provides a partial implementation of the systemd-journald
Unix socket. Datagrams received on its socket are decoded and stored for unit
tests to examine.
*/
package testsink
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
)
// Message returns the Nth message received. It waits for the message to arrive.
func (sink *Sink) Message(N int) (Message, error) {
sink.lock.Lock()
defer sink.lock.Unlock()
for len(sink.msgs) <= N {
sink.mcond.Wait()
if sink.err != nil {
return Message{}, sink.err
}
}
return sink.msgs[N], nil
}
// Message is recorded for each datagram received.
type Message struct {
Raw []byte
}
type DecodedAttr struct {
Key, Val string
}
func (m *Message) Decode() (msg string, attr []DecodedAttr, err error) {
raw := m.Raw
var errs []error
DecodeLoop:
for len(raw) > 0 {
n, key := decodeAttrKey(raw)
raw = raw[n:]
switch {
case len(raw) == 0:
errs = append(errs, fmt.Errorf("unterminated attribute name %q", key))
break DecodeLoop
case key == "":
errs = append(errs, errors.New("empty attribute name"))
}
var val string
switch raw[0] {
case '=':
n, val = decodeAttrValText(raw[1:])
case '\n':
var err error
n, val, err = decodeAttrValLen(raw[1:])
if err != nil {
errs = append(errs, err)
}
}
raw = raw[1+n:]
if len(raw) == 0 {
errs = append(errs, fmt.Errorf("unterminated value for attribute %q", key))
break DecodeLoop
}
if raw[0] != '\n' {
errs = append(errs, errors.New("incorrectly terminated attribute value"))
}
raw = raw[1:]
switch key {
case "MESSAGE":
msg = val
default:
attr = append(attr, DecodedAttr{Key: key, Val: val})
}
}
return msg, attr, errors.Join(errs...)
}
func decodeAttrKey(raw []byte) (n int, key string) {
for i := range raw {
switch raw[i] {
case '\n', '=':
return i, string(raw[:i])
}
}
return len(raw), string(raw)
}
func decodeAttrValText(raw []byte) (n int, val string) {
term := bytes.IndexByte(raw, '\n')
if term == -1 {
term = len(raw)
}
return term, string(raw[:term])
}
func decodeAttrValLen(raw []byte) (n int, val string, err error) {
if len(raw) < 8 {
return len(raw), "", errors.New("not enough bytes for binary attribute value length")
}
amt := binary.LittleEndian.Uint64(raw)
raw = raw[8:]
if uint64(len(raw)) < amt {
return 8 + len(raw), string(raw), errors.New("not enough bytes for binary attribute value")
}
return int(8 + amt), string(raw[:amt]), nil
}
// GetAttr returns the value of the attribute whose key name matches, and a
// boolean to indicate if it found a match.
func GetAttr(attr []DecodedAttr, key string) (value string, ok bool) {
for i := range attr {
if attr[i].Key == key {
return attr[i].Val, true
}
}
return "", false
}

82
testsink/testsink.go Normal file
View file

@ -0,0 +1,82 @@
/*
Package testsink provides a partial implementation of the systemd-journald
Unix socket. Datagrams received on its socket are decoded and stored for unit
tests to examine.
*/
package testsink
import (
"net"
"slices"
"sync"
)
// Sink provides a Unix socket and captures messages sent to it using the
// systemd-journald wire protocol.
type Sink struct {
sock *net.UnixConn
stop chan struct{}
lock sync.Mutex
mcond *sync.Cond
msgs []Message
err error
}
// New returns a new Sink that is listening on the given path.
func New(sockpath string) (*Sink, error) {
sock, err := net.ListenUnixgram("unixgram", &net.UnixAddr{
Name: sockpath,
Net: "unixgram",
})
if err != nil {
return nil, err
}
sink := &Sink{
sock: sock,
stop: make(chan struct{}, 1),
}
sink.mcond = sync.NewCond(&sink.lock)
go sink.stopper()
go sink.recv()
return sink, nil
}
// Stop listening and close the socket.
func (sink *Sink) Stop() {
// non-blocking write onto channel; we only need to read from it once in
// order to stop the receiver, but using this rather than close ensures
// Stop() can be called multiple times without negative side effects
select {
case sink.stop <- struct{}{}:
default:
}
}
func (sink *Sink) stopper() {
<-sink.stop
sink.sock.Close()
}
func (sink *Sink) recv() {
buf := make([]byte, 131072)
for {
n, err := sink.sock.Read(buf)
sink.lock.Lock()
if n > 0 {
sink.msgs = append(sink.msgs, Message{
Raw: slices.Clone(buf[:n]),
})
}
if err != nil {
sink.err = err
}
sink.lock.Unlock()
sink.mcond.Broadcast()
if err != nil {
return
}
}
}