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
#![warn(clippy::cognitive_complexity)]
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

            
50
use std::fmt::{Display, Formatter, Write};
51
use std::num::NonZeroUsize;
52
use std::str::FromStr;
53

            
54
mod err;
55
use digest::Digest;
56
pub use err::Error;
57
use imara_diff::{Algorithm, Diff, Hunk, InternedInput};
58
use tor_error::internal;
59
use tor_netdoc::parse2::{ErrorProblem, ItemStream, KeywordRef, ParseError, ParseInput};
60

            
61
use crate::err::GenEdDiffError;
62

            
63
/// Result type used by this crate
64
type 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.
68
const 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.
71
const CONSENSUS_SIGNED_SHA3_256_HASH_TAIL: &str = "directory-signature ";
72

            
73
// Do not compile if we cannot safely convert a u32 into a usize.
74
static_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.
94
41
pub fn gen_cons_diff(base: &str, target: &str) -> Result<String> {
95
    // Throw away the signatures.
96
41
    let (base_signed, _) = split_directory_signatures(base)?;
97
184479
    let base_lines = base_signed.chars().filter(|c| *c == '\n').count() + 1;
98

            
99
    // Compute the hashes for the header.
100
41
    let base_signed_hash = hex::encode_upper({
101
41
        let mut h = tor_llcrypto::d::Sha3_256::new();
102
41
        h.update(base_signed);
103
41
        h.update(CONSENSUS_SIGNED_SHA3_256_HASH_TAIL);
104
41
        h.finalize()
105
    });
106
41
    let target_hash = hex::encode_upper(tor_llcrypto::d::Sha3_256::digest(target.as_bytes()));
107

            
108
    // Compose the result with header.
109
41
    let ed_diff = gen_ed_diff(base_signed, target).map_err(|e| match e {
110
        GenEdDiffError::MissingUnixLineEnding { lno } => Error::InvalidInput(ParseError::new(
111
            ErrorProblem::OtherBadDocument("line does not end with '\\n'"),
112
            "consdiff",
113
            "",
114
            lno,
115
            None,
116
        )),
117
        GenEdDiffError::ContainsDotLine { lno } => Error::InvalidInput(ParseError::new(
118
            ErrorProblem::OtherBadDocument("contains dotline"),
119
            "consdiff",
120
            "",
121
            lno,
122
            None,
123
        )),
124
        GenEdDiffError::Write(_) => internal!("string write was not infallible?").into(),
125
    })?;
126

            
127
41
    let result = format!(
128
        "network-status-diff-version 1\n\
129
        hash {base_signed_hash} {target_hash}\n\
130
        {base_lines},$d\n\
131
        {ed_diff}"
132
    );
133

            
134
    // Ensure it is valid, refuse to emit an invalid diff.
135
41
    let check = apply_diff(base, &result, None).map_err(|_| internal!("apply call failed"))?;
136
41
    if check.to_string() != target {
137
        Err(internal!("result does not match?"))?;
138
41
    }
139

            
140
41
    Ok(result)
141
41
}
142

            
143
/// Splits `input` at the first `directory-signature`.
144
#[allow(clippy::string_slice)] // TODO
145
41
fn split_directory_signatures(input: &str) -> Result<(&str, &str)> {
146
41
    let parse_input = ParseInput::new(input, "");
147
41
    let mut items = ItemStream::new(&parse_input)?;
148

            
149
    // Parse the consensus item by item until the first `directory-signature`.
150
    loop {
151
        // We only peek in order to get the proper byte offset.
152
        // This is required because doing next() and breaking in the case of
153
        // a `directory-signature` would then lead to `.byte_offset()` yielding
154
        // the start of the second signature and not the start of the first one.
155
4816
        let item = items
156
4816
            .peek_keyword()
157
4816
            .map_err(|e| ParseError::new(e, "consdiff", "", items.lno_for_error(), None))?;
158

            
159
4816
        match item {
160
4816
            Some(DIRECTORY_SIGNATURE_KEYWORD) => {
161
41
                let offset = items.byte_position();
162
41
                return Ok((&input[..offset], &input[offset..]));
163
            }
164
4775
            Some(_) => {
165
4775
                // Consume the just peeked item.
166
4775
                let _ = items.next();
167
4775
            }
168
            None => {
169
                // We are finished.
170
                return Err(Error::InvalidInput(ParseError::new(
171
                    ErrorProblem::MissingItem {
172
                        keyword: DIRECTORY_SIGNATURE_KEYWORD.as_str(),
173
                    },
174
                    "consdiff",
175
                    "",
176
                    items.lno_for_error(),
177
                    None,
178
                )));
179
            }
180
        }
181
    }
182
41
}
183

            
184
/// Generates an input agnostic ed diff.
185
///
186
/// This function does the general logic of [`gen_cons_diff()`] but works in a
187
/// document agnostic fashion.
188
51
fn gen_ed_diff(base: &str, target: &str) -> std::result::Result<String, GenEdDiffError> {
189
51
    let mut result = String::new();
190

            
191
    // We use Myers' algorithm as benchmarks have shown that it provides an
192
    // equal diff size as the ctor one while keeping an acceptable performance.
193
51
    let input = InternedInput::new(base, target);
194
51
    let mut diff = Diff::compute(Algorithm::Myers, &input);
195
51
    diff.postprocess_lines(&input);
196

            
197
    // Iterate through every a hunk, with a hunk being a block of changes.
198
51
    let hunks = diff.hunks().collect::<Vec<_>>();
199
941
    for hunk in hunks.into_iter().rev() {
200
        // Format the header.
201
941
        let hunk_type = HunkType::determine(&hunk);
202
941
        match hunk_type {
203
            // No need to do +1 because append is AFTER.
204
217
            HunkType::Append => writeln!(result, "{}{hunk_type}", hunk.before.start)?,
205
            HunkType::Delete | HunkType::Change => {
206
724
                if hunk.before.start + 1 == hunk.before.end {
207
                    // +1 because 1-indexed.
208
218
                    writeln!(result, "{}{hunk_type}", hunk.before.start + 1)?;
209
                } else {
210
                    // +1 because 1-indexed; no need to do +1 on end because
211
                    // the range is inclusive.
212
506
                    writeln!(
213
506
                        result,
214
                        "{},{}{hunk_type}",
215
506
                        hunk.before.start + 1,
216
                        hunk.before.end
217
                    )?;
218
                }
219
            }
220
        }
221

            
222
        // Format the body.
223
941
        match hunk_type {
224
            HunkType::Append | HunkType::Change => {
225
756
                let range = (hunk.after.start)..(hunk.after.end);
226
756
                let tlines = range
227
3040
                    .map(|idx| {
228
2802
                        let idx = usize::try_from(idx).expect("32-bit static assertion violated?");
229
2802
                        input.interner[input.after[idx]]
230
2802
                    })
231
756
                    .collect::<Vec<_>>();
232

            
233
2788
                for (lno, line) in tlines.iter().copied().enumerate() {
234
                    // Check that all lines end with a Unix line ending.
235
2788
                    if line.ends_with("\r\n") || !line.ends_with("\n") {
236
                        // +1 because 1-indexed.
237
4
                        return Err(GenEdDiffError::MissingUnixLineEnding { lno: lno + 1 });
238
2784
                    }
239

            
240
                    // Check for lines consisting of a single dot plus trailing
241
                    // whitespace characters.  No need to bother about "\r\n",
242
                    // because we checked that one above.  Although technically
243
                    // lines such as `. \n` are possible and understood
244
                    // as part of ed diffs, they are not legal in tor netdocs, and
245
                    // we want to be more defensive here for now; if it becomes a
246
                    // problem, we may remove it later.
247
2784
                    if line.trim_end() == "." {
248
                        // +1 because 1-indexed.
249
4
                        return Err(GenEdDiffError::ContainsDotLine { lno: lno + 1 });
250
2780
                    }
251

            
252
                    // All lines are newline terminated, no need to use writeln!
253
2780
                    write!(result, "{line}")?;
254
                }
255

            
256
                // Write the terminating dot.
257
748
                writeln!(result, ".")?;
258
            }
259
185
            HunkType::Delete => {}
260
        }
261
    }
262

            
263
43
    Ok(result)
264
51
}
265

            
266
/// The operational type of the hunk.
267
#[derive(Clone, Copy, Debug, derive_more::Display)]
268
enum HunkType {
269
    /// This is a pure appending.
270
    #[display("a")]
271
    Append,
272
    /// This is a pure deletion.
273
    #[display("d")]
274
    Delete,
275
    /// This is change with potential additions and deletions.
276
    #[display("c")]
277
    Change,
278
}
279

            
280
impl HunkType {
281
    /// Determines the type of the hunk.
282
941
    fn determine(hunk: &Hunk) -> Self {
283
941
        if hunk.is_pure_insertion() {
284
217
            Self::Append
285
724
        } else if hunk.is_pure_removal() {
286
185
            Self::Delete
287
        } else {
288
539
            Self::Change
289
        }
290
941
    }
291
}
292

            
293
/// Return true if `s` looks more like a consensus diff than some other kind
294
/// of document.
295
148
pub fn looks_like_diff(s: &str) -> bool {
296
148
    s.starts_with("network-status-diff-version")
297
148
}
298

            
299
/// Apply a given diff to an input text, and return the result from applying
300
/// that diff.
301
///
302
/// This is a slow version, for testing and correctness checking.  It uses
303
/// an O(n) operation to apply diffs, and therefore runs in O(n^2) time.
304
#[cfg(any(test, feature = "slow-diff-apply"))]
305
2
pub fn apply_diff_trivial<'a>(input: &'a str, diff: &'a str) -> Result<DiffResult<'a>> {
306
2
    let mut diff_lines = diff.lines();
307
2
    let (_, d2) = parse_diff_header(&mut diff_lines)?;
308

            
309
2
    let mut diffable = DiffResult::from_str(input, d2);
310

            
311
24
    for command in DiffCommandIter::new(diff_lines) {
312
24
        command?.apply_to(&mut diffable)?;
313
    }
314

            
315
2
    Ok(diffable)
316
2
}
317

            
318
/// Apply a given diff to an input text, and return the result from applying
319
/// that diff.
320
///
321
/// If `check_digest_in` is provided, require the diff to say that it
322
/// applies to a document with the provided digest.
323
191
pub fn apply_diff<'a>(
324
191
    input: &'a str,
