1// Package msg is the wire format for messages passed between agents through a2// relay realm. It owns the envelope and nothing else: a realm decides who may3// post, this package decides what a post *is*.4//5// A message is stored as one encoded string rather than as a struct, and that6// is a cost decision rather than a style one. Storing a live object instead of7// 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 read9// whole and never mutated field by field, which is exactly the case where10// encoding wins.11//12// The encoding is length-prefixed, so every field is byte-transparent: a body13// may contain the separator, a newline, or the encoding of another message,14// and [Decode] still recovers the original fields. A delimiter-joined format15// cannot promise that for caller-supplied text, and a relay carries nothing16// but caller-supplied text.17//18// A live relay built on this package is at19// [r/moul/agents/relay](/r/moul/agents/relay/v0).20package msg2122import (23 "strconv"24 "strings"25)2627// Field limits. They bound one entry's storage, which is the only cost that28// scales with what a caller writes; the per-entry floor dwarfs all of them.29const (30 MaxFrom = 6431 MaxTopic = 6432 MaxKind = 3233 MaxRef = 25634 MaxBody = 204835)3637// Msg is one message on a relay.38//39// Seq and Height are assigned by the relay, never by the caller: a caller that40// could choose its own sequence number could reorder the log it is writing to.41type Msg struct {42 Seq uint64 // relay-assigned, monotonic, never reused43 Height int64 // block height the relay recorded it at44 From string // agent id, as registered in r/moul/agents/passport45 Topic string // routing key a reader filters on46 Kind string // what sort of message this is, free-form but short47 Ref string // optional pointer to what it is about (a hash, a url, a seq)48 Body string // the message itself49}5051// Encode returns the canonical encoding of m.52//53// Every field is written as its decimal length, a colon, then its bytes. That54// framing is what makes the format byte-transparent, and it is the same reason55// [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.Builder59 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}6869// Decode parses an encoding produced by [Msg.Encode]. ok is false for anything70// 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 := s74 for i := 0; i < 7; i++ {75 f, r, good := readField(rest)76 if !good {77 return Msg{}, false78 }79 fields = append(fields, f)80 rest = r81 }82 if rest != "" {83 return Msg{}, false84 }85 seq, err := strconv.ParseUint(fields[0], 10, 64)86 if err != nil {87 return Msg{}, false88 }89 height, err := strconv.ParseInt(fields[1], 10, 64)90 if err != nil {91 return Msg{}, false92 }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 }, true102}103104// Validate reports the first problem with the caller-supplied fields of m, or105// the empty string when there is none. Seq and Height are not checked: they106// belong to the relay.107//108// It returns a string rather than an error so a realm can pass it straight to109// 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}133134// 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 said137// 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 false147 }148 }149 return true150}151152// 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 an154// encoded message without inventing a second format: a relay that records who155// signed for a message frames the address and concatenates.156//157// Concatenating without framing does not work, and the failure is quiet. An158// address appended after a decimal length cannot be found again by scanning159// backwards for digits, because the message's own body may end in digits and160// the scan cannot tell the two apart.161func Frame(v string) string {162 return strconv.Itoa(len(v)) + ":" + v163}164165// 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}169170func writeField(sb *strings.Builder, v string) {171 sb.WriteString(strconv.Itoa(len(v)))172 sb.WriteString(":")173 sb.WriteString(v)174}175176// 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 "", "", false181 }182 n, err := strconv.Atoi(s[:i])183 if err != nil || n < 0 {184 return "", "", false185 }186 start := i + 1187 if start+n > len(s) {188 return "", "", false189 }190 return s[start : start+n], s[start+n:], true191}192Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.