retex-kpath 1.2.0

kpathsea-compatible file lookup for retex
Documentation
//! Checks of file names before files are opened: `openout_any` and
//! `openin_any` (kpathsea manual, "Safe filenames").
//!
//! The rules, from black-box tests of `kpsewhich -safe-out-name` and its
//! siblings (TeX Live 2025):
//!
//! * The level is the first character of the variable: `a`, `y` or `1`
//!   allow anything; `r`, `n` or `0` are "restricted"; anything else,
//!   including an empty value, is "paranoid". Without the variable,
//!   writing is paranoid and reading unrestricted.
//! * Restricted and paranoid refuse names with a component that starts
//!   with a dot (`.x`, `a/.x`, `..x`, `a/..`, `/home/u/.cache/x`), except
//!   `.` and `..` followed by a slash (`./x`, `../x`).
//! * Paranoid also refuses `..` followed by a slash anywhere (`../x`,
//!   `a/../x`) and absolute names outside `TEXMFOUTPUT` and
//!   `TEXMF_OUTPUT_DIRECTORY` (the latter from the environment only). A
//!   name is in a directory when it starts with the directory's value
//!   followed by a slash or nothing (`/tmp/` does not contain `/tmp/x`).
//! * The "extended" check of LuaTeX (`io.open`, `kpse.*_extended`) also
//!   accepts what the normal check refuses if the name is absolute and,
//!   when paranoid, in `TEXMFOUTPUT`, `TEXMF_OUTPUT_DIRECTORY`,
//!   `TEXMFVAR` or `TEXMFSYSVAR` without a `../`: dotted components no
//!   longer count there.

use crate::Kpathsea;

/// What a file is opened for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
    /// Reading (`openin_any`).
    Read,
    /// Writing (`openout_any`).
    Write,
}

/// A file name refused by [`Kpathsea::name_ok`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
    /// The variable that refused it.
    pub var: &'static str,
    /// Its value.
    pub value: String,
}

impl Refusal {
    /// kpathsea's warning, without the program name: `Not writing to NAME
    /// (openout_any = p; no extended check).`
    pub fn message(&self, name: &str) -> String {
        let verb = match self.var {
            "openout_any" => "Not writing to",
            _ => "Not reading from",
        };
        format!(
            "{verb} {name} ({} = {}; no extended check).",
            self.var, self.value
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Level {
    Any,
    Restricted,
    Paranoid,
}

fn level(value: &str) -> Level {
    match value.bytes().next() {
        Some(b'a' | b'y' | b'1') => Level::Any,
        Some(b'r' | b'n' | b'0') => Level::Restricted,
        _ => Level::Paranoid,
    }
}

/// Whether a component of `name` starts with a dot, other than `.` or `..`
/// followed by a slash.
fn has_dot_component(name: &str) -> bool {
    let b = name.as_bytes();
    (0..b.len()).any(|i| {
        b[i] == b'.'
            && (i == 0 || b[i - 1] == b'/')
            && !(b[i + 1..].starts_with(b"/") || b[i + 1..].starts_with(b"./"))
    })
}

/// Whether `name` goes up a directory (`..` followed by a slash).
fn has_parent_component(name: &str) -> bool {
    name.starts_with("../") || name.contains("/../")
}

/// Whether `name` is `dir` or in it.
fn in_dir(name: &str, dir: &str) -> bool {
    !dir.is_empty()
        && name.starts_with(dir)
        && matches!(name.as_bytes().get(dir.len()), None | Some(b'/'))
}

impl Kpathsea {
    /// The output directory that stands for `TEXMF_OUTPUT_DIRECTORY` in the
    /// checks of [`Kpathsea::name_ok`], instead of the environment's: LuaTeX
    /// sets the variable from `-output-directory`.
    pub fn set_output_directory(&mut self, dir: Option<String>) {
        self.output_directory = Some(dir);
    }

    fn output_directory(&self) -> Option<String> {
        match &self.output_directory {
            Some(d) => d.clone(),
            None => self.getenv("TEXMF_OUTPUT_DIRECTORY"),
        }
    }

    /// Checks that file `name` may be opened for `access`, like
    /// `kpsewhich -safe-in-name` and its siblings; `extended` is LuaTeX's
    /// extended check, which also accepts the cache trees.
    pub fn name_ok(&self, name: &str, access: Access, extended: bool) -> Result<(), Refusal> {
        let (var, default) = match access {
            Access::Read => ("openin_any", "a"),
            Access::Write => ("openout_any", "p"),
        };
        let value = self.var_value(var).unwrap_or_else(|| default.to_owned());
        let level = level(&value);
        let refusal = || Refusal {
            var,
            value: value.clone(),
        };
        if self.normal_name_ok(name, level) {
            return Ok(());
        }
        if extended && name.starts_with('/') {
            if level == Level::Restricted {
                return Ok(());
            }
            let dirs = [
                self.var_value("TEXMFOUTPUT"),
                self.output_directory(),
                self.var_value("TEXMFVAR"),
                self.var_value("TEXMFSYSVAR"),
            ];
            if !has_parent_component(name) && dirs.iter().flatten().any(|d| in_dir(name, d)) {
                return Ok(());
            }
        }
        Err(refusal())
    }

    fn normal_name_ok(&self, name: &str, level: Level) -> bool {
        match level {
            Level::Any => true,
            Level::Restricted => !has_dot_component(name),
            Level::Paranoid => {
                if has_dot_component(name) || has_parent_component(name) {
                    return false;
                }
                !name.starts_with('/')
                    || [self.var_value("TEXMFOUTPUT"), self.output_directory()]
                        .iter()
                        .flatten()
                        .any(|d| in_dir(name, d))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dot_components() {
        for n in [".x", "a/.x", "..x", "a/..", ".", "..", "/.x/y", ".../x"] {
            assert!(has_dot_component(n), "{n}");
        }
        for n in ["x", "./x", "../x", "a/../x", "a/./x", "./", "x/", "/tmp/x"] {
            assert!(!has_dot_component(n), "{n}");
        }
    }

    #[test]
    fn directories() {
        assert!(in_dir("/tmp/x", "/tmp"));
        assert!(in_dir("/tmp", "/tmp"));
        assert!(!in_dir("/tmpx/y", "/tmp"));
        assert!(!in_dir("/tmp/x", "/tmp/"));
        assert!(!in_dir("/tmp/x", ""));
    }
}