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