325
191
    diff: &'a str,
326
191
    check_digest_in: Option<[u8; 32]>,
327
191
) -> Result<DiffResult<'a>> {
328
191
    let mut input = DiffResult::from_str(input, [0; 32]);
329

            
330
191
    let mut diff_lines = diff.lines();
331
191
    let (d1, d2) = parse_diff_header(&mut diff_lines)?;
332
191
    if let Some(d_want) = check_digest_in {
333
74
        if d1 != d_want {
334
            return Err(Error::CantApply("listed digest does not match document"));
335
74
        }
336
117
    }
337

            
338
191
    let mut output = DiffResult::new(d2);
339

            
340
2458
    for command in DiffCommandIter::new(diff_lines) {
341
2458
        command?.apply_transformation(&mut input, &mut output)?;
342
    }
343

            
344
191
    output.push_reversed(&input.lines[..]);
345

            
346
191
    output.lines.reverse();
347
191
    Ok(output)
348
191
}
349

            
350
/// Given a line iterator, check to make sure the first two lines are
351
/// a valid diff header as specified in dir-spec.txt.
352
213
fn parse_diff_header<'a, I>(iter: &mut I) -> Result<([u8; 32], [u8; 32])>
353
213
where
354
213
    I: Iterator<Item = &'a str>,
