msg.gno
5.96 Kb · 191 lines
1// Package msg is the wire format for messages passed between agents through a
2// relay realm. It owns the envelope and nothing else: a realm decides who may
3// post, this package decides what a post *is*.
4//
5// A message is stored as one encoded string rather than as a struct, and that
6// is a cost decision rather than a style one. Storing a live object instead of
7// an encoded string costs a flat ~780 B per entry in gno, in every container,
8// because it is a pointer plus a struct plus a field. An envelope is read
9// whole and never mutated field by field, which is exactly the case where
10// encoding wins.
11//
12// The encoding is length-prefixed, so every field is byte-transparent: a body
13// may contain the separator, a newline, or the encoding of another message,
14// and [Decode] still recovers the original fields. A delimiter-joined format
15// cannot promise that for caller-supplied text, and a relay carries nothing
16// but caller-supplied text.
17//
18// A live relay built on this package is at
19// [r/moul/agents/relay](/r/moul/agents/relay/v0).
20package msg
21
22import (
23 "strconv"
24 "strings"
25)
26
27// Field limits. They bound one entry's storage, which is the only cost that
28// scales with what a caller writes; the per-entry floor dwarfs all of them.
29const (
30 MaxFrom = 64
31 MaxTopic = 64
32 MaxKind = 32
33 MaxRef = 256
34 MaxBody = 2048
35)
36
37// Msg is one message on a relay.
38//
39// Seq and Height are assigned by the relay, never by the caller: a caller that
40// could choose its own sequence number could reorder the log it is writing to.
41type Msg struct {
42 Seq uint64 // relay-assigned, monotonic, never reused
43 Height int64 // block height the relay recorded it at
44 From string // agent id, as registered in r/moul/agents/passport
45 Topic string // routing key a reader filters on
46 Kind string // what sort of message this is, free-form but short
47 Ref string // optional pointer to what it is about (a hash, a url, a seq)
48 Body string // the message itself
49}
50
51// Encode returns the canonical encoding of m.
52//
53// Every field is written as its decimal length, a colon, then its bytes. That
54// framing is what makes the format byte-transparent, and it is the same reason
55// [gno.land/p/moul/agents/commit] length-prefixes before hashing: without it,
56// ("ab","c") and ("a","bc") are the same bytes.
57func (m Msg) Encode() string {
58 var sb strings.Builder
59 writeField(&sb, strconv.FormatUint(m.Seq, 10))
60 writeField(&sb, strconv.FormatInt(m.Height, 10))
61 writeField(&sb, m.From)
62 writeField(&sb, m.Topic)
63 writeField(&sb, m.Kind)
64 writeField(&sb, m.Ref)
65 writeField(&sb, m.Body)
66 return sb.String()
67}
68
69// Decode parses an encoding produced by [Msg.Encode]. ok is false for anything
70// else, including the empty string, which is what an unwritten ring slot holds.
71func Decode(s string) (m Msg, ok bool) {
72 fields := make([]string, 0, 7)
73 rest := s
74 for i := 0; i < 7; i++ {
75 f, r, good := readField(rest)
76 if !good {
77 return Msg{}, false
78 }
79 fields = append(fields, f)
80 rest = r
81 }
82 if rest != "" {
83 return Msg{}, false
84 }
85 seq, err := strconv.ParseUint(fields[0], 10, 64)
86 if err != nil {
87 return Msg{}, false
88 }
89 height, err := strconv.ParseInt(fields[1], 10, 64)
90 if err != nil {
91 return Msg{}, false
92 }
93 return Msg{
94 Seq: seq,
95 Height: height,
96 From: fields[2],
97 Topic: fields[3],
98 Kind: fields[4],
99 Ref: fields[5],
100 Body: fields[6],
101 }, true
102}
103
104// Validate reports the first problem with the caller-supplied fields of m, or
105// the empty string when there is none. Seq and Height are not checked: they
106// belong to the relay.
107//
108// It returns a string rather than an error so a realm can pass it straight to
109// a panic without an errors import, which is the only thing realms do with it.
110func (m Msg) Validate() string {
111 switch {
112 case m.From == "":
113 return "empty from"
114 case len(m.From) > MaxFrom:
115 return "from too long"
116 case m.Topic == "":
117 return "empty topic"
118 case len(m.Topic) > MaxTopic:
119 return "topic too long"
120 case !validTopic(m.Topic):
121 return "topic must be lowercase a-z, 0-9, dash or dot"
122 case len(m.Kind) > MaxKind:
123 return "kind too long"
124 case len(m.Ref) > MaxRef:
125 return "ref too long"
126 case m.Body == "":
127 return "empty body"
128 case len(m.Body) > MaxBody:
129 return "body too long"
130 }
131 return ""
132}
133
134// validTopic keeps topics usable as a filter and as a URL path element. Body,
135// kind and ref are deliberately unrestricted: they are escaped at render time,
136// not at write time, because a relay that silently rewrites what an agent said
137// is worse than one that renders it carefully.
138func validTopic(s string) bool {
139 for i := 0; i < len(s); i++ {
140 c := s[i]
141 switch {
142 case c >= 'a' && c <= 'z',
143 c >= '0' && c <= '9',
144 c == '-', c == '.':
145 default:
146 return false
147 }
148 }
149 return true
150}
151
152// Frame returns v as one length-prefixed field, the same framing [Msg.Encode]
153// uses internally. Pair it with [Unframe] to carry a value alongside an
154// encoded message without inventing a second format: a relay that records who
155// signed for a message frames the address and concatenates.
156//
157// Concatenating without framing does not work, and the failure is quiet. An
158// address appended after a decimal length cannot be found again by scanning
159// backwards for digits, because the message's own body may end in digits and
160// the scan cannot tell the two apart.
161func Frame(v string) string {
162 return strconv.Itoa(len(v)) + ":" + v
163}
164
165// Unframe reads one framed field and returns it with the remainder.
166func Unframe(s string) (field, rest string, ok bool) {
167 return readField(s)
168}
169
170func writeField(sb *strings.Builder, v string) {
171 sb.WriteString(strconv.Itoa(len(v)))
172 sb.WriteString(":")
173 sb.WriteString(v)
174}
175
176// readField reads one length-prefixed field and returns it with the remainder.
177func readField(s string) (field, rest string, ok bool) {
178 i := strings.IndexByte(s, ':')
179 if i <= 0 {
180 return "", "", false
181 }
182 n, err := strconv.Atoi(s[:i])
183 if err != nil || n < 0 {
184 return "", "", false
185 }
186 start := i + 1
187 if start+n > len(s) {
188 return "", "", false
189 }
190 return s[start : start+n], s[start+n:], true
191}