Skip to main content

mdzip_core/
writer.rs

1//! Writing `.mdz` archives as a conforming producer (Section 14.1).
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::time::SystemTime;
6
7use serde_json::{Map, Value};
8
9use crate::archive::{MANIFEST_NAME, MdzArchive};
10use crate::entrypoint::resolve_entry_point;
11use crate::error::{ErrorCode, MdzError, Result, err};
12use crate::filters::{DEFAULT_FILTERS, matches_filters};
13use crate::manifest::{
14    Manifest, Mode, SPEC_NAME, SPEC_VERSION, entry_point_value_problem, parse_manifest, serialize_manifest,
15    set_modified,
16};
17use crate::paths::{find_path, is_markdown, nfc, strip_dot_slash, validate_entry_set, validate_path};
18use crate::time::Utc;
19use crate::zip::{WriteEntry, write_zip};
20
21/// This library, as written in `producer.core` (Section 6.1).
22pub const PRODUCER_CORE_NAME: &str = "mdzip-core-rust";
23const PRODUCER_CORE_URL: &str = "https://github.com/mdzip-project/mdzip-core-rust";
24
25fn producer_core() -> Value {
26    let mut core = Map::new();
27    core.insert("name".into(), PRODUCER_CORE_NAME.into());
28    core.insert("version".into(), env!("CARGO_PKG_VERSION").into());
29    core.insert("url".into(), PRODUCER_CORE_URL.into());
30    Value::Object(core)
31}
32
33/// The application writing the archive, recorded in `producer.application`.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct Application {
36    /// Application name, for example `"Marco"`.
37    pub name: String,
38    /// Application version.
39    pub version: Option<String>,
40    /// Application home page.
41    pub url: Option<String>,
42}
43
44impl Application {
45    /// An application with a name and version.
46    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
47        Application {
48            name: name.into(),
49            version: Some(version.into()),
50            url: None,
51        }
52    }
53
54    /// Set the home page.
55    pub fn url(mut self, url: impl Into<String>) -> Self {
56        self.url = Some(url.into());
57        self
58    }
59
60    fn to_value(&self) -> Value {
61        let mut o = Map::new();
62        o.insert("name".into(), self.name.clone().into());
63        if let Some(v) = &self.version {
64            o.insert("version".into(), v.clone().into());
65        }
66        if let Some(u) = &self.url {
67            o.insert("url".into(), u.clone().into());
68        }
69        Value::Object(o)
70    }
71}
72
73/// Options for [`create`].
74#[derive(Clone, Debug, Default)]
75pub struct CreateOptions {
76    /// Title. Defaults to the manifest's, then the entry point's first
77    /// heading, then its file name.
78    pub title: Option<String>,
79    /// Mode. Defaults to the manifest's, then `document`.
80    pub mode: Option<Mode>,
81    /// Entry point. Defaults to the manifest's, then discovery (Section 5.5).
82    pub entry_point: Option<String>,
83    /// Starting manifest: its fields are kept, with this library's required
84    /// fields set over them.
85    pub manifest: Option<Manifest>,
86    /// The application writing the archive.
87    pub application: Option<Application>,
88    /// Timestamp for `created`, `modified` and the ZIP entries. Defaults to now.
89    pub when: Option<SystemTime>,
90    /// Keep CRLF in Markdown files. By default Markdown files and the
91    /// manifest get LF line endings; other files are always written as given
92    /// (Section 12.2).
93    pub keep_line_endings: bool,
94    /// Store every file without compression.
95    pub store_only: bool,
96}
97
98impl CreateOptions {
99    /// Default options.
100    pub fn new() -> Self {
101        Self::default()
102    }
103    /// Set the title.
104    pub fn title(mut self, title: impl Into<String>) -> Self {
105        self.title = Some(title.into());
106        self
107    }
108    /// Set the mode.
109    pub fn mode(mut self, mode: Mode) -> Self {
110        self.mode = Some(mode);
111        self
112    }
113    /// Set the entry point.
114    pub fn entry_point(mut self, path: impl Into<String>) -> Self {
115        self.entry_point = Some(path.into());
116        self
117    }
118    /// Set the starting manifest.
119    pub fn manifest(mut self, manifest: Manifest) -> Self {
120        self.manifest = Some(manifest);
121        self
122    }
123    /// Set the application.
124    pub fn application(mut self, application: Application) -> Self {
125        self.application = Some(application);
126        self
127    }
128    /// Set the timestamp.
129    pub fn when(mut self, when: SystemTime) -> Self {
130        self.when = Some(when);
131        self
132    }
133    /// Keep CRLF line endings in Markdown files.
134    pub fn keep_line_endings(mut self) -> Self {
135        self.keep_line_endings = true;
136        self
137    }
138    /// Store files without compression.
139    pub fn store_only(mut self) -> Self {
140        self.store_only = true;
141        self
142    }
143}
144
145/// Markdown files and the manifest get LF line endings (Section 12.2).
146fn gets_lf(path: &str) -> bool {
147    is_markdown(path) || path == MANIFEST_NAME
148}
149
150fn to_lf(data: &[u8]) -> Vec<u8> {
151    let mut out = Vec::with_capacity(data.len());
152    let mut i = 0;
153    while i < data.len() {
154        if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') {
155            i += 1;
156            continue;
157        }
158        out.push(data[i]);
159        i += 1;
160    }
161    out
162}
163
164/// NFC paths, validated (Section 5.4); no two equal after NFC. Keeps order.
165fn normalize_files<P, C>(
166    files: impl IntoIterator<Item = (P, C)>,
167    keep_line_endings: bool,
168) -> Result<Vec<(String, Vec<u8>)>>
169where
170    P: AsRef<str>,
171    C: AsRef<[u8]>,
172{
173    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
174    let mut seen = HashSet::new();
175    for (raw, content) in files {
176        let raw = raw.as_ref();
177        // Archive paths, never OS paths: a backslash is invalid (Section 5.4).
178        let path = nfc(raw);
179        validate_path(&path)?;
180        if path.ends_with('/') {
181            return err(
182                ErrorCode::PathInvalid,
183                format!("'{raw}' ends in '/'; a file path can't name a directory"),
184            );
185        }
186        if !seen.insert(path.clone()) {
187            return err(
188                ErrorCode::DuplicatePath,
189                format!("'{raw}' duplicates another path after NFC normalization"),
190            );
191        }
192        let content = content.as_ref();
193        let data = if !keep_line_endings && gets_lf(&path) {
194            to_lf(content)
195        } else {
196            content.to_vec()
197        };
198        out.push((path, data));
199    }
200    Ok(out)
201}
202
203/// The first ATX level-1 heading's text, if any.
204fn first_heading(markdown: &[u8]) -> Option<String> {
205    let text = String::from_utf8_lossy(markdown);
206    text.lines().find_map(|line| {
207        let rest = line.strip_prefix('#')?;
208        if !rest.starts_with([' ', '\t']) {
209            return None;
210        }
211        let mut title = rest.trim();
212        // An optional closing sequence of #s, preceded by a space.
213        let without = title.trim_end_matches('#');
214        if without.len() != title.len() && (without.is_empty() || without.ends_with([' ', '\t'])) {
215            title = without.trim_end();
216        }
217        (!title.is_empty()).then(|| title.to_owned())
218    })
219}
220
221fn file_stem(path: &str) -> String {
222    let name = path.rsplit('/').next().unwrap_or(path);
223    match name.rfind('.') {
224        Some(i) if i > 0 => name[..i].to_owned(),
225        _ => name.to_owned(),
226    }
227}
228
229fn choose_entry_point(files: &[(String, Vec<u8>)], requested: Option<&Value>) -> Result<String> {
230    let names = files.iter().map(|(p, _)| p.as_str());
231    match requested {
232        Some(value) => {
233            if let Some(problem) = entry_point_value_problem(value) {
234                return err(ErrorCode::EntryPointInvalid, problem);
235            }
236            let wanted = value.as_str().unwrap_or_default();
237            find_path(names, strip_dot_slash(wanted))
238                .map(str::to_owned)
239                .ok_or_else(|| {
240                    MdzError::new(
241                        ErrorCode::EntryPointMissing,
242                        format!("entry point '{wanted}' is not among the files"),
243                    )
244                })
245        }
246        None => resolve_entry_point(names, None),
247    }
248}
249
250/// Build a new `.mdz` archive and return its bytes.
251///
252/// `files` are archive paths and their content, written in order. The
253/// archive always gets a `manifest.json` (Section 14.1) with `spec`,
254/// `producer`, `title`, `mode`, `entryPoint`, `created` and `modified`;
255/// fields from [`CreateOptions::manifest`] are kept. A `manifest.json` among
256/// `files` is used as the starting manifest when none is given.
257///
258/// Paths are written in NFC with the UTF-8 flag. Fails for an invalid or
259/// duplicate path, a file that's also a directory, an entry point that's
260/// invalid, missing or ambiguous, or an unknown mode.
261///
262/// ```
263/// let data = mdzip_core::create(
264///     [("index.md", "# Hello\n\n![logo](images/logo.png)\n".as_bytes()), ("images/logo.png", b"\x89PNG")],
265///     &mdzip_core::CreateOptions::new(),
266/// )?;
267/// let archive = mdzip_core::MdzArchive::open(data)?;
268/// assert_eq!(archive.entry_point()?, "index.md");
269/// assert_eq!(archive.title(), Some("Hello"));
270/// # Ok::<(), mdzip_core::MdzError>(())
271/// ```
272pub fn create<P, C>(files: impl IntoIterator<Item = (P, C)>, options: &CreateOptions) -> Result<Vec<u8>>
273where
274    P: AsRef<str>,
275    C: AsRef<[u8]>,
276{
277    let mut contents = normalize_files(files, options.keep_line_endings)?;
278    let mut base: Manifest = options.manifest.clone().unwrap_or_default();
279    if let Some(i) = contents.iter().position(|(p, _)| p == MANIFEST_NAME) {
280        let (_, given) = contents.remove(i);
281        if options.manifest.is_none() {
282            base = parse_manifest(&given)?.manifest;
283        }
284    }
285
286    let mode = match options.mode {
287        Some(m) => m,
288        None => match base.get("mode") {
289            None => Mode::Document,
290            Some(v) => v
291                .as_str()
292                .and_then(Mode::parse)
293                .ok_or_else(|| MdzError::new(ErrorCode::ModeUnsupported, "mode must be \"document\" or \"project\""))?,
294        },
295    };
296    let requested = options.entry_point.clone().map(Value::String);
297    let entry_point = choose_entry_point(&contents, requested.as_ref().or_else(|| base.get("entryPoint")))?;
298    let stamp = Utc::from_system_time(options.when.unwrap_or_else(SystemTime::now));
299    let iso = stamp.rfc3339();
300
301    let mut out = Manifest::new();
302    let mut spec = base.get("spec").and_then(Value::as_object).cloned().unwrap_or_default();
303    spec.insert("name".into(), SPEC_NAME.into());
304    spec.insert("version".into(), SPEC_VERSION.into());
305    out.insert("spec".into(), Value::Object(spec));
306    let mut producer = base
307        .get("producer")
308        .and_then(Value::as_object)
309        .cloned()
310        .unwrap_or_default();
311    producer.insert("core".into(), producer_core());
312    if let Some(app) = &options.application {
313        producer.insert("application".into(), app.to_value());
314    }
315    out.insert("producer".into(), Value::Object(producer));
316    let title = options
317        .title
318        .clone()
319        .or_else(|| {
320            base.get("title")
321                .and_then(Value::as_str)
322                .filter(|s| !s.is_empty())
323                .map(str::to_owned)
324        })
325        .or_else(|| {
326            let data = &contents.iter().find(|(p, _)| *p == entry_point)?.1;
327            first_heading(data)
328        })
329        .unwrap_or_else(|| file_stem(&entry_point));
330    out.insert("title".into(), title.into());
331    out.insert("mode".into(), mode.as_str().into());
332    out.insert("entryPoint".into(), entry_point.into());
333    for (k, v) in &base {
334        if !out.contains_key(k) {
335            out.insert(k.clone(), v.clone());
336        }
337    }
338    if !out.contains_key("created") {
339        let mut created = Map::new();
340        created.insert("when".into(), iso.clone().into());
341        out.insert("created".into(), Value::Object(created));
342    }
343    set_modified(&mut out, &iso);
344
345    let date = stamp.dos();
346    let compress = !options.store_only;
347    let mut entries = vec![WriteEntry::file(
348        MANIFEST_NAME.into(),
349        &serialize_manifest(&out),
350        compress,
351        date,
352    )];
353    for (path, data) in &contents {
354        entries.push(WriteEntry::file(path.clone(), data, compress, date));
355    }
356    finish(&entries)
357}
358
359fn finish(entries: &[WriteEntry]) -> Result<Vec<u8>> {
360    validate_entry_set(entries.iter().map(|e| e.name.as_str()))?;
361    // A ZIP name length is 16 bits (Section 4).
362    if let Some(e) = entries.iter().find(|e| e.name.len() > 0xFFFF) {
363        return err(
364            ErrorCode::PathInvalid,
365            format!("a path of {} bytes is longer than ZIP allows (65,535)", e.name.len()),
366        );
367    }
368    Ok(write_zip(entries))
369}
370
371/// Pack a folder into an `.mdz`, with paths relative to `directory`.
372///
373/// Only files matching `filters` are included ([`DEFAULT_FILTERS`] when
374/// `None`: Markdown and common images; pass `Some(&["**"])` for everything).
375/// Symbolic links are skipped. A `manifest.json` in the folder is the
376/// starting manifest unless `options` has one.
377pub fn create_from_directory(
378    directory: impl AsRef<Path>,
379    filters: Option<&[&str]>,
380    options: &CreateOptions,
381) -> Result<Vec<u8>> {
382    let root = directory.as_ref();
383    let filters = filters.unwrap_or(&DEFAULT_FILTERS);
384    let mut files = Vec::new();
385    let mut existing = None;
386    let io = |e: std::io::Error, p: &Path| MdzError::new(ErrorCode::Io, format!("'{}': {e}", p.display()));
387    let mut stack = vec![root.to_path_buf()];
388    while let Some(dir) = stack.pop() {
389        let mut items: Vec<_> = std::fs::read_dir(&dir)
390            .map_err(|e| io(e, &dir))?
391            .collect::<std::io::Result<_>>()
392            .map_err(|e| io(e, &dir))?;
393        items.sort_by_key(|e| e.file_name());
394        for item in items.into_iter().rev() {
395            let path = item.path();
396            let kind = item.file_type().map_err(|e| io(e, &path))?;
397            if kind.is_symlink() {
398                continue;
399            }
400            if kind.is_dir() {
401                stack.push(path);
402                continue;
403            }
404            let rel = path
405                .strip_prefix(root)
406                .unwrap_or(&path)
407                .components()
408                .map(|c| c.as_os_str().to_string_lossy())
409                .collect::<Vec<_>>()
410                .join("/");
411            if rel == MANIFEST_NAME {
412                existing = Some(parse_manifest(&std::fs::read(&path).map_err(|e| io(e, &path))?)?.manifest);
413            } else if matches_filters(&rel, filters) {
414                files.push((rel, std::fs::read(&path).map_err(|e| io(e, &path))?));
415            }
416        }
417    }
418    files.sort_by(|a, b| a.0.cmp(&b.0));
419    let mut options = options.clone();
420    if options.manifest.is_none() {
421        options.manifest = existing;
422    }
423    create(files, &options)
424}
425
426/// Changes for [`MdzArchive::update`]. Build one with the methods below.
427///
428/// ```
429/// # let data = mdzip_core::create([("index.md", "# Hi\n")], &Default::default())?;
430/// let archive = mdzip_core::MdzArchive::open(data)?;
431/// let changes = mdzip_core::Update::new()
432///     .write("images/logo.png", b"\x89PNG")
433///     .set("title", "New title");
434/// let updated = mdzip_core::MdzArchive::open(archive.update(&changes)?)?;
435/// assert_eq!(updated.title(), Some("New title"));
436/// # Ok::<(), mdzip_core::MdzError>(())
437/// ```
438#[derive(Clone, Debug, Default)]
439pub struct Update {
440    write: Vec<(String, Vec<u8>)>,
441    remove: Vec<String>,
442    fields: Vec<(String, Option<Value>)>,
443    entry_point: Option<String>,
444    when: Option<SystemTime>,
445    keep_line_endings: bool,
446    drop_special_files: bool,
447}
448
449impl Update {
450    /// No changes: saving keeps everything and refreshes `modified`.
451    pub fn new() -> Self {
452        Self::default()
453    }
454    /// Add or replace a file.
455    pub fn write(mut self, path: impl Into<String>, content: impl AsRef<[u8]>) -> Self {
456        self.write.push((path.into(), content.as_ref().to_vec()));
457        self
458    }
459    /// Remove a file, directory entry or symbolic link.
460    pub fn remove(mut self, path: impl Into<String>) -> Self {
461        self.remove.push(path.into());
462        self
463    }
464    /// Set a top-level manifest field.
465    pub fn set(mut self, field: impl Into<String>, value: impl Into<Value>) -> Self {
466        self.fields.push((field.into(), Some(value.into())));
467        self
468    }
469    /// Delete a top-level manifest field.
470    pub fn unset(mut self, field: impl Into<String>) -> Self {
471        self.fields.push((field.into(), None));
472        self
473    }
474    /// Change the entry point. Without this, an update that would change
475    /// which file the entry point resolves to fails.
476    pub fn entry_point(mut self, path: impl Into<String>) -> Self {
477        self.entry_point = Some(path.into());
478        self
479    }
480    /// Timestamp for `modified` and written entries. Defaults to now.
481    pub fn when(mut self, when: SystemTime) -> Self {
482        self.when = Some(when);
483        self
484    }
485    /// Keep CRLF in written Markdown files.
486    pub fn keep_line_endings(mut self) -> Self {
487        self.keep_line_endings = true;
488        self
489    }
490    /// Drop symbolic link entries (when the user agrees, Section 14.1).
491    pub fn drop_special_files(mut self) -> Self {
492        self.drop_special_files = true;
493        self
494    }
495}
496
497enum Kept<'a> {
498    Copy(&'a crate::zip::ZipEntry),
499    New(Vec<u8>),
500}
501
502impl MdzArchive {
503    /// Apply `changes` and return the new archive bytes (Section 14.1). The
504    /// archive itself isn't changed.
505    ///
506    /// - Entries not written or removed are copied as stored: same bytes,
507    ///   compression, timestamp and attributes. Each is checked (CRC-32,
508    ///   limits) before it's copied.
509    /// - Directory entries, such as empty folders, are kept.
510    /// - Symbolic links are kept unless removed by name or with
511    ///   [`Update::drop_special_files`]; the archive then stays
512    ///   non-conforming and the warning still applies. Writing a file at the
513    ///   same path replaces the link.
514    /// - The manifest keeps every field, including unknown ones; it's added
515    ///   if missing, with the entry point the archive resolved to before.
516    /// - The entry point must resolve to the same file afterwards unless
517    ///   [`Update::entry_point`] names a new one; otherwise
518    ///   [`ErrorCode::EntryPointChanged`].
519    pub fn update(&self, changes: &Update) -> Result<Vec<u8>> {
520        let before = self.entry_point().ok();
521        let writes = normalize_files(changes.write.iter().map(|(p, c)| (p, c)), changes.keep_line_endings)?;
522        if writes.iter().any(|(p, _)| p == MANIFEST_NAME) {
523            return err(
524                ErrorCode::ManifestInvalid,
525                "change the manifest with Update::set, not by writing manifest.json",
526            );
527        }
528        let mut removals = HashSet::new();
529        for path in &changes.remove {
530            let stored = self
531                .find(path)
532                .or_else(|| find_path(self.special_files(), path))
533                .or_else(|| self.find_directory(path))
534                .ok_or_else(|| MdzError::new(ErrorCode::NotFound, format!("'{path}' is not in the archive")))?;
535            removals.insert(nfc(stored));
536        }
537        let mut write_map: HashMap<String, Vec<u8>> = HashMap::new();
538        let mut write_order = Vec::new();
539        for (p, data) in writes {
540            write_order.push(p.clone());
541            write_map.insert(p, data);
542        }
543
544        // Kept entries in archive order, replaced in place; then new files.
545        let mut entries: Vec<(String, Kept)> = Vec::new();
546        let mut placed = HashSet::new();
547        for e in &self.files {
548            let key = nfc(&e.name);
549            if e.name == MANIFEST_NAME || removals.contains(&key) {
550                continue;
551            }
552            if let Some(data) = write_map.get(&key) {
553                entries.push((key.clone(), Kept::New(data.clone())));
554                placed.insert(key);
555            } else {
556                self.read_entry(e)?; // never copy corrupt data
557                entries.push((e.name.clone(), Kept::Copy(e)));
558            }
559        }
560        for e in &self.directories {
561            if !removals.contains(&nfc(&e.name)) {
562                entries.push((e.name.clone(), Kept::Copy(e)));
563            }
564        }
565        let mut kept_special = HashSet::new();
566        if !changes.drop_special_files {
567            for e in &self.special {
568                let key = nfc(&e.name);
569                if !removals.contains(&key) && !write_map.contains_key(&key) {
570                    entries.push((e.name.clone(), Kept::Copy(e)));
571                    kept_special.insert(e.name.clone());
572                }
573            }
574        }
575        for p in &write_order {
576            if !placed.contains(p) {
577                entries.push((p.clone(), Kept::New(write_map[p].clone())));
578            }
579        }
580
581        let stamp = Utc::from_system_time(changes.when.unwrap_or_else(SystemTime::now));
582        let iso = stamp.rfc3339();
583        let mut manifest = self.manifest().cloned().unwrap_or_default();
584        if self.manifest().is_none() {
585            let mut spec = Map::new();
586            spec.insert("name".into(), SPEC_NAME.into());
587            spec.insert("version".into(), SPEC_VERSION.into());
588            manifest.insert("spec".into(), Value::Object(spec));
589            let mut producer = Map::new();
590            producer.insert("core".into(), producer_core());
591            manifest.insert("producer".into(), Value::Object(producer));
592            if let Some(ep) = &before {
593                manifest.insert("entryPoint".into(), ep.clone().into());
594            }
595        }
596        for (field, value) in &changes.fields {
597            match value {
598                Some(v) => {
599                    manifest.insert(field.clone(), v.clone());
600                }
601                None => {
602                    manifest.shift_remove(field);
603                }
604            }
605        }
606        if let Some(ep) = &changes.entry_point {
607            manifest.insert("entryPoint".into(), ep.clone().into());
608        }
609        set_modified(&mut manifest, &iso);
610
611        // Links and directories are never documents.
612        let paths = entries
613            .iter()
614            .map(|(p, _)| p.as_str())
615            .filter(|p| !kept_special.contains(*p) && !p.ends_with('/'));
616        let after = resolve_entry_point(paths, Some(&manifest)).map_err(|e| {
617            MdzError::new(
618                ErrorCode::EntryPointChanged,
619                format!("the update would leave the entry point unresolvable ({})", e.message()),
620            )
621        })?;
622        if changes.entry_point.is_none() {
623            if let Some(before) = &before {
624                if nfc(&after) != nfc(before) {
625                    return err(
626                        ErrorCode::EntryPointChanged,
627                        format!("the update would change the entry point from '{before}' to '{after}'"),
628                    );
629                }
630            }
631        }
632
633        let date = stamp.dos();
634        let mut out = vec![WriteEntry::file(
635            MANIFEST_NAME.into(),
636            &serialize_manifest(&manifest),
637            true,
638            date,
639        )];
640        for (path, kept) in entries {
641            out.push(match kept {
642                Kept::Copy(e) => WriteEntry::copy(path, e, self.as_bytes()),
643                Kept::New(data) => WriteEntry::file(path, &data, true, date),
644            });
645        }
646        finish(&out)
647    }
648}
649
650/// Open `data`, apply `changes` and return the new bytes. See
651/// [`MdzArchive::update`].
652pub fn update(data: impl Into<Vec<u8>>, changes: &Update) -> Result<Vec<u8>> {
653    MdzArchive::open(data)?.update(changes)
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn headings() {
662        assert_eq!(first_heading(b"intro\n# Guide ##\n").as_deref(), Some("Guide"));
663        assert_eq!(first_heading(b"# C#\n").as_deref(), Some("C#"));
664        assert_eq!(first_heading(b"#Tag\n## Sub\n").as_deref(), None);
665    }
666
667    #[test]
668    fn crlf_becomes_lf() {
669        assert_eq!(to_lf(b"a\r\nb\rc\n"), b"a\nb\rc\n");
670    }
671}