355
{
356
213
    let line1 = iter.next();
357
213
    if line1 != Some("network-status-diff-version 1") {
358
6
        return Err(Error::BadDiff("unrecognized or missing header"));
359
207
    }
360
207
    let line2 = iter.next().ok_or(Error::BadDiff("header truncated"))?;
361
205
    if !line2.starts_with("hash ") {
362
2
        return Err(Error::BadDiff("missing 'hash' line"));
363
203
    }
364
203
    let elts: Vec<_> = line2.split_ascii_whitespace().collect();
365
203
    if elts.len() != 3 {
366
2
        return Err(Error::BadDiff("invalid 'hash' line"));
367
201
    }
368
201
    let d1 = hex::decode(elts[1])?;
369
197
    let d2 = hex::decode(elts[2])?;
370
197
    match (d1.try_into(), d2.try_into()) {
371
195
        (Ok(a), Ok(b)) => Ok((a, b)),
372
2
        _ => Err(Error::BadDiff("wrong digest lengths on 'hash' line")),
373
    }
374
213
}
375

            
376
/// A command that can appear in a diff.  Each command tells us to
377
/// remove zero or more lines, and insert zero or more lines in their
378
/// place.
379
///
380
/// Commands refer to lines by 1-indexed line number.
381
#[derive(Clone, Debug)]
382
enum DiffCommand<'a> {
383
    /// Remove the lines from low through high, inclusive.
384
    Delete {
385
        /// The first line to remove
386
        low: usize,
387
        /// The last line to remove
388
        high: usize,
389
    },
390
    /// Remove the lines from low through the end of the file, inclusive.
391
    DeleteToEnd {
392
        /// The first line to remove
393
        low: usize,
394
    },
395
    /// Replace the lines from low through high, inclusive, with the
396
    /// lines in 'lines'.
397
    Replace {
398
        /// The first line to replace
399
        low: usize,
400
        /// The last line to replace
401
        high: usize,
402
        /// The text to insert instead
403
        lines: Vec<&'a str>,
404
    },
405
    /// Insert the provided 'lines' after the line with index 'pos'.
406
    Insert {
407
        /// The position after which to insert the text
408
        pos: usize,
409
        /// The text to insert
410
        lines: Vec<&'a str>,
411
    },
412
}
413

            
414
/// The result of applying one or more diff commands to an input string.
415
///
416
/// It refers to lines from the diff and the input by reference, to
417
/// avoid copying.
418
#[derive(Clone, Debug)]
419
pub struct DiffResult<'a> {
420
    /// An expected digest of the output, after it has been assembled.
421
    d_post: [u8; 32],
422
    /// The lines in the output.
423
    lines: Vec<&'a str>,
424
}
425

            
426
/// A possible value for the end of a range.  It can be either a line number,
427
/// or a dollar sign indicating "end of file".
428
#[derive(Clone, Copy, Debug)]
429
enum RangeEnd {
430
    /// A line number in the file.
431
    Num(NonZeroUsize),
432
    /// A dollar sign, indicating "end of file" in a delete command.
433
    DollarSign,
434
}
435

            
436
impl FromStr for RangeEnd {
437
    type Err = Error;
438
1436
    fn from_str(s: &str) -> Result<RangeEnd> {
439
1436
        if s == "$" {
440
123
            Ok(RangeEnd::DollarSign)
441
        } else {
442
1313
            let v: NonZeroUsize = s.parse()?;
443
1311
            if v.get() == usize::MAX {
444
2
                return Err(Error::BadDiff("range cannot end at usize::MAX"));
445
1309
            }
446
1309
            Ok(RangeEnd::Num(v))
447
        }
448
1436
    }
449
}
450

            
451
impl<'a> DiffCommand<'a> {
452
    /// Transform 'target' according to the this command.
453
    ///
454
    /// Because DiffResult internally uses a vector of line, this
455
    /// implementation is potentially O(n) in the size of the input.
456
    #[cfg(any(test, feature = "slow-diff-apply"))]
457
32
    fn apply_to(&self, target: &mut DiffResult<'a>) -> Result<()> {
458
32
        match self {
459
8
            Self::Delete { low, high } => {
460
8
                target.remove_lines(*low, *high)?;
461
            }
462
4
            Self::DeleteToEnd { low } => {
463
4
                target.remove_lines(*low, target.lines.len())?;
464
            }
465
16
            Self::Replace { low, high, lines } => {
466
16
                target.remove_lines(*low, *high)?;
467
16
                target.insert_at(*low, lines)?;
468
            }
469
4
            Self::Insert { pos, lines } => {
470
                // This '+1' seems off, but it's what the spec says. I wonder
471
                // if the spec is wrong.
472
4
                target.insert_at(*pos + 1, lines)?;
473
            }
474
        };
475
32
        Ok(())
476
32
    }
477

            
478
    /// Apply this command to 'input', moving lines into 'output'.
479
    ///
480
    /// This is a more efficient algorithm, but it requires that the
481
    /// diff commands are sorted in reverse order by line
482
    /// number. (Fortunately, the Tor ed diff format guarantees this.)
483
    ///
484
    /// Before calling this method, input and output must contain the
485
    /// results of having applied the previous command in the diff.
486
    /// (When no commands have been applied, input starts out as the
487
    /// original text, and output starts out empty.)
488
    ///
489
    /// This method applies the command by copying unaffected lines
490
    /// from the _end_ of input into output, adding any lines inserted
491
    /// by this command, and finally deleting any affected lines from
492
    /// input.
493
    ///
494
    /// We build the `output` value in reverse order, and then put it
495
    /// back to normal before giving it to the user.
496
2478
    fn apply_transformation(
497
2478
        &self,
498
2478
        input: &mut DiffResult<'a>,
499
2478
        output: &mut DiffResult<'a>,
500
2478
    ) -> Result<()> {
501
2478
        if let Some(succ) = self.following_lines() {
502
2355
            if let Some(subslice) = input.lines.get(succ - 1..) {
503
2351
                // Lines from `succ` onwards are unaffected.  Copy them.
504
2351
                output.push_reversed(subslice);
505
2351
            } else {
506
                // Oops, dubious line number.
507
4
                return Err(Error::CantApply(
508
4
                    "ending line number didn't correspond to document",
509
4
                ));
510
            }
511
123
        }
512

            
513
2474
        if let Some(lines) = self.lines() {
514
1866
            // These are the lines we're inserting.
515
1866
            output.push_reversed(lines);
516
1866
        }
517

            
518
2474
        let remove = self.first_removed_line();
519
2474
        if remove == 0 || (!self.is_insert() && remove > input.lines.len()) {
520
4
            return Err(Error::CantApply(
521
4
                "starting line number didn't correspond to document",
522
4
            ));
523
2470
        }
524
2470
        input.lines.truncate(remove - 1);
525

            
526
2470
        Ok(())
527
2478
    }
528

            
529
    /// Return the lines that we should add to the output
530
2482
    fn lines(&self) -> Option<&[&'a str]> {
531
2482
        match self {
532
1874
            Self::Replace { lines, .. } | Self::Insert { lines, .. } => Some(lines.as_slice()),
533
608
            _ => None,
534
        }
535
2482
    }
536

            
537
    /// Return a mutable reference to the vector of lines we should
538
    /// add to the output.
539
2512
    fn linebuf_mut(&mut self) -> Option<&mut Vec<&'a str>> {
540
2512
        match self {
541
1884
            Self::Replace { lines, .. } | Self::Insert { lines, .. } => Some(lines),
542
628
            _ => None,
543
        }
544
2512
    }
