Skip to main content

retex_kpath/
safe.rs

1//! Checks of file names before files are opened: `openout_any` and
2//! `openin_any` (kpathsea manual, "Safe filenames").
3//!
4//! The rules, from black-box tests of `kpsewhich -safe-out-name` and its
5//! siblings (TeX Live 2025):
6//!
7//! * The level is the first character of the variable: `a`, `y` or `1`
8//!   allow anything; `r`, `n` or `0` are "restricted"; anything else,
9//!   including an empty value, is "paranoid". Without the variable,
10//!   writing is paranoid and reading unrestricted.
11//! * Restricted and paranoid refuse names with a component that starts
12//!   with a dot (`.x`, `a/.x`, `..x`, `a/..`, `/home/u/.cache/x`), except
13//!   `.` and `..` followed by a slash (`./x`, `../x`).
14//! * Paranoid also refuses `..` followed by a slash anywhere (`../x`,
15//!   `a/../x`) and absolute names outside `TEXMFOUTPUT` and
16//!   `TEXMF_OUTPUT_DIRECTORY` (the latter from the environment only). A
17//!   name is in a directory when it starts with the directory's value
18//!   followed by a slash or nothing (`/tmp/` does not contain `/tmp/x`).
19//! * The "extended" check of LuaTeX (`io.open`, `kpse.*_extended`) also
20//!   accepts what the normal check refuses if the name is absolute and,
21//!   when paranoid, in `TEXMFOUTPUT`, `TEXMF_OUTPUT_DIRECTORY`,
22//!   `TEXMFVAR` or `TEXMFSYSVAR` without a `../`: dotted components no
23//!   longer count there.
24
25use crate::Kpathsea;
26
27/// What a file is opened for.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Access {
30    /// Reading (`openin_any`).
31    Read,
32    /// Writing (`openout_any`).
33    Write,
34}
35
36/// A file name refused by [`Kpathsea::name_ok`].
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Refusal {
39    /// The variable that refused it.
40    pub var: &'static str,
41    /// Its value.
42    pub value: String,
43}
44
45impl Refusal {
46    /// kpathsea's warning, without the program name: `Not writing to NAME
47    /// (openout_any = p; no extended check).`
48    pub fn message(&self, name: &str) -> String {
49        let verb = match self.var {
50            "openout_any" => "Not writing to",
51            _ => "Not reading from",
52        };
53        format!(
54            "{verb} {name} ({} = {}; no extended check).",
55            self.var, self.value
56        )
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum Level {
62    Any,
63    Restricted,
64    Paranoid,
65}
66
67fn level(value: &str) -> Level {
68    match value.bytes().next() {
69        Some(b'a' | b'y' | b'1') => Level::Any,
70        Some(b'r' | b'n' | b'0') => Level::Restricted,
71        _ => Level::Paranoid,
72    }
73}
74
75/// Whether a component of `name` starts with a dot, other than `.` or `..`
76/// followed by a slash.
77fn has_dot_component(name: &str) -> bool {
78    let b = name.as_bytes();
79    (0..b.len()).any(|i| {
80        b[i] == b'.'
81            && (i == 0 || b[i - 1] == b'/')
82            && !(b[i + 1..].starts_with(b"/") || b[i + 1..].starts_with(b"./"))
83    })
84}
85
86/// Whether `name` goes up a directory (`..` followed by a slash).
87fn has_parent_component(name: &str) -> bool {
88    name.starts_with("../") || name.contains("/../")
89}
90
91/// Whether `name` is `dir` or in it.
92fn in_dir(name: &str, dir: &str) -> bool {
93    !dir.is_empty()
94        && name.starts_with(dir)
95        && matches!(name.as_bytes().get(dir.len()), None | Some(b'/'))
96}
97
98impl Kpathsea {
99    /// The output directory that stands for `TEXMF_OUTPUT_DIRECTORY` in the
100    /// checks of [`Kpathsea::name_ok`], instead of the environment's: LuaTeX
101    /// sets the variable from `-output-directory`.
102    pub fn set_output_directory(&mut self, dir: Option<String>) {
103        self.output_directory = Some(dir);
104    }
105
106    fn output_directory(&self) -> Option<String> {
107        match &self.output_directory {
108            Some(d) => d.clone(),
109            None => self.getenv("TEXMF_OUTPUT_DIRECTORY"),
110        }
111    }
112
113    /// Checks that file `name` may be opened for `access`, like
114    /// `kpsewhich -safe-in-name` and its siblings; `extended` is LuaTeX's
115    /// extended check, which also accepts the cache trees.
116    pub fn name_ok(&self, name: &str, access: Access, extended: bool) -> Result<(), Refusal> {
117        let (var, default) = match access {
118            Access::Read => ("openin_any", "a"),
119            Access::Write => ("openout_any", "p"),
120        };
121        let value = self.var_value(var).unwrap_or_else(|| default.to_owned());
122        let level = level(&value);
123        let refusal = || Refusal {
124            var,
125            value: value.clone(),
126        };
127        if self.normal_name_ok(name, level) {
128            return Ok(());
129        }
130        if extended && name.starts_with('/') {
131            if level == Level::Restricted {
132                return Ok(());
133            }
134            let dirs = [
135                self.var_value("TEXMFOUTPUT"),
136                self.output_directory(),
137                self.var_value("TEXMFVAR"),
138                self.var_value("TEXMFSYSVAR"),
139            ];
140            if !has_parent_component(name) && dirs.iter().flatten().any(|d| in_dir(name, d)) {
141                return Ok(());
142            }
143        }
144        Err(refusal())
145    }
146
147    fn normal_name_ok(&self, name: &str, level: Level) -> bool {
148        match level {
149            Level::Any => true,
150            Level::Restricted => !has_dot_component(name),
151            Level::Paranoid => {
152                if has_dot_component(name) || has_parent_component(name) {
153                    return false;
154                }
155                !name.starts_with('/')
156                    || [self.var_value("TEXMFOUTPUT"), self.output_directory()]
157                        .iter()
158                        .flatten()
159                        .any(|d| in_dir(name, d))
160            }
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn dot_components() {
171        for n in [".x", "a/.x", "..x", "a/..", ".", "..", "/.x/y", ".../x"] {
172            assert!(has_dot_component(n), "{n}");
173        }
174        for n in ["x", "./x", "../x", "a/../x", "a/./x", "./", "x/", "/tmp/x"] {
175            assert!(!has_dot_component(n), "{n}");
176        }
177    }
178
179    #[test]
180    fn directories() {
181        assert!(in_dir("/tmp/x", "/tmp"));
182        assert!(in_dir("/tmp", "/tmp"));
183        assert!(!in_dir("/tmpx/y", "/tmp"));
184        assert!(!in_dir("/tmp/x", "/tmp/"));
185        assert!(!in_dir("/tmp/x", ""));
186    }
187}