tectonic_io_base/
lib.rs

1// Copyright 2016-2021 the Tectonic Project
2// Licensed under the MIT License.
3
4#![deny(missing_docs)]
5
6//! Tectonic’s pluggable I/O backend.
7//!
8//! This crates defines the core traits and types used by Tectonic’s I/O
9//! subsystem, and provides implementations for common stdlib types. It provides
10//! a simplified I/O model compatible with TeX’s usage patterns, as encapsulated
11//! in the [`IoProvider`] trait. Files are exposed as [`InputHandle`] or
12//! [`OutputHandle`] structs, which add a layer of bookkeeping to allow the
13//! higher levels of Tectonic to determine when the engine needs to be re-run.
14
15use sha2::Digest;
16use std::{
17    borrow::Cow,
18    fs::File,
19    io::{self, Cursor, Read, Seek, SeekFrom, Write},
20    path::{Path, PathBuf},
21};
22use tectonic_errors::{
23    anyhow::{bail, ensure},
24    Error, Result,
25};
26use tectonic_status_base::StatusBackend;
27use thiserror::Error as ThisError;
28
29use crate::digest::DigestData;
30
31pub mod app_dirs;
32pub mod digest;
33pub mod filesystem;
34pub mod flate2;
35pub mod stack;
36pub mod stdstreams;
37
38/// Errors that are generic to Tectonic's framework, but not capturable as
39/// IoErrors.
40#[derive(ThisError, Debug)]
41pub enum TectonicIoError {
42    /// The stream is not seekable.
43    #[error("cannot seek within this stream")]
44    NotSeekable,
45
46    /// The stream does not have a fixed size.
47    #[error("cannot obtain the size of this stream")]
48    NotSizeable,
49
50    /// Access to this path is forbidden.
51    #[error("access to the path `{}` is forbidden", .0.display())]
52    PathForbidden(PathBuf),
53}
54
55/// An extension to the basic Read trait supporting additional features
56/// needed for Tectonic's I/O system.
57pub trait InputFeatures: Read {
58    /// Get the size of the stream. Return `TectonicIoError::NotSizeable`
59    /// if the operation is not well-defined for this stream.
60    fn get_size(&mut self) -> Result<usize>;
61
62    /// Try to seek within the stream. Return `TectonicIoError::NotSeekable`
63    /// if the operation is not possible for this stream.
64    fn try_seek(&mut self, pos: SeekFrom) -> Result<u64>;
65
66    /// Get the modification time of this file as a Unix time. If that quantity
67    /// is not meaningfully defined for this input, return `Ok(None)`. This is
68    /// what the default implementation does.
69    fn get_unix_mtime(&mut self) -> Result<Option<i64>> {
70        Ok(None)
71    }
72}
73
74/// What kind of source an input file ultimately came from. We keep track of
75/// this in order to be able to emit Makefile-style dependencies for input
76/// files. Right now, we only provide enough options to achieve this goal; we
77/// could add more.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum InputOrigin {
80    /// This file lives on the filesystem and might change under us. (That is,
81    /// it is not a cached bundle file.)
82    Filesystem,
83
84    /// This file was never used as an input.
85    NotInput,
86
87    /// This file is none of the above.
88    Other,
89}
90
91/// Input handles are basically Read objects with a few extras. We don't
92/// require the standard io::Seek because we need to provide a dummy
93/// implementation for GZip streams, which we wouldn't be allowed to do
94/// because both the trait and the target struct are outside of our crate.
95///
96/// An important role for the InputHandle struct is computing a cryptographic
97/// digest of the input file. The driver uses this information in order to
98/// figure out if the TeX engine needs rerunning. TeX makes our life more
99/// difficult, though, since it has somewhat funky file access patterns. LaTeX
100/// file opens work by opening a file and immediately closing it, which tests
101/// whether the file exists, and then by opening it again for real. Under the
102/// hood, XeTeX reads a couple of bytes from each file upon open to sniff its
103/// encoding. So we can't just stream data from `read()` calls into the SHA2
104/// computer, since we end up seeking and reading redundant data.
105///
106/// The current system maintains some internal state that, so far, helps us Do
107/// The Right Thing given all this. If there's a seek on the file, we give up
108/// on our digest computation. But if there's a seek back to the file
109/// beginning, we are open to the possibility of restarting the computation.
110/// But if nothing is ever read from the file, we once again give up on the
111/// computation. The `ExecutionState` code then has further pieces that track
112/// access to nonexistent files, which we treat as being equivalent to an
113/// existing empty file for these purposes.
114pub struct InputHandle {
115    name: String,
116    inner: Box<dyn InputFeatures>,
117    /// Indicates that the file cannot be written to (provided by a read-only IoProvider) and
118    /// therefore it is useless to compute the digest.
119    read_only: bool,
120    digest: digest::DigestComputer,
121    origin: InputOrigin,
122    ever_read: bool,
123    did_unhandled_seek: bool,
124    ungetc_char: Option<u8>,
125}
126
127impl InputHandle {
128    /// Create a new InputHandle wrapping an underlying type that implements
129    /// `InputFeatures`.
130    pub fn new<T: 'static + InputFeatures>(
131        name: impl Into<String>,
132        inner: T,
133        origin: InputOrigin,
134    ) -> InputHandle {
135        InputHandle {
136            name: name.into(),
137            inner: Box::new(inner),
138            read_only: false,
139            digest: Default::default(),
140            origin,
141            ever_read: false,
142            did_unhandled_seek: false,
143            ungetc_char: None,
144        }
145    }
146
147    /// Create a new InputHandle in read-only mode.
148    pub fn new_read_only<T: 'static + InputFeatures>(
149        name: impl Into<String>,
150        inner: T,
151        origin: InputOrigin,
152    ) -> InputHandle {
153        InputHandle {
154            name: name.into(),
155            inner: Box::new(inner),
156            read_only: true,
157            digest: Default::default(),
158            origin,
159            ever_read: false,
160            did_unhandled_seek: false,
161            ungetc_char: None,
162        }
163    }
164
165    /// Get the name associated with this handle.
166    pub fn name(&self) -> &str {
167        &self.name
168    }
169
170    /// Get the "origin" associated with this handle.
171    pub fn origin(&self) -> InputOrigin {
172        self.origin
173    }
174
175    /// Consumes the object and returns the underlying readable handle that
176    /// it references.
177    pub fn into_inner(self) -> Box<dyn InputFeatures> {
178        self.inner
179    }
180
181    /// Read any remaining data in the file and incorporate them into the
182    /// digest. This helps the rerun detection logic work correctly in
183    /// the somewhat-unusual case that a file is read then written, but
184    /// only part of the file is read, not the entire thing. This seems
185    /// to happen with biblatex XML state files.
186    pub fn scan_remainder(&mut self) -> Result<()> {
187        const BUFSIZE: usize = 1024;
188        let mut buf: [u8; BUFSIZE] = [0; BUFSIZE];
189
190        loop {
191            let n = match self.inner.read(&mut buf[..]) {
192                Ok(n) => n,
193
194                // There are times when the engine tries to open and read
195                // directories. When closing out such a handle, we'll get this
196                // error, but we should ignore it.
197                Err(ref ioe) if ioe.raw_os_error() == Some(libc::EISDIR) => return Ok(()),
198
199                Err(e) => return Err(e.into()),
200            };
201
202            if n == 0 {
203                break;
204            }
205
206            self.digest.update(&buf[..n]);
207        }
208
209        Ok(())
210    }
211
212    /// Consumes the object and returns the SHA256 sum of the content that was
213    /// read. No digest is returned if there was ever a seek on the input
214    /// stream, since in that case the results will not be reliable. We also
215    /// return None if the stream was never read, which is another common
216    /// TeX access pattern: files are opened, immediately closed, and then
217    /// opened again. Finally, no digest is returned if the file is marked read-only.
218    pub fn into_name_digest(self) -> (String, Option<DigestData>) {
219        if self.did_unhandled_seek || !self.ever_read || self.read_only {
220            (self.name, None)
221        } else {
222            (self.name, Some(DigestData::from(self.digest)))
223        }
224    }
225
226    /// Various piece of TeX want to use the libc `ungetc()` function a lot.
227    /// It's kind of gross, but happens often enough that we provide special
228    /// support for it. Here's `getc()` emulation that can return a previously
229    /// `ungetc()`-ed character.
230    pub fn getc(&mut self) -> Result<u8> {
231        if let Some(c) = self.ungetc_char {
232            self.ungetc_char = None;
233            return Ok(c);
234        }
235
236        let mut byte = [0u8; 1];
237
238        if self.read(&mut byte[..1])? == 0 {
239            // EOF
240            return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "EOF in getc").into());
241        }
242
243        Ok(byte[0])
244    }
245
246    /// Here's the `ungetc()` emulation.
247    pub fn ungetc(&mut self, byte: u8) -> Result<()> {
248        ensure!(
249            self.ungetc_char.is_none(),
250            "internal problem: cannot ungetc() more than once in a row"
251        );
252        self.ungetc_char = Some(byte);
253        Ok(())
254    }
255}
256
257impl Read for InputHandle {
258    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
259        if !buf.is_empty() {
260            if let Some(c) = self.ungetc_char {
261                // This does sometimes happen, so we need to deal with it. It's not that bad really.
262                buf[0] = c;
263                self.ungetc_char = None;
264                return Ok(self.read(&mut buf[1..])? + 1);
265            }
266        }
267
268        self.ever_read = true;
269        let n = self.inner.read(buf)?;
270        if !self.read_only {
271            self.digest.update(&buf[..n]);
272        }
273        Ok(n)
274    }
275}
276
277impl Seek for InputHandle {
278    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
279        self.try_seek(pos)
280            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
281    }
282}
283
284impl InputFeatures for InputHandle {
285    fn get_size(&mut self) -> Result<usize> {
286        self.inner.get_size()
287    }
288
289    fn get_unix_mtime(&mut self) -> Result<Option<i64>> {
290        self.inner.get_unix_mtime()
291    }
292
293    fn try_seek(&mut self, pos: SeekFrom) -> Result<u64> {
294        match pos {
295            SeekFrom::Start(0) => {
296                // As described above, there is a common pattern in TeX file
297                // accesses: read a few bytes to sniff, then go back to the
298                // beginning. We should tidy up the I/O to just buffer instead
299                // of seeking, but in the meantime, we can handle this.
300                self.digest = Default::default();
301                self.ever_read = false;
302                self.ungetc_char = None;
303            }
304            SeekFrom::Current(0) => {
305                // Noop. This must *not* clear the ungetc buffer for our
306                // current PDF startxref/xref parsing code to work.
307            }
308            _ => {
309                self.did_unhandled_seek = true;
310                self.ungetc_char = None;
311            }
312        }
313
314        let mut offset = self.inner.try_seek(pos)?;
315
316        // If there was an ungetc, the effective position in the stream is one
317        // byte before that of the underlying handle. Some of the code does
318        // noop seeks to get the current offset for various file parsing
319        // needs, so it's important that we return the right value. It should
320        // never happen that the underlying stream thinks that the offset is
321        // zero after we've ungetc'ed -- famous last words?
322
323        if self.ungetc_char.is_some() {
324            offset -= 1;
325        }
326
327        Ok(offset)
328    }
329}
330
331/// A handle for Tectonic output streams.
332pub struct OutputHandle {
333    name: String,
334    inner: Box<dyn Write>,
335    digest: digest::DigestComputer,
336}
337
338impl OutputHandle {
339    /// Create a new output handle.
340    pub fn new<T: 'static + Write>(name: impl Into<String>, inner: T) -> OutputHandle {
341        OutputHandle {
342            name: name.into(),
343            inner: Box::new(inner),
344            digest: digest::create(),
345        }
346    }
347
348    /// Get the name associated with this handle.
349    pub fn name(&self) -> &str {
350        &self.name
351    }
352
353    /// Consumes the object and returns the underlying writable handle that
354    /// it references.
355    pub fn into_inner(self) -> Box<dyn Write> {
356        self.inner
357    }
358
359    /// Consumes the object and returns the SHA256 sum of the content that was
360    /// written.
361    pub fn into_name_digest(self) -> (String, DigestData) {
362        (self.name, DigestData::from(self.digest))
363    }
364}
365
366impl Write for OutputHandle {
367    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
368        let n = self.inner.write(buf)?;
369        self.digest.update(&buf[..n]);
370        Ok(n)
371    }
372
373    fn flush(&mut self) -> io::Result<()> {
374        self.inner.flush()
375    }
376}
377
378/// A convenience type for file-open operations when "not found" needs to be
379/// handled specially.
380#[derive(Debug)]
381pub enum OpenResult<T> {
382    /// The open operation succeeded.
383    Ok(T),
384
385    /// The file was not available.
386    NotAvailable,
387
388    /// Something else went wrong.
389    Err(Error),
390}
391
392impl<T> OpenResult<T> {
393    /// Obtain the underlying file object, or panic.
394    pub fn unwrap(self) -> T {
395        match self {
396            OpenResult::Ok(t) => t,
397            _ => panic!("expected an open file"),
398        }
399    }
400
401    /// Returns true if this result is of the NotAvailable variant.
402    pub fn is_not_available(&self) -> bool {
403        matches!(*self, OpenResult::NotAvailable)
404    }
405
406    /// Convert this object into a plain Result, erroring if the item was not available.
407    pub fn must_exist(self) -> Result<T> {
408        match self {
409            OpenResult::Ok(t) => Ok(t),
410            OpenResult::Err(e) => Err(e),
411            OpenResult::NotAvailable => {
412                Err(io::Error::new(io::ErrorKind::NotFound, "not found").into())
413            }
414        }
415    }
416}
417
418/// A hack to allow casting of Bundles to IoProviders.
419///
420/// The code that sets up the I/O stack is handed a reference to a Bundle
421/// trait object. For the actual I/O, it needs to convert this to an
422/// IoProvider trait object. [According to
423/// StackExchange](https://stackoverflow.com/a/28664881/3760486), the
424/// following pattern is the least-bad way to achieve the necessary upcasting.
425pub trait AsIoProviderMut {
426    /// Represent this value as an IoProvider trait object.
427    fn as_ioprovider_mut(&mut self) -> &mut dyn IoProvider;
428}
429
430impl<T: IoProvider> AsIoProviderMut for T {
431    fn as_ioprovider_mut(&mut self) -> &mut dyn IoProvider {
432        self
433    }
434}
435
436/// A trait for types that can read or write files needed by the TeX engine.
437///
438/// An IO provider is a source of handles. One wrinkle is that it's good to be
439/// able to distinguish between unavailability of a given name and error
440/// accessing it.
441pub trait IoProvider: AsIoProviderMut {
442    /// Open the named file for output.
443    fn output_open_name(&mut self, _name: &str) -> OpenResult<OutputHandle> {
444        OpenResult::NotAvailable
445    }
446
447    /// Open the standard output stream.
448    fn output_open_stdout(&mut self) -> OpenResult<OutputHandle> {
449        OpenResult::NotAvailable
450    }
451
452    /// Open the named file for input.
453    fn input_open_name(
454        &mut self,
455        _name: &str,
456        _status: &mut dyn StatusBackend,
457    ) -> OpenResult<InputHandle> {
458        OpenResult::NotAvailable
459    }
460
461    /// Open the named file for input and return filesystem path information.
462    ///
463    /// This method extends [`Self::input_open_name`] to help support SyncTeX output.
464    /// While SyncTeX output files should contain absolute source file paths,
465    /// Tectonic’s pluggable I/O system makes it so that the mapping between
466    /// input names and filesystem paths is not well-defined. This optional
467    /// interface enables backends to provide filesystem information at the time
468    /// of opening.
469    ///
470    /// The default implementation returns None for the path information, to
471    /// preserve backwards compatibility. If you are implementing a new backend
472    /// that might provide path information, or you are implementing an I/O
473    /// provider that delegates to other I/O providers, you should implement
474    /// this function fully, and then provide a simple implementation of
475    /// [`Self::input_open_name`] that drops the pathing information.
476    fn input_open_name_with_abspath(
477        &mut self,
478        name: &str,
479        status: &mut dyn StatusBackend,
480    ) -> OpenResult<(InputHandle, Option<PathBuf>)> {
481        match self.input_open_name(name, status) {
482            OpenResult::Ok(h) => OpenResult::Ok((h, None)),
483            OpenResult::Err(e) => OpenResult::Err(e),
484            OpenResult::NotAvailable => OpenResult::NotAvailable,
485        }
486    }
487
488    /// Open the "primary" input file, which in the context of TeX is the main
489    /// input that it's given. When the build is being done using the
490    /// filesystem and the input is a file on the filesystem, this function
491    /// isn't necesssarily that important, but those conditions don't always
492    /// hold.
493    fn input_open_primary(&mut self, _status: &mut dyn StatusBackend) -> OpenResult<InputHandle> {
494        OpenResult::NotAvailable
495    }
496
497    /// Open the primary input and return filesystem path information.
498    ///
499    /// This method is as to [`Self::input_open_primary`] as
500    /// [`Self::input_open_name_with_abspath`] is to [`Self::input_open_name`].
501    fn input_open_primary_with_abspath(
502        &mut self,
503        status: &mut dyn StatusBackend,
504    ) -> OpenResult<(InputHandle, Option<PathBuf>)> {
505        match self.input_open_primary(status) {
506            OpenResult::Ok(h) => OpenResult::Ok((h, None)),
507            OpenResult::Err(e) => OpenResult::Err(e),
508            OpenResult::NotAvailable => OpenResult::NotAvailable,
509        }
510    }
511
512    /// Open a format file with the specified name. Format files have a
513    /// specialized entry point because IOProviders may wish to handle them
514    /// specially: namely, to munge the filename to one that includes the
515    /// current version of the Tectonic engine, since the format contents
516    /// depend sensitively on the engine internals.
517    fn input_open_format(
518        &mut self,
519        name: &str,
520        status: &mut dyn StatusBackend,
521    ) -> OpenResult<InputHandle> {
522        self.input_open_name(name, status)
523    }
524
525    /// Save an a format dump in some way that this provider may be able to
526    /// recover in the future. This awkward interface is needed to write
527    /// formats with their special munged file names.
528    fn write_format(
529        &mut self,
530        _name: &str,
531        _data: &[u8],
532        _status: &mut dyn StatusBackend,
533    ) -> Result<()> {
534        bail!("this I/O layer cannot save format files");
535    }
536}
537
538impl<P: IoProvider + ?Sized> IoProvider for Box<P> {
539    fn output_open_name(&mut self, name: &str) -> OpenResult<OutputHandle> {
540        (**self).output_open_name(name)
541    }
542
543    fn output_open_stdout(&mut self) -> OpenResult<OutputHandle> {
544        (**self).output_open_stdout()
545    }
546
547    fn input_open_name(
548        &mut self,
549        name: &str,
550        status: &mut dyn StatusBackend,
551    ) -> OpenResult<InputHandle> {
552        (**self).input_open_name(name, status)
553    }
554
555    fn input_open_name_with_abspath(
556        &mut self,
557        name: &str,
558        status: &mut dyn StatusBackend,
559    ) -> OpenResult<(InputHandle, Option<PathBuf>)> {
560        (**self).input_open_name_with_abspath(name, status)
561    }
562
563    fn input_open_primary(&mut self, status: &mut dyn StatusBackend) -> OpenResult<InputHandle> {
564        (**self).input_open_primary(status)
565    }
566
567    fn input_open_primary_with_abspath(
568        &mut self,
569        status: &mut dyn StatusBackend,
570    ) -> OpenResult<(InputHandle, Option<PathBuf>)> {
571        (**self).input_open_primary_with_abspath(status)
572    }
573
574    fn input_open_format(
575        &mut self,
576        name: &str,
577        status: &mut dyn StatusBackend,
578    ) -> OpenResult<InputHandle> {
579        (**self).input_open_format(name, status)
580    }
581
582    fn write_format(
583        &mut self,
584        name: &str,
585        data: &[u8],
586        status: &mut dyn StatusBackend,
587    ) -> Result<()> {
588        (**self).write_format(name, data, status)
589    }
590}
591
592// Some generically helpful InputFeatures impls
593
594impl InputFeatures for Cursor<Vec<u8>> {
595    fn get_size(&mut self) -> Result<usize> {
596        Ok(self.get_ref().len())
597    }
598
599    fn get_unix_mtime(&mut self) -> Result<Option<i64>> {
600        Ok(None)
601    }
602
603    fn try_seek(&mut self, pos: SeekFrom) -> Result<u64> {
604        Ok(self.seek(pos)?)
605    }
606}
607
608// Helpful.
609
610/// Try to open a file on the fileystem, returning an `OpenResult` type
611/// allowing easy handling if the file was not found.
612pub fn try_open_file<P: AsRef<Path>>(path: P) -> OpenResult<File> {
613    use std::io::ErrorKind::NotFound;
614
615    match File::open(path) {
616        Ok(f) => OpenResult::Ok(f),
617        Err(e) => {
618            if e.kind() == NotFound {
619                OpenResult::NotAvailable
620            } else {
621                OpenResult::Err(e.into())
622            }
623        }
624    }
625}
626
627// TeX path normalization:
628
629/// Normalize a path from a TeX build.
630///
631/// We attempt to do this in a system independent™ way by stripping any `.`,
632/// `..`, or extra separators '/' so that it is of the form.
633///
634/// ```text
635/// path/to/my/file.txt
636/// ../../path/to/parent/dir/file.txt
637/// /absolute/path/to/file.txt
638/// ```
639///
640/// Does not strip whitespace.
641///
642/// Returns `None` if the path refers to a parent of the root.
643fn try_normalize_tex_path(path: &str) -> Option<String> {
644    // TODO: we should normalize directory separators to "/".
645    // And do we need to handle Windows drive prefixes, etc?
646    use std::iter::repeat;
647
648    if path.is_empty() {
649        return Some("".into());
650    }
651
652    let mut r = Vec::new();
653    let mut parent_level = 0;
654    let mut has_root = false;
655
656    for (i, c) in path.split('/').enumerate() {
657        match c {
658            "" if i == 0 => {
659                has_root = true;
660                r.push("");
661            }
662            "" | "." => {}
663            ".." => {
664                match r.pop() {
665                    // about to pop the root
666                    Some("") => return None,
667                    None => parent_level += 1,
668                    _ => {}
669                }
670            }
671            _ => r.push(c),
672        }
673    }
674
675    let r = repeat("..")
676        .take(parent_level)
677        .chain(r)
678        // No `join` on `Iterator`.
679        .collect::<Vec<_>>()
680        .join("/");
681
682    if r.is_empty() {
683        if has_root {
684            Some("/".into())
685        } else {
686            Some(".".into())
687        }
688    } else {
689        Some(r)
690    }
691}
692
693/// Normalize a TeX path if possible, otherwise return the original path.
694///
695/// A _TeX path_ is a path that obeys simplified semantics: Unix-like syntax (`/`
696/// for separators, etc.), must be Unicode-able, no symlinks allowed such that
697/// `..` can be stripped lexically.
698pub fn normalize_tex_path(path: &str) -> Cow<str> {
699    if let Some(t) = try_normalize_tex_path(path).map(String::from) {
700        Cow::Owned(t)
701    } else {
702        Cow::Borrowed(path)
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn test_try_normalize_tex_path() {
712        // edge cases
713        assert_eq!(try_normalize_tex_path(""), Some("".into()));
714        assert_eq!(try_normalize_tex_path("/"), Some("/".into()));
715        assert_eq!(try_normalize_tex_path("//"), Some("/".into()));
716        assert_eq!(try_normalize_tex_path("."), Some(".".into()));
717        assert_eq!(try_normalize_tex_path("./"), Some(".".into()));
718        assert_eq!(try_normalize_tex_path(".."), Some("..".into()));
719        assert_eq!(try_normalize_tex_path("././/./"), Some(".".into()));
720        assert_eq!(try_normalize_tex_path("/././/."), Some("/".into()));
721
722        assert_eq!(
723            try_normalize_tex_path("my/path/file.txt"),
724            Some("my/path/file.txt".into())
725        );
726        // preserve spaces
727        assert_eq!(
728            try_normalize_tex_path("  my/pa  th/file .txt "),
729            Some("  my/pa  th/file .txt ".into())
730        );
731        assert_eq!(
732            try_normalize_tex_path("/my/path/file.txt"),
733            Some("/my/path/file.txt".into())
734        );
735        assert_eq!(
736            try_normalize_tex_path("./my///path/././file.txt"),
737            Some("my/path/file.txt".into())
738        );
739        assert_eq!(
740            try_normalize_tex_path("./../my/../../../file.txt"),
741            Some("../../../file.txt".into())
742        );
743        assert_eq!(
744            try_normalize_tex_path("././my//../path/../here/file.txt"),
745            Some("here/file.txt".into())
746        );
747        assert_eq!(
748            try_normalize_tex_path("./my/.././/path/../../here//file.txt"),
749            Some("../here/file.txt".into())
750        );
751
752        assert_eq!(try_normalize_tex_path("/my/../../file.txt"), None);
753        assert_eq!(
754            try_normalize_tex_path("/my/./.././path//../../file.txt"),
755            None
756        );
757    }
758}