545

            
546
    /// Return the (1-indexed) line number of the first line in the
547
    /// input that comes _after_ this command, and is not affected by it.
548
    ///
549
    /// We use this line number to know which lines we should copy.
550
4976
    fn following_lines(&self) -> Option<usize> {
551
4976
        match self {
552
3830
            Self::Delete { high, .. } | Self::Replace { high, .. } => Some(high + 1),
553
242
            Self::DeleteToEnd { .. } => None,
554
904
            Self::Insert { pos, .. } => Some(pos + 1),
555
        }
556
4976
    }
557

            
558
    /// Return the (1-indexed) line number of the first line that we
559
    /// should clear from the input when processing this command.
560
    ///
561
    /// This can be the same as following_lines(), if we shouldn't
562
    /// actually remove any lines.
563
4966
    fn first_removed_line(&self) -> usize {
564
4966
        match self {
565
982
            Self::Delete { low, .. } => *low,
566
242
            Self::DeleteToEnd { low } => *low,
567
2838
            Self::Replace { low, .. } => *low,
568
904
            Self::Insert { pos, .. } => *pos + 1,
569
        }
570
4966
    }
571

            
572
    /// Return true if this is an Insert command.
573
2472
    fn is_insert(&self) -> bool {
574
2472
        matches!(self, Self::Insert { .. })
575
2472
    }
576

            
577
    /// Extract a single command from a line iterator that yields lines
578
    /// of the diffs.  Return None if we're at the end of the iterator.
579
    #[allow(clippy::string_slice)] // TODO
580
2747
    fn from_line_iterator<I>(iter: &mut I) -> Result<Option<Self>>
581
2747
    where
582
2747
        I: Iterator<Item = &'a str>,
583
    {
584
2747
        let command = match iter.next() {
585
2538
            Some(s) => s,
586
209
            None => return Ok(None),
587
        };
588

            
589
        // `command` can be of these forms: `Rc`, `Rd`, `N,$d`, and `Na`,
590
        // where R is a range of form `N,N`, and where N is a line number.
591

            
592
2538
        if command.len() < 2 || !command.is_ascii() {
593
6
            return Err(Error::BadDiff("command too short"));
594
2532
        }
595

            
596
2532
        let (range, command) = command.split_at(command.len() - 1);
597
2532
        let (low, high) = if let Some(comma_pos) = range.find(',') {
598
            (
599
1438
                range[..comma_pos].parse::<usize>()?,
600
1436
                Some(range[comma_pos + 1..].parse::<RangeEnd>()?),
601
            )
602
        } else {
603
1094
            (range.parse::<usize>()?, None)
604
        };
605

            
606
2520
        if low == usize::MAX {
607
2
            return Err(Error::BadDiff("range cannot begin at usize::MAX"));
608
2518
        }
609

            
610
2518
        match (low, high) {
611
1309
            (lo, Some(RangeEnd::Num(hi))) if lo > hi.into() => {
612
2
                return Err(Error::BadDiff("mis-ordered lines in range"));
613
            }
614
2516
            (_, _) => (),
615
        }
616

            
617
2516
        let mut cmd = match (command, low, high) {
618
2516
            ("d", low, None) => Self::Delete { low, high: low },
619
429
            ("d", low, Some(RangeEnd::Num(high))) => Self::Delete {
620
429
                low,
621
429
                high: high.into(),
622
429
            },
623
121
            ("d", low, Some(RangeEnd::DollarSign)) => Self::DeleteToEnd { low },
624
1888
            ("c", low, None) => Self::Replace {
625
553
                low,
626
553
                high: low,
627
553
                lines: Vec::new(),
628
553
            },
629
876
            ("c", low, Some(RangeEnd::Num(high))) => Self::Replace {
630
876
                low,
631
876
                high: high.into(),
632
876
                lines: Vec::new(),
633
876
            },
634
457
            ("a", low, None) => Self::Insert {
635
455
                pos: low,
636
455
                lines: Vec::new(),
637
455
            },
638
4
            (_, _, _) => return Err(Error::BadDiff("can't parse command line")),
639
        };
640

            
641
2512
        if let Some(ref mut linebuf) = cmd.linebuf_mut() {
642
            // The 'c' and 'a' commands take a series of lines followed by a
643
            // line containing a period.
644
            loop {
645
8966
                match iter.next() {
646
                    None => return Err(Error::BadDiff("unterminated block to insert")),
647
8966
                    Some(".") => break,
648
7082
                    Some(line) => linebuf.push(line),
649
                }
650
            }
651
628
        }
652

            
653
2512
        Ok(Some(cmd))
654
2747
    }
