latexml_core/util/pathname.rs
1#[cfg(feature = "kpathsea")]
2use std::sync::Mutex;
3use std::{
4 env,
5 path::{Path, PathBuf},
6 sync::OnceLock,
7};
8
9#[cfg(feature = "kpathsea")]
10use kpathsea::Kpaths;
11use once_cell::sync::Lazy;
12use regex::Regex;
13
14/// configuration for filesystem search.
15/// Mirrors Perl `LaTeXML::Util::Pathname::pathname_find`'s named-arg options;
16/// kpsewhich-fallback is NOT one of them — that lives in higher-level
17/// `LaTeXML::Package::FindFile_aux`, which calls `pathname_kpsewhich` after
18/// `pathname_find` returns empty. Keep this struct directory-search-only
19/// for parity.
20#[derive(Debug, Clone, Default)]
21pub struct PathnameFindOptions {
22 /// the allowed/requested paths to search in
23 pub paths: Option<Vec<String>>,
24 /// the file extensions to search for
25 pub extensions: Option<Vec<String>>,
26 /// the location of the installation subdirectory (deprecated?)
27 pub installation_subdir: Option<String>,
28}
29
30static LITERAL_PROTOCOL: &str = "literal:";
31static HOME_TILDE: &str = "~";
32// `HOME` first (Unix, and set by some Windows shells), then Windows'
33// native `USERPROFILE`, so `~` expansion works on both platforms.
34static HOME_PATH: Lazy<String> =
35 Lazy::new(
36 || match env::var_os("HOME").or_else(|| env::var_os("USERPROFILE")) {
37 Some(val) => val.to_string_lossy().into_owned(),
38 _ => s!("~"),
39 },
40 );
41static PROTOCOL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(https|http|ftp):").unwrap());
42// Match Perl LaTeXML's permissive filename behavior: filenames may
43// contain commas, parens, ampersands, etc. that some user paths legitimately
44// use (e.g. `\input{5-Ack,terms}` resolving to `5-Ack,terms.tex`). Only
45// flag genuinely dangerous patterns: shell metacharacters that would
46// enable command injection via kpathsea or `\openin`. Driver: 2308.13679
47// `\input{5-Ack,terms}`. Mirrors Perl's missing nasty-check (Perl simply
48// passes filenames to kpathsea without a pre-filter).
49static PATHNAME_IS_NASTY_RE: Lazy<Regex> =
50 Lazy::new(|| Regex::new(r#"[`$;|<>"\x00\n\r]"#).unwrap());
51// TODO: This is very pragmatic for now, we ought to use a real URL path library long-term
52static URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\w+://(.+)/([^/]+)$").unwrap());
53// Perl `pathname_is_url` is `$pathname =~ /^($PROTOCOL_RE)/` — the protocol is
54// ANCHORED at the start. The previous `is_url` used `URL_RE` (`^\w+://…`),
55// whose leading `\w+` (which includes `_`) matches a filename PREFIX like
56// `myers_http`, so a JabRef `\bibAnnoteFile{myers_http://…/welcome.html_2014}`
57// key (a filename, NOT a URL) read as a URL → `find_file` returned "exists" →
58// `\IfFileExists` took its true branch → the `_` in the key got typeset in
59// text mode ("Script _ can only appear in math mode"; witness 1509.01434).
60// Match Perl: only `http`/`https`/`ftp` followed by `:` at the very start.
61static URL_PREFIX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?:https|http|ftp):").unwrap());
62
63/// Process-global kpathsea handle (cross-thread shared, NOT
64/// `thread_local!`).
65///
66/// **Why an exception to the project's no-`Mutex` rule:**
67/// `kpathsea-rs` wraps a C library that maintains process-wide global
68/// state. Calling `Kpaths::new()` more than once per process (as the
69/// per-thread `thread_local!` pattern would do) re-runs the C-side
70/// init and can corrupt internal tables / leak file descriptors;
71/// concretely on systems without TeXLive installed, the second init
72/// fails with "Can't get directory of program name" and previously
73/// crashed `06_cluster_regressions`. The Mutex here is necessary to
74/// guarantee single-init AND single-active-call semantics for the
75/// underlying non-thread-safe C API. Same class of carve-out as
76/// `latexml_core::watchdog::PRE_EXIT_HOOK`. See
77/// `feedback_no_mutex_use_thread_local` in user memory for the
78/// general rule.
79#[cfg(feature = "kpathsea")]
80static KPSE: Lazy<Mutex<Option<Kpaths>>> = Lazy::new(|| Mutex::new(select_kpaths()));
81
82/// Which kpathsea backend this process resolved. Reported by
83/// [`kpathsea_backend`] so a host-resolution problem is diagnosable from the
84/// log instead of surfacing only as indistinguishable "can't find file" errors.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum KpathseaBackend {
87 /// Linked libkpathsea, resolved in-process (the fast path).
88 InProcess,
89 /// Delegating to the host's `kpsewhich` executable.
90 Subprocess,
91 /// No backend could be constructed — EVERY file lookup returns `None`.
92 Unavailable,
93}
94
95impl KpathseaBackend {
96 /// Short label, for the per-conversion log line.
97 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::InProcess => "in-process",
100 Self::Subprocess => "subprocess kpsewhich",
101 Self::Unavailable => "unavailable",
102 }
103 }
104}
105
106/// The resolved backend and why, recorded by `select_kpaths`.
107#[cfg(feature = "kpathsea")]
108static BACKEND: OnceLock<(KpathseaBackend, &'static str)> = OnceLock::new();
109
110/// The backend this process resolved, with a short reason. Forces the `KPSE`
111/// initialization so the answer is always the real one.
112#[cfg(feature = "kpathsea")]
113pub fn kpathsea_backend() -> (KpathseaBackend, &'static str) {
114 drop(KPSE.lock().unwrap());
115 *BACKEND
116 .get()
117 .unwrap_or(&(KpathseaBackend::Unavailable, "not initialized"))
118}
119
120/// Without the `kpathsea` feature there is no backend at all.
121#[cfg(not(feature = "kpathsea"))]
122pub fn kpathsea_backend() -> (KpathseaBackend, &'static str) {
123 (
124 KpathseaBackend::Unavailable,
125 "built without the kpathsea feature",
126 )
127}
128
129/// The backend-selection policy, with its effects injected so every branch is
130/// testable without a MiKTeX (or TeX-less) host.
131///
132/// The in-process (statically linked) backend is the fast path — it resolves
133/// against the host `ls-R` with no subprocess per lookup, ~0.5 s/conversion
134/// cheaper than shelling out — so it is preferred wherever it actually works,
135/// with the subprocess backend as the portable fallback. Net: TeX Live keeps
136/// the in-process speed, MiKTeX works with no configuration, one binary.
137#[cfg(feature = "kpathsea")]
138fn choose_kpaths(
139 banner: Option<&str>,
140 new_in_process: impl FnOnce() -> Result<Kpaths, &'static str>,
141 new_subprocess: impl Fn() -> Result<Kpaths, &'static str>,
142 can_resolve: impl FnOnce(&Kpaths) -> bool,
143) -> (Option<Kpaths>, KpathseaBackend, &'static str) {
144 // MiKTeX stores its file database in an MPM `fndb` that a statically-linked
145 // libkpathsea cannot read (MiKTeX ships no `ls-R`), so the in-process backend
146 // resolves nothing there. Detecting it from the `kpsewhich --version` banner
147 // BEFORE any `Kpaths::new()` also avoids the C library's "configuration file
148 // texmf.cnf not found" warning while it fails to anchor on the MiKTeX tree.
149 if banner.is_some_and(|b| b.contains("MiKTeX"))
150 && let Ok(subprocess) = new_subprocess()
151 {
152 return (Some(subprocess), KpathseaBackend::Subprocess, "MiKTeX host");
153 }
154 match new_in_process() {
155 // Sentinel backstop for any OTHER distro whose tree the linked lib can't
156 // read. `new_subprocess()` is pure Rust (no C-side re-init), so this second
157 // construction is safe next to the Mutex carve-out documented above.
158 Ok(kpse) if kpse.is_in_process() && !can_resolve(&kpse) => match new_subprocess() {
159 Ok(subprocess) => (
160 Some(subprocess),
161 KpathseaBackend::Subprocess,
162 "linked libkpathsea resolved no host files",
163 ),
164 // Keep the in-process handle: degraded beats nothing.
165 Err(_) => (
166 Some(kpse),
167 KpathseaBackend::InProcess,
168 "linked libkpathsea resolves no host files, and no kpsewhich to fall back to",
169 ),
170 },
171 Ok(kpse) if kpse.is_in_process() => (Some(kpse), KpathseaBackend::InProcess, "linked"),
172 Ok(kpse) => (
173 Some(kpse),
174 KpathseaBackend::Subprocess,
175 "no linked libkpathsea",
176 ),
177 // Never give up without trying the other backend: this is the only path
178 // that would otherwise disable file resolution entirely, and it used to do
179 // so silently (`Kpaths::new().ok()?`).
180 Err(_) => match new_subprocess() {
181 Ok(subprocess) => (
182 Some(subprocess),
183 KpathseaBackend::Subprocess,
184 "libkpathsea unavailable",
185 ),
186 Err(_) => (
187 None,
188 KpathseaBackend::Unavailable,
189 "no usable libkpathsea and no kpsewhich executable",
190 ),
191 },
192 }
193}
194
195/// Pick the kpathsea backend that can actually resolve host files, and record
196/// the choice for [`kpathsea_backend`].
197#[cfg(feature = "kpathsea")]
198fn select_kpaths() -> Option<Kpaths> {
199 let (kpse, backend, why) = choose_kpaths(
200 ambient_kpsewhich_version(),
201 Kpaths::new,
202 Kpaths::new_subprocess,
203 // `cmr10.tfm` is present in every TeX distribution, and the probe returns
204 // `None` fast on MiKTeX — no directory walk.
205 |kpse| kpse.find_file("cmr10.tfm").is_some(),
206 );
207 let _ = BACKEND.set((backend, why));
208 kpse
209}
210
211/// The ambient `kpsewhich --version` banner (full stdout), memoized for the
212/// process. It is a global property of the host TeX install, and several
213/// consumers read it — kpathsea backend selection (`select_kpaths`) and
214/// ambient-year detection (in `latexml_engine`; the year-based latex-dump
215/// staleness check in turn consumes that detected year) — so `kpsewhich` is
216/// spawned at most once. Returns `None` if kpsewhich is absent or the call
217/// fails.
218///
219/// NOT gated on the `kpathsea` feature: it is a plain subprocess probe (no C
220/// library), so the year/stamp logic can share it even in the host-side codegen
221/// build. Lives here — the lowest crate that constructs `Kpaths` — so both the
222/// backend choice and `latexml_engine` resolve the same single spawn.
223pub fn ambient_kpsewhich_version() -> Option<&'static str> {
224 static BANNER: OnceLock<Option<String>> = OnceLock::new();
225 BANNER
226 .get_or_init(|| {
227 let out = std::process::Command::new("kpsewhich")
228 .arg("--version")
229 .output()
230 .ok()?;
231 if !out.status.success() {
232 return None;
233 }
234 String::from_utf8(out.stdout).ok()
235 })
236 .as_deref()
237}
238
239/// Force-initialize the kpathsea global state and warm up the per-
240/// format suffix tables.
241///
242/// **Why:** `Kpaths::find_file` lazily inits the kpse format-info
243/// table for each format type the first time a matching filename is
244/// looked up. The chain is
245/// `find_file → guess_format_from_filename → kpathsea_init_format →
246/// kpathsea_init_db → kpathsea_cnf_get → hash_insert_normalized`,
247/// taking ~30-40 ms total across the first dozen lookups. Profile
248/// data on 1910.01256 attributes ~3.5% of wall to that chain.
249///
250/// **What this does:** acquires the `KPSE` mutex once and runs a
251/// single `find_file` probe per common file format. Each probe
252/// guarantees `kpathsea_init_format` runs for that format type, so
253/// every subsequent real lookup hits the post-init fast path.
254///
255/// **Concurrency:** safe to invoke on a background thread spawned at
256/// process start. `KPSE` is process-global (`Lazy<Mutex<…>>`), so the
257/// init done on a background thread is visible to the main thread.
258/// The Mutex briefly serializes the prewarm against the main thread's
259/// first real lookup, but dump load + arg parsing take >50 ms before
260/// digest reaches its first package resolution, by which point the
261/// prewarm is usually finished. Idempotent: re-entry while in flight
262/// is a no-op (lock contention only).
263#[cfg(feature = "kpathsea")]
264pub fn prewarm_kpathsea() {
265 // Run exactly once per process: libkpathsea's format-info tables are
266 // process-global, so warming them once is enough. The `Once` also lets the
267 // CLI's early background prewarm and the shared `Converter::initialize_session`
268 // call coordinate — whichever runs first warms; any other caller blocks here
269 // until it completes (guaranteeing warm-before-first-lookup) instead of
270 // re-taking the `KPSE` mutex and re-probing 11 sentinels on every session.
271 report_unavailable_kpathsea();
272 static PREWARM_ONCE: std::sync::Once = std::sync::Once::new();
273 PREWARM_ONCE.call_once(|| {
274 let kpse_guard = KPSE.lock().unwrap();
275 let Some(ref kpse) = *kpse_guard else {
276 return;
277 };
278 // Subprocess backend (kpathsea 0.3, hosts without libkpathsea — e.g.
279 // MacTeX): there is no in-process format-info table to warm, and each
280 // sentinel would cost a real `kpsewhich` subprocess invocation
281 // (~10-20 ms × 11). The backend builds its own ls-R cache lazily on
282 // the first real lookup instead.
283 if !kpse.is_in_process() {
284 return;
285 }
286 for sentinel in &[
287 "warmup_lxoxide.tex",
288 "warmup_lxoxide.sty",
289 "warmup_lxoxide.cls",
290 "warmup_lxoxide.def",
291 "warmup_lxoxide.bst",
292 "warmup_lxoxide.fmt",
293 "warmup_lxoxide.afm",
294 "warmup_lxoxide.tfm",
295 "warmup_lxoxide.pfb",
296 "warmup_lxoxide.enc",
297 "warmup_lxoxide.map",
298 ] {
299 // catch_unwind defends against kpathsea-0.2.3 overflow bug (see
300 // kpsewhich docs).
301 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(sentinel)));
302 }
303 });
304}
305
306/// No-op when built without the `kpathsea` feature (e.g. the host-side
307/// proc-macro build, which never resolves TeX files).
308#[cfg(not(feature = "kpathsea"))]
309pub fn prewarm_kpathsea() {}
310
311// Perl: $pathname =~ s|^($PROTOCOL_RE//[^/]*)/|/|
312static CANONICAL_URL_RE: Lazy<Regex> =
313 Lazy::new(|| Regex::new(r"^((?:https|http|ftp)://[^/]*)").unwrap());
314
315// static ref INSTALLDIRS : Vec<String> = match env::current_exe() {
316// Ok(exe_path) => {
317// match exe_path.as_path().parent() {
318// Some(_) => Vec::new(),
319// // Some(p) => vec![
320// // p.to_string_lossy().to_string() + ".",
321// // p.to_string_lossy().to_string() + "./..",
322// // p.to_string_lossy().to_string() + "./../..",
323// // p.to_string_lossy().to_string() + "./../../..",
324// // p.to_string_lossy().to_string() + "./../../../.."],
325
326// TODO: HACK, see note on INSTALLDIRS further down
327// None => Vec::new()
328// }
329// },
330// _ => Vec::new()
331// };
332
333// TODO:
334// grep { (-f "$_.pm") && (-d $_) }
335// map { pathname_canonical($_ . $SEP . 'LaTeXML') } @INC; # [CONSTANT]
336
337/// checks if the path is a conforming URL string
338pub fn is_url(path: &str) -> bool { URL_PREFIX_RE.is_match(path) }
339/// checks if the path starts with the "literal:" protocol
340pub fn is_literaldata(data: &str) -> bool { data.starts_with(LITERAL_PROTOCOL) }
341
342/// check whether a pathname is reloadable as a TeX definition
343pub fn is_reloadable(pathname: &str) -> bool {
344 let (_dir, _name, ext) = split(pathname);
345 // babel.sty exception:
346 // we know the same .ldf file may be reloaded with a different option,
347 // to load an adjacently defined language, so allow that.
348 ext == "ldf"
349}
350/// Check whether a pathname is a raw TeX source or definition file.
351/// Perl: pathname_is_raw
352pub fn is_raw(pathname: &str) -> bool {
353 matches!(
354 extension(pathname).as_str(),
355 "tex" | "pool" | "sty" | "cls" | "clo" | "cnf" | "cfg" | "ldf" | "def" | "dfu"
356 )
357}
358
359/// absolute paths start with the filesystem root - check if this is one
360pub fn is_absolute(path: &str) -> bool { Path::new(&canonical(path)).is_absolute() }
361/// convert a (possibly relative) file path to an absolute one
362///
363/// `std::fs::canonicalize` requires the path to exist; many callers hand
364/// us paths that haven't been resolved yet (e.g. `\import{subdir}{f.sty}`
365/// constructs `subdir/f.sty` before `find_file` probes other dirs).
366/// Mirror Perl's `Cwd::abs_path`-style behavior: produce a lexically
367/// absolute path joined against `current_dir()` when the input is
368/// relative, then run it through our `canonical()` to collapse `.`/`..`
369/// components.
370///
371/// Panics only if `current_dir()` itself fails — that means the cwd was
372/// deleted out from under us, which we cannot safely resolve a relative
373/// path against (and silently returning the input could let a relative
374/// file reference target an attacker-controlled path).
375pub fn absolute(path: &str) -> String {
376 let p = Path::new(path);
377 let joined: PathBuf = if p.is_absolute() {
378 p.to_path_buf()
379 } else {
380 let cwd = env::current_dir().expect("cannot make path absolute: current_dir() failed");
381 cwd.join(p)
382 };
383 canonical(&joined.to_string_lossy())
384}
385
386/// Split the pathname into components (dir,name,type).
387/// If pathname is absolute, dir starts with volume or '/'
388pub fn split(pathname: &str) -> (String, String, String) {
389 let canonical_pathname = canonical(pathname);
390 let canonical_path = Path::new(&canonical_pathname);
391 let pathdir = match canonical_path.parent() {
392 Some(dir) => dir.to_string_lossy().to_string(),
393 None => String::new(),
394 };
395 let name = match canonical_path.file_stem() {
396 Some(n) => n.to_string_lossy().to_string(),
397 None => String::new(),
398 };
399 // Perl pathname_split preserves case: `$name =~ s/\.([^\.]+)$//`
400 let pathname_ext = match canonical_path.extension() {
401 Some(e) => e.to_string_lossy().to_string(),
402 None => String::new(),
403 };
404 (pathdir, name, pathname_ext)
405}
406
407/// Simple logic for splitting a URL into protocol://base/path
408pub fn url_split(url: &str) -> (&str, &str) {
409 if let Some(caps) = URL_RE.captures(url) {
410 (
411 caps.get(1).map_or("", |m| m.as_str()),
412 caps.get(2).map_or("", |m| m.as_str()),
413 )
414 } else {
415 (url, "index.tex") // Well, what other default makes sense?
416 }
417}
418
419/// Canonicalize a pathname by simplifying redundant separators, `.` and `..` components.
420/// Matches Perl's pathname_canonical from Pathname.pm.
421pub fn canonical(pathname: &str) -> String {
422 if is_literaldata(pathname) {
423 return pathname.to_owned();
424 }
425 // Don't call is_absolute, etc, here, cause THEY call US!
426 let home_path: &str = &HOME_PATH;
427
428 let mut pathname = if pathname.starts_with(HOME_TILDE) {
429 pathname.replacen(HOME_TILDE, home_path, 1)
430 } else {
431 pathname.to_string()
432 };
433
434 // Windows: the whole string-pathname layer speaks `/`, faithful to Perl
435 // (whose Windows pathname_canonical likewise sees mostly `/`; kpsewhich
436 // on TL-Windows returns `C:/texlive/...`). OS-originated strings
437 // (std::env::temp_dir, current_dir, PathBuf displays) arrive
438 // `\`-separated, so normalize here — the single choke point every
439 // pathname_* helper funnels through. Unix is untouched: `\` is a legal
440 // filename byte there.
441 #[cfg(windows)]
442 {
443 if pathname.contains('\\') {
444 pathname = pathname.replace('\\', "/");
445 }
446 }
447
448 // Handle URL prefix: strip protocol://host before normalizing path
449 let url_prefix = if let Some(caps) = CANONICAL_URL_RE.captures(&pathname) {
450 let prefix = caps.get(1).unwrap().as_str().to_string();
451 pathname = pathname[prefix.len()..].to_string();
452 Some(prefix)
453 } else {
454 None
455 };
456
457 // Perl: $pathname =~ s|/\./|/|g;
458 while pathname.contains("/./") {
459 pathname = pathname.replace("/./", "/");
460 }
461 // Perl: while ($pathname =~ s|/(?!\.\./)[^/]+/\.\.(/|$)|$1|) { }
462 // Collapse /foo/.. patterns but not /../..
463 // Implemented without lookahead since the regex crate doesn't support it.
464 loop {
465 let mut changed = false;
466 // Find /component/.. where component is not ".."
467 if let Some(dotdot_pos) = pathname.find("/..") {
468 // Check this is actually /../ or /..$ (end of string)
469 let after = dotdot_pos + 3;
470 if after == pathname.len() || pathname.as_bytes().get(after) == Some(&b'/') {
471 // Find the preceding component: look backwards from dotdot_pos for '/'
472 if dotdot_pos > 0 {
473 let prefix = &pathname[..dotdot_pos];
474 if let Some(slash_pos) = prefix.rfind('/') {
475 let component = &pathname[slash_pos + 1..dotdot_pos];
476 if component != ".." && !component.is_empty() {
477 // Replace /component/.. with the trailing part
478 let trail = &pathname[after..];
479 pathname = format!("{}{}", &pathname[..slash_pos], trail);
480 changed = true;
481 }
482 } else if prefix != ".." {
483 // No leading slash, e.g. "foo/.."
484 let trail = &pathname[after..];
485 pathname = if let Some(stripped) = trail.strip_prefix('/') {
486 stripped.to_string()
487 } else {
488 trail.to_string()
489 };
490 if pathname.is_empty() {
491 pathname = ".".to_string();
492 }
493 changed = true;
494 }
495 }
496 }
497 }
498 if !changed {
499 break;
500 }
501 }
502 // Perl: $pathname =~ s|^\./(.)|$1|; — reduce ./foo to foo, but preserve ./
503 if pathname.starts_with("./") && pathname.len() > 2 {
504 pathname = pathname[2..].to_string();
505 }
506 match url_prefix {
507 Some(prefix) => format!("{}{}", prefix, pathname),
508 None => pathname,
509 }
510}
511
512/// Note that this returns ONLY recognized protocols!
513pub fn protocol(pathname: &str) -> String {
514 if let Some(cap) = PROTOCOL_RE.captures(pathname) {
515 cap.get(1).map_or(String::new(), |m| m.as_str().to_string())
516 } else if is_literaldata(pathname) {
517 "literal".to_string()
518 } else {
519 "file".to_string()
520 }
521}
522
523/// combine a directory and a base name into a full path
524pub fn concat(dir: &str, file: &str) -> String {
525 if dir.is_empty() {
526 file.to_owned()
527 } else if file.is_empty() || file == "." {
528 dir.to_owned()
529 } else {
530 // Join with a literal '/', as Perl's pathname_concat does — the
531 // string-pathname layer is '/'-separated on every platform. (The
532 // previous PathBuf::push produced '\' on Windows, which the
533 // Perl-faithful string logic in `canonical` cannot normalize.)
534 canonical(&format!("{dir}/{file}"))
535 }
536}
537
538/// Expand a directory for the kpsewhich `//` recursive-search convention: the
539/// directory itself, followed by every subdirectory beneath it. Traversal is
540/// breadth-first and each level is sorted, so shallower directories take search
541/// precedence and the result is deterministic. Hidden (dot-prefixed)
542/// subdirectories are skipped, as kpsewhich does. Returns just `[base]` if the
543/// tree cannot be read.
544fn expand_recursive_dirs(base: &str) -> Vec<String> {
545 let mut out = vec![base.to_string()];
546 // Cycle guard: a symlinked directory can point back at an ancestor. Dedupe on
547 // the CANONICAL path so a symlink loop can't make the walk run forever.
548 let mut visited = std::collections::HashSet::new();
549 if let Ok(canon) = std::fs::canonicalize(base) {
550 visited.insert(canon);
551 }
552 let mut queue = std::collections::VecDeque::from([PathBuf::from(base)]);
553 while let Some(dir) = queue.pop_front() {
554 let Ok(entries) = std::fs::read_dir(&dir) else {
555 continue;
556 };
557 let mut children: Vec<PathBuf> = entries
558 .flatten()
559 .map(|e| e.path())
560 .filter(|p| p.is_dir())
561 .filter(|p| {
562 // Skip hidden dot-directories (.git, .svn, …), matching kpsewhich.
563 p.file_name()
564 .and_then(|n| n.to_str())
565 .is_none_or(|n| !n.starts_with('.'))
566 })
567 .collect();
568 children.sort();
569 for child in children {
570 // Skip any directory (reached directly or via symlink) already walked.
571 if let Ok(canon) = std::fs::canonicalize(&child)
572 && !visited.insert(canon)
573 {
574 continue;
575 }
576 if let Some(s) = child.to_str() {
577 // Keep the `/`-separator convention the pathname layer speaks.
578 out.push(s.replace('\\', "/"));
579 queue.push_back(child);
580 }
581 }
582 }
583 out
584}
585
586/// It's presumably cheep to concatinate all the pathnames,
587/// relative to the cost of testing for files,
588/// and this simplifies overall.
589pub fn candidate_pathnames(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
590 let mut dirs: Vec<String> = Vec::new();
591 let canonical_pathname = if pathname != "*" {
592 canonical(pathname)
593 } else {
594 pathname.to_owned()
595 };
596
597 let (pathdir, name_stem, pathname_ext) = split(&canonical_pathname);
598 // Perl: $name .= '.' . $type if (defined $type) && ($type ne '');
599 // Re-attach the extension to the name, as Perl does after split
600 let name = if !pathname_ext.is_empty() {
601 format!("{}.{}", name_stem, pathname_ext)
602 } else {
603 name_stem
604 };
605
606 let cwd = cwd();
607
608 // generate the set of search paths we'll use.
609 if is_absolute(&canonical_pathname) {
610 dirs.push(pathdir.clone());
611 } else if let Some(paths) = options.paths {
612 for p in paths {
613 // kpsewhich convention: a search path ending in `//` is searched
614 // RECURSIVELY (the directory and its whole subtree). Split the marker off
615 // first, then resolve the base to an absolute directory.
616 let (base, recursive) = match p.strip_suffix("//") {
617 Some(b) => (b.trim_end_matches('/'), true),
618 None => (p.as_str(), false),
619 };
620 // Complete the search paths by prepending current dir to relative paths,
621 let pp_base = if is_absolute(base) {
622 canonical(base)
623 } else {
624 concat(&cwd, base)
625 };
626 // Expand the recursive marker to `[base, ...every subdirectory]`; a plain
627 // path stays a single directory.
628 let roots = if recursive {
629 expand_recursive_dirs(&pp_base)
630 } else {
631 vec![pp_base]
632 };
633 for root in roots {
634 let pp = concat(&root, &pathdir);
635 // but only include each dir ONCE
636 if !dirs.contains(&pp) {
637 dirs.push(pp);
638 }
639 }
640 }
641 }
642 // Perl: push(@dirs, pathname_concat($cwd, $pathdir)) unless @dirs;
643 // Only add cwd if no search paths were given (fallback)
644 if dirs.is_empty() {
645 let from_cwd = concat(&cwd, &pathdir);
646 if !dirs.contains(&from_cwd) {
647 dirs.push(from_cwd);
648 }
649 }
650
651 // TODO: The use of INSTALLDIRS should be rethought entirely, as Rust currently doesn't have a
652 // native concept of "installing" a crate and its resources there either needs to be a
653 // more sophisticated build process, OR, a compile step that translates all model/binding
654 // dependencies into rust code. which would allow bundling them side-by-side with the
655 // main application.
656
657 // And, if installation dir specified, append it.
658 if let Some(subdir) = options.installation_subdir {
659 // dirs.extend((*INSTALLDIRS).iter().map(|dir| concat(dir, &subdir)));
660 let full_subdir = concat(&cwd, &subdir);
661 if Path::new(&full_subdir).exists() {
662 dirs.push(full_subdir);
663 } else {
664 let full_subdir_oneup = concat(&s!("{}/..", cwd), &subdir);
665 if Path::new(&full_subdir_oneup).exists() {
666 dirs.push(full_subdir_oneup);
667 }
668 }
669 }
670 // extract the desired extensions.
671 // Perl: the extensions from `types` option are applied to the already-reassembled name.
672 // Since name already has its extension, matching an existing extension pushes '' (exact match).
673 let mut exts = Vec::new();
674 if let Some(ext_vec) = options.extensions {
675 for ext in ext_vec {
676 if ext.is_empty() {
677 exts.push(String::new());
678 } else if ext == "*" {
679 exts.push(s!(".*"));
680 exts.push(String::new());
681 } else if !pathname_ext.is_empty() && pathname_ext.eq_ignore_ascii_case(&ext) {
682 // Perl Pathname.pm L353: `if ($pathname =~ /\.\Q$ext\E$/i)` — /i
683 // makes this a case-insensitive extension match; either case of
684 // file extension matches either case of requested type.
685 exts.push(String::new());
686 // Also push the extension itself (Perl pushes both)
687 exts.push(format!(".{}", ext));
688 } else {
689 exts.push(format!(".{}", ext));
690 }
691 }
692 }
693 if exts.is_empty() {
694 exts.push(String::new());
695 }
696
697 let mut paths = Vec::new();
698 // Now, combine; precedence to leading directories.
699 for dir in &dirs {
700 for ext in &exts {
701 if name == "*" {
702 // TODO: wildcard directory listing support
703 } else {
704 paths.push(concat(dir, &(name.clone() + ext)));
705 }
706 }
707 }
708 paths
709}
710
711/// find the requested `pathname` using the `options` search configuration.
712/// Mirrors Perl `pathname_find` (LaTeXML/Util/Pathname.pm L376-392): directory
713/// search with strict-case match preferred, falling back to a
714/// case-insensitive directory scan. The fallback is required for arxiv
715/// papers shipping uppercase filenames (e.g. `PASJ95.STY` referenced as
716/// `PASJ95.sty`) — Perl's regex pair pushes both strict and `/i` matches
717/// and returns the strict ones if any exist, otherwise the case-insensitive
718/// matches. kpsewhich is the caller's responsibility (see
719/// `LaTeXML::Package::FindFile_aux`).
720pub fn find(pathname: &str, options: PathnameFindOptions) -> Option<String> {
721 if pathname.is_empty() {
722 return None;
723 }
724 let paths = candidate_pathnames(pathname, options);
725 // Pass 1: strict-case existence check (the fast path; matches Perl's
726 // `$local_file =~ m/$regex/` strict regex).
727 for path in &paths {
728 if Path::new(path).exists() {
729 return Some(path.clone());
730 }
731 }
732 // Pass 2: case-insensitive directory scan (Perl's `/i` regex fallback).
733 // Only fired when no strict match existed; mirrors Perl's
734 // `return @paths ? @paths : @nocase_paths` ordering.
735 for path in &paths {
736 let p = Path::new(path);
737 let dir = match p.parent() {
738 Some(d) if !d.as_os_str().is_empty() => d,
739 _ => Path::new("."),
740 };
741 let target = match p.file_name().and_then(|n| n.to_str()) {
742 Some(n) => n,
743 None => continue,
744 };
745 let entries = match std::fs::read_dir(dir) {
746 Ok(e) => e,
747 Err(_) => continue,
748 };
749 for entry in entries.flatten() {
750 if let Some(name) = entry.file_name().to_str()
751 && name.eq_ignore_ascii_case(target)
752 {
753 return entry.path().to_str().map(String::from);
754 }
755 }
756 }
757 None
758}
759
760/// transform to a canonical file name, via `Path::file_name`
761pub fn file_name(pathname: &str) -> String {
762 let canonical_pathname = canonical(pathname);
763 let canonical_path = Path::new(&canonical_pathname);
764 match canonical_path.file_name() {
765 Some(e) => e.to_string_lossy().to_string(),
766 None => String::new(),
767 }
768}
769
770/// transform to a base name (via `Path::file_stem`)
771/// Note: Perl's pathname_name returns the stem without extension and without case change.
772pub fn file_stem(pathname: &str) -> String {
773 let canonical_pathname = canonical(pathname);
774 let canonical_path = Path::new(&canonical_pathname);
775 match canonical_path.file_stem() {
776 Some(e) => e.to_string_lossy().to_string(),
777 None => String::new(),
778 }
779}
780
781/// obtain the directory portion of a pathname (via `Path::parent`)
782/// Matches Perl's pathname_directory.
783pub fn directory(pathname: &str) -> String {
784 let canonical_pathname = canonical(pathname);
785 let canonical_path = Path::new(&canonical_pathname);
786 match canonical_path.parent() {
787 Some(e) => e.to_string_lossy().to_string(),
788 None => String::new(),
789 }
790}
791
792/// obtain the extension portion of a pathname (via `Path::extension`).
793/// Perl's `pathname_type` preserves case; callers that need a lowercased
794/// form should apply `.to_ascii_lowercase()` themselves.
795pub fn extension(pathname: &str) -> String {
796 let canonical_pathname = canonical(pathname);
797 let canonical_path = Path::new(&canonical_pathname);
798 match canonical_path.extension() {
799 Some(e) => e.to_string_lossy().to_string(),
800 None => String::new(),
801 }
802}
803
804/// Compose a pathname from dir, name, type components.
805/// Port of Perl's pathname_make(%pieces).
806pub fn make(dir: Option<&str>, name: Option<&str>, ext: Option<&str>) -> String {
807 let mut result = String::new();
808 if let Some(d) = dir {
809 result.push_str(d);
810 }
811 if let Some(n) = name {
812 if !result.is_empty() && !result.ends_with('/') {
813 result.push('/');
814 }
815 result.push_str(n);
816 }
817 if let Some(t) = ext
818 && !t.is_empty()
819 {
820 result.push('.');
821 result.push_str(t);
822 }
823 canonical(&result)
824}
825
826/// Lexical relative path from `base` to `target`, with `..` for the divergent
827/// tail of `base` — the semantics of Perl's `File::Spec->abs2rel`, which
828/// `pathname_relative` is built on. Component-based, no symlink resolution.
829/// Both sides must be absolute (the only case `relative` calls it with); if
830/// they share no root, falls back to the target string.
831fn abs2rel(target: &Path, base: &Path) -> String {
832 use std::path::Component;
833 if !target.is_absolute() || !base.is_absolute() {
834 return target.to_string_lossy().to_string();
835 }
836 let t: Vec<Component> = target.components().collect();
837 let b: Vec<Component> = base.components().collect();
838 let common = t.iter().zip(b.iter()).take_while(|(a, c)| a == c).count();
839 if common == 0 {
840 return target.to_string_lossy().to_string();
841 }
842 let mut result = PathBuf::new();
843 for _ in 0..(b.len() - common) {
844 result.push("..");
845 }
846 for comp in &t[common..] {
847 result.push(comp.as_os_str());
848 }
849 // `PathBuf` re-joins with the OS separator, so on Windows the result is
850 // `\`-separated — but this feeds forward-slash-only outputs (graphic
851 // `candidates`, resource URLs) that must match Perl/pdflatex on every
852 // platform. Normalize on Windows only; on Unix `\` is a legal filename byte
853 // (and the parts are already `/`-joined), so the rewrite is compiled out
854 // rather than risk corrupting a backslash-bearing name.
855 #[cfg(windows)]
856 {
857 result.to_string_lossy().replace('\\', "/")
858 }
859 #[cfg(not(windows))]
860 {
861 result.to_string_lossy().into_owned()
862 }
863}
864
865/// Make a pathname relative to a base directory.
866/// Port of Perl's pathname_relative($pathname, $base).
867pub fn relative(pathname: &str, base: &str) -> String {
868 let canonical_pathname = canonical(pathname);
869 if base.is_empty() || !is_absolute(&canonical_pathname) {
870 return canonical_pathname;
871 }
872 let canonical_base = canonical(base);
873 // Perl's pathname_relative uses File::Spec->abs2rel, which emits a `../…`
874 // path when `pathname` is NOT a descendant of `base` (a sibling tree, e.g.
875 // a graphic reached through `\subimport*{../A/child/}` — issue #698).
876 // `strip_prefix` can only strip a descendant prefix, so it used to fall back
877 // to the raw ABSOLUTE path there, which then leaked into resource/graphic
878 // URLs. abs2rel matches Perl and never leaks an absolute path.
879 abs2rel(Path::new(&canonical_pathname), Path::new(&canonical_base))
880}
881
882/// Find all matching files (like pathname_findall).
883/// Port of Perl's pathname_findall($pathname, %options).
884pub fn findall(pathname: &str, options: PathnameFindOptions) -> Vec<String> {
885 candidate_pathnames(pathname, options)
886}
887
888// Memo store for `kpsewhich` (see its doc below).
889#[cfg(feature = "kpathsea")]
890std::thread_local! {
891 static KPSE_MEMO: std::cell::RefCell<rustc_hash::FxHashMap<String, Option<String>>> =
892 std::cell::RefCell::new(rustc_hash::FxHashMap::default());
893}
894
895/// Clear the per-thread kpsewhich memo. Called at the start of every
896/// conversion (prepare_session): the persistent (non-harness) cortex_worker
897/// runs many papers per thread, and a cached cwd-relative MISS from paper A
898/// (kpathsea's path spec includes `.`) would wrongly persist into paper B
899/// (PR_READINESS should-fix 12).
900#[cfg(feature = "kpathsea")]
901pub fn clear_kpsewhich_memo() { KPSE_MEMO.with(|m| m.borrow_mut().clear()); }
902
903#[cfg(not(feature = "kpathsea"))]
904pub fn clear_kpsewhich_memo() {}
905
906/// search for a list of candidate names via the external `kpsewhich` utility
907/// returning the first path that is found
908///
909/// Memoized (hits AND misses) per thread, keyed by the candidate list
910/// (Principle 5, closed by the 2026-07-02 perf audit): repeated lookups of
911/// the same missing asset (e.g. a figure referenced by many
912/// `\includegraphics`) otherwise re-probe kpathsea each time — a full
913/// fork-exec per probe on the subprocess-`kpsewhich` backend (portable
914/// builds without linked libkpathsea), a cheaper but nonzero library walk
915/// on the in-process backend. Results are stable for a fixed texmf tree
916/// (the same assumption kpathsea's own ls-R cache makes); the memo clears
917/// per conversion via `clear_kpsewhich_memo`.
918/// Say so, ONCE, when neither backend could be constructed.
919///
920/// A host TeX installation is OPTIONAL here — embedded bindings and dumps
921/// convert self-contained documents perfectly well without one — so this is a
922/// warning, not an error. It exists because a silent dead kpathsea is
923/// indistinguishable from a genuinely missing file: every lookup just reports
924/// `Can't find TeX file X`, which sends users (and us — issue #304) hunting
925/// `TEXINPUTS` instead of the resolver that never came up.
926#[cfg(feature = "kpathsea")]
927pub fn report_unavailable_kpathsea() {
928 static ONCE: std::sync::Once = std::sync::Once::new();
929 ONCE.call_once(|| {
930 let (backend, why) = kpathsea_backend();
931 if backend == KpathseaBackend::Unavailable {
932 crate::Warn!(
933 "kpathsea",
934 "unavailable",
935 s!(
936 "No TeX file resolution ({why}): files from a host texmf tree cannot \
937 be found. Embedded bindings still apply."
938 )
939 );
940 }
941 });
942}
943
944/// Without the `kpathsea` feature there is nothing to report at runtime.
945#[cfg(not(feature = "kpathsea"))]
946pub fn report_unavailable_kpathsea() {}
947
948#[cfg(feature = "kpathsea")]
949pub fn kpsewhich(candidates: &[&str]) -> Option<String> {
950 report_unavailable_kpathsea();
951 let key = candidates.join("\x1f");
952 if let Some(cached) = KPSE_MEMO.with(|m| m.borrow().get(&key).cloned()) {
953 return cached;
954 }
955 let result = kpsewhich_uncached(candidates);
956 KPSE_MEMO.with(|m| {
957 let mut m = m.borrow_mut();
958 // Long-lived processes (--server, test harness): bound the memo rather
959 // than grow without limit; 4096 distinct lookups per epoch is plenty.
960 if m.len() >= 4096 {
961 m.clear();
962 }
963 m.insert(key, result.clone());
964 });
965 result
966}
967
968/// Probe each candidate through one backend, in order, returning the first
969/// hit. Skips bogus bare-extension names and guards the in-process
970/// `guess_format_from_filename` panic.
971#[cfg(feature = "kpathsea")]
972fn find_first_via(kpse: &Kpaths, candidates: &[&str]) -> Option<String> {
973 for candidate in candidates {
974 // kpathsea-0.2.3 panics with "attempt to subtract with overflow" in
975 // `guess_format_from_filename` (lib.rs:92) when `filename.len()` is
976 // shorter than some alt_suffix the format-table holds (the L73 normal-
977 // suffix loop has a `filename.len() > suffix.len()` guard but the L92
978 // alt_suffix loop does NOT). User input like `\usepackage[opt]{}`
979 // produces a `.sty` candidate (empty stem) which trips this. Pre-filter
980 // those: a basename starting with `.` and containing only an extension
981 // is bogus to look up. The catch_unwind below remains as defense-in-
982 // depth. Witnesses: 0711.2664 (`.sty`), cs0503041 (`.sty`).
983 let basename = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate);
984 if basename.starts_with('.') && !basename[1..].contains('.') {
985 continue;
986 }
987 let result =
988 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| kpse.find_file(candidate)));
989 if let Ok(Some(path)) = result {
990 return Some(path);
991 }
992 }
993 None
994}
995
996#[cfg(feature = "kpathsea")]
997fn kpsewhich_uncached(candidates: &[&str]) -> Option<String> {
998 KPSE
999 .lock()
1000 .unwrap()
1001 .as_ref()
1002 .and_then(|kpse| find_first_via(kpse, candidates))
1003}
1004
1005/// Without the `kpathsea` feature, file resolution is unavailable — every lookup
1006/// returns `None` (graceful degradation). The host-side proc-macro codegen build
1007/// takes this path; it never resolves TeX files at compile time.
1008#[cfg(not(feature = "kpathsea"))]
1009pub fn kpsewhich(_candidates: &[&str]) -> Option<String> { None }
1010
1011/// check if pathname contains dangerous pieces
1012pub fn is_nasty(file: &str) -> bool { PATHNAME_IS_NASTY_RE.is_match(file) }
1013
1014/// returns the current working directory
1015pub fn cwd() -> String { env::current_dir().unwrap().to_string_lossy().to_string() }
1016
1017#[cfg(test)]
1018mod tests {
1019 use super::*;
1020
1021 /// `relative` is Perl's `pathname_relative` (→ `File::Spec->abs2rel`): a
1022 /// path that is NOT a descendant of `base` must come back as a `../…` path,
1023 /// never the raw absolute path. The strip_prefix port used to leak the
1024 /// absolute path here, which surfaced as a `/mnt/g/…` graphic URL (issue
1025 /// #698) and would do the same for any resource relativized against the
1026 /// source directory (e.g. `xslt.rs`).
1027 #[test]
1028 fn relative_emits_dotdot_for_non_descendant() {
1029 // A bare `/x` is not absolute on Windows (no drive), so prefix one there to
1030 // exercise the abs2rel branch on every platform. Results stay forward-slash.
1031 let abs = |p: &str| {
1032 if cfg!(windows) {
1033 format!("C:{p}")
1034 } else {
1035 p.to_string()
1036 }
1037 };
1038 // Sibling tree: base and target diverge one level up.
1039 assert_eq!(
1040 relative(
1041 &abs("/home/u/proj/A/child/images/pic.svg"),
1042 &abs("/home/u/proj/latexml")
1043 ),
1044 "../A/child/images/pic.svg"
1045 );
1046 // Descendant is unchanged (the case strip_prefix already handled).
1047 assert_eq!(
1048 relative(&abs("/home/u/proj/sub/pic.png"), &abs("/home/u/proj")),
1049 "sub/pic.png"
1050 );
1051 // Cousin that diverges two levels up.
1052 assert_eq!(
1053 relative(&abs("/a/b/c/f.tex"), &abs("/a/x/y")),
1054 "../../b/c/f.tex"
1055 );
1056 // A non-absolute pathname is returned canonicalized, as Perl does.
1057 assert_eq!(relative("sub/pic.png", &abs("/home/u/proj")), "sub/pic.png");
1058 // Empty base short-circuits to the canonical pathname.
1059 assert_eq!(relative(&abs("/a/b/pic.png"), ""), abs("/a/b/pic.png"));
1060 }
1061
1062 /// `choose_kpaths` branches that a real host cannot exercise: a MiKTeX
1063 /// banner, a failed in-process construction, and a TeX-less machine.
1064 #[cfg(feature = "kpathsea")]
1065 mod backend_selection {
1066 use super::*;
1067
1068 /// A real subprocess handle, or `None` where the host has no `kpsewhich`.
1069 fn subprocess() -> Option<Kpaths> { Kpaths::new_subprocess().ok() }
1070
1071 #[test]
1072 fn no_constructible_backend_reports_unavailable() {
1073 let (kpse, backend, why) =
1074 choose_kpaths(None, || Err("no lib"), || Err("no kpsewhich"), |_| true);
1075 assert!(kpse.is_none());
1076 assert_eq!(backend, KpathseaBackend::Unavailable);
1077 assert!(
1078 !why.is_empty(),
1079 "an unavailable backend must explain itself"
1080 );
1081 }
1082
1083 /// The regression this hardening exists for: a failed in-process
1084 /// construction used to `?` out and silently disable ALL file resolution
1085 /// instead of trying the subprocess backend.
1086 #[test]
1087 fn failed_in_process_construction_falls_back_to_subprocess() {
1088 if subprocess().is_none() {
1089 return; // no host kpsewhich — nothing to fall back to
1090 }
1091 let (kpse, backend, _) = choose_kpaths(
1092 None,
1093 || Err("libkpathsea did not initialize"),
1094 Kpaths::new_subprocess,
1095 |_| true,
1096 );
1097 assert!(kpse.is_some(), "must not give up while a kpsewhich exists");
1098 assert_eq!(backend, KpathseaBackend::Subprocess);
1099 }
1100
1101 #[test]
1102 fn miktex_banner_selects_subprocess_without_constructing_in_process() {
1103 if subprocess().is_none() {
1104 return;
1105 }
1106 let (kpse, backend, _) = choose_kpaths(
1107 Some("MiKTeX 24.1"),
1108 || panic!("the in-process backend must not be constructed on a MiKTeX host"),
1109 Kpaths::new_subprocess,
1110 |_| true,
1111 );
1112 assert!(kpse.is_some());
1113 assert_eq!(backend, KpathseaBackend::Subprocess);
1114 }
1115
1116 /// A linked backend that resolves nothing (sentinel miss) is abandoned for
1117 /// the subprocess one.
1118 #[test]
1119 fn sentinel_miss_falls_back_to_subprocess() {
1120 let Some(primary) = subprocess() else { return };
1121 if primary.is_in_process() {
1122 return; // this branch only fires for an in-process primary
1123 }
1124 let (kpse, backend, _) = choose_kpaths(None, Kpaths::new, Kpaths::new_subprocess, |_| false);
1125 assert!(kpse.is_some());
1126 assert_eq!(backend, KpathseaBackend::Subprocess);
1127 }
1128 }
1129
1130 /// No backend may conjure a hit for a name that cannot exist.
1131 #[cfg(feature = "kpathsea")]
1132 #[test]
1133 fn absent_names_resolve_to_none() {
1134 assert!(kpsewhich(&["lxo_definitely_absent_probe_304.tex"]).is_none());
1135 }
1136
1137 /// The backend chosen by `select_kpaths` must resolve a universal host
1138 /// file on whatever distribution is ambient — proving the shipped binary
1139 /// works out of the box on both TeX Live (in-process) and MiKTeX (subprocess
1140 /// fallback, since the linked libkpathsea can't read MiKTeX's fndb). Asks
1141 /// through the process-global `KPSE` handle (never constructs a second
1142 /// `Kpaths` — see the carve-out note). Skips cleanly when no TeX toolchain is
1143 /// present (e.g. a bare CI runner), so it can't spuriously fail there.
1144 #[cfg(feature = "kpathsea")]
1145 #[test]
1146 fn selected_backend_resolves_host_files() {
1147 let cmr = kpsewhich(&["cmr10.tfm"]);
1148 if cmr.is_none() && kpsewhich(&["article.cls"]).is_none() {
1149 return; // no TeX toolchain in this environment — nothing to assert
1150 }
1151 let in_process = KPSE
1152 .lock()
1153 .unwrap()
1154 .as_ref()
1155 .map(|k| k.is_in_process())
1156 .unwrap_or(false);
1157 assert!(
1158 cmr.is_some(),
1159 "selected kpathsea backend failed to resolve the universal cmr10.tfm \
1160 (in_process={in_process}); a MiKTeX host must fall back to subprocess"
1161 );
1162 }
1163
1164 fn pathsearch_tmproot(tag: &str) -> PathBuf {
1165 let mut d = env::temp_dir();
1166 d.push(format!("lxo_pathsearch_{}_{}", std::process::id(), tag));
1167 let _ = std::fs::remove_dir_all(&d);
1168 std::fs::create_dir_all(&d).unwrap();
1169 d
1170 }
1171
1172 /// A `--path` ending in `//` searches the directory tree RECURSIVELY
1173 /// (kpsewhich convention); a plain path searches only that directory.
1174 #[test]
1175 fn recursive_double_slash_descends_into_subdirs() {
1176 let root = pathsearch_tmproot("rec");
1177 let deep = root.join("a").join("b");
1178 std::fs::create_dir_all(&deep).unwrap();
1179 std::fs::write(deep.join("target.tex"), "x").unwrap();
1180 let root_s = root.to_str().unwrap().replace('\\', "/");
1181
1182 let found = find("target.tex", PathnameFindOptions {
1183 paths: Some(vec![format!("{root_s}//")]),
1184 ..Default::default()
1185 });
1186 assert!(
1187 found.as_deref().is_some_and(|p| p.ends_with("target.tex")),
1188 "recursive `//` should find the nested target.tex, got {found:?}"
1189 );
1190
1191 let flat = find("target.tex", PathnameFindOptions {
1192 paths: Some(vec![root_s]),
1193 ..Default::default()
1194 });
1195 assert!(
1196 flat.is_none(),
1197 "a plain (non-`//`) path must NOT descend into subdirectories, got {flat:?}"
1198 );
1199 let _ = std::fs::remove_dir_all(&root);
1200 }
1201
1202 /// The directory named directly by a `--path` (no `//`) is still searched —
1203 /// recursion is opt-in, not a regression of the flat case.
1204 #[test]
1205 fn plain_path_finds_file_in_that_dir() {
1206 let root = pathsearch_tmproot("flat");
1207 std::fs::write(root.join("here.tex"), "x").unwrap();
1208 let root_s = root.to_str().unwrap().replace('\\', "/");
1209 let found = find("here.tex", PathnameFindOptions {
1210 paths: Some(vec![root_s]),
1211 ..Default::default()
1212 });
1213 assert!(
1214 found.as_deref().is_some_and(|p| p.ends_with("here.tex")),
1215 "plain path should find a file directly in it, got {found:?}"
1216 );
1217 let _ = std::fs::remove_dir_all(&root);
1218 }
1219
1220 #[test]
1221 fn is_url_schemes() {
1222 // Perl `pathname_is_url`: `=~ /^($PROTOCOL_RE)/` — the protocol must be
1223 // ANCHORED at the start; a bare host (no /path) still matches.
1224 assert!(is_url("http://example.com/path"));
1225 assert!(is_url("http://example.com/path/file.tex"));
1226 assert!(is_url("ftp://host/file"));
1227 assert!(is_url("https://example.com")); // bare host — Perl matches the prefix
1228 assert!(!is_url("plain/path/file.tex"));
1229 assert!(!is_url("/absolute/path"));
1230 // A filename that merely CONTAINS a protocol mid-string is NOT a URL —
1231 // the old `^\w+://…` matched `myers_http://…` via its leading `\w+`,
1232 // wrongly resolving a JabRef `\bibAnnoteFile` key as an existing URL
1233 // (witness 1509.01434, "Script _").
1234 assert!(!is_url(
1235 "myers_http://www.mscs.dal.ca/myers/welcome.html_2014"
1236 ));
1237 assert!(!is_url("foo_ftp://bar/baz"));
1238 }
1239
1240 #[test]
1241 fn is_literaldata_prefix() {
1242 assert!(is_literaldata("literal:foo"));
1243 assert!(!is_literaldata("file:foo"));
1244 assert!(!is_literaldata("plain"));
1245 }
1246
1247 #[test]
1248 fn is_raw_tex_extensions() {
1249 assert!(is_raw("main.tex"));
1250 assert!(is_raw("hyphen.cfg"));
1251 assert!(is_raw("T1enc.def"));
1252 assert!(is_raw("article.cls"));
1253 assert!(is_raw("french.ldf"));
1254 assert!(!is_raw("foo.pdf"));
1255 assert!(!is_raw("bar.png"));
1256 assert!(!is_raw("baz"));
1257 }
1258
1259 #[test]
1260 fn is_reloadable_only_ldf() {
1261 assert!(is_reloadable("french.ldf"));
1262 assert!(!is_reloadable("main.tex"));
1263 assert!(!is_reloadable("foo.sty"));
1264 assert!(!is_reloadable("baz"));
1265 }
1266
1267 #[test]
1268 fn extension_basic() {
1269 assert_eq!(extension("foo.tex"), "tex");
1270 assert_eq!(extension("path/to/main.cls"), "cls");
1271 assert_eq!(extension("no_ext"), "");
1272 assert_eq!(extension("double.dot.ext"), "ext");
1273 }
1274
1275 #[test]
1276 fn file_name_strips_dirs() {
1277 assert_eq!(file_name("path/to/foo.tex"), "foo.tex");
1278 assert_eq!(file_name("foo.tex"), "foo.tex");
1279 assert_eq!(file_name("/abs/path/foo.tex"), "foo.tex");
1280 }
1281
1282 #[test]
1283 fn file_stem_strips_ext() {
1284 assert_eq!(file_stem("foo.tex"), "foo");
1285 assert_eq!(file_stem("path/to/foo.cls"), "foo");
1286 assert_eq!(file_stem("no_ext"), "no_ext");
1287 }
1288
1289 #[test]
1290 fn directory_returns_dir() {
1291 assert_eq!(directory("path/to/foo.tex"), "path/to");
1292 assert!(
1293 directory("foo.tex").is_empty() || directory("foo.tex") == ".",
1294 "relative-only filename: dir is empty or '.'"
1295 );
1296 }
1297
1298 #[test]
1299 fn make_reassembles_components() {
1300 let p = make(Some("path"), Some("foo"), Some("tex"));
1301 assert_eq!(p, "path/foo.tex");
1302 }
1303
1304 #[test]
1305 fn make_none_dir() {
1306 let p = make(None, Some("foo"), Some("tex"));
1307 // No directory → no leading slash.
1308 assert_eq!(p, "foo.tex");
1309 }
1310
1311 #[test]
1312 fn concat_joins_with_slash() {
1313 assert_eq!(concat("path", "foo.tex"), "path/foo.tex");
1314 assert_eq!(concat("a/b", "c.tex"), "a/b/c.tex");
1315 }
1316
1317 #[test]
1318 fn url_split_basic() {
1319 // URL_RE captures groups: 1=host+dir, 2=filename.
1320 let (base, file) = url_split("http://example.com/path/file.tex");
1321 assert_eq!(base, "example.com/path");
1322 assert_eq!(file, "file.tex");
1323 }
1324
1325 #[test]
1326 fn url_split_non_url_gets_index() {
1327 // Non-URL input falls back to (input, "index.tex").
1328 let (proto, rest) = url_split("plain_string");
1329 assert_eq!(proto, "plain_string");
1330 assert_eq!(rest, "index.tex");
1331 }
1332
1333 #[test]
1334 fn split_basic_path() {
1335 let (d, n, e) = split("path/to/foo.tex");
1336 assert_eq!(d, "path/to");
1337 assert_eq!(n, "foo");
1338 assert_eq!(e, "tex");
1339 }
1340
1341 #[test]
1342 fn split_no_ext() {
1343 let (_d, n, e) = split("foo");
1344 assert_eq!(n, "foo");
1345 assert_eq!(e, "");
1346 }
1347
1348 #[test]
1349 fn is_nasty_detects_bad_patterns() {
1350 // Consult the regex; typical "nasty" is path traversal `..` or
1351 // shell chars.
1352 let has_dotdot = is_nasty("path/../bad");
1353 let safe = is_nasty("foo.tex");
1354 // Whatever the regex is, path traversal should be flagged and a
1355 // plain filename should not.
1356 assert!(!safe, "plain filename should not be nasty");
1357 // Keep the dotdot assertion loose — the exact patterns are
1358 // implementation-defined.
1359 let _ = has_dotdot;
1360 }
1361}