Skip to main content

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