655
}
656

            
657
/// Iterator that wraps a line iterator and returns a sequence of
658
/// `Result<DiffCommand>`.
659
///
660
/// This iterator forces the commands to affect the file in reverse order,
661
/// so that we can use the O(n) algorithm for applying these diffs.
662
struct DiffCommandIter<'a, I>
663
where
664
    I: Iterator<Item = &'a str>,
665
{
666
    /// The underlying iterator.
667
    iter: I,
668

            
669
    /// The 'first removed line' of the last-parsed command; used to ensure
670
    /// that commands appear in reverse order.
671
    last_cmd_first_removed: Option<usize>,
672
}
673

            
674
impl<'a, I> DiffCommandIter<'a, I>
675
where
676
    I: Iterator<Item = &'a str>,
677
{
678
    /// Construct a new DiffCommandIter wrapping `iter`.
679
201
    fn new(iter: I) -> Self {
680
201
        DiffCommandIter {
681
201
            iter,
682
201
            last_cmd_first_removed: None,
683
201
        }
684
201
    }
685
}
686

            
687
impl<'a, I> Iterator for DiffCommandIter<'a, I>
688
where
689
    I: Iterator<Item = &'a str>,
690
{
691
    type Item = Result<DiffCommand<'a>>;
692
2693
    fn next(&mut self) -> Option<Result<DiffCommand<'a>>> {
693
2693
        match DiffCommand::from_line_iterator(&mut self.iter) {
694
            Err(e) => Some(Err(e)),
695
195
            Ok(None) => None,
696
2498
            Ok(Some(c)) => match (self.last_cmd_first_removed, c.following_lines()) {
697
                (Some(_), None) => Some(Err(Error::BadDiff("misordered commands"))),
698
2297
                (Some(a), Some(b)) if a < b => Some(Err(Error::BadDiff("misordered commands"))),
699
                (_, _) => {
700
2492
                    self.last_cmd_first_removed = Some(c.first_removed_line());
701
2492
                    Some(Ok(c))
702
                }
703
            },
704
        }
705
2693
    }
