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