Skip to main content

tor_consdiff/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3// @@ begin lint list maintained by maint/add_warning @@
4#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable)
5#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly)
6#![warn(missing_docs)]
7#![warn(noop_method_call)]
8#![warn(unreachable_pub)]
9#![warn(clippy::all)]
10#![deny(clippy::await_holding_lock)]
11#![deny(clippy::cargo_common_metadata)]
12#![deny(clippy::cast_lossless)]
13#![deny(clippy::checked_conversions)]
14#![allow(clippy::cognitive_complexity)] // See arti#2556
15#![deny(clippy::debug_assert_with_mut_call)]
16#![deny(clippy::exhaustive_enums)]
17#![deny(clippy::exhaustive_structs)]
18#![deny(clippy::expl_impl_clone_on_copy)]
19#![deny(clippy::fallible_impl_from)]
20#![deny(clippy::implicit_clone)]
21#![deny(clippy::large_stack_arrays)]
22#![warn(clippy::manual_ok_or)]
23#![deny(clippy::missing_docs_in_private_items)]
24#![warn(clippy::needless_borrow)]
25#![warn(clippy::needless_pass_by_value)]
26#![warn(clippy::option_option)]
27#![deny(clippy::print_stderr)]
28#![deny(clippy::print_stdout)]
29#![warn(clippy::rc_buffer)]
30#![deny(clippy::ref_option_ref)]
31#![warn(clippy::semicolon_if_nothing_returned)]
32#![warn(clippy::trait_duplication_in_bounds)]
33#![deny(clippy::unchecked_time_subtraction)]
34#![deny(clippy::unnecessary_wraps)]
35#![warn(clippy::unseparated_literal_suffix)]
36#![deny(clippy::unwrap_used)]
37#![deny(clippy::mod_module_files)]
38#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness
39#![allow(clippy::uninlined_format_args)]
40#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945
41#![allow(clippy::result_large_err)] // temporary workaround for arti#587
42#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best
43#![allow(clippy::needless_lifetimes)] // See arti#1765
44#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060
45#![allow(clippy::collapsible_if)] // See arti#2342
46#![deny(clippy::unused_async)]
47#![deny(clippy::string_slice)] // See arti#2571
48//! <!-- @@ end lint list maintained by maint/add_warning @@ -->
49
50use std::fmt::{Display, Formatter, Write};
51use std::num::NonZeroUsize;
52use std::str::FromStr;
53
54mod err;
55use digest::Digest;
56pub use err::Error;
57use imara_diff::{Algorithm, Diff, Hunk, InternedInput};
58use tor_error::{internal, into_internal};
59use tor_netdoc::parse2::{ErrorProblem, ItemStream, KeywordRef, ParseError, ParseInput};
60
61use crate::err::GenEdDiffError;
62
63/// Result type used by this crate
64type Result<T> = std::result::Result<T, Error>;
65
66/// The keyword that identifies a directory signature line.
67// TODO: We probably want this in tor-netdoc.
68const DIRECTORY_SIGNATURE_KEYWORD: KeywordRef = KeywordRef::new_const("directory-signature");
69
70/// When hashing the signed part of the consensus, append this tail to the end.
71const CONSENSUS_SIGNED_SHA3_256_HASH_TAIL: &str = "directory-signature ";
72
73// Do not compile if we cannot safely convert a u32 into a usize.
74static_assertions::const_assert!(std::mem::size_of::<usize>() >= std::mem::size_of::<u32>());
75
76/// Generates a consensus diff.
77///
78/// This implementation is different from the one in CTor, because it uses a
79/// different algorithm, namely [`Algorithm::Myers`] from the [`imara_diff`]
80/// crate, which is more efficient than CTor in terms of runtime and about as
81/// equally efficient as CTor in output size.
82///
83/// The CTor implementation makes heavy use of the fact that the input is a
84/// valid consensus and that the routers in it are ordered.  This allows for
85/// some divide-and-conquer mechanisms and the cost of requiring more parsing.
86///
87/// Here, we only minimally parse the consensus, in order to only obtain the
88/// first `directory-signature` item and to cut everything including itself off
89/// from the input, as demanded by the specification.
90///
91/// All outputs of this function are guaranteed to work with this
92/// [`apply_diff()`] implementation as a check is performed before returning,
93/// because returning an unusable diff would be terrible.
94pub fn gen_cons_diff(
95    base: &str,
96    target: &str,
97    size_strictness: DiffSizeStrictness,
98) -> Result<String> {
99    // Throw away the signatures.
100    let (base_signed, _) = split_directory_signatures(base)?;
101    let base_lines = base_signed.chars().filter(|c| *c == '\n').count() + 1;
102
103    // Compute the hashes for the header.
104    let base_signed_hash = hex::encode_upper({
105        let mut h = tor_llcrypto::d::Sha3_256::new();
106        h.update(base_signed);
107        h.update(CONSENSUS_SIGNED_SHA3_256_HASH_TAIL);
108        h.finalize()
109    });
110    let target_hash = hex::encode_upper(tor_llcrypto::d::Sha3_256::digest(target.as_bytes()));
111
112    // Compose the result with header.
113    let ed_diff = gen_ed_diff(base_signed, target).map_err(|e| match e {
114        GenEdDiffError::MissingUnixLineEnding { lno } => Error::InvalidInput(ParseError::new(
115            ErrorProblem::OtherBadDocument("line does not end with '\\n'"),
116            "consdiff",
117            "",
118            lno,
119            None,
120        )),
121        GenEdDiffError::ContainsDotLine { lno } => Error::InvalidInput(ParseError::new(
122            ErrorProblem::OtherBadDocument("contains dotline"),
123            "consdiff",
124            "",
125            lno,
126            None,
127        )),
128        GenEdDiffError::Write(_) => internal!("string write was not infallible?").into(),
129    })?;
130
131    let result = format!(
132        "network-status-diff-version 1\n\
133        hash {base_signed_hash} {target_hash}\n\
134        {base_lines},$d\n\
135        {ed_diff}"
136    );
137
138    // Ensure it is valid, refuse to emit an invalid diff.
139    let check = match apply_diff(base, &result, None, size_strictness) {
140        Ok(v) => v,
141        Err(Error::DiffTooLarge) => return Err(Error::DiffTooLarge),
142        Err(e) => return Err(into_internal!("unable to apply generated diff")(e).into()),
143    };
144
145    if check.to_string() != target {
146        Err(internal!("result does not match?"))?;
147    }
148
149    Ok(result)
150}
151
152/// Splits `input` at the first `directory-signature`.
153fn split_directory_signatures(input: &str) -> Result<(&str, &str)> {
154    let parse_input = ParseInput::new(input, "");
155    let mut items = ItemStream::new(&parse_input);
156
157    // Parse the consensus item by item until the first `directory-signature`.
158    loop {
159        // We only peek in order to get the proper byte offset.
160        // This is required because doing next() and breaking in the case of
161        // a `directory-signature` would then lead to `.byte_offset()` yielding
162        // the start of the second signature and not the start of the first one.
163        let item = items
164            .peek_keyword()
165            .map_err(|e| ParseError::new(e, "consdiff", "", items.lno_for_error(), None))?;
166
167        match item {
168            Some(DIRECTORY_SIGNATURE_KEYWORD) => {
169                let offset = items.byte_position();
170                return Ok(input
171                    .split_at_checked(offset)
172                    .ok_or_else(|| internal!("Calculated an invalid offset"))?);
173            }
174            Some(_) => {
175                // Consume the just peeked item.
176                let _ = items.next();
177            }
178            None => {
179                // We are finished.
180                return Err(Error::InvalidInput(ParseError::new(
181                    ErrorProblem::MissingItem {
182                        keyword: DIRECTORY_SIGNATURE_KEYWORD.as_str(),
183                    },
184                    "consdiff",
185                    "",
186                    items.lno_for_error(),
187                    None,
188                )));
189            }
190        }
191    }
192}
193
194/// Generates an input agnostic ed diff.
195///
196/// This function does the general logic of [`gen_cons_diff()`] but works in a
197/// document agnostic fashion.
198fn gen_ed_diff(base: &str, target: &str) -> std::result::Result<String, GenEdDiffError> {
199    let mut result = String::new();
200
201    // We use Myers' algorithm as benchmarks have shown that it provides an
202    // equal diff size as the ctor one while keeping an acceptable performance.
203    let input = InternedInput::new(base, target);
204    let mut diff = Diff::compute(Algorithm::Myers, &input);
205    diff.postprocess_lines(&input);
206
207    // Iterate through every a hunk, with a hunk being a block of changes.
208    let hunks = diff.hunks().collect::<Vec<_>>();
209    for hunk in hunks.into_iter().rev() {
210        // Format the header.
211        let hunk_type = HunkType::determine(&hunk);
212        match hunk_type {
213            // No need to do +1 because append is AFTER.
214            HunkType::Append => writeln!(result, "{}{hunk_type}", hunk.before.start)?,
215            HunkType::Delete | HunkType::Change => {
216                if hunk.before.start + 1 == hunk.before.end {
217                    // +1 because 1-indexed.
218                    writeln!(result, "{}{hunk_type}", hunk.before.start + 1)?;
219                } else {
220                    // +1 because 1-indexed; no need to do +1 on end because
221                    // the range is inclusive.
222                    writeln!(
223                        result,
224                        "{},{}{hunk_type}",
225                        hunk.before.start + 1,
226                        hunk.before.end
227                    )?;
228                }
229            }
230        }
231
232        // Format the body.
233        match hunk_type {
234            HunkType::Append | HunkType::Change => {
235                let range = (hunk.after.start)..(hunk.after.end);
236                let tlines = range
237                    .map(|idx| {
238                        let idx = usize::try_from(idx).expect("32-bit static assertion violated?");
239                        input.interner[input.after[idx]]
240                    })
241                    .collect::<Vec<_>>();
242
243                for (lno, line) in tlines.iter().copied().enumerate() {
244                    // Check that all lines end with a Unix line ending.
245                    if line.ends_with("\r\n") || !line.ends_with("\n") {
246                        // +1 because 1-indexed.
247                        return Err(GenEdDiffError::MissingUnixLineEnding { lno: lno + 1 });
248                    }
249
250                    // Check for lines consisting of a single dot plus trailing
251                    // whitespace characters.  No need to bother about "\r\n",
252                    // because we checked that one above.  Although technically
253                    // lines such as `. \n` are possible and understood
254                    // as part of ed diffs, they are not legal in tor netdocs, and
255                    // we want to be more defensive here for now; if it becomes a
256                    // problem, we may remove it later.
257                    if line.trim_end() == "." {
258                        // +1 because 1-indexed.
259                        return Err(GenEdDiffError::ContainsDotLine { lno: lno + 1 });
260                    }
261
262                    // All lines are newline terminated, no need to use writeln!
263                    write!(result, "{line}")?;
264                }
265
266                // Write the terminating dot.
267                writeln!(result, ".")?;
268            }
269            HunkType::Delete => {}
270        }
271    }
272
273    Ok(result)
274}
275
276/// The operational type of the hunk.
277#[derive(Clone, Copy, Debug, derive_more::Display)]
278enum HunkType {
279    /// This is a pure appending.
280    #[display("a")]
281    Append,
282    /// This is a pure deletion.
283    #[display("d")]
284    Delete,
285    /// This is change with potential additions and deletions.
286    #[display("c")]
287    Change,
288}
289
290impl HunkType {
291    /// Determines the type of the hunk.
292    fn determine(hunk: &Hunk) -> Self {
293        if hunk.is_pure_insertion() {
294            Self::Append
295        } else if hunk.is_pure_removal() {
296            Self::Delete
297        } else {
298            Self::Change
299        }
300    }
301}
302
303/// Return true if `s` looks more like a consensus diff than some other kind
304/// of document.
305pub fn looks_like_diff(s: &str) -> bool {
306    s.starts_with("network-status-diff-version")
307}
308
309/// Apply a given diff to an input text, and return the result from applying
310/// that diff.
311///
312/// This is a slow version, for testing and correctness checking.  It uses
313/// an O(n) operation to apply diffs, and therefore runs in O(n^2) time.
314#[cfg(any(test, feature = "slow-diff-apply"))]
315pub fn apply_diff_trivial<'a>(input: &'a str, diff: &'a str) -> Result<DiffResult<'a>> {
316    let mut diff_lines = diff.lines();
317    let (_, d2) = parse_diff_header(&mut diff_lines)?;
318
319    let mut diffable = DiffResult::from_str(input, d2);
320
321    for command in DiffCommandIter::new(diff_lines) {
322        command?.apply_to(&mut diffable)?;
323    }
324
325    Ok(diffable)
326}
327
328/// Apply a given diff to an input text, and return the result from applying
329/// that diff.
330///
331/// If `check_digest_in` is provided, require the diff to say that it
332/// applies to a document with the provided digest.
333pub fn apply_diff<'a>(
334    input: &'a str,
335    diff: &'a str,
336    check_digest_in: Option<[u8; 32]>,
337    size_strictness: DiffSizeStrictness,
338) -> Result<DiffResult<'a>> {
339    let input_bytes = input.len();
340    let diff_bytes = diff.len();
341    // This actually counts newlines, and can be off-by-one, but that's okay.
342    let diff_lines = count_nl(diff);
343
344    let mut input = DiffResult::from_str(input, [0; 32]);
345    let input_lines = input.lines.len();
346
347    size_strictness.check(input_bytes, input_lines, diff_bytes, diff_lines)?;
348
349    let mut diff_lines = diff.lines();
350    let (d1, d2) = parse_diff_header(&mut diff_lines)?;
351    if let Some(d_want) = check_digest_in {
352        if d1 != d_want {
353            return Err(Error::CantApply("listed digest does not match document"));
354        }
355    }
356
357    let mut output = DiffResult::new(d2);
358
359    for command in DiffCommandIter::new(diff_lines) {
360        command?.apply_transformation(&mut input, &mut output)?;
361    }
362
363    output.push_reversed(&input.lines[..]);
364
365    output.lines.reverse();
366    Ok(output)
367}
368
369/// A degree of strictness to apply when checking the size of a diff versus
370/// the size of the associated consensus.
371#[derive(Clone, Copy, Debug)]
372#[non_exhaustive]
373pub enum DiffSizeStrictness {
374    /// Do not perform any checks.
375    None,
376
377    /// Check whether the diff is reasonable to apply to the input.
378    Apply,
379
380    /// Check whether the diff is reasonable to serve as a diff against the input.
381    ///
382    /// This is an even stricter version of [`DiffSizeStrictness::Apply`].
383    Generate,
384}
385
386impl DiffSizeStrictness {
387    /// Check whether a given diff is implausibly or uselessly large in comparison with
388    /// the input, and return an error if so.
389    fn check(
390        &self,
391        input_bytes: usize,
392        input_lines: usize,
393        diff_bytes: usize,
394        diff_lines: usize,
395    ) -> Result<()> {
396        use DiffSizeStrictness as S;
397        use Error::DiffTooLarge;
398
399        match self {
400            S::None => {}
401            S::Generate => {
402                // If we just generated a diff that isn't smaller than the consensus we started with,
403                // there is no point in serving it. It wouldn't save size.
404                if diff_bytes >= input_bytes {
405                    return Err(DiffTooLarge);
406                }
407
408                // Also, check whether the client would reject this diff when trying to apply it.
409                S::Apply.check(input_bytes, input_lines, diff_bytes, diff_lines)?;
410            }
411            S::Apply => {
412                const BYTE_THRESHOLD: usize = 64 * 1024;
413                const LINE_THRESHOLD: usize = 1024;
414                if diff_bytes >= BYTE_THRESHOLD && diff_bytes >= input_bytes.saturating_mul(2) {
415                    return Err(DiffTooLarge);
416                }
417                if diff_lines >= LINE_THRESHOLD && diff_lines >= input_lines.saturating_mul(3) {
418                    return Err(DiffTooLarge);
419                }
420            }
421        }
422
423        Ok(())
424    }
425}
426
427/// Given a line iterator, check to make sure the first two lines are
428/// a valid diff header as specified in dir-spec.txt.
429fn parse_diff_header<'a, I>(iter: &mut I) -> Result<([u8; 32], [u8; 32])>
430where
431    I: Iterator<Item = &'a str>,
432{
433    let line1 = iter.next();
434    if line1 != Some("network-status-diff-version 1") {
435        return Err(Error::BadDiff("unrecognized or missing header"));
436    }
437    let line2 = iter.next().ok_or(Error::BadDiff("header truncated"))?;
438    if !line2.starts_with("hash ") {
439        return Err(Error::BadDiff("missing 'hash' line"));
440    }
441    let elts: Vec<_> = line2.split_ascii_whitespace().collect();
442    if elts.len() != 3 {
443        return Err(Error::BadDiff("invalid 'hash' line"));
444    }
445    let d1 = hex::decode(elts[1])?;
446    let d2 = hex::decode(elts[2])?;
447    match (d1.try_into(), d2.try_into()) {
448        (Ok(a), Ok(b)) => Ok((a, b)),
449        _ => Err(Error::BadDiff("wrong digest lengths on 'hash' line")),
450    }
451}
452
453/// A command that can appear in a diff.  Each command tells us to
454/// remove zero or more lines, and insert zero or more lines in their
455/// place.
456///
457/// Commands refer to lines by 1-indexed line number.
458#[derive(Clone, Debug)]
459enum DiffCommand<'a> {
460    /// Remove the lines from low through high, inclusive.
461    Delete {
462        /// The first line to remove
463        low: usize,
464        /// The last line to remove
465        high: usize,
466    },
467    /// Remove the lines from low through the end of the file, inclusive.
468    DeleteToEnd {
469        /// The first line to remove
470        low: usize,
471    },
472    /// Replace the lines from low through high, inclusive, with the
473    /// lines in 'lines'.
474    Replace {
475        /// The first line to replace
476        low: usize,
477        /// The last line to replace
478        high: usize,
479        /// The text to insert instead
480        lines: Vec<&'a str>,
481    },
482    /// Insert the provided 'lines' after the line with index 'pos'.
483    Insert {
484        /// The position after which to insert the text
485        pos: usize,
486        /// The text to insert
487        lines: Vec<&'a str>,
488    },
489}
490
491/// The result of applying one or more diff commands to an input string.
492///
493/// It refers to lines from the diff and the input by reference, to
494/// avoid copying.
495#[derive(Clone, Debug)]
496pub struct DiffResult<'a> {
497    /// An expected digest of the output, after it has been assembled.
498    d_post: [u8; 32],
499    /// The lines in the output.
500    lines: Vec<&'a str>,
501}
502
503/// A possible value for the end of a range.  It can be either a line number,
504/// or a dollar sign indicating "end of file".
505#[derive(Clone, Copy, Debug)]
506enum RangeEnd {
507    /// A line number in the file.
508    Num(NonZeroUsize),
509    /// A dollar sign, indicating "end of file" in a delete command.
510    DollarSign,
511}
512
513impl FromStr for RangeEnd {
514    type Err = Error;
515    fn from_str(s: &str) -> Result<RangeEnd> {
516        if s == "$" {
517            Ok(RangeEnd::DollarSign)
518        } else {
519            let v: NonZeroUsize = s.parse()?;
520            if v.get() == usize::MAX {
521                return Err(Error::BadDiff("range cannot end at usize::MAX"));
522            }
523            Ok(RangeEnd::Num(v))
524        }
525    }
526}
527
528impl<'a> DiffCommand<'a> {
529    /// Transform 'target' according to the this command.
530    ///
531    /// Because DiffResult internally uses a vector of line, this
532    /// implementation is potentially O(n) in the size of the input.
533    #[cfg(any(test, feature = "slow-diff-apply"))]
534    fn apply_to(&self, target: &mut DiffResult<'a>) -> Result<()> {
535        match self {
536            Self::Delete { low, high } => {
537                target.remove_lines(*low, *high)?;
538            }
539            Self::DeleteToEnd { low } => {
540                target.remove_lines(*low, target.lines.len())?;
541            }
542            Self::Replace { low, high, lines } => {
543                target.remove_lines(*low, *high)?;
544                target.insert_at(*low, lines)?;
545            }
546            Self::Insert { pos, lines } => {
547                // This '+1' seems off, but it's what the spec says. I wonder
548                // if the spec is wrong.
549                target.insert_at(*pos + 1, lines)?;
550            }
551        };
552        Ok(())
553    }
554
555    /// Apply this command to 'input', moving lines into 'output'.
556    ///
557    /// This is a more efficient algorithm, but it requires that the
558    /// diff commands are sorted in reverse order by line
559    /// number. (Fortunately, the Tor ed diff format guarantees this.)
560    ///
561    /// Before calling this method, input and output must contain the
562    /// results of having applied the previous command in the diff.
563    /// (When no commands have been applied, input starts out as the
564    /// original text, and output starts out empty.)
565    ///
566    /// This method applies the command by copying unaffected lines
567    /// from the _end_ of input into output, adding any lines inserted
568    /// by this command, and finally deleting any affected lines from
569    /// input.
570    ///
571    /// We build the `output` value in reverse order, and then put it
572    /// back to normal before giving it to the user.
573    fn apply_transformation(
574        &self,
575        input: &mut DiffResult<'a>,
576        output: &mut DiffResult<'a>,
577    ) -> Result<()> {
578        if let Some(succ) = self.following_lines() {
579            if let Some(subslice) = input.lines.get(succ - 1..) {
580                // Lines from `succ` onwards are unaffected.  Copy them.
581                output.push_reversed(subslice);
582            } else {
583                // Oops, dubious line number.
584                return Err(Error::CantApply(
585                    "ending line number didn't correspond to document",
586                ));
587            }
588        }
589
590        if let Some(lines) = self.lines() {
591            // These are the lines we're inserting.
592            output.push_reversed(lines);
593        }
594
595        let remove = self.first_removed_line();
596        if remove == 0 || (!self.is_insert() && remove > input.lines.len()) {
597            return Err(Error::CantApply(
598                "starting line number didn't correspond to document",
599            ));
600        }
601        input.lines.truncate(remove - 1);
602
603        Ok(())
604    }
605
606    /// Return the lines that we should add to the output
607    fn lines(&self) -> Option<&[&'a str]> {
608        match self {
609            Self::Replace { lines, .. } | Self::Insert { lines, .. } => Some(lines.as_slice()),
610            _ => None,
611        }
612    }
613
614    /// Return a mutable reference to the vector of lines we should
615    /// add to the output.
616    fn linebuf_mut(&mut self) -> Option<&mut Vec<&'a str>> {
617        match self {
618            Self::Replace { lines, .. } | Self::Insert { lines, .. } => Some(lines),
619            _ => None,
620        }
621    }
622
623    /// Return the (1-indexed) line number of the first line in the
624    /// input that comes _after_ this command, and is not affected by it.
625    ///
626    /// We use this line number to know which lines we should copy.
627    fn following_lines(&self) -> Option<usize> {
628        match self {
629            Self::Delete { high, .. } | Self::Replace { high, .. } => Some(high + 1),
630            Self::DeleteToEnd { .. } => None,
631            Self::Insert { pos, .. } => Some(pos + 1),
632        }
633    }
634
635    /// Return the (1-indexed) line number of the first line that we
636    /// should clear from the input when processing this command.
637    ///
638    /// This can be the same as following_lines(), if we shouldn't
639    /// actually remove any lines.
640    fn first_removed_line(&self) -> usize {
641        match self {
642            Self::Delete { low, .. } => *low,
643            Self::DeleteToEnd { low } => *low,
644            Self::Replace { low, .. } => *low,
645            Self::Insert { pos, .. } => *pos + 1,
646        }
647    }
648
649    /// Return true if this is an Insert command.
650    fn is_insert(&self) -> bool {
651        matches!(self, Self::Insert { .. })
652    }
653
654    /// Extract a single command from a line iterator that yields lines
655    /// of the diffs.  Return None if we're at the end of the iterator.
656    fn from_line_iterator<I>(iter: &mut I) -> Result<Option<Self>>
657    where
658        I: Iterator<Item = &'a str>,
659    {
660        let command = match iter.next() {
661            Some(s) => s,
662            None => return Ok(None),
663        };
664
665        // `command` can be of these forms: `Rc`, `Rd`, `N,$d`, and `Na`,
666        // where R is a range of form `N,N`, and where N is a line number.
667
668        if command.len() < 2 || !command.is_ascii() {
669            return Err(Error::BadDiff("command too short"));
670        }
671
672        let (range, command) = command.split_at(command.len() - 1);
673        let (low, high) = if let Some((lo, hi)) = range.split_once(',') {
674            (lo.parse::<usize>()?, Some(hi.parse::<RangeEnd>()?))
675        } else {
676            (range.parse::<usize>()?, None)
677        };
678
679        if low == usize::MAX {
680            return Err(Error::BadDiff("range cannot begin at usize::MAX"));
681        }
682
683        match (low, high) {
684            (lo, Some(RangeEnd::Num(hi))) if lo > hi.into() => {
685                return Err(Error::BadDiff("mis-ordered lines in range"));
686            }
687            (_, _) => (),
688        }
689
690        let mut cmd = match (command, low, high) {
691            ("d", low, None) => Self::Delete { low, high: low },
692            ("d", low, Some(RangeEnd::Num(high))) => Self::Delete {
693                low,
694                high: high.into(),
695            },
696            ("d", low, Some(RangeEnd::DollarSign)) => Self::DeleteToEnd { low },
697            ("c", low, None) => Self::Replace {
698                low,
699                high: low,
700                lines: Vec::new(),
701            },
702            ("c", low, Some(RangeEnd::Num(high))) => Self::Replace {
703                low,
704                high: high.into(),
705                lines: Vec::new(),
706            },
707            ("a", low, None) => Self::Insert {
708                pos: low,
709                lines: Vec::new(),
710            },
711            (_, _, _) => return Err(Error::BadDiff("can't parse command line")),
712        };
713
714        if let Some(ref mut linebuf) = cmd.linebuf_mut() {
715            // The 'c' and 'a' commands take a series of lines followed by a
716            // line containing a period.
717            loop {
718                match iter.next() {
719                    None => return Err(Error::BadDiff("unterminated block to insert")),
720                    Some(".") => break,
721                    Some(line) => linebuf.push(line),
722                }
723            }
724        }
725
726        Ok(Some(cmd))
727    }
728}
729
730/// Iterator that wraps a line iterator and returns a sequence of
731/// `Result<DiffCommand>`.
732///
733/// This iterator forces the commands to affect the file in reverse order,
734/// so that we can use the O(n) algorithm for applying these diffs.
735struct DiffCommandIter<'a, I>
736where
737    I: Iterator<Item = &'a str>,
738{
739    /// The underlying iterator.
740    iter: I,
741
742    /// The 'first removed line' of the last-parsed command; used to ensure
743    /// that commands appear in reverse order.
744    last_cmd_first_removed: Option<usize>,
745}
746
747impl<'a, I> DiffCommandIter<'a, I>
748where
749    I: Iterator<Item = &'a str>,
750{
751    /// Construct a new DiffCommandIter wrapping `iter`.
752    fn new(iter: I) -> Self {
753        DiffCommandIter {
754            iter,
755            last_cmd_first_removed: None,
756        }
757    }
758}
759
760impl<'a, I> Iterator for DiffCommandIter<'a, I>
761where
762    I: Iterator<Item = &'a str>,
763{
764    type Item = Result<DiffCommand<'a>>;
765    fn next(&mut self) -> Option<Result<DiffCommand<'a>>> {
766        match DiffCommand::from_line_iterator(&mut self.iter) {
767            Err(e) => Some(Err(e)),
768            Ok(None) => None,
769            Ok(Some(c)) => match (self.last_cmd_first_removed, c.following_lines()) {
770                (Some(_), None) => Some(Err(Error::BadDiff("misordered commands"))),
771                (Some(a), Some(b)) if a < b => Some(Err(Error::BadDiff("misordered commands"))),
772                (_, _) => {
773                    self.last_cmd_first_removed = Some(c.first_removed_line());
774                    Some(Ok(c))
775                }
776            },
777        }
778    }
779}
780
781impl<'a> DiffResult<'a> {
782    /// Construct a new DiffResult containing the provided string
783    /// split into lines, and an expected post-transformation digest.
784    fn from_str(s: &'a str, d_post: [u8; 32]) -> Self {
785        // As per the [netdoc syntax], newlines should be discarded and ignored.
786        //
787        // [netdoc syntax]: https://spec.torproject.org/dir-spec/netdoc.html#netdoc-syntax
788        let lines: Vec<_> = s.lines().collect();
789
790        DiffResult { d_post, lines }
791    }
792
793    /// Return a new empty DiffResult with an expected
794    /// post-transformation digests
795    fn new(d_post: [u8; 32]) -> Self {
796        DiffResult {
797            d_post,
798            lines: Vec::new(),
799        }
800    }
801
802    /// Put every member of `lines` at the end of this DiffResult, in
803    /// reverse order.
804    fn push_reversed(&mut self, lines: &[&'a str]) {
805        self.lines.extend(lines.iter().rev());
806    }
807
808    /// Remove the 1-indexed lines from `first` through `last` inclusive.
809    ///
810    /// This has to move elements around within the vector, and so it
811    /// is potentially O(n) in its length.
812    #[cfg(any(test, feature = "slow-diff-apply"))]
813    fn remove_lines(&mut self, first: usize, last: usize) -> Result<()> {
814        if first > self.lines.len() || last > self.lines.len() || first == 0 || last == 0 {
815            Err(Error::CantApply("line out of range"))
816        } else {
817            let n_to_remove = last - first + 1;
818            if last != self.lines.len() {
819                self.lines[..].copy_within((last).., first - 1);
820            }
821            self.lines.truncate(self.lines.len() - n_to_remove);
822            Ok(())
823        }
824    }
825
826    /// Insert the provided `lines` so that they appear at 1-indexed
827    /// position `pos`.
828    ///
829    /// This has to move elements around within the vector, and so it
830    /// is potentially O(n) in its length.
831    #[cfg(any(test, feature = "slow-diff-apply"))]
832    fn insert_at(&mut self, pos: usize, lines: &[&'a str]) -> Result<()> {
833        if pos > self.lines.len() + 1 || pos == 0 {
834            Err(Error::CantApply("position out of range"))
835        } else {
836            let orig_len = self.lines.len();
837            self.lines.resize(self.lines.len() + lines.len(), "");
838            self.lines
839                .copy_within(pos - 1..orig_len, pos - 1 + lines.len());
840            self.lines[(pos - 1)..(pos + lines.len() - 1)].copy_from_slice(lines);
841            Ok(())
842        }
843    }
844
845    /// See whether the output of this diff matches the target digest.
846    ///
847    /// If not, return an error.
848    pub fn check_digest(&self) -> Result<()> {
849        use digest::Digest;
850        use tor_llcrypto::d::Sha3_256;
851        let mut d = Sha3_256::new();
852        for line in &self.lines {
853            d.update(line.as_bytes());
854            d.update(b"\n");
855        }
856        if d.finalize() == self.d_post.into() {
857            Ok(())
858        } else {
859            Err(Error::CantApply("Wrong digest after applying diff"))
860        }
861    }
862}
863
864impl<'a> Display for DiffResult<'a> {
865    fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
866        for elt in &self.lines {
867            writeln!(f, "{}", elt)?;
868        }
869        Ok(())
870    }
871}
872
873/// Count the number of newlines that appear in s.
874///
875/// This is potentially one less than the number of lines in s, if the final line is not terminated,
876///but that's okay for our uses here.
877fn count_nl(s: &str) -> usize {
878    s.bytes().filter(|b| *b == b'\n').count()
879}
880
881#[cfg(test)]
882mod test {
883    // @@ begin test lint list maintained by maint/add_warning @@
884    #![allow(clippy::bool_assert_comparison)]
885    #![allow(clippy::clone_on_copy)]
886    #![allow(clippy::dbg_macro)]
887    #![allow(clippy::mixed_attributes_style)]
888    #![allow(clippy::print_stderr)]
889    #![allow(clippy::print_stdout)]
890    #![allow(clippy::single_char_pattern)]
891    #![allow(clippy::unwrap_used)]
892    #![allow(clippy::unchecked_time_subtraction)]
893    #![allow(clippy::useless_vec)]
894    #![allow(clippy::needless_pass_by_value)]
895    #![allow(clippy::string_slice)] // See arti#2571
896    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
897
898    use rand::seq::IndexedRandom;
899    use tor_basic_utils::test_rng::testing_rng;
900
901    use super::DiffSizeStrictness as DSS;
902    use super::*;
903
904    #[test]
905    fn remove() -> Result<()> {
906        let example = DiffResult::from_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n", [0; 32]);
907
908        let mut d = example.clone();
909        d.remove_lines(5, 7)?;
910        assert_eq!(d.to_string(), "1\n2\n3\n4\n8\n9\n");
911
912        let mut d = example.clone();
913        d.remove_lines(1, 9)?;
914        assert_eq!(d.to_string(), "");
915
916        let mut d = example.clone();
917        d.remove_lines(1, 1)?;
918        assert_eq!(d.to_string(), "2\n3\n4\n5\n6\n7\n8\n9\n");
919
920        let mut d = example.clone();
921        d.remove_lines(6, 9)?;
922        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\n");
923
924        let mut d = example.clone();
925        assert!(d.remove_lines(6, 10).is_err());
926        assert!(d.remove_lines(0, 1).is_err());
927        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\n6\n7\n8\n9\n");
928
929        Ok(())
930    }
931
932    #[test]
933    fn insert() -> Result<()> {
934        let example = DiffResult::from_str("1\n2\n3\n4\n5\n", [0; 32]);
935        let mut d = example.clone();
936        d.insert_at(3, &["hello", "world"])?;
937        assert_eq!(d.to_string(), "1\n2\nhello\nworld\n3\n4\n5\n");
938
939        let mut d = example.clone();
940        d.insert_at(6, &["hello", "world"])?;
941        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\nhello\nworld\n");
942
943        let mut d = example.clone();
944        assert!(d.insert_at(0, &["hello", "world"]).is_err());
945        assert!(d.insert_at(7, &["hello", "world"]).is_err());
946        Ok(())
947    }
948
949    #[test]
950    fn push_reversed() {
951        let mut d = DiffResult::new([0; 32]);
952        d.push_reversed(&["7", "8", "9"]);
953        assert_eq!(d.to_string(), "9\n8\n7\n");
954        d.push_reversed(&["world", "hello", ""]);
955        assert_eq!(d.to_string(), "9\n8\n7\n\nhello\nworld\n");
956    }
957
958    #[test]
959    fn apply_command_simple() {
960        let example = DiffResult::from_str("a\nb\nc\nd\ne\nf\n", [0; 32]);
961
962        let mut d = example.clone();
963        assert_eq!(d.to_string(), "a\nb\nc\nd\ne\nf\n".to_string());
964        assert!(DiffCommand::DeleteToEnd { low: 5 }.apply_to(&mut d).is_ok());
965        assert_eq!(d.to_string(), "a\nb\nc\nd\n".to_string());
966
967        let mut d = example.clone();
968        assert!(
969            DiffCommand::Delete { low: 3, high: 5 }
970                .apply_to(&mut d)
971                .is_ok()
972        );
973        assert_eq!(d.to_string(), "a\nb\nf\n".to_string());
974
975        let mut d = example.clone();
976        assert!(
977            DiffCommand::Replace {
978                low: 3,
979                high: 5,
980                lines: vec!["hello", "world"]
981            }
982            .apply_to(&mut d)
983            .is_ok()
984        );
985        assert_eq!(d.to_string(), "a\nb\nhello\nworld\nf\n".to_string());
986
987        let mut d = example.clone();
988        assert!(
989            DiffCommand::Insert {
990                pos: 3,
991                lines: vec!["hello", "world"]
992            }
993            .apply_to(&mut d)
994            .is_ok()
995        );
996        assert_eq!(
997            d.to_string(),
998            "a\nb\nc\nhello\nworld\nd\ne\nf\n".to_string()
999        );
1000    }
1001
1002    #[test]
1003    fn parse_command() -> Result<()> {
1004        fn parse(s: &str) -> Result<DiffCommand<'_>> {
1005            let mut iter = s.lines();
1006            let cmd = DiffCommand::from_line_iterator(&mut iter)?;
1007            let cmd2 = DiffCommand::from_line_iterator(&mut iter)?;
1008            if cmd2.is_some() {
1009                panic!("Unexpected second command");
1010            }
1011            Ok(cmd.unwrap())
1012        }
1013
1014        fn parse_err(s: &str) {
1015            let mut iter = s.lines();
1016            let cmd = DiffCommand::from_line_iterator(&mut iter);
1017            assert!(matches!(cmd, Err(Error::BadDiff(_))));
1018        }
1019
1020        let p = parse("3,8d\n")?;
1021        assert!(matches!(p, DiffCommand::Delete { low: 3, high: 8 }));
1022        let p = parse("3d\n")?;
1023        assert!(matches!(p, DiffCommand::Delete { low: 3, high: 3 }));
1024        let p = parse("100,$d\n")?;
1025        assert!(matches!(p, DiffCommand::DeleteToEnd { low: 100 }));
1026
1027        let p = parse("30,40c\nHello\nWorld\n.\n")?;
1028        assert!(matches!(
1029            p,
1030            DiffCommand::Replace {
1031                low: 30,
1032                high: 40,
1033                ..
1034            }
1035        ));
1036        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
1037        let p = parse("30c\nHello\nWorld\n.\n")?;
1038        assert!(matches!(
1039            p,
1040            DiffCommand::Replace {
1041                low: 30,
1042                high: 30,
1043                ..
1044            }
1045        ));
1046        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
1047
1048        let p = parse("999a\nHello\nWorld\n.\n")?;
1049        assert!(matches!(p, DiffCommand::Insert { pos: 999, .. }));
1050        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
1051        let p = parse("0a\nHello\nWorld\n.\n")?;
1052        assert!(matches!(p, DiffCommand::Insert { pos: 0, .. }));
1053        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
1054
1055        parse_err("hello world");
1056        parse_err("\n\n");
1057        parse_err("$,5d");
1058        parse_err("5,6,8d");
1059        parse_err("8,5d");
1060        parse_err("6");
1061        parse_err("d");
1062        parse_err("-10d");
1063        parse_err("4,$c\na\n.");
1064        parse_err("foo");
1065        parse_err("5,10p");
1066        parse_err("18446744073709551615a");
1067        parse_err("1,18446744073709551615d");
1068
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn apply_transformation() -> Result<()> {
1074        let example = DiffResult::from_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n", [0; 32]);
1075        let empty = DiffResult::new([1; 32]);
1076
1077        let mut inp = example.clone();
1078        let mut out = empty.clone();
1079        DiffCommand::DeleteToEnd { low: 5 }.apply_transformation(&mut inp, &mut out)?;
1080        assert_eq!(inp.to_string(), "1\n2\n3\n4\n");
1081        assert_eq!(out.to_string(), "");
1082
1083        let mut inp = example.clone();
1084        let mut out = empty.clone();
1085        DiffCommand::DeleteToEnd { low: 9 }.apply_transformation(&mut inp, &mut out)?;
1086        assert_eq!(inp.to_string(), "1\n2\n3\n4\n5\n6\n7\n8\n");
1087        assert_eq!(out.to_string(), "");
1088
1089        let mut inp = example.clone();
1090        let mut out = empty.clone();
1091        DiffCommand::Delete { low: 3, high: 5 }.apply_transformation(&mut inp, &mut out)?;
1092        assert_eq!(inp.to_string(), "1\n2\n");
1093        assert_eq!(out.to_string(), "9\n8\n7\n6\n");
1094
1095        let mut inp = example.clone();
1096        let mut out = empty.clone();
1097        DiffCommand::Replace {
1098            low: 5,
1099            high: 6,
1100            lines: vec!["oh hey", "there"],
1101        }
1102        .apply_transformation(&mut inp, &mut out)?;
1103        assert_eq!(inp.to_string(), "1\n2\n3\n4\n");
1104        assert_eq!(out.to_string(), "9\n8\n7\nthere\noh hey\n");
1105
1106        let mut inp = example.clone();
1107        let mut out = empty.clone();
1108        DiffCommand::Insert {
1109            pos: 3,
1110            lines: vec!["oh hey", "there"],
1111        }
1112        .apply_transformation(&mut inp, &mut out)?;
1113        assert_eq!(inp.to_string(), "1\n2\n3\n");
1114        assert_eq!(out.to_string(), "9\n8\n7\n6\n5\n4\nthere\noh hey\n");
1115        DiffCommand::Insert {
1116            pos: 0,
1117            lines: vec!["boom!"],
1118        }
1119        .apply_transformation(&mut inp, &mut out)?;
1120        assert_eq!(inp.to_string(), "");
1121        assert_eq!(
1122            out.to_string(),
1123            "9\n8\n7\n6\n5\n4\nthere\noh hey\n3\n2\n1\nboom!\n"
1124        );
1125
1126        let mut inp = example.clone();
1127        let mut out = empty.clone();
1128        let r = DiffCommand::Delete {
1129            low: 100,
1130            high: 200,
1131        }
1132        .apply_transformation(&mut inp, &mut out);
1133        assert!(r.is_err());
1134        let r = DiffCommand::Delete { low: 5, high: 200 }.apply_transformation(&mut inp, &mut out);
1135        assert!(r.is_err());
1136        let r = DiffCommand::Delete { low: 0, high: 1 }.apply_transformation(&mut inp, &mut out);
1137        assert!(r.is_err());
1138        let r = DiffCommand::DeleteToEnd { low: 10 }.apply_transformation(&mut inp, &mut out);
1139        assert!(r.is_err());
1140        Ok(())
1141    }
1142
1143    #[test]
1144    fn header() -> Result<()> {
1145        fn header_from(s: &str) -> Result<([u8; 32], [u8; 32])> {
1146            let mut iter = s.lines();
1147            parse_diff_header(&mut iter)
1148        }
1149
1150        let (a,b) = header_from(
1151            "network-status-diff-version 1
1152hash B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663 F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB"
1153        )?;
1154
1155        assert_eq!(
1156            &a[..],
1157            hex::decode("B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663")?
1158        );
1159        assert_eq!(
1160            &b[..],
1161            hex::decode("F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB")?
1162        );
1163
1164        assert!(header_from("network-status-diff-version 2\n").is_err());
1165        assert!(header_from("").is_err());
1166        assert!(header_from("5,$d\n1,2d\n").is_err());
1167        assert!(header_from("network-status-diff-version 1\n").is_err());
1168        assert!(
1169            header_from(
1170                "network-status-diff-version 1
1171hash x y
11725,5d"
1173            )
1174            .is_err()
1175        );
1176        assert!(
1177            header_from(
1178                "network-status-diff-version 1
1179hash x y
11805,5d"
1181            )
1182            .is_err()
1183        );
1184        assert!(
1185            header_from(
1186                "network-status-diff-version 1
1187hash AA BB
11885,5d"
1189            )
1190            .is_err()
1191        );
1192        assert!(
1193            header_from(
1194                "network-status-diff-version 1
1195oh hello there
11965,5d"
1197            )
1198            .is_err()
1199        );
1200        assert!(header_from("network-status-diff-version 1
1201hash B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663 F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB extra").is_err());
1202
1203        Ok(())
1204    }
1205
1206    #[test]
1207    fn apply_simple() {
1208        let pre = include_str!("../testdata/consensus1.txt");
1209        let diff = include_str!("../testdata/diff1.txt");
1210        let post = include_str!("../testdata/consensus2.txt");
1211
1212        let result = apply_diff_trivial(pre, diff).unwrap();
1213        assert!(result.check_digest().is_ok());
1214        assert_eq!(result.to_string(), post);
1215    }
1216
1217    #[test]
1218    fn sort_order() -> Result<()> {
1219        fn cmds(s: &str) -> Result<Vec<DiffCommand<'_>>> {
1220            let mut out = Vec::new();
1221            for cmd in DiffCommandIter::new(s.lines()) {
1222                out.push(cmd?);
1223            }
1224            Ok(out)
1225        }
1226
1227        let _ = cmds("6,9d\n5,5d\n")?;
1228        assert!(cmds("5,5d\n6,9d\n").is_err());
1229        assert!(cmds("5,5d\n6,6d\n").is_err());
1230        assert!(cmds("5,5d\n5,6d\n").is_err());
1231
1232        Ok(())
1233    }
1234
1235    /// Test for cons diff using a random word generator.
1236    #[test]
1237    fn cons_diff() {
1238        // cat /usr/share/dict/words | sort -R | head -n 20 | sed 's/^/"/g' | sed 's/$/",/g'
1239        const WORDS: &[&str] = &[
1240            "citole",
1241            "aflow",
1242            "plowfoot",
1243            "coom",
1244            "retape",
1245            "perish",
1246            "overstifle",
1247            "ramshackle",
1248            "Romeo",
1249            "alme",
1250            "expressivity",
1251            "Kieffer",
1252            "tobe",
1253            "pronucleus",
1254            "countersconce",
1255            "puli",
1256            "acupunctuate",
1257            "heterolysis",
1258            "unwattled",
1259            "bismerpund",
1260        ];
1261
1262        let rng = &mut testing_rng();
1263        let mut left = (0..1000)
1264            .map(|_| WORDS.choose(rng).unwrap().to_string() + "\n")
1265            .collect::<String>();
1266        left += "directory-signature foo bar\n";
1267        let mut right = (0..1015)
1268            .map(|_| WORDS.choose(rng).unwrap().to_string() + "\n")
1269            .collect::<String>();
1270        right += "directory-signature foo baz\n";
1271
1272        let diff = gen_cons_diff(&left, &right, DSS::None).unwrap();
1273        let check = apply_diff(&left, &diff, None, DSS::None)
1274            .unwrap()
1275            .to_string();
1276        assert_eq!(right, check);
1277    }
1278
1279    #[test]
1280    fn dot_line() {
1281        let base = "";
1282        let target = "foo\nbar\n.\nbaz\nfoo\n";
1283        assert_eq!(
1284            gen_ed_diff(base, target).unwrap_err(),
1285            GenEdDiffError::ContainsDotLine { lno: 3 },
1286        );
1287
1288        // Also check for dot lines with trailing spaces.
1289        let target = "foo\nbar\n.   \t \nbaz\nfoo\n";
1290        assert_eq!(
1291            gen_ed_diff(base, target).unwrap_err(),
1292            GenEdDiffError::ContainsDotLine { lno: 3 },
1293        );
1294
1295        // A line starting with a dot and not ending in WS shall be fine though.
1296        let target = "foo\nbar\n.   foo\nbaz\nfoo\n";
1297        let _ = gen_ed_diff(base, target).unwrap();
1298
1299        // Use gen_cons_diff here to assume that it is actually applied.
1300        let base = "directory-signature foo baz\n";
1301        let target = ".foo bar\n. bar\ndirectory-signature foo baz\n";
1302        assert_eq!(
1303            gen_cons_diff(base, target, DSS::None).unwrap(),
1304            "network-status-diff-version 1\n\
1305            hash D8138DC27D9A66F5760058A6BCB71B755462B9D26B811828F124D036DE329A58 \
1306            506AC3A4407BC5305DD0D08FED3F09C2FE69847541F642A8FD13D3BD06FFE432\n\
1307            1,$d\n\
1308            0a\n\
1309            .foo bar\n\
1310            . bar\n\
1311            directory-signature foo baz\n\
1312            .\n"
1313        );
1314    }
1315
1316    #[test]
1317    fn missing_newline() {
1318        let base = "";
1319        let target = "foo\nbar\nbaz";
1320        assert_eq!(
1321            gen_ed_diff(base, target).unwrap_err(),
1322            GenEdDiffError::MissingUnixLineEnding { lno: 3 }
1323        );
1324    }
1325
1326    #[test]
1327    fn mixed_with_crlf() {
1328        let base = "";
1329        let target = "foo\r\nbar\r\nbaz\nhello\r\n";
1330        assert_eq!(
1331            gen_ed_diff(base, target).unwrap_err(),
1332            GenEdDiffError::MissingUnixLineEnding { lno: 1 }
1333        );
1334    }
1335}