706
}
707

            
708
impl<'a> DiffResult<'a> {
709
    /// Construct a new DiffResult containing the provided string
710
    /// split into lines, and an expected post-transformation digest.
711
201
    fn from_str(s: &'a str, d_post: [u8; 32]) -> Self {
712
        // As per the [netdoc syntax], newlines should be discarded and ignored.
713
        //
714
        // [netdoc syntax]: https://spec.torproject.org/dir-spec/netdoc.html#netdoc-syntax
715
201
        let lines: Vec<_> = s.lines().collect();
716

            
717
201
        DiffResult { d_post, lines }
718
201
    }
719

            
720
    /// Return a new empty DiffResult with an expected
721
    /// post-transformation digests
722
195
    fn new(d_post: [u8; 32]) -> Self {
723
195
        DiffResult {
724
195
            d_post,
725
195
            lines: Vec::new(),
726
195
        }
727
195
    }
728

            
729
    /// Put every member of `lines` at the end of this DiffResult, in
730
    /// reverse order.
731
4412
    fn push_reversed(&mut self, lines: &[&'a str]) {
732
4412
        self.lines.extend(lines.iter().rev());
733
4412
    }
734

            
735
    /// Remove the 1-indexed lines from `first` through `last` inclusive.
736
    ///
737
    /// This has to move elements around within the vector, and so it
738
    /// is potentially O(n) in its length.
739
    #[cfg(any(test, feature = "slow-diff-apply"))]
740
40
    fn remove_lines(&mut self, first: usize, last: usize) -> Result<()> {
741
40
        if first > self.lines.len() || last > self.lines.len() || first == 0 || last == 0 {
742
4
            Err(Error::CantApply("line out of range"))
743
        } else {
744
36
            let n_to_remove = last - first + 1;
745
36
            if last != self.lines.len() {
746
28
                self.lines[..].copy_within((last).., first - 1);
747
28
            }
748
36
            self.lines.truncate(self.lines.len() - n_to_remove);
749
36
            Ok(())
750
        }
751
40
    }
752

            
753
    /// Insert the provided `lines` so that they appear at 1-indexed
754
    /// position `pos`.
755
    ///
756
    /// This has to move elements around within the vector, and so it
757
    /// is potentially O(n) in its length.
758
    #[cfg(any(test, feature = "slow-diff-apply"))]
759
28
    fn insert_at(&mut self, pos: usize, lines: &[&'a str]) -> Result<()> {
760
28
        if pos > self.lines.len() + 1 || pos == 0 {
761
4
            Err(Error::CantApply("position out of range"))
762
        } else {
763
24
            let orig_len = self.lines.len();
764
24
            self.lines.resize(self.lines.len() + lines.len(), "");
765
24
            self.lines
766
24
                .copy_within(pos - 1..orig_len, pos - 1 + lines.len());
767
24
            self.lines[(pos - 1)..(pos + lines.len() - 1)].copy_from_slice(lines);
768
24
            Ok(())
769
        }
770
28
    }
771

            
772
    /// See whether the output of this diff matches the target digest.
773
    ///
774
    /// If not, return an error.
775
76
    pub fn check_digest(&self) -> Result<()> {
776
        use digest::Digest;
777
        use tor_llcrypto::d::Sha3_256;
778
76
        let mut d = Sha3_256::new();
779
362
        for line in &self.lines {
780
362
            d.update(line.as_bytes());
781
362
            d.update(b"\n");
782
362
        }
783
76
        if d.finalize() == self.d_post.into() {
784
39
            Ok(())
785
        } else {
786
37
            Err(Error::CantApply("Wrong digest after applying diff"))
787
        }
788
76
    }
789
}
790

            
791
impl<'a> Display for DiffResult<'a> {
792
208
    fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
793
12345
        for elt in &self.lines {
794
12345
            writeln!(f, "{}", elt)?;
795
        }
796
208
        Ok(())
797
208
    }
798
}
799

            
800
#[cfg(test)]
801
mod test {
802
    // @@ begin test lint list maintained by maint/add_warning @@
803
    #![allow(clippy::bool_assert_comparison)]
804
    #![allow(clippy::clone_on_copy)]
805
    #![allow(clippy::dbg_macro)]
806
    #![allow(clippy::mixed_attributes_style)]
807
    #![allow(clippy::print_stderr)]
808
    #![allow(clippy::print_stdout)]
809
    #![allow(clippy::single_char_pattern)]
810
    #![allow(clippy::unwrap_used)]
811
    #![allow(clippy::unchecked_time_subtraction)]
812
    #![allow(clippy::useless_vec)]
813
    #![allow(clippy::needless_pass_by_value)]
814
    #![allow(clippy::string_slice)] // See arti#2571
815
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
816

            
817
    use rand::seq::IndexedRandom;
818
    use tor_basic_utils::test_rng::testing_rng;
819

            
820
    use super::*;
821

            
822
    #[test]
823
    fn remove() -> Result<()> {
824
        let example = DiffResult::from_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n", [0; 32]);
825

            
826
        let mut d = example.clone();
827
        d.remove_lines(5, 7)?;
828
        assert_eq!(d.to_string(), "1\n2\n3\n4\n8\n9\n");
829

            
830
        let mut d = example.clone();
831
        d.remove_lines(1, 9)?;
832
        assert_eq!(d.to_string(), "");
833

            
834
        let mut d = example.clone();
835
        d.remove_lines(1, 1)?;
836
        assert_eq!(d.to_string(), "2\n3\n4\n5\n6\n7\n8\n9\n");
837

            
838
        let mut d = example.clone();
839
        d.remove_lines(6, 9)?;
840
        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\n");
841

            
842
        let mut d = example.clone();
843
        assert!(d.remove_lines(6, 10).is_err());
844
        assert!(d.remove_lines(0, 1).is_err());
845
        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\n6\n7\n8\n9\n");
846

            
847
        Ok(())
848
    }
849

            
850
    #[test]
851
    fn insert() -> Result<()> {
852
        let example = DiffResult::from_str("1\n2\n3\n4\n5\n", [0; 32]);
853
        let mut d = example.clone();
854
        d.insert_at(3, &["hello", "world"])?;
855
        assert_eq!(d.to_string(), "1\n2\nhello\nworld\n3\n4\n5\n");
856

            
857
        let mut d = example.clone();
858
        d.insert_at(6, &["hello", "world"])?;
859
        assert_eq!(d.to_string(), "1\n2\n3\n4\n5\nhello\nworld\n");
860

            
861
        let mut d = example.clone();
862
        assert!(d.insert_at(0, &["hello", "world"]).is_err());
863
        assert!(d.insert_at(7, &["hello", "world"]).is_err());
864
        Ok(())
865
    }
866

            
867
    #[test]
868
    fn push_reversed() {
869
        let mut d = DiffResult::new([0; 32]);
870
        d.push_reversed(&["7", "8", "9"]);
871
        assert_eq!(d.to_string(), "9\n8\n7\n");
872
        d.push_reversed(&["world", "hello", ""]);
873
        assert_eq!(d.to_string(), "9\n8\n7\n\nhello\nworld\n");
874
    }
875

            
876
    #[test]
877
    fn apply_command_simple() {
878
        let example = DiffResult::from_str("a\nb\nc\nd\ne\nf\n", [0; 32]);
879

            
880
        let mut d = example.clone();
881
        assert_eq!(d.to_string(), "a\nb\nc\nd\ne\nf\n".to_string());
882
        assert!(DiffCommand::DeleteToEnd { low: 5 }.apply_to(&mut d).is_ok());
883
        assert_eq!(d.to_string(), "a\nb\nc\nd\n".to_string());
884

            
885
        let mut d = example.clone();
886
        assert!(
887
            DiffCommand::Delete { low: 3, high: 5 }
888
                .apply_to(&mut d)
889
                .is_ok()
890
        );
891
        assert_eq!(d.to_string(), "a\nb\nf\n".to_string());
892

            
893
        let mut d = example.clone();
894
        assert!(
895
            DiffCommand::Replace {
896
                low: 3,
897
                high: 5,
898
                lines: vec!["hello", "world"]
899
            }
900
            .apply_to(&mut d)
901
            .is_ok()
902
        );
903
        assert_eq!(d.to_string(), "a\nb\nhello\nworld\nf\n".to_string());
904

            
905
        let mut d = example.clone();
906
        assert!(
907
            DiffCommand::Insert {
908
                pos: 3,
909
                lines: vec!["hello", "world"]
910
            }
911
            .apply_to(&mut d)
912
            .is_ok()
913
        );
914
        assert_eq!(
915
            d.to_string(),
916
            "a\nb\nc\nhello\nworld\nd\ne\nf\n".to_string()
917
        );
918
    }
919

            
920
    #[test]
921
    fn parse_command() -> Result<()> {
922
        fn parse(s: &str) -> Result<DiffCommand<'_>> {
923
            let mut iter = s.lines();
924
            let cmd = DiffCommand::from_line_iterator(&mut iter)?;
925
            let cmd2 = DiffCommand::from_line_iterator(&mut iter)?;
926
            if cmd2.is_some() {
927
                panic!("Unexpected second command");
928
            }
929
            Ok(cmd.unwrap())
930
        }
931

            
932
        fn parse_err(s: &str) {
933
            let mut iter = s.lines();
934
            let cmd = DiffCommand::from_line_iterator(&mut iter);
935
            assert!(matches!(cmd, Err(Error::BadDiff(_))));
936
        }
937

            
938
        let p = parse("3,8d\n")?;
939
        assert!(matches!(p, DiffCommand::Delete { low: 3, high: 8 }));
940
        let p = parse("3d\n")?;
941
        assert!(matches!(p, DiffCommand::Delete { low: 3, high: 3 }));
942
        let p = parse("100,$d\n")?;
943
        assert!(matches!(p, DiffCommand::DeleteToEnd { low: 100 }));
944

            
945
        let p = parse("30,40c\nHello\nWorld\n.\n")?;
946
        assert!(matches!(
947
            p,
948
            DiffCommand::Replace {
949
                low: 30,
950
                high: 40,
951
                ..
952
            }
953
        ));
954
        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
955
        let p = parse("30c\nHello\nWorld\n.\n")?;
956
        assert!(matches!(
957
            p,
958
            DiffCommand::Replace {
959
                low: 30,
960
                high: 30,
961
                ..
962
            }
963
        ));
964
        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
965

            
966
        let p = parse("999a\nHello\nWorld\n.\n")?;
967
        assert!(matches!(p, DiffCommand::Insert { pos: 999, .. }));
968
        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
969
        let p = parse("0a\nHello\nWorld\n.\n")?;
970
        assert!(matches!(p, DiffCommand::Insert { pos: 0, .. }));
971
        assert_eq!(p.lines(), Some(&["Hello", "World"][..]));
972

            
973
        parse_err("hello world");
974
        parse_err("\n\n");
975
        parse_err("$,5d");
976
        parse_err("5,6,8d");
977
        parse_err("8,5d");
978
        parse_err("6");
979
        parse_err("d");
980
        parse_err("-10d");
981
        parse_err("4,$c\na\n.");
982
        parse_err("foo");
983
        parse_err("5,10p");
984
        parse_err("18446744073709551615a");
985
        parse_err("1,18446744073709551615d");
986

            
987
        Ok(())
988
    }
989

            
990
    #[test]
991
    fn apply_transformation() -> Result<()> {
992
        let example = DiffResult::from_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n", [0; 32]);
993
        let empty = DiffResult::new([1; 32]);
994

            
995
        let mut inp = example.clone();
996
        let mut out = empty.clone();
997
        DiffCommand::DeleteToEnd { low: 5 }.apply_transformation(&mut inp, &mut out)?;
998
        assert_eq!(inp.to_string(), "1\n2\n3\n4\n");
999
        assert_eq!(out.to_string(), "");
        let mut inp = example.clone();
        let mut out = empty.clone();
        DiffCommand::DeleteToEnd { low: 9 }.apply_transformation(&mut inp, &mut out)?;
        assert_eq!(inp.to_string(), "1\n2\n3\n4\n5\n6\n7\n8\n");
        assert_eq!(out.to_string(), "");
        let mut inp = example.clone();
        let mut out = empty.clone();
        DiffCommand::Delete { low: 3, high: 5 }.apply_transformation(&mut inp, &mut out)?;
        assert_eq!(inp.to_string(), "1\n2\n");
        assert_eq!(out.to_string(), "9\n8\n7\n6\n");
        let mut inp = example.clone();
        let mut out = empty.clone();
        DiffCommand::Replace {
            low: 5,
            high: 6,
            lines: vec!["oh hey", "there"],
        }
        .apply_transformation(&mut inp, &mut out)?;
        assert_eq!(inp.to_string(), "1\n2\n3\n4\n");
        assert_eq!(out.to_string(), "9\n8\n7\nthere\noh hey\n");
        let mut inp = example.clone();
        let mut out = empty.clone();
        DiffCommand::Insert {
            pos: 3,
            lines: vec!["oh hey", "there"],
        }
        .apply_transformation(&mut inp, &mut out)?;
        assert_eq!(inp.to_string(), "1\n2\n3\n");
        assert_eq!(out.to_string(), "9\n8\n7\n6\n5\n4\nthere\noh hey\n");
        DiffCommand::Insert {
            pos: 0,
            lines: vec!["boom!"],
        }
        .apply_transformation(&mut inp, &mut out)?;
        assert_eq!(inp.to_string(), "");
        assert_eq!(
            out.to_string(),
            "9\n8\n7\n6\n5\n4\nthere\noh hey\n3\n2\n1\nboom!\n"
        );
        let mut inp = example.clone();
        let mut out = empty.clone();
        let r = DiffCommand::Delete {
            low: 100,
            high: 200,
        }
        .apply_transformation(&mut inp, &mut out);
        assert!(r.is_err());
        let r = DiffCommand::Delete { low: 5, high: 200 }.apply_transformation(&mut inp, &mut out);
        assert!(r.is_err());
        let r = DiffCommand::Delete { low: 0, high: 1 }.apply_transformation(&mut inp, &mut out);
        assert!(r.is_err());
        let r = DiffCommand::DeleteToEnd { low: 10 }.apply_transformation(&mut inp, &mut out);
        assert!(r.is_err());
        Ok(())
    }
    #[test]
    fn header() -> Result<()> {
        fn header_from(s: &str) -> Result<([u8; 32], [u8; 32])> {
            let mut iter = s.lines();
            parse_diff_header(&mut iter)
        }
        let (a,b) = header_from(
            "network-status-diff-version 1
hash B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663 F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB"
        )?;
        assert_eq!(
            &a[..],
            hex::decode("B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663")?
        );
        assert_eq!(
            &b[..],
            hex::decode("F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB")?
        );
        assert!(header_from("network-status-diff-version 2\n").is_err());
        assert!(header_from("").is_err());
        assert!(header_from("5,$d\n1,2d\n").is_err());
        assert!(header_from("network-status-diff-version 1\n").is_err());
        assert!(
            header_from(
                "network-status-diff-version 1
hash x y
5,5d"
            )
            .is_err()
        );
        assert!(
            header_from(
                "network-status-diff-version 1
hash x y
5,5d"
            )
            .is_err()
        );
        assert!(
            header_from(
                "network-status-diff-version 1
hash AA BB
5,5d"
            )
            .is_err()
        );
        assert!(
            header_from(
                "network-status-diff-version 1
oh hello there
5,5d"
            )
            .is_err()
        );
        assert!(header_from("network-status-diff-version 1
hash B03DA3ACA1D3C1D083E3FF97873002416EBD81A058B406D5C5946EAB53A79663 F6789F35B6B3BA58BB23D29E53A8ED6CBB995543DBE075DD5671481C4BA677FB extra").is_err());
        Ok(())
    }
    #[test]
    fn apply_simple() {
        let pre = include_str!("../testdata/consensus1.txt");
        let diff = include_str!("../testdata/diff1.txt");
        let post = include_str!("../testdata/consensus2.txt");
        let result = apply_diff_trivial(pre, diff).unwrap();
        assert!(result.check_digest().is_ok());
        assert_eq!(result.to_string(), post);
    }
    #[test]
    fn sort_order() -> Result<()> {
        fn cmds(s: &str) -> Result<Vec<DiffCommand<'_>>> {
            let mut out = Vec::new();
            for cmd in DiffCommandIter::new(s.lines()) {
                out.push(cmd?);
            }
            Ok(out)
        }
        let _ = cmds("6,9d\n5,5d\n")?;
        assert!(cmds("5,5d\n6,9d\n").is_err());
        assert!(cmds("5,5d\n6,6d\n").is_err());
        assert!(cmds("5,5d\n5,6d\n").is_err());
        Ok(())
    }
    /// Test for cons diff using a random word generator.
    #[test]
    fn cons_diff() {
        // cat /usr/share/dict/words | sort -R | head -n 20 | sed 's/^/"/g' | sed 's/$/",/g'
        const WORDS: &[&str] = &[
            "citole",
            "aflow",
            "plowfoot",
            "coom",
            "retape",
            "perish",
            "overstifle",
            "ramshackle",
            "Romeo",
            "alme",
            "expressivity",
            "Kieffer",
            "tobe",
            "pronucleus",
            "countersconce",
            "puli",
            "acupunctuate",
            "heterolysis",
            "unwattled",
            "bismerpund",
        ];
        let rng = &mut testing_rng();
        let mut left = (0..1000)
            .map(|_| WORDS.choose(rng).unwrap().to_string() + "\n")
            .collect::<String>();
        left += "directory-signature foo bar\n";
        let mut right = (0..1015)
            .map(|_| WORDS.choose(rng).unwrap().to_string() + "\n")
            .collect::<String>();
        right += "directory-signature foo baz\n";
        let diff = gen_cons_diff(&left, &right).unwrap();
        let check = apply_diff(&left, &diff, None).unwrap().to_string();
        assert_eq!(right, check);
    }
    #[test]
    fn dot_line() {
        let base = "";
        let target = "foo\nbar\n.\nbaz\nfoo\n";
        assert_eq!(
            gen_ed_diff(base, target).unwrap_err(),
            GenEdDiffError::ContainsDotLine { lno: 3 },
        );
        // Also check for dot lines with trailing spaces.
        let target = "foo\nbar\n.   \t \nbaz\nfoo\n";
        assert_eq!(
            gen_ed_diff(base, target).unwrap_err(),
            GenEdDiffError::ContainsDotLine { lno: 3 },
        );
        // A line starting with a dot and not ending in WS shall be fine though.
        let target = "foo\nbar\n.   foo\nbaz\nfoo\n";
        let _ = gen_ed_diff(base, target).unwrap();
        // Use gen_cons_diff here to assume that it is actually applied.
        let base = "directory-signature foo baz\n";
        let target = ".foo bar\n. bar\ndirectory-signature foo baz\n";
        assert_eq!(
            gen_cons_diff(base, target).unwrap(),
            "network-status-diff-version 1\n\
            hash D8138DC27D9A66F5760058A6BCB71B755462B9D26B811828F124D036DE329A58 \
            506AC3A4407BC5305DD0D08FED3F09C2FE69847541F642A8FD13D3BD06FFE432\n\
            1,$d\n\
            0a\n\
            .foo bar\n\
            . bar\n\
            directory-signature foo baz\n\
            .\n"
        );
    }
    #[test]
    fn missing_newline() {
        let base = "";
        let target = "foo\nbar\nbaz";
        assert_eq!(
            gen_ed_diff(base, target).unwrap_err(),
            GenEdDiffError::MissingUnixLineEnding { lno: 3 }
        );
    }
    #[test]
    fn mixed_with_crlf() {
        let base = "";
        let target = "foo\r\nbar\r\nbaz\nhello\r\n";
        assert_eq!(
            gen_ed_diff(base, target).unwrap_err(),
            GenEdDiffError::MissingUnixLineEnding { lno: 1 }
        );
    }
}