1use crate::SpfContext;
2use dns_resolver::IpDisplay;
3use std::fmt::{self, Write};
4use std::time::SystemTime;
5
6fn starts_with_number(input: &str) -> Result<(Option<u32>, &str), String> {
7 let i = input
8 .find(|c: char| !c.is_numeric() && c != '.')
9 .unwrap_or(input.len());
10 if i == 0 {
11 return Ok((None, input));
12 }
13 let number = input[..i]
14 .parse::<u32>()
15 .map_err(|err| format!("error parsing number from {input}: {err}"))?;
16 Ok((Some(number), &input[i..]))
17}
18
19#[derive(Debug)]
20pub(crate) struct MacroSpec {
21 elements: Vec<MacroElement>,
22}
23
24impl MacroSpec {
25 pub(crate) fn parse(s: &str) -> Result<Self, String> {
26 let mut elements = vec![];
27
28 fn add_literal(elements: &mut Vec<MacroElement>, literal: &str) {
29 match elements.last_mut() {
30 Some(MacroElement::Literal(prior)) => {
31 prior.push_str(literal);
32 }
33 _ => {
34 elements.push(MacroElement::Literal(literal.to_string()));
35 }
36 }
37 }
38
39 fn is_macro_literal(c: char) -> bool {
40 let c = c as u32;
41 (0x21..=0x24).contains(&c) || (0x26..=0x7e).contains(&c)
42 }
43
44 let mut s = s;
45 while !s.is_empty() {
46 if s.starts_with("%%") {
47 add_literal(&mut elements, "%");
48 s = &s[2..];
49 continue;
50 }
51 if s.starts_with("%_") {
52 add_literal(&mut elements, " ");
53 s = &s[2..];
54 continue;
55 }
56 if s.starts_with("%-") {
57 add_literal(&mut elements, "%20");
58 s = &s[2..];
59 continue;
60 }
61 if s.starts_with("%{") {
62 if s.len() < 4 {
63 return Err(format!("unexpected end of input in {s}"));
64 }
65
66 let (name, url_escape) = MacroName::parse(
67 s.chars()
68 .nth(2)
69 .ok_or_else(|| format!("unexpected end of input in {s}"))?,
70 )?;
71 let mut transformer_digits = None;
72 let mut reverse = false;
73
74 let remain = if let Ok((n, r)) = starts_with_number(&s[3..]) {
75 transformer_digits = n;
76 r
77 } else {
78 &s[3..]
79 };
80
81 let delimiters = if remain.starts_with('r') {
82 reverse = true;
83 &remain[1..]
84 } else {
85 remain
86 };
87
88 let (delimiters, remain) = delimiters
89 .split_once('}')
90 .ok_or_else(|| format!("expected '}}' to close macro in {s}"))?;
91
92 elements.push(MacroElement::Macro(MacroTerm {
93 name,
94 transformer_digits,
95 reverse,
96 url_escape,
97 delimiters: delimiters.to_string(),
98 }));
99
100 s = remain;
101 continue;
102 }
103
104 if !is_macro_literal(s.chars().next().unwrap()) {
105 return Err(format!("invalid macro char in {s}"));
106 }
107
108 add_literal(&mut elements, &s[0..1]);
109 s = &s[1..];
110 }
111
112 Ok(Self { elements })
113 }
114
115 pub(crate) fn expand(&self, cx: &SpfContext<'_>) -> Result<String, String> {
116 let (mut result, mut buf) = (String::new(), String::new());
117 for element in &self.elements {
118 let m = match element {
119 MacroElement::Literal(t) => {
120 result.push_str(t);
121 continue;
122 }
123 MacroElement::Macro(m) => m,
124 };
125
126 buf.clear();
127 match m.name {
128 MacroName::Sender => buf.push_str(cx.sender),
129 MacroName::LocalPart => buf.push_str(cx.local_part),
130 MacroName::SenderDomain => buf.push_str(cx.sender_domain),
131 MacroName::Domain => buf.push_str(cx.domain),
132 MacroName::ReverseDns => buf.push_str(match cx.client_ip.is_ipv4() {
133 true => "in-addr",
134 false => "ip6",
135 }),
136 MacroName::ClientIp => {
137 buf.write_fmt(format_args!("{}", cx.client_ip)).unwrap();
138 }
139 MacroName::Ip => buf
140 .write_fmt(format_args!(
141 "{}",
142 IpDisplay {
143 ip: cx.client_ip,
144 reverse: false
145 }
146 ))
147 .unwrap(),
148 MacroName::CurrentUnixTimeStamp => buf
149 .write_fmt(format_args!(
150 "{}",
151 cx.now
152 .duration_since(SystemTime::UNIX_EPOCH)
153 .map(|d| d.as_secs())
154 .unwrap_or(0)
155 ))
156 .unwrap(),
157 MacroName::RelayingHostName
158 | MacroName::HeloDomain
159 | MacroName::ValidatedDomainName => {
160 return Err(format!("{:?} has not been implemented", m.name))
161 }
162 };
163
164 let delimiters = if m.delimiters.is_empty() {
165 "."
166 } else {
167 &m.delimiters
168 };
169
170 let mut tokens: Vec<&str> = buf.split(|c| delimiters.contains(c)).collect();
171
172 if m.reverse {
173 tokens.reverse();
174 }
175
176 if let Some(n) = m.transformer_digits {
177 let n = n as usize;
178 while tokens.len() > n {
179 tokens.remove(0);
180 }
181 }
182
183 let output = tokens.join(".");
184
185 if m.url_escape {
186 for c in output.chars() {
194 if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '~' {
195 result.push(c);
196 } else {
197 let mut bytes = [0u8; 4];
198 for b in c.encode_utf8(&mut bytes).bytes() {
199 result.push_str(&format!("%{b:02x}"));
200 }
201 }
202 }
203 } else {
204 result.push_str(&output);
205 }
206 }
207
208 Ok(result)
209 }
210}
211
212impl fmt::Display for MacroSpec {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 let mut first = true;
215 for element in &self.elements {
216 if first {
217 first = false;
218 } else {
219 f.write_str(" ")?;
220 }
221
222 match element {
223 MacroElement::Literal(lit) => write!(f, "{lit}")?,
224 MacroElement::Macro(term) => write!(f, "{term}")?,
225 }
226 }
227 Ok(())
228 }
229}
230
231#[derive(Debug)]
232enum MacroElement {
233 Literal(String),
234 Macro(MacroTerm),
235}
236
237#[derive(Debug)]
238struct MacroTerm {
239 pub name: MacroName,
240 pub transformer_digits: Option<u32>,
242 pub url_escape: bool,
244 pub reverse: bool,
246 pub delimiters: String,
248}
249
250impl fmt::Display for MacroTerm {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 write!(f, "%{}{}", self.name.as_char(), self.delimiters)?;
253 if let Some(digits) = self.transformer_digits {
254 write!(f, "{}", digits)?;
255 }
256 if self.reverse {
257 f.write_str("r")?;
258 }
259 if self.url_escape {
260 f.write_str("/")?;
261 }
262 Ok(())
263 }
264}
265
266#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)]
267enum MacroName {
268 Sender,
270 LocalPart,
272 SenderDomain,
274 Domain,
276 Ip,
278 ValidatedDomainName,
280 ReverseDns,
282 HeloDomain,
284 ClientIp,
286 RelayingHostName,
288 CurrentUnixTimeStamp,
290}
291
292impl MacroName {
293 fn parse(c: char) -> Result<(Self, bool), String> {
294 let escape = c.is_ascii_uppercase();
295 Ok((
296 match c.to_ascii_lowercase() {
297 's' => Self::Sender,
298 'l' => Self::LocalPart,
299 'o' => Self::SenderDomain,
300 'd' => Self::Domain,
301 'i' => Self::Ip,
302 'p' => Self::ValidatedDomainName,
303 'v' => Self::ReverseDns,
304 'h' => Self::HeloDomain,
305 'c' => Self::ClientIp,
306 'r' => Self::RelayingHostName,
307 't' => Self::CurrentUnixTimeStamp,
308 _ => return Err(format!("invalid macro name {c}")),
309 },
310 escape,
311 ))
312 }
313
314 fn as_char(&self) -> char {
315 match self {
316 Self::Sender => 's',
317 Self::LocalPart => 'l',
318 Self::SenderDomain => 'o',
319 Self::Domain => 'd',
320 Self::Ip => 'i',
321 Self::ValidatedDomainName => 'p',
322 Self::ReverseDns => 'v',
323 Self::HeloDomain => 'h',
324 Self::ClientIp => 'c',
325 Self::RelayingHostName => 'r',
326 Self::CurrentUnixTimeStamp => 't',
327 }
328 }
329}
330
331#[cfg(test)]
332mod test {
333 use std::net::IpAddr;
334
335 use super::*;
336 use crate::spec::MacroSpec;
337
338 #[test]
339 fn test_eval() {
340 let mut ctx = SpfContext::new(
343 "strong-bad@email.example.com",
344 "email.example.com",
345 IpAddr::from([192, 0, 2, 3]),
346 )
347 .unwrap();
348
349 for (input, expect) in &[
350 ("%{s}", "strong-bad@email.example.com"),
351 ("%{o}", "email.example.com"),
352 ("%{d}", "email.example.com"),
353 ("%{d4}", "email.example.com"),
354 ("%{d3}", "email.example.com"),
355 ("%{d2}", "example.com"),
356 ("%{d1}", "com"),
357 ("%{dr}", "com.example.email"),
358 ("%{d2r}", "example.email"),
359 ("%{l}", "strong-bad"),
360 ("%{l-}", "strong.bad"),
361 ("%{lr}", "strong-bad"),
362 ("%{lr-}", "bad.strong"),
363 ("%{l1r-}", "strong"),
364 ] {
365 let spec = MacroSpec::parse(input).unwrap();
366 let output = spec.expand(&ctx).unwrap();
367 k9::assert_equal!(&output, expect, "{input}");
368 }
369
370 for (input, expect) in &[
371 (
372 "%{ir}.%{v}._spf.%{d2}",
373 "3.2.0.192.in-addr._spf.example.com",
374 ),
375 ("%{lr-}.lp._spf.%{d2}", "bad.strong.lp._spf.example.com"),
376 (
377 "%{lr-}.lp.%{ir}.%{v}._spf.%{d2}",
378 "bad.strong.lp.3.2.0.192.in-addr._spf.example.com",
379 ),
380 (
381 "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}",
382 "3.2.0.192.in-addr.strong.lp._spf.example.com",
383 ),
384 (
385 "%{d2}.trusted-domains.example.net",
386 "example.com.trusted-domains.example.net",
387 ),
388 ("%{c}", "192.0.2.3"),
389 ] {
390 let spec = MacroSpec::parse(input).unwrap();
391 let output = spec.expand(&ctx).unwrap();
392 k9::assert_equal!(&output, expect, "{input}");
393 }
394
395 ctx.client_ip = IpAddr::from([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0xcb01]);
396 for (input, expect) in &[
397 (
398 "%{ir}.%{v}._spf.%{d2}",
399 "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\
400 .0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com",
401 ),
402 ("%{c}", "2001:db8::cb01"),
403 ("%{C}", "2001%3adb8%3a%3acb01"),
404 ] {
405 let spec = MacroSpec::parse(input).unwrap();
406 let output = spec.expand(&ctx).unwrap();
407 k9::assert_equal!(&output, expect, "{input}");
408 }
409 }
410}