Skip to main content

mant_engine/tldr/
cache.rs

1//! Resolves installed-client caches and reads tldr pages without network I/O.
2
3use std::{
4    collections::{BTreeMap, HashSet},
5    env,
6    error::Error,
7    ffi::OsStr,
8    fmt, fs, io,
9    path::{Path, PathBuf},
10};
11
12use mant_ir::TldrDocument;
13
14use crate::executable::{environment_value, find_executable};
15
16use super::parser::{TldrPageLocation, TldrParseError, parse_tldr_page};
17
18const ALL_PLATFORMS: &[&str] = &[
19    "common",
20    "linux",
21    "osx",
22    "macos",
23    "windows",
24    "android",
25    "freebsd",
26    "openbsd",
27    "netbsd",
28    "sunos",
29    "cisco-ios",
30    "dos",
31];
32
33/// Native host families supported by `ManT` distributions.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HostPlatform {
36    /// Linux filesystem and environment conventions.
37    Linux,
38    /// macOS filesystem and environment conventions.
39    Macos,
40    /// Windows filesystem and environment conventions.
41    Windows,
42}
43
44impl HostPlatform {
45    /// Identify the current build target.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`TldrCacheError::UnsupportedPlatform`] outside supported hosts.
50    pub fn current() -> Result<Self, TldrCacheError> {
51        if cfg!(target_os = "linux") {
52            Ok(Self::Linux)
53        } else if cfg!(target_os = "macos") {
54            Ok(Self::Macos)
55        } else if cfg!(windows) {
56            Ok(Self::Windows)
57        } else {
58            Err(TldrCacheError::UnsupportedPlatform)
59        }
60    }
61}
62
63/// Offline cache discovery or page-read failure.
64#[derive(Debug)]
65pub enum TldrCacheError {
66    /// The build target has no defined cache convention.
67    UnsupportedPlatform,
68    /// A Unix-like cache path requires `HOME`, but none is available.
69    MissingHomeDirectory,
70    /// A Windows cache path requires `LOCALAPPDATA`, but none is available.
71    MissingLocalAppData,
72    /// A selected cache page could not be read.
73    Read {
74        /// Physical cached page path.
75        path: PathBuf,
76        /// Underlying filesystem failure.
77        source: io::Error,
78    },
79    /// A selected cache page did not satisfy the tldr dialect.
80    Parse {
81        /// Physical cached page path.
82        path: PathBuf,
83        /// Structured tldr parser failure.
84        source: TldrParseError,
85    },
86}
87
88impl fmt::Display for TldrCacheError {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::UnsupportedPlatform => {
92                formatter.write_str("tldr cache lookup is unsupported on this platform")
93            }
94            Self::MissingHomeDirectory => {
95                formatter.write_str("cannot locate a tldr cache without HOME")
96            }
97            Self::MissingLocalAppData => {
98                formatter.write_str("cannot locate a tldr cache without LOCALAPPDATA")
99            }
100            Self::Read { path, source } => {
101                write!(
102                    formatter,
103                    "cannot read cached tldr page {}: {source}",
104                    path.display()
105                )
106            }
107            Self::Parse { path, source } => {
108                write!(
109                    formatter,
110                    "cannot parse cached tldr page {}: {source}",
111                    path.display()
112                )
113            }
114        }
115    }
116}
117
118impl Error for TldrCacheError {
119    fn source(&self) -> Option<&(dyn Error + 'static)> {
120        match self {
121            Self::Read { source, .. } => Some(source),
122            Self::Parse { source, .. } => Some(source),
123            Self::UnsupportedPlatform | Self::MissingHomeDirectory | Self::MissingLocalAppData => {
124                None
125            }
126        }
127    }
128}
129
130/// Resolve the `ManT`-owned fallback checkout for an explicit environment.
131///
132/// # Errors
133///
134/// Returns a platform-specific location error when neither an explicit
135/// override nor the native cache base (`HOME` or `LOCALAPPDATA`) is available.
136pub fn get_tldr_cache_dir(
137    environment: &BTreeMap<String, String>,
138    platform: HostPlatform,
139) -> Result<PathBuf, TldrCacheError> {
140    if let Some(path) = environment_value(environment, "MANT_TLDR_DIR") {
141        return Ok(PathBuf::from(path));
142    }
143    match platform {
144        HostPlatform::Linux => {
145            let home = home_dir(environment)?;
146            Ok(
147                environment_value(environment, "XDG_CACHE_HOME").map_or_else(
148                    || home.join(".cache").join("mant").join("tldr-pages"),
149                    |cache| PathBuf::from(cache).join("mant").join("tldr-pages"),
150                ),
151            )
152        }
153        HostPlatform::Macos => Ok(home_dir(environment)?
154            .join("Library")
155            .join("Caches")
156            .join("ManT")
157            .join("tldr-pages")),
158        HostPlatform::Windows => Ok(local_app_data(environment)?
159            .join("ManT")
160            .join("cache")
161            .join("tldr-pages")),
162    }
163}
164
165/// Return known installed-client cache roots in priority order.
166///
167/// # Errors
168///
169/// Returns [`TldrCacheError::MissingHomeDirectory`] when `HOME` is absent.
170pub fn get_system_tldr_cache_dirs(
171    environment: &BTreeMap<String, String>,
172    platform: HostPlatform,
173) -> Result<Vec<PathBuf>, TldrCacheError> {
174    if platform == HostPlatform::Windows {
175        let local = local_app_data(environment)?;
176        let roaming = environment_value(environment, "APPDATA").map(PathBuf::from);
177        let mut candidates = vec![
178            local.join("tldr"),
179            local.join("tlrc"),
180            local.join("tealdeer").join("tldr-pages"),
181        ];
182        if let Some(roaming) = roaming {
183            candidates.push(roaming.join("tldr"));
184            candidates.push(roaming.join("tlrc"));
185        }
186        if let Some(home) = optional_home_dir(environment) {
187            candidates.push(home.join(".tldrc").join("tldr"));
188            candidates.push(home.join(".tldr").join("cache"));
189            candidates.push(home.join(".tldr"));
190        }
191        return Ok(deduplicate_paths(candidates));
192    }
193
194    let home = home_dir(environment)?;
195    let portable_cache = environment_value(environment, "XDG_CACHE_HOME")
196        .map_or_else(|| home.join(".cache"), PathBuf::from);
197    let native_cache = match platform {
198        HostPlatform::Linux => portable_cache.clone(),
199        HostPlatform::Macos => home.join("Library").join("Caches"),
200        HostPlatform::Windows => unreachable!("Windows returned above"),
201    };
202    let mut candidates = vec![
203        portable_cache.join("tldr"),
204        native_cache.join("tlrc"),
205        portable_cache.join("tlrc"),
206        native_cache.join("tealdeer").join("tldr-pages"),
207        portable_cache.join("tealdeer").join("tldr-pages"),
208        // Homebrew's `tldr` formula installs tldr-c-client, which extracts
209        // the upstream repository below this root on every supported host.
210        home.join(".tldrc").join("tldr"),
211        // The official Node client adds one private `cache` layer beneath its
212        // configured root (which defaults to ~/.tldr).
213        home.join(".tldr").join("cache"),
214        home.join(".tldr"),
215    ];
216
217    if let Some(value) = environment_value(environment, "XDG_DATA_DIRS") {
218        candidates.extend(
219            env::split_paths(OsStr::new(value))
220                .filter(|path| !path.as_os_str().is_empty())
221                .map(|path| path.join("tldr")),
222        );
223    } else {
224        candidates.extend(
225            ["/usr/local/share", "/usr/share"]
226                .into_iter()
227                .map(|path| PathBuf::from(path).join("tldr")),
228        );
229    }
230    Ok(deduplicate_paths(candidates))
231}
232
233/// Select installed-client caches followed by `ManT`'s private fallback.
234///
235/// # Errors
236///
237/// Propagates cache path resolution failures.
238pub fn get_tldr_read_cache_dirs(
239    environment: &BTreeMap<String, String>,
240    platform: HostPlatform,
241    tldr_installed: bool,
242) -> Result<Vec<PathBuf>, TldrCacheError> {
243    if environment_value(environment, "MANT_TLDR_DIR").is_some() {
244        return get_tldr_cache_dir(environment, platform).map(|path| vec![path]);
245    }
246    let private_cache = get_tldr_cache_dir(environment, platform)?;
247    if !tldr_installed {
248        return Ok(vec![private_cache]);
249    }
250    let mut caches = get_system_tldr_cache_dirs(environment, platform)?;
251    caches.push(private_cache);
252    Ok(deduplicate_paths(caches))
253}
254
255/// Resolve locale candidates, retaining first occurrence priority.
256#[must_use]
257pub fn get_tldr_languages(environment: &BTreeMap<String, String>) -> Vec<String> {
258    let mut languages = Vec::new();
259    if environment
260        .get("LANG")
261        .is_some_and(|lang| !matches!(lang.as_str(), "C" | "POSIX"))
262    {
263        if let Some(language) = environment.get("LANGUAGE") {
264            for locale in language.split(':') {
265                languages.extend(normalize_locale(locale));
266            }
267        }
268        if let Some(locale) = environment.get("LANG") {
269            languages.extend(normalize_locale(locale));
270        }
271    }
272    languages.push("en".to_owned());
273    deduplicate_strings(languages)
274}
275
276/// Resolve host, common, then cross-platform fallback page directories.
277#[must_use]
278pub fn get_tldr_platforms(platform: HostPlatform) -> Vec<String> {
279    let mut platforms = match platform {
280        HostPlatform::Linux => vec!["linux".to_owned()],
281        HostPlatform::Macos => vec!["osx".to_owned(), "macos".to_owned()],
282        HostPlatform::Windows => vec!["windows".to_owned()],
283    };
284    platforms.extend(ALL_PLATFORMS.iter().map(ToString::to_string));
285    deduplicate_strings(platforms)
286}
287
288/// Convert a multi-word query to the tldr filename convention.
289#[must_use]
290pub fn normalize_tldr_topic(topic: &str) -> String {
291    topic
292        .trim()
293        .to_lowercase()
294        .split_whitespace()
295        .collect::<Vec<_>>()
296        .join("-")
297}
298
299/// Reject a normalized topic that would escape the platform page directory.
300///
301/// The topic becomes a single `<page>.md` filename joined onto a cache root,
302/// so it must be exactly one ordinary path component. Anything containing a
303/// path separator, a `.`/`..` segment, or an absolute or prefix component is
304/// refused before it reaches the filesystem, which prevents an untrusted topic
305/// (for example one supplied over MCP) from reading files outside the cache.
306fn is_safe_page_name(page_name: &str) -> bool {
307    let mut components = Path::new(page_name).components();
308    matches!(
309        (components.next(), components.next()),
310        (Some(std::path::Component::Normal(_)), None)
311    )
312}
313
314/// Read one cached tldr page using current host conventions; never updates it.
315///
316/// # Errors
317///
318/// Returns a cache path, I/O, or parser error. A missing page is `Ok(None)`.
319pub fn read_cached_tldr_page(topic: &str) -> Result<Option<TldrDocument>, TldrCacheError> {
320    let environment = env::vars().collect::<BTreeMap<_, _>>();
321    let platform = HostPlatform::current()?;
322    let cache_dirs = get_tldr_read_cache_dirs(
323        &environment,
324        platform,
325        find_executable("tldr", &environment).is_some(),
326    )?;
327    read_cached_tldr_page_with(
328        topic,
329        &cache_dirs,
330        &get_tldr_languages(&environment),
331        &get_tldr_platforms(platform),
332        &SystemFileReader,
333    )
334}
335
336trait TldrFileReader {
337    fn is_file(&self, path: &Path) -> bool;
338    fn read_to_string(&self, path: &Path) -> io::Result<String>;
339}
340
341struct SystemFileReader;
342
343impl TldrFileReader for SystemFileReader {
344    fn is_file(&self, path: &Path) -> bool {
345        path.is_file()
346    }
347
348    fn read_to_string(&self, path: &Path) -> io::Result<String> {
349        // A tldr page is markdown, so hold it to the same byte ceiling as any
350        // other markdown source. Reading unbounded here lets a corrupt cache
351        // entry or a device file streamed in its place exhaust memory.
352        let file = fs::File::open(path)?;
353        crate::bounded::read_utf8(file, crate::query::MAX_MARKDOWN_BYTES, "Markdown document")
354    }
355}
356
357fn read_cached_tldr_page_with(
358    topic: &str,
359    cache_dirs: &[PathBuf],
360    languages: &[String],
361    platforms: &[String],
362    files: &dyn TldrFileReader,
363) -> Result<Option<TldrDocument>, TldrCacheError> {
364    let page_name = normalize_tldr_topic(topic);
365    if page_name.is_empty() || !is_safe_page_name(&page_name) {
366        return Ok(None);
367    }
368
369    // The client specification gives host platform precedence over language.
370    for platform in platforms {
371        for language in languages {
372            let page_directories = if language == "en" {
373                vec!["pages".to_owned(), "pages.en".to_owned()]
374            } else {
375                vec![format!("pages.{language}")]
376            };
377            for cache_dir in cache_dirs {
378                for pages in &page_directories {
379                    let source_path = cache_dir
380                        .join(pages)
381                        .join(platform)
382                        .join(format!("{page_name}.md"));
383                    if !files.is_file(&source_path) {
384                        continue;
385                    }
386                    let markdown = files.read_to_string(&source_path).map_err(|source| {
387                        TldrCacheError::Read {
388                            path: source_path.clone(),
389                            source,
390                        }
391                    })?;
392                    let page = parse_tldr_page(
393                        &markdown,
394                        TldrPageLocation {
395                            platform: platform.clone(),
396                            language: language.clone(),
397                            source_path: source_path.to_string_lossy().into_owned(),
398                        },
399                    )
400                    .map_err(|source| TldrCacheError::Parse {
401                        path: source_path,
402                        source,
403                    })?;
404                    return Ok(Some(page));
405                }
406            }
407        }
408    }
409    Ok(None)
410}
411
412fn home_dir(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
413    optional_home_dir(environment).ok_or(TldrCacheError::MissingHomeDirectory)
414}
415
416fn optional_home_dir(environment: &BTreeMap<String, String>) -> Option<PathBuf> {
417    environment_value(environment, "HOME")
418        .or_else(|| environment_value(environment, "USERPROFILE"))
419        .filter(|home| !home.is_empty())
420        .map(PathBuf::from)
421}
422
423fn local_app_data(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
424    environment_value(environment, "LOCALAPPDATA")
425        .filter(|path| !path.is_empty())
426        .map(PathBuf::from)
427        .ok_or(TldrCacheError::MissingLocalAppData)
428}
429
430fn normalize_locale(locale: &str) -> Vec<String> {
431    let normalized = locale
432        .split('.')
433        .next()
434        .unwrap_or_default()
435        .replace('-', "_");
436    if normalized.is_empty() || matches!(normalized.as_str(), "C" | "POSIX") {
437        return Vec::new();
438    }
439    let language = normalized.split('_').next().unwrap_or_default().to_owned();
440    if normalized == language {
441        vec![language]
442    } else {
443        vec![normalized, language]
444    }
445}
446
447fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
448    let mut seen = HashSet::new();
449    paths
450        .into_iter()
451        .filter(|path| seen.insert(path.clone()))
452        .collect()
453}
454
455fn deduplicate_strings(values: Vec<String>) -> Vec<String> {
456    let mut seen = HashSet::new();
457    values
458        .into_iter()
459        .filter(|value| seen.insert(value.clone()))
460        .collect()
461}
462
463#[cfg(test)]
464mod tests {
465    use std::{
466        collections::{BTreeMap, HashMap},
467        fs, io,
468        path::{Path, PathBuf},
469    };
470
471    use super::{
472        HostPlatform, SystemFileReader, TldrFileReader, get_system_tldr_cache_dirs,
473        get_tldr_cache_dir, get_tldr_languages, get_tldr_platforms, get_tldr_read_cache_dirs,
474        normalize_tldr_topic, read_cached_tldr_page_with,
475    };
476    use crate::query::MAX_MARKDOWN_BYTES;
477
478    const PAGE: &str = "# tar\n\n> Archiving utility.\n\n- List: `tar --list`\n";
479
480    #[derive(Default)]
481    struct MemoryFiles {
482        files: HashMap<PathBuf, String>,
483    }
484
485    impl TldrFileReader for MemoryFiles {
486        fn is_file(&self, path: &Path) -> bool {
487            self.files.contains_key(path)
488        }
489
490        fn read_to_string(&self, path: &Path) -> io::Result<String> {
491            self.files
492                .get(path)
493                .cloned()
494                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "memory fixture is missing"))
495        }
496    }
497
498    fn env(values: &[(&str, &str)]) -> BTreeMap<String, String> {
499        values
500            .iter()
501            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
502            .collect()
503    }
504
505    #[test]
506    fn resolves_mant_and_installed_client_cache_conventions() {
507        let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
508        assert_eq!(
509            get_tldr_cache_dir(&environment, HostPlatform::Linux).expect("cache dir"),
510            PathBuf::from("/cache/mant/tldr-pages")
511        );
512        assert_eq!(
513            get_tldr_cache_dir(&environment, HostPlatform::Macos).expect("cache dir"),
514            PathBuf::from("/home/test/Library/Caches/ManT/tldr-pages")
515        );
516        assert_eq!(
517            get_system_tldr_cache_dirs(&environment, HostPlatform::Linux).expect("system caches"),
518            [
519                "/cache/tldr",
520                "/cache/tlrc",
521                "/cache/tealdeer/tldr-pages",
522                "/home/test/.tldrc/tldr",
523                "/home/test/.tldr/cache",
524                "/home/test/.tldr",
525                "/usr/local/share/tldr",
526                "/usr/share/tldr",
527            ]
528            .map(PathBuf::from)
529        );
530        assert_eq!(
531            get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, false)
532                .expect("fallback cache"),
533            [PathBuf::from("/cache/mant/tldr-pages")]
534        );
535        assert_eq!(
536            get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
537                .expect("client caches and fallback"),
538            [
539                "/cache/tldr",
540                "/cache/tlrc",
541                "/cache/tealdeer/tldr-pages",
542                "/home/test/.tldrc/tldr",
543                "/home/test/.tldr/cache",
544                "/home/test/.tldr",
545                "/usr/local/share/tldr",
546                "/usr/share/tldr",
547                "/cache/mant/tldr-pages",
548            ]
549            .map(PathBuf::from)
550        );
551    }
552
553    #[test]
554    fn windows_uses_local_application_data_for_private_and_client_caches() {
555        let environment = env(&[
556            ("LOCALAPPDATA", r"C:\Users\test\AppData\Local"),
557            ("APPDATA", r"C:\Users\test\AppData\Roaming"),
558            ("USERPROFILE", r"C:\Users\test"),
559        ]);
560        assert_eq!(
561            get_tldr_cache_dir(&environment, HostPlatform::Windows).expect("private cache"),
562            PathBuf::from(r"C:\Users\test\AppData\Local").join("ManT/cache/tldr-pages")
563        );
564        assert_eq!(
565            get_system_tldr_cache_dirs(&environment, HostPlatform::Windows).expect("client caches"),
566            [
567                PathBuf::from(r"C:\Users\test\AppData\Local").join("tldr"),
568                PathBuf::from(r"C:\Users\test\AppData\Local").join("tlrc"),
569                PathBuf::from(r"C:\Users\test\AppData\Local").join("tealdeer/tldr-pages"),
570                PathBuf::from(r"C:\Users\test\AppData\Roaming").join("tldr"),
571                PathBuf::from(r"C:\Users\test\AppData\Roaming").join("tlrc"),
572                PathBuf::from(r"C:\Users\test").join(".tldrc/tldr"),
573                PathBuf::from(r"C:\Users\test").join(".tldr/cache"),
574                PathBuf::from(r"C:\Users\test").join(".tldr"),
575            ]
576        );
577        assert_eq!(
578            &get_tldr_platforms(HostPlatform::Windows)[..2],
579            ["windows", "common"]
580        );
581    }
582
583    #[test]
584    fn reads_homebrew_c_client_and_node_client_cache_layouts_on_macos() {
585        let environment = env(&[("HOME", "/Users/test")]);
586        let cache_dirs = get_system_tldr_cache_dirs(&environment, HostPlatform::Macos)
587            .expect("macOS client caches");
588
589        for source in [
590            PathBuf::from("/Users/test/.tldrc/tldr/pages/common/tar.md"),
591            PathBuf::from("/Users/test/.tldr/cache/pages/common/tar.md"),
592        ] {
593            let files = MemoryFiles {
594                files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
595            };
596            let page = read_cached_tldr_page_with(
597                "tar",
598                &cache_dirs,
599                &["en".to_owned()],
600                &["osx".to_owned(), "common".to_owned()],
601                &files,
602            )
603            .expect("cache read")
604            .expect("page");
605
606            assert_eq!(Path::new(&page.source_path), source);
607        }
608    }
609
610    #[test]
611    fn installed_client_miss_falls_back_to_mant_private_cache() {
612        let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
613        let cache_dirs = get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
614            .expect("client and private caches");
615        let private_page = PathBuf::from("/cache/mant/tldr-pages/pages/common/tar.md");
616        let files = MemoryFiles {
617            files: [(private_page.clone(), PAGE.to_owned())]
618                .into_iter()
619                .collect(),
620        };
621
622        let page = read_cached_tldr_page_with(
623            "tar",
624            &cache_dirs,
625            &["en".to_owned()],
626            &["linux".to_owned(), "common".to_owned()],
627            &files,
628        )
629        .expect("cache read")
630        .expect("private fallback page");
631
632        assert_eq!(Path::new(&page.source_path), private_page);
633    }
634
635    #[test]
636    fn explicit_cache_is_independent_from_an_installed_client() {
637        let environment = env(&[("HOME", "/home/test"), ("MANT_TLDR_DIR", "/custom/tldr")]);
638        assert_eq!(
639            get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
640                .expect("explicit cache"),
641            [PathBuf::from("/custom/tldr")]
642        );
643    }
644
645    #[test]
646    fn normalizes_topic_locale_and_platform_priority() {
647        let environment = env(&[("LANG", "pt_BR.UTF-8"), ("LANGUAGE", "zh_TW:pt_BR")]);
648        assert_eq!(
649            get_tldr_languages(&environment),
650            ["zh_TW", "zh", "pt_BR", "pt", "en"]
651        );
652        assert_eq!(
653            &get_tldr_platforms(HostPlatform::Linux)[..3],
654            ["linux", "common", "osx"]
655        );
656        assert_eq!(normalize_tldr_topic(" Git Commit "), "git-commit");
657    }
658
659    #[test]
660    fn host_platform_precedes_a_translated_common_page() {
661        let root = PathBuf::from("/cache");
662        let english_linux = root.join("pages/linux/tar.md");
663        let translated_common = root.join("pages.zh/common/tar.md");
664        let files = MemoryFiles {
665            files: [
666                (english_linux.clone(), PAGE.to_owned()),
667                (translated_common, PAGE.to_owned()),
668            ]
669            .into_iter()
670            .collect(),
671        };
672        let page = read_cached_tldr_page_with(
673            "tar",
674            &[root],
675            &["zh".to_owned(), "en".to_owned()],
676            &["linux".to_owned(), "common".to_owned()],
677            &files,
678        )
679        .expect("cache read")
680        .expect("page");
681        assert_eq!(Path::new(&page.source_path), english_linux);
682        assert_eq!(page.language, "en");
683        assert_eq!(page.platform, "linux");
684    }
685
686    #[test]
687    fn reads_pages_dot_en_layout_after_repository_layout() {
688        let root = PathBuf::from("/cache/tlrc");
689        let source = root.join("pages.en/linux/tar.md");
690        let files = MemoryFiles {
691            files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
692        };
693        let page = read_cached_tldr_page_with(
694            "tar",
695            &[root],
696            &["en".to_owned()],
697            &["linux".to_owned()],
698            &files,
699        )
700        .expect("cache read")
701        .expect("page");
702        assert_eq!(Path::new(&page.source_path), source);
703    }
704
705    #[test]
706    fn refuses_topics_that_escape_the_platform_page_directory() {
707        let root = PathBuf::from("/cache");
708        // A page planted where a naive join of a traversal topic would land.
709        let escaped = PathBuf::from("/etc/hostname.md");
710        let files = MemoryFiles {
711            files: [(escaped, PAGE.to_owned())].into_iter().collect(),
712        };
713
714        for topic in ["../../../../etc/hostname", "/etc/hostname", "..", "a/b"] {
715            let result = read_cached_tldr_page_with(
716                topic,
717                std::slice::from_ref(&root),
718                &["en".to_owned()],
719                &["linux".to_owned()],
720                &files,
721            )
722            .expect("cache read must not error");
723            assert!(
724                result.is_none(),
725                "traversal topic {topic:?} must not resolve a page"
726            );
727        }
728    }
729
730    #[test]
731    fn only_single_ordinary_components_are_safe_page_names() {
732        assert!(super::is_safe_page_name("tar"));
733        assert!(super::is_safe_page_name("git-commit"));
734        assert!(!super::is_safe_page_name("../etc/passwd"));
735        assert!(!super::is_safe_page_name("/etc/passwd"));
736        assert!(!super::is_safe_page_name(".."));
737        assert!(!super::is_safe_page_name("a/b"));
738    }
739
740    fn temporary_page(label: &str) -> PathBuf {
741        std::env::temp_dir().join(format!(
742            "mant-tldr-cap-{label}-{}-{:?}.md",
743            std::process::id(),
744            std::thread::current().id()
745        ))
746    }
747
748    #[test]
749    fn system_reader_reads_ordinary_pages_and_rejects_oversized_ones() {
750        // The disk-backed reader must enforce the same byte ceiling as every
751        // other markdown source; a small page reads through, one past the limit
752        // is refused as invalid data rather than buffered whole.
753        let ordinary = temporary_page("ordinary");
754        fs::write(&ordinary, PAGE).expect("write ordinary page");
755        assert_eq!(
756            SystemFileReader
757                .read_to_string(&ordinary)
758                .expect("ordinary page reads"),
759            PAGE
760        );
761        fs::remove_file(&ordinary).expect("remove ordinary fixture");
762
763        let oversized = temporary_page("oversized");
764        let bytes = usize::try_from(MAX_MARKDOWN_BYTES).expect("limit fits usize") + 1;
765        fs::write(&oversized, vec![b'a'; bytes]).expect("write oversized page");
766        let error = SystemFileReader
767            .read_to_string(&oversized)
768            .expect_err("oversized page is refused");
769        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
770        fs::remove_file(&oversized).expect("remove oversized fixture");
771    }
772}