1package tlsrpt
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7)
8
9// Extension is an additional key/value pair for a TLSRPT record.
10type Extension struct {
11 Key string
12 Value string
13}
14
15// Record is a parsed TLSRPT record, to be served under "_smtp._tls.<domain>".
16//
17// Example:
18//
19// v=TLSRPTv1; rua=mailto:tlsrpt@mox.example;
20type Record struct {
21 Version string // "TLSRPTv1", for "v=".
22
23 // Aggregate reporting URI, for "rua=". "rua=" can occur multiple times, each can
24 // be a list.
25 RUAs [][]RUA
26 // ../rfc/8460:383
27
28 Extensions []Extension
29}
30
31// RUA is a reporting address with scheme and special characters ",", "!" and
32// ";" not encoded.
33type RUA string
34
35// String returns the RUA with special characters encoded, for inclusion in a
36// TLSRPT record.
37func (rua RUA) String() string {
38 s := string(rua)
39 s = strings.ReplaceAll(s, ",", "%2C")
40 s = strings.ReplaceAll(s, "!", "%21")
41 s = strings.ReplaceAll(s, ";", "%3B")
42 return s
43}
44
45// URI parses a RUA as URI, with either a mailto or https scheme.
46func (rua RUA) URI() (*url.URL, error) {
47 return url.Parse(string(rua))
48}
49
50// String returns a string or use as a TLSRPT DNS TXT record.
51func (r Record) String() string {
52 b := &strings.Builder{}
53 fmt.Fprint(b, "v="+r.Version)
54 for _, ruas := range r.RUAs {
55 l := make([]string, len(ruas))
56 for i, rua := range ruas {
57 l[i] = rua.String()
58 }
59 fmt.Fprint(b, "; rua="+strings.Join(l, ","))
60 }
61 for _, p := range r.Extensions {
62 fmt.Fprint(b, "; "+p.Key+"="+p.Value)
63 }
64 return b.String()
65}
66
67type parseErr string
68
69func (e parseErr) Error() string {
70 return string(e)
71}
72
73var _ error = parseErr("")
74
75// ParseRecord parses a TLSRPT record.
76func ParseRecord(txt string) (record *Record, istlsrpt bool, err error) {
77 defer func() {
78 x := recover()
79 if x == nil {
80 return
81 }
82 if xerr, ok := x.(parseErr); ok {
83 record = nil
84 err = fmt.Errorf("%w: %s", ErrRecordSyntax, xerr)
85 return
86 }
87 panic(x)
88 }()
89
90 p := newParser(txt)
91
92 record = &Record{
93 Version: "TLSRPTv1",
94 }
95
96 p.xtake("v=TLSRPTv1")
97 p.xdelim()
98 istlsrpt = true
99 for {
100 k := p.xkey()
101 p.xtake("=")
102 // note: duplicates are allowed.
103 switch k {
104 case "rua":
105 record.RUAs = append(record.RUAs, p.xruas())
106 default:
107 v := p.xvalue()
108 record.Extensions = append(record.Extensions, Extension{k, v})
109 }
110 if !p.delim() || p.empty() {
111 break
112 }
113 }
114 if !p.empty() {
115 p.xerrorf("leftover chars")
116 }
117 if record.RUAs == nil {
118 p.xerrorf("missing rua")
119 }
120 return
121}
122
123type parser struct {
124 s string
125 o int
126}
127
128func newParser(s string) *parser {
129 return &parser{s: s}
130}
131
132func (p *parser) xerrorf(format string, args ...any) {
133 msg := fmt.Sprintf(format, args...)
134 if p.o < len(p.s) {
135 msg += fmt.Sprintf(" (remain %q)", p.s[p.o:])
136 }
137 panic(parseErr(msg))
138}
139
140func (p *parser) xtake(s string) string {
141 if !p.prefix(s) {
142 p.xerrorf("expected %q", s)
143 }
144 p.o += len(s)
145 return s
146}
147
148func (p *parser) xdelim() {
149 if !p.delim() {
150 p.xerrorf("expected semicolon")
151 }
152}
153
154func (p *parser) xtaken(n int) string {
155 r := p.s[p.o : p.o+n]
156 p.o += n
157 return r
158}
159
160func (p *parser) prefix(s string) bool {
161 return strings.HasPrefix(p.s[p.o:], s)
162}
163
164func (p *parser) take(s string) bool {
165 if p.prefix(s) {
166 p.o += len(s)
167 return true
168 }
169 return false
170}
171
172func (p *parser) xtakefn1(fn func(rune, int) bool) string {
173 for i, b := range p.s[p.o:] {
174 if !fn(b, i) {
175 if i == 0 {
176 p.xerrorf("expected at least one char")
177 }
178 return p.xtaken(i)
179 }
180 }
181 if p.empty() {
182 p.xerrorf("expected at least 1 char")
183 }
184 return p.xtaken(len(p.s) - p.o)
185}
186
187// ../rfc/8460:368
188func (p *parser) xkey() string {
189 return p.xtakefn1(func(b rune, i int) bool {
190 return i < 32 && (b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || (i > 0 && b == '_' || b == '-' || b == '.'))
191 })
192}
193
194// ../rfc/8460:371
195func (p *parser) xvalue() string {
196 return p.xtakefn1(func(b rune, i int) bool {
197 return b > ' ' && b < 0x7f && b != '=' && b != ';'
198 })
199}
200
201// ../rfc/8460:399
202func (p *parser) delim() bool {
203 o := p.o
204 e := len(p.s)
205 for o < e && (p.s[o] == ' ' || p.s[o] == '\t') {
206 o++
207 }
208 if o >= e || p.s[o] != ';' {
209 return false
210 }
211 o++
212 for o < e && (p.s[o] == ' ' || p.s[o] == '\t') {
213 o++
214 }
215 p.o = o
216 return true
217}
218
219func (p *parser) empty() bool {
220 return p.o >= len(p.s)
221}
222
223func (p *parser) wsp() {
224 for p.o < len(p.s) && (p.s[p.o] == ' ' || p.s[p.o] == '\t') {
225 p.o++
226 }
227}
228
229// ../rfc/8460:358
230func (p *parser) xruas() []RUA {
231 l := []RUA{p.xuri()}
232 p.wsp()
233 for p.take(",") {
234 p.wsp()
235 l = append(l, p.xuri())
236 p.wsp()
237 }
238 return l
239}
240
241// ../rfc/8460:360
242func (p *parser) xuri() RUA {
243 v := p.xtakefn1(func(b rune, i int) bool {
244 return b != ',' && b != '!' && b != ' ' && b != '\t' && b != ';'
245 })
246 u, err := url.Parse(v)
247 if err != nil {
248 p.xerrorf("parsing uri %q: %s", v, err)
249 }
250 if u.Scheme == "" {
251 p.xerrorf("missing scheme in uri")
252 }
253 return RUA(v)
254}
255