1use 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 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 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, ¤t).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 #[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}