Skip to main content

mail_auth/dkim2/
recipe.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::AuthenticatedMessage;
8use crate::dkim2::Dkim2Error;
9use crate::dkim2::canonicalize::{cmp_ignore_ascii_case, is_non_signed_header};
10use similar::{Algorithm, DiffOp, capture_diff_slices};
11use std::cmp::Ordering;
12use std::collections::BTreeMap;
13
14#[derive(Debug, PartialEq, Eq, Clone, Default)]
15pub struct Recipe {
16    pub headers: Vec<HeaderRecipe>,
17    pub body: BodyRecipe,
18}
19
20#[derive(Debug, PartialEq, Eq, Clone)]
21pub struct HeaderRecipe {
22    pub name: String,
23    pub steps: Vec<Step>,
24}
25
26#[derive(Debug, PartialEq, Eq, Clone, Default)]
27pub enum BodyRecipe {
28    #[default]
29    None,
30    Steps(Vec<Step>),
31    Unreconstructable,
32}
33
34#[derive(Debug, PartialEq, Eq, Clone)]
35pub enum Step {
36    Copy { start: u32, end: u32 },
37    Data(Vec<String>),
38}
39
40struct LowerHeader<'x>(&'x [u8]);
41
42impl<'x> LowerHeader<'x> {
43    fn new(header: &'x [u8]) -> Self {
44        LowerHeader(header.trim_ascii())
45    }
46}
47
48impl PartialEq for LowerHeader<'_> {
49    fn eq(&self, other: &Self) -> bool {
50        self.0.eq_ignore_ascii_case(other.0)
51    }
52}
53
54impl Ord for LowerHeader<'_> {
55    fn cmp(&self, other: &Self) -> Ordering {
56        cmp_ignore_ascii_case(self.0, other.0)
57    }
58}
59
60impl PartialOrd for LowerHeader<'_> {
61    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
62        Some(self.cmp(other))
63    }
64}
65
66impl Eq for LowerHeader<'_> {}
67
68impl std::hash::Hash for LowerHeader<'_> {
69    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
70        for byte in self.0 {
71            state.write_u8(byte.to_ascii_lowercase());
72        }
73    }
74}
75
76#[derive(Default)]
77struct HeaderDiff<'x> {
78    original: Vec<&'x [u8]>,
79    modified: Vec<&'x [u8]>,
80}
81
82#[derive(Default)]
83struct HeaderApply<'x> {
84    current: Vec<&'x [u8]>,
85    recipe: Option<&'x HeaderRecipe>,
86}
87
88impl Recipe {
89    /// Generates the recipe that turns `modified` back into `original`.
90    pub fn diff(
91        original: &AuthenticatedMessage<'_>,
92        modified: &AuthenticatedMessage<'_>,
93    ) -> Recipe {
94        let mut header_diffs: BTreeMap<LowerHeader<'_>, HeaderDiff> = BTreeMap::new();
95
96        for (name, value) in &original.headers {
97            if !is_non_signed_header(name) {
98                header_diffs
99                    .entry(LowerHeader::new(name))
100                    .or_default()
101                    .original
102                    .push(value.trim_ascii());
103            }
104        }
105        for (name, value) in &modified.headers {
106            if !is_non_signed_header(name) {
107                header_diffs
108                    .entry(LowerHeader::new(name))
109                    .or_default()
110                    .modified
111                    .push(value.trim_ascii());
112            }
113        }
114
115        let mut headers = Vec::new();
116        for (header, mut values) in header_diffs {
117            if values.original != values.modified {
118                values.original.reverse();
119                values.modified.reverse();
120
121                let steps = diff_steps(&values.original, &values.modified);
122                headers.push(HeaderRecipe {
123                    name: std::str::from_utf8(header.0)
124                        .map(str::to_ascii_lowercase)
125                        .unwrap_or_else(|_| String::from_utf8_lossy(header.0).to_ascii_lowercase()),
126                    steps,
127                });
128            }
129        }
130
131        let orig_body = original.raw_body();
132        let mod_body = modified.raw_body();
133        let body = if orig_body == mod_body {
134            BodyRecipe::None
135        } else {
136            let orig_lines = body_lines(orig_body);
137            let mod_lines = body_lines(mod_body);
138            BodyRecipe::Steps(diff_steps(&orig_lines, &mod_lines))
139        };
140
141        Recipe { headers, body }
142    }
143
144    /// Applies this recipe to reconstruct the previous message state.
145    pub fn apply(&self, headers: &[(&[u8], &[u8])], body: &[u8]) -> crate::Result<Vec<u8>> {
146        let mut header_apply: BTreeMap<LowerHeader<'_>, HeaderApply> = BTreeMap::new();
147
148        for (name, value) in headers {
149            if !is_non_signed_header(name) {
150                header_apply
151                    .entry(LowerHeader::new(name))
152                    .or_default()
153                    .current
154                    .push(value.trim_ascii());
155            }
156        }
157
158        for recipe in &self.headers {
159            header_apply
160                .entry(LowerHeader::new(recipe.name.as_bytes()))
161                .or_default()
162                .recipe = Some(recipe);
163        }
164
165        let mut out = Vec::new();
166        for (name, apply) in header_apply {
167            let header_values = if let Some(recipe) = apply.recipe {
168                apply_header_recipe(&apply.current, &recipe.steps)
169            } else {
170                apply.current
171            };
172
173            for current in header_values {
174                out.extend_from_slice(name.0);
175                out.extend_from_slice(b": ");
176                out.extend_from_slice(current);
177                out.extend_from_slice(b"\r\n");
178            }
179        }
180
181        out.extend_from_slice(b"\r\n");
182
183        match &self.body {
184            BodyRecipe::None => {
185                out.extend_from_slice(body);
186            }
187            BodyRecipe::Unreconstructable => {
188                return Err(crate::Error::Dkim2(Dkim2Error::Modified));
189            }
190            BodyRecipe::Steps(steps) => {
191                let lines = body_lines(body);
192                apply_body_recipe(&lines, steps, &mut out);
193            }
194        }
195
196        Ok(out)
197    }
198
199    pub fn to_json(&self, out: &mut Vec<u8>) -> crate::Result<()> {
200        serde_json::to_writer(out, self).map_err(|_| crate::Error::Dkim2(Dkim2Error::Modified))
201    }
202
203    pub fn from_json(bytes: &[u8]) -> crate::Result<Recipe> {
204        serde_json::from_slice(bytes).map_err(|_| crate::Error::Dkim2(Dkim2Error::Modified))
205    }
206}
207
208pub(crate) fn body_lines(body: &[u8]) -> Vec<&[u8]> {
209    let mut lines = Vec::with_capacity(memchr::memchr_iter(b'\n', body).count() + 1);
210    let mut start = 0;
211
212    for pos in memchr::memchr_iter(b'\n', body) {
213        let line = body.get(start..pos).unwrap_or_default();
214        lines.push(line.strip_suffix(b"\r").unwrap_or(line));
215        start = pos + 1;
216    }
217    let line = body.get(start..).unwrap_or_default();
218    lines.push(line.strip_suffix(b"\r").unwrap_or(line));
219
220    if lines.last().is_some_and(|l| l.is_empty()) {
221        lines.pop();
222    }
223
224    lines
225}
226
227pub(crate) fn apply_header_recipe<'x>(instances: &[&'x [u8]], steps: &'x [Step]) -> Vec<&'x [u8]> {
228    let mut emitted: Vec<&'x [u8]> = Vec::new();
229
230    for step in steps {
231        match step {
232            Step::Copy { start, end } => {
233                let high = (*end).min(instances.len() as u32);
234                for i in *start..=high {
235                    if let Some(line) = instances
236                        .len()
237                        .checked_sub(i as usize)
238                        .and_then(|idx| instances.get(idx))
239                    {
240                        emitted.push(*line);
241                    }
242                }
243            }
244            Step::Data(values) => {
245                for value in values {
246                    emitted.push(value.as_bytes());
247                }
248            }
249        }
250    }
251
252    emitted.reverse();
253    emitted
254}
255
256pub(crate) fn apply_body_recipe(lines: &[&[u8]], steps: &[Step], out: &mut Vec<u8>) {
257    let mark = out.len();
258
259    for step in steps {
260        match step {
261            Step::Copy { start, end } => {
262                let high = (*end).min(lines.len() as u32);
263                for i in *start..=high {
264                    if let Some(idx) = (i as usize).checked_sub(1)
265                        && let Some(line) = lines.get(idx)
266                    {
267                        out.extend_from_slice(line);
268                        out.extend_from_slice(b"\r\n");
269                    }
270                }
271            }
272            Step::Data(values) => {
273                for value in values {
274                    out.extend_from_slice(value.as_bytes());
275                    out.extend_from_slice(b"\r\n");
276                }
277            }
278        }
279    }
280
281    if out.len() == mark {
282        out.extend_from_slice(b"\r\n");
283    }
284}
285
286fn diff_steps(original: &[&[u8]], modified: &[&[u8]]) -> Vec<Step> {
287    let mut steps: Vec<Step> = Vec::new();
288    let mut data: Vec<String> = Vec::new();
289
290    for op in capture_diff_slices(Algorithm::Myers, modified, original) {
291        match op {
292            DiffOp::Equal { old_index, len, .. } => {
293                if !data.is_empty() {
294                    steps.push(Step::Data(std::mem::take(&mut data)));
295                }
296                steps.push(Step::Copy {
297                    start: old_index as u32 + 1,
298                    end: (old_index + len) as u32,
299                });
300            }
301            DiffOp::Insert {
302                new_index, new_len, ..
303            }
304            | DiffOp::Replace {
305                new_index, new_len, ..
306            } => {
307                for line in &original[new_index..new_index + new_len] {
308                    data.push(unfold_lossy(line));
309                }
310            }
311            DiffOp::Delete { .. } => {}
312        }
313    }
314    if !data.is_empty() {
315        steps.push(Step::Data(data));
316    }
317    steps
318}
319
320pub(crate) fn unfold_lossy(value: &[u8]) -> String {
321    if memchr::memchr2(b'\r', b'\n', value).is_none() {
322        return lossy_string(value.to_vec());
323    }
324
325    let mut result = Vec::with_capacity(value.len());
326    let mut last_is_crlf = false;
327    let mut rest = value;
328
329    loop {
330        let split_at = memchr::memchr2(b'\r', b'\n', rest).unwrap_or(rest.len());
331        let (run, tail) = rest.split_at(split_at);
332        if let Some((&first, others)) = run.split_first() {
333            if last_is_crlf && !first.is_ascii_whitespace() && !result.is_empty() {
334                result.push(b' ');
335            }
336            result.push(first);
337            result.extend_from_slice(others);
338        }
339        match tail.split_first() {
340            Some((_, next)) => {
341                last_is_crlf = true;
342                rest = next;
343            }
344            None => break,
345        }
346    }
347
348    lossy_string(result)
349}
350
351fn lossy_string(bytes: Vec<u8>) -> String {
352    String::from_utf8(bytes)
353        .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
354}
355
356#[cfg(test)]
357mod test {
358    use super::*;
359
360    fn r_headers(name: &str, steps: Vec<Step>) -> Recipe {
361        Recipe {
362            headers: vec![HeaderRecipe {
363                name: name.to_string(),
364                steps,
365            }],
366            body: BodyRecipe::None,
367        }
368    }
369
370    fn json(recipe: &Recipe) -> Vec<u8> {
371        let mut out = Vec::new();
372        recipe.to_json(&mut out).unwrap();
373        out
374    }
375
376    #[test]
377    fn to_json_byte_equality() {
378        let r1 = r_headers("list-unsubscribe", vec![]);
379        assert_eq!(json(&r1), b"{\"h\":{\"list-unsubscribe\":[]}}");
380
381        let r2 = Recipe {
382            headers: vec![],
383            body: BodyRecipe::Steps(vec![Step::Copy { start: 1, end: 1 }]),
384        };
385        assert_eq!(json(&r2), b"{\"b\":[{\"c\":[1,1]}]}");
386
387        let r3 = r_headers(
388            "subject",
389            vec![Step::Data(vec![" Simple test message".to_string()])],
390        );
391        assert_eq!(
392            json(&r3),
393            b"{\"h\":{\"subject\":[{\"d\":[\" Simple test message\"]}]}}"
394        );
395
396        let r4 = r_headers(
397            "authentication-results",
398            vec![Step::Copy { start: 1, end: 3 }],
399        );
400        assert_eq!(
401            json(&r4),
402            b"{\"h\":{\"authentication-results\":[{\"c\":[1,3]}]}}"
403        );
404    }
405
406    #[test]
407    fn from_json_round_trips() {
408        let cases: [&[u8]; 4] = [
409            b"{\"h\":{\"list-unsubscribe\":[]}}",
410            b"{\"b\":[{\"c\":[1,1]}]}",
411            b"{\"h\":{\"subject\":[{\"d\":[\" Simple test message\"]}]}}",
412            b"{\"h\":{\"authentication-results\":[{\"c\":[1,3]}]}}",
413        ];
414        for case in cases {
415            let recipe = Recipe::from_json(case).unwrap();
416            assert_eq!(json(&recipe), case);
417        }
418    }
419
420    #[test]
421    fn from_json_rejects_null_header() {
422        assert!(Recipe::from_json(b"{\"h\":null}").is_err());
423        assert!(Recipe::from_json(b"{\"h\":{\"subject\":null}}").is_err());
424    }
425
426    #[test]
427    fn from_json_body_null_unreconstructable() {
428        let recipe = Recipe::from_json(b"{\"b\":null}").unwrap();
429        assert_eq!(recipe.body, BodyRecipe::Unreconstructable);
430    }
431
432    #[test]
433    fn from_json_ignores_z_step() {
434        let recipe = Recipe::from_json(b"{\"b\":[{\"c\":[1,2]},{\"z\":true}]}").unwrap();
435        assert_eq!(
436            recipe.body,
437            BodyRecipe::Steps(vec![Step::Copy { start: 1, end: 2 }])
438        );
439    }
440
441    fn diff_bytes(original: &[u8], modified: &[u8]) -> Recipe {
442        let o = crate::AuthenticatedMessage::parse(original).unwrap();
443        let m = crate::AuthenticatedMessage::parse(modified).unwrap();
444        Recipe::diff(&o, &m)
445    }
446
447    fn apply_bytes(recipe: &Recipe, message: &[u8]) -> crate::Result<Vec<u8>> {
448        let p = crate::AuthenticatedMessage::parse(message).unwrap();
449        recipe.apply(&p.headers, p.raw_body())
450    }
451
452    fn signed_hashes(message: &[u8]) -> (Vec<u8>, Vec<u8>) {
453        use crate::common::crypto::HashAlgorithm;
454        let p = crate::AuthenticatedMessage::parse(message).unwrap();
455        (
456            HashAlgorithm::Sha256
457                .headers_hash(p.headers.iter().copied())
458                .as_ref()
459                .to_vec(),
460            HashAlgorithm::Sha256
461                .body_hash(p.raw_body())
462                .as_ref()
463                .to_vec(),
464        )
465    }
466
467    #[test]
468    fn diff_apply_round_trips() {
469        let cases: &[(&str, &[u8], &[u8])] = &[
470            (
471                "body_change",
472                b"Subject: test\r\n\r\nhello\r\nworld\r\ngoodbye\r\n",
473                b"Subject: test\r\n\r\nhello\r\nMODIFIED\r\ngoodbye\r\n",
474            ),
475            (
476                "body_insert_delete",
477                b"From: a\r\n\r\nline1\r\nline2\r\nline3\r\nline4\r\n",
478                b"From: a\r\n\r\nline1\r\nline3\r\nline4\r\nextra\r\n",
479            ),
480            (
481                "header_add",
482                b"Subject: hi\r\nFrom: a\r\nTo: b\r\n\r\nbody\r\n",
483                b"From: a\r\nTo: b\r\n\r\nbody\r\n",
484            ),
485            (
486                "header_remove",
487                b"From: a\r\nTo: b\r\n\r\nbody\r\n",
488                b"From: a\r\nSubject: spam\r\nTo: b\r\n\r\nbody\r\n",
489            ),
490            (
491                "header_value_change",
492                b"Subject: original subject\r\nFrom: a\r\n\r\nbody\r\n",
493                b"Subject: changed subject\r\nFrom: a\r\n\r\nbody\r\n",
494            ),
495            (
496                "dup_header_change",
497                b"From: a\r\nList-Id: one\r\nList-Id: two\r\n\r\nbody\r\n",
498                b"From: a\r\nList-Id: one\r\nList-Id: CHANGED\r\n\r\nbody\r\n",
499            ),
500            (
501                "unchanged",
502                b"From: a\r\nTo: b\r\n\r\nbody line\r\n",
503                b"From: a\r\nTo: b\r\n\r\nbody line\r\n",
504            ),
505            (
506                "header_reorder",
507                b"Subject: one\r\nComment: two\r\nSubject: three\r\n\r\nbody\r\n",
508                b"Comment: two\r\nSubject: three\r\nSubject: one\r\n\r\nbody\r\n",
509            ),
510            (
511                "body_multi_range",
512                b"From: a\r\n\r\none\r\ntwo\r\nthree\r\nfour\r\nfive\r\n",
513                b"From: a\r\n\r\nzero\r\none\r\ntwo\r\nthree\r\nbanana\r\nfour\r\n",
514            ),
515        ];
516        for (label, original, modified) in cases {
517            let recipe = diff_bytes(original, modified);
518            let reconstructed = apply_bytes(&recipe, modified).unwrap();
519            assert_eq!(
520                signed_hashes(&reconstructed),
521                signed_hashes(original),
522                "case {label}: recipe={recipe:?} reconstructed={:?}",
523                String::from_utf8_lossy(&reconstructed)
524            );
525        }
526    }
527
528    #[test]
529    fn myers_diff_serialization() {
530        let cases: &[(&str, &[u8], &[u8], &str)] = &[
531            (
532                "deleted",
533                b"Comment: two\r\nSubject: three\r\nSubject: four\r\nSubject: one\r\n\r\nbody\r\n",
534                b"Comment: two\r\nSubject: three\r\nSubject: one\r\n\r\nbody\r\n",
535                r#"{"h":{"subject":[{"c":[1,1]},{"d":["four"]},{"c":[2,2]}]}}"#,
536            ),
537            (
538                "ranges",
539                b"Subject: one\r\nSubject: two\r\nSubject: three\r\nSubject: four\r\nSubject: five\r\n\r\nbody\r\n",
540                b"Subject: zero\r\nSubject: one\r\nSubject: two\r\nSubject: three\r\nSubject: banana\r\nSubject: four\r\n\r\nbody\r\n",
541                r#"{"h":{"subject":[{"d":["five"]},{"c":[1,1]},{"c":[3,5]}]}}"#,
542            ),
543            (
544                "reorder",
545                b"Subject: one\r\nComment: two\r\nSubject: three\r\n\r\nbody\r\n",
546                b"Comment: two\r\nSubject: three\r\nSubject: one\r\n\r\nbody\r\n",
547                r#"{"h":{"subject":[{"d":["three"]},{"c":[1,1]}]}}"#,
548            ),
549            (
550                "body_multi_range",
551                b"From: a\r\n\r\nl1\r\nl2\r\nl3\r\nold-line\r\nl5\r\n",
552                b"From: a\r\n\r\nl1\r\nl2\r\nl3\r\nnew-line\r\nl5\r\n",
553                r#"{"b":[{"c":[1,3]},{"d":["old-line"]},{"c":[5,5]}]}"#,
554            ),
555        ];
556        for (label, original, modified, want) in cases {
557            let recipe = diff_bytes(original, modified);
558            let mut json = Vec::new();
559            recipe.to_json(&mut json).unwrap();
560            assert_eq!(String::from_utf8_lossy(&json), *want, "case {label}");
561            let reconstructed = apply_bytes(&recipe, modified).unwrap();
562            assert_eq!(
563                signed_hashes(&reconstructed),
564                signed_hashes(original),
565                "case {label} round-trip"
566            );
567        }
568    }
569
570    #[test]
571    #[allow(clippy::type_complexity)]
572    fn apply_header_oracles() {
573        fn headers_of(message: &[u8]) -> Vec<(String, String)> {
574            let p = crate::AuthenticatedMessage::parse(message).unwrap();
575            let mut headers: Vec<(String, String)> = p
576                .headers
577                .iter()
578                .map(|(n, v)| {
579                    (
580                        String::from_utf8_lossy(n).trim().to_ascii_lowercase(),
581                        String::from_utf8_lossy(v).trim().to_string(),
582                    )
583                })
584                .collect();
585            headers.sort();
586            headers
587        }
588
589        let input = b"Subject: one\r\nComment: two\r\nSubject: three\r\n\r\n";
590        let cases: &[(&str, &[u8], &[(&str, &str)])] = &[
591            (
592                "copy_one",
593                br#"{"h":{"subject":[{"c":[2,2]}],"comment":[]}}"#,
594                &[("subject", "one")],
595            ),
596            (
597                "preserve_unmentioned",
598                br#"{"h":{"comment":[]}}"#,
599                &[("subject", "one"), ("subject", "three")],
600            ),
601            (
602                "no_change",
603                br#"{}"#,
604                &[("comment", "two"), ("subject", "one"), ("subject", "three")],
605            ),
606        ];
607
608        for (label, json, want) in cases {
609            let recipe = Recipe::from_json(json).unwrap();
610            let reconstructed = apply_bytes(&recipe, input).unwrap();
611            let mut want: Vec<(String, String)> = want
612                .iter()
613                .map(|(n, v)| (n.to_string(), v.to_string()))
614                .collect();
615            want.sort();
616            assert_eq!(
617                headers_of(&reconstructed),
618                want,
619                "case {label}: {}",
620                String::from_utf8_lossy(&reconstructed)
621            );
622        }
623    }
624
625    #[test]
626    fn apply_unreconstructable_errors() {
627        let recipe = Recipe {
628            headers: vec![],
629            body: BodyRecipe::Unreconstructable,
630        };
631        assert!(apply_bytes(&recipe, b"From: a\r\n\r\nbody\r\n").is_err());
632    }
633
634    #[test]
635    fn to_json_non_ascii_round_trips() {
636        let recipe = r_headers("subject", vec![Step::Data(vec!["café \u{1}".to_string()])]);
637        let encoded = json(&recipe);
638        assert_eq!(
639            encoded,
640            "{\"h\":{\"subject\":[{\"d\":[\"café \\u0001\"]}]}}".as_bytes()
641        );
642        let decoded = Recipe::from_json(&encoded).unwrap();
643        assert_eq!(decoded.headers[0].steps, recipe.headers[0].steps);
644    }
645
646    #[test]
647    fn copy_range_huge_end_is_bounded() {
648        let recipe = Recipe {
649            headers: vec![],
650            body: BodyRecipe::Steps(vec![Step::Copy {
651                start: 1,
652                end: u32::MAX,
653            }]),
654        };
655        let start = std::time::Instant::now();
656        let out = apply_bytes(&recipe, b"From: a\r\n\r\nline1\r\nline2\r\n").unwrap();
657        assert!(start.elapsed().as_secs() < 1);
658        assert!(out.windows(5).any(|w| w == b"line1"));
659    }
660
661    #[test]
662    fn non_utf8_body_round_trips() {
663        let original: &[u8] = b"From: a\r\n\r\nhel\x80lo\r\nworld\r\n";
664        let modified: &[u8] = b"From: a\r\n\r\nhel\x80lo\r\nWORLD\r\n";
665        let recipe = diff_bytes(original, modified);
666        let reconstructed = apply_bytes(&recipe, modified).unwrap();
667        assert_eq!(signed_hashes(&reconstructed), signed_hashes(original));
668        assert!(reconstructed.contains(&0x80));
669    }
670
671    #[test]
672    fn copy_out_of_range_does_not_panic() {
673        let recipe = Recipe {
674            headers: vec![],
675            body: BodyRecipe::Steps(vec![Step::Copy { start: 5, end: 10 }]),
676        };
677        let out = apply_bytes(&recipe, b"From: a\r\n\r\nline1\r\nline2\r\n").unwrap();
678        assert!(String::from_utf8_lossy(&out).contains("\r\n\r\n"));
679    }
680
681    #[test]
682    fn copy_zero_start_does_not_panic() {
683        let recipe = Recipe {
684            headers: vec![],
685            body: BodyRecipe::Steps(vec![Step::Copy { start: 0, end: 2 }]),
686        };
687        apply_bytes(&recipe, b"X: y\r\n\r\nl1\r\nl2\r\n").unwrap();
688    }
689
690    #[test]
691    fn from_json_huge_copy_is_bounded_on_apply() {
692        let r = Recipe::from_json(b"{\"b\":[{\"c\":[4294967295,4294967295]}]}").unwrap();
693        let out = apply_bytes(&r, b"X: y\r\n\r\nl1\r\n").unwrap();
694        assert!(String::from_utf8_lossy(&out).contains("\r\n\r\n"));
695    }
696
697    #[test]
698    fn apply_inserts_missing_header() {
699        let recipe = Recipe {
700            headers: vec![HeaderRecipe {
701                name: "subject".to_string(),
702                steps: vec![Step::Data(vec!["Injected".to_string()])],
703            }],
704            body: BodyRecipe::None,
705        };
706        let out = apply_bytes(&recipe, b"From: a\r\nTo: b\r\n\r\nbody\r\n").unwrap();
707        assert!(String::from_utf8_lossy(&out).contains("subject: Injected"));
708    }
709
710    fn assert_chain_reconstructs(versions: &[&[u8]], via_json: bool) {
711        assert!(versions.len() >= 2, "a chain needs at least two versions");
712
713        let recipes: Vec<Recipe> = versions
714            .windows(2)
715            .map(|pair| {
716                let recipe = diff_bytes(pair[0], pair[1]);
717                if via_json {
718                    Recipe::from_json(&json(&recipe)).unwrap()
719                } else {
720                    recipe
721                }
722            })
723            .collect();
724
725        let mut current = versions[versions.len() - 1].to_vec();
726        for (hop, recipe) in recipes.iter().enumerate().rev() {
727            let reconstructed = apply_bytes(recipe, &current).unwrap();
728            let expected = versions[hop];
729            assert_eq!(
730                signed_hashes(&reconstructed),
731                signed_hashes(expected),
732                "hop {hop} (via_json={via_json}) recipe={recipe:?}\n  reconstructed={:?}\n  expected={:?}",
733                String::from_utf8_lossy(&reconstructed),
734                String::from_utf8_lossy(expected),
735            );
736            current = reconstructed;
737        }
738    }
739
740    fn assert_chain(versions: &[&[u8]]) {
741        assert_chain_reconstructs(versions, false);
742        assert_chain_reconstructs(versions, true);
743    }
744
745    #[test]
746    fn chain_header_value_changes() {
747        assert_chain(&[
748            b"From: alice@example.com\r\nTo: bob@example.com\r\nSubject: v0\r\n\r\nHello world\r\n",
749            b"From: alice@example.com\r\nTo: bob@example.com\r\nSubject: v1\r\n\r\nHello world\r\n",
750            b"From: alice@example.com\r\nTo: carol@example.com\r\nSubject: v2\r\n\r\nHello world\r\n",
751            b"From: alice@example.com\r\nTo: carol@example.com\r\nSubject: final\r\n\r\nHello world\r\n",
752        ]);
753    }
754
755    #[test]
756    fn chain_header_insertions() {
757        assert_chain(&[
758            b"From: a\r\nTo: b\r\n\r\nbody\r\n",
759            b"From: a\r\nTo: b\r\nSubject: added\r\n\r\nbody\r\n",
760            b"From: a\r\nReply-To: r\r\nTo: b\r\nSubject: added\r\n\r\nbody\r\n",
761            b"From: a\r\nReply-To: r\r\nTo: b\r\nSubject: added\r\nList-Id: <l>\r\n\r\nbody\r\n",
762        ]);
763    }
764
765    #[test]
766    fn chain_header_deletions() {
767        assert_chain(&[
768            b"From: a\r\nReply-To: r\r\nTo: b\r\nSubject: s\r\nList-Id: <l>\r\n\r\nbody\r\n",
769            b"From: a\r\nTo: b\r\nSubject: s\r\nList-Id: <l>\r\n\r\nbody\r\n",
770            b"From: a\r\nTo: b\r\nList-Id: <l>\r\n\r\nbody\r\n",
771            b"From: a\r\nTo: b\r\n\r\nbody\r\n",
772        ]);
773    }
774
775    #[test]
776    fn chain_mixed_header_insert_change_delete() {
777        assert_chain(&[
778            b"From: a\r\nTo: b\r\nSubject: original\r\n\r\nbody\r\n",
779            b"From: a\r\nReply-To: r\r\nTo: b\r\nSubject: original\r\n\r\nbody\r\n",
780            b"From: a\r\nReply-To: r\r\nTo: b\r\nSubject: edited\r\n\r\nbody\r\n",
781            b"From: a\r\nReply-To: r\r\nTo: b\r\n\r\nbody\r\n",
782        ]);
783    }
784
785    #[test]
786    fn chain_duplicate_header_evolution() {
787        assert_chain(&[
788            b"From: a\r\nComments: alpha\r\nComments: beta\r\n\r\nbody\r\n",
789            b"From: a\r\nComments: alpha\r\nComments: beta\r\nComments: gamma\r\n\r\nbody\r\n",
790            b"From: a\r\nComments: alpha\r\nComments: gamma\r\n\r\nbody\r\n",
791            b"From: a\r\nComments: ALPHA\r\nComments: gamma\r\n\r\nbody\r\n",
792        ]);
793    }
794
795    #[test]
796    fn chain_duplicate_header_interleaved_changes() {
797        assert_chain(&[
798            b"From: a\r\nReceived-SPF: a\r\nComments: 1\r\nComments: 2\r\nComments: 3\r\nComments: 4\r\nComments: 5\r\n\r\nbody\r\n",
799            b"From: a\r\nReceived-SPF: a\r\nComments: 1\r\nComments: 2\r\nComments: THREE\r\nComments: 4\r\nComments: 5\r\n\r\nbody\r\n",
800            b"From: a\r\nReceived-SPF: a\r\nComments: 1\r\nComments: THREE\r\nComments: 5\r\n\r\nbody\r\n",
801            b"From: a\r\nReceived-SPF: a\r\nComments: 0\r\nComments: 1\r\nComments: THREE\r\nComments: 5\r\nComments: 6\r\n\r\nbody\r\n",
802        ]);
803    }
804
805    #[test]
806    fn chain_body_line_changes() {
807        assert_chain(&[
808            b"From: a\r\n\r\nline1\r\nline2\r\nline3\r\n",
809            b"From: a\r\n\r\nline1\r\nCHANGED\r\nline3\r\n",
810            b"From: a\r\n\r\nFIRST\r\nCHANGED\r\nlast\r\n",
811            b"From: a\r\n\r\nFIRST\r\nCHANGED\r\nLAST\r\n",
812        ]);
813    }
814
815    #[test]
816    fn chain_body_insertions() {
817        assert_chain(&[
818            b"From: a\r\n\r\nline1\r\nline2\r\n",
819            b"From: a\r\n\r\nline1\r\ninserted\r\nline2\r\n",
820            b"From: a\r\n\r\nline1\r\ninserted\r\nline2\r\nappended\r\n",
821            b"From: a\r\n\r\nprepended\r\nline1\r\ninserted\r\nline2\r\nappended\r\n",
822        ]);
823    }
824
825    #[test]
826    fn chain_body_deletions() {
827        assert_chain(&[
828            b"From: a\r\n\r\nl1\r\nl2\r\nl3\r\nl4\r\nl5\r\n",
829            b"From: a\r\n\r\nl1\r\nl2\r\nl4\r\nl5\r\n",
830            b"From: a\r\n\r\nl2\r\nl4\r\nl5\r\n",
831            b"From: a\r\n\r\nl2\r\nl4\r\n",
832        ]);
833    }
834
835    #[test]
836    fn chain_body_and_headers_each_hop() {
837        assert_chain(&[
838            b"From: a\r\nSubject: s0\r\n\r\nintro\r\nmiddle\r\nclose\r\n",
839            b"From: a\r\nSubject: s1\r\n\r\nintro\r\nMIDDLE\r\nclose\r\n",
840            b"From: a\r\nSubject: s2\r\nX-Tag: keep\r\n\r\nintro\r\nMIDDLE\r\nadded\r\nclose\r\n",
841            b"From: a\r\nSubject: s2\r\nX-Tag: keep\r\n\r\nMIDDLE\r\nadded\r\n",
842        ]);
843    }
844
845    #[test]
846    fn chain_with_noop_hop() {
847        assert_chain(&[
848            b"From: a\r\nSubject: keep\r\n\r\nbody line\r\n",
849            b"From: a\r\nSubject: keep\r\n\r\nbody line\r\n",
850            b"From: a\r\nSubject: changed\r\n\r\nbody line\r\n",
851        ]);
852    }
853
854    #[test]
855    fn chain_body_emptied_then_refilled() {
856        assert_chain(&[
857            b"From: a\r\n\r\nfirst\r\nsecond\r\n",
858            b"From: a\r\n\r\n",
859            b"From: a\r\n\r\nbrand new line\r\n",
860        ]);
861    }
862
863    #[test]
864    fn chain_empty_header_value() {
865        assert_chain(&[
866            b"From: a\r\nSubject: hello\r\n\r\nbody\r\n",
867            b"From: a\r\nSubject:\r\n\r\nbody\r\n",
868            b"From: a\r\nSubject: world\r\n\r\nbody\r\n",
869        ]);
870    }
871
872    #[test]
873    fn chain_non_utf8_body_copied_lines() {
874        assert_chain(&[
875            b"From: a\r\n\r\nhel\x80lo\r\nworld\r\n\xff\xfe binary\r\n",
876            b"From: a\r\n\r\nhel\x80lo\r\nWORLD\r\n\xff\xfe binary\r\n",
877            b"From: a\r\n\r\nhel\x80lo\r\nWORLD\r\ninserted\r\n\xff\xfe binary\r\n",
878        ]);
879    }
880
881    #[test]
882    fn chain_body_without_trailing_crlf() {
883        assert_chain(&[
884            b"From: a\r\n\r\nl1\r\nl2\r\nl3",
885            b"From: a\r\n\r\nl1\r\nCHANGED\r\nl3",
886            b"From: a\r\n\r\nl1\r\nCHANGED",
887        ]);
888    }
889
890    #[test]
891    fn chain_header_reinserted_after_deletion() {
892        assert_chain(&[
893            b"From: a\r\nSubject: present\r\n\r\nbody\r\n",
894            b"From: a\r\n\r\nbody\r\n",
895            b"From: a\r\nSubject: back again\r\n\r\nbody\r\n",
896        ]);
897    }
898
899    #[test]
900    fn chain_internal_whitespace_changes_are_reconstructed() {
901        assert_chain(&[
902            b"From: a\r\nSubject: spaced   out   value\r\n\r\nbody\r\n",
903            b"From: a\r\nSubject: spaced out value\r\n\r\nbody\r\n",
904        ]);
905    }
906
907    #[test]
908    fn diff_numbers_duplicate_headers_bottom_up() {
909        let recipe = diff_bytes(
910            b"From: a\r\nComments: top\r\nComments: middle\r\nComments: bottom\r\n\r\nbody\r\n",
911            b"From: a\r\nComments: top\r\nComments: bottom\r\n\r\nbody\r\n",
912        );
913        let header = recipe
914            .headers
915            .iter()
916            .find(|h| h.name.eq_ignore_ascii_case("comments"))
917            .expect("comments recipe present");
918        assert_eq!(
919            header.steps,
920            vec![
921                Step::Copy { start: 1, end: 1 },
922                Step::Data(vec!["middle".to_string()]),
923                Step::Copy { start: 2, end: 2 },
924            ],
925        );
926    }
927
928    #[test]
929    fn diff_numbers_body_lines_top_down() {
930        let recipe = diff_bytes(
931            b"From: a\r\n\r\nfirst\r\nsecond\r\nthird\r\n",
932            b"From: a\r\n\r\nfirst\r\nCHANGED\r\nthird\r\n",
933        );
934        assert_eq!(
935            recipe.body,
936            BodyRecipe::Steps(vec![
937                Step::Copy { start: 1, end: 1 },
938                Step::Data(vec!["second".to_string()]),
939                Step::Copy { start: 3, end: 3 },
940            ]),
941        );
942    }
943
944    #[test]
945    fn diff_encodes_full_add_and_remove() {
946        let added = diff_bytes(
947            b"From: a\r\nSubject: was here\r\n\r\nbody\r\n",
948            b"From: a\r\n\r\nbody\r\n",
949        );
950        let subject = added
951            .headers
952            .iter()
953            .find(|h| h.name.eq_ignore_ascii_case("subject"))
954            .unwrap();
955        assert_eq!(
956            subject.steps,
957            vec![Step::Data(vec!["was here".to_string()])]
958        );
959
960        let removed = diff_bytes(
961            b"From: a\r\n\r\nbody\r\n",
962            b"From: a\r\nSubject: injected\r\n\r\nbody\r\n",
963        );
964        let subject = removed
965            .headers
966            .iter()
967            .find(|h| h.name.eq_ignore_ascii_case("subject"))
968            .unwrap();
969        assert!(
970            subject.steps.is_empty(),
971            "empty recipe removes all instances"
972        );
973    }
974
975    #[test]
976    fn diff_copy_ranges_are_strictly_ascending() {
977        let recipe = diff_bytes(
978            b"From: a\r\nComments: 1\r\nComments: 2\r\nComments: 3\r\nComments: 4\r\nComments: 5\r\n\r\nbody\r\n",
979            b"From: a\r\nComments: 1\r\nComments: TWO\r\nComments: 3\r\nComments: FOUR\r\nComments: 5\r\n\r\nbody\r\n",
980        );
981        let header = recipe
982            .headers
983            .iter()
984            .find(|h| h.name.eq_ignore_ascii_case("comments"))
985            .unwrap();
986        assert_copy_ranges_ascending(&header.steps);
987
988        let body = diff_bytes(
989            b"From: a\r\n\r\na\r\nb\r\nc\r\nd\r\ne\r\nf\r\n",
990            b"From: a\r\n\r\na\r\nB\r\nc\r\nd\r\nE\r\nf\r\n",
991        )
992        .body;
993        if let BodyRecipe::Steps(steps) = body {
994            assert_copy_ranges_ascending(&steps);
995        } else {
996            panic!("expected body steps");
997        }
998    }
999
1000    fn assert_no_crlf_in_data(recipe: &Recipe) {
1001        let check = |values: &[String], ctx: &str| {
1002            for value in values {
1003                assert!(
1004                    !value.contains('\r') && !value.contains('\n'),
1005                    "{ctx} data string contains CR/LF: {value:?}"
1006                );
1007            }
1008        };
1009        for header in &recipe.headers {
1010            for step in &header.steps {
1011                if let Step::Data(values) = step {
1012                    check(values, &format!("header {}", header.name));
1013                }
1014            }
1015        }
1016        if let BodyRecipe::Steps(steps) = &recipe.body {
1017            for step in steps {
1018                if let Step::Data(values) = step {
1019                    check(values, "body");
1020                }
1021            }
1022        }
1023    }
1024
1025    #[test]
1026    fn data_strings_never_contain_crlf() {
1027        let recipes = [
1028            diff_bytes(
1029                b"From: a\r\nSubject: short value\r\n\r\nbody\r\n",
1030                b"From: a\r\nSubject: a much longer replacement subject value\r\n\r\nbody\r\n",
1031            ),
1032            diff_bytes(
1033                b"From: a\r\nSubject: one\r\n two\r\n three\r\n\r\nbody\r\n",
1034                b"From: a\r\nSubject: unrelated\r\n\r\nbody\r\n",
1035            ),
1036            diff_bytes(
1037                b"From: a\r\nSubject: line one;\r\n\tline two;\r\n\tline three\r\n\r\nbody\r\n",
1038                b"From: a\r\n\r\nbody\r\n",
1039            ),
1040            diff_bytes(
1041                b"From: a\r\n\r\nplain\r\nbody\r\n",
1042                b"From: a\r\n\r\nplain\r\ndifferent\r\n",
1043            ),
1044        ];
1045        for recipe in &recipes {
1046            assert_no_crlf_in_data(recipe);
1047        }
1048    }
1049
1050    #[test]
1051    fn unfold_lossy_replaces_terminators_with_space() {
1052        assert_eq!(unfold_lossy(b"one\r\n two"), "one two");
1053        assert_eq!(unfold_lossy(b"alpha\r\nbeta"), "alpha beta");
1054        assert_eq!(unfold_lossy(b"lone\rcr"), "lone cr");
1055        assert_eq!(unfold_lossy(b"lone\nlf"), "lone lf");
1056        assert_eq!(unfold_lossy(b"no terminators"), "no terminators");
1057    }
1058
1059    /// A folded header reconstructed from a "d" step must hash identically to the
1060    /// original folded header: the fold collapses to whitespace either way.
1061    #[test]
1062    fn folded_header_data_step_is_hash_equivalent() {
1063        let recipe = diff_bytes(
1064            b"From: a\r\nSubject: part one\r\n part two\r\n\r\nbody\r\n",
1065            b"From: a\r\n\r\nbody\r\n",
1066        );
1067        assert_no_crlf_in_data(&recipe);
1068        let reconstructed = apply_bytes(&recipe, b"From: a\r\n\r\nbody\r\n").unwrap();
1069        assert_eq!(
1070            signed_hashes(&reconstructed),
1071            signed_hashes(b"From: a\r\nSubject: part one part two\r\n\r\nbody\r\n"),
1072        );
1073    }
1074
1075    #[test]
1076    fn chain_folded_header_round_trips() {
1077        assert_chain(&[
1078            b"From: a\r\nSubject: a folded header\r\n value spanning\r\n three lines\r\n\r\nbody\r\n",
1079            b"From: a\r\nSubject: now unfolded and changed\r\n\r\nbody\r\n",
1080            b"From: a\r\nSubject: refolded value\r\n that wraps again\r\n\r\nbody\r\n",
1081        ]);
1082    }
1083
1084    fn assert_copy_ranges_ascending(steps: &[Step]) {
1085        let mut prev_end = 0u32;
1086        for step in steps {
1087            if let Step::Copy { start, end } = step {
1088                assert!(
1089                    *start > prev_end,
1090                    "copy start {start} must exceed previous end {prev_end}: {steps:?}"
1091                );
1092                assert!(
1093                    start <= end,
1094                    "copy start {start} after end {end}: {steps:?}"
1095                );
1096                prev_end = *end;
1097            }
1098        }
1099    }
1100}