1use std::{
4 collections::{BTreeMap, HashSet},
5 env,
6 error::Error,
7 ffi::OsStr,
8 fmt, fs, io,
9 os::unix::fs::PermissionsExt,
10 path::{Path, PathBuf},
11};
12
13use mant_ast::TldrDocument;
14
15use super::parser::{TldrPageLocation, TldrParseError, parse_tldr_page};
16
17const ALL_PLATFORMS: &[&str] = &[
18 "common",
19 "linux",
20 "osx",
21 "macos",
22 "windows",
23 "android",
24 "freebsd",
25 "openbsd",
26 "netbsd",
27 "sunos",
28 "cisco-ios",
29 "dos",
30];
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HostPlatform {
35 Linux,
36 Macos,
37}
38
39impl HostPlatform {
40 pub fn current() -> Result<Self, TldrCacheError> {
46 if cfg!(target_os = "linux") {
47 Ok(Self::Linux)
48 } else if cfg!(target_os = "macos") {
49 Ok(Self::Macos)
50 } else {
51 Err(TldrCacheError::UnsupportedPlatform)
52 }
53 }
54}
55
56#[derive(Debug)]
58pub enum TldrCacheError {
59 UnsupportedPlatform,
60 MissingHomeDirectory,
61 Read {
62 path: PathBuf,
63 source: io::Error,
64 },
65 Parse {
66 path: PathBuf,
67 source: TldrParseError,
68 },
69}
70
71impl fmt::Display for TldrCacheError {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::UnsupportedPlatform => {
75 formatter.write_str("tldr cache lookup is supported only on Linux and macOS")
76 }
77 Self::MissingHomeDirectory => {
78 formatter.write_str("cannot locate a tldr cache without HOME")
79 }
80 Self::Read { path, source } => {
81 write!(
82 formatter,
83 "cannot read cached tldr page {}: {source}",
84 path.display()
85 )
86 }
87 Self::Parse { path, source } => {
88 write!(
89 formatter,
90 "cannot parse cached tldr page {}: {source}",
91 path.display()
92 )
93 }
94 }
95 }
96}
97
98impl Error for TldrCacheError {
99 fn source(&self) -> Option<&(dyn Error + 'static)> {
100 match self {
101 Self::Read { source, .. } => Some(source),
102 Self::Parse { source, .. } => Some(source),
103 Self::UnsupportedPlatform | Self::MissingHomeDirectory => None,
104 }
105 }
106}
107
108pub fn get_tldr_cache_dir(
115 environment: &BTreeMap<String, String>,
116 platform: HostPlatform,
117) -> Result<PathBuf, TldrCacheError> {
118 if let Some(path) = environment.get("MANT_TLDR_DIR") {
119 return Ok(PathBuf::from(path));
120 }
121 let home = home_dir(environment)?;
122 Ok(match platform {
123 HostPlatform::Linux => environment.get("XDG_CACHE_HOME").map_or_else(
124 || home.join(".cache").join("mant").join("tldr-pages"),
125 |cache| PathBuf::from(cache).join("mant").join("tldr-pages"),
126 ),
127 HostPlatform::Macos => home
128 .join("Library")
129 .join("Caches")
130 .join("mant")
131 .join("tldr-pages"),
132 })
133}
134
135pub fn get_system_tldr_cache_dirs(
141 environment: &BTreeMap<String, String>,
142 platform: HostPlatform,
143) -> Result<Vec<PathBuf>, TldrCacheError> {
144 let home = home_dir(environment)?;
145 let portable_cache = environment
146 .get("XDG_CACHE_HOME")
147 .map_or_else(|| home.join(".cache"), PathBuf::from);
148 let native_cache = match platform {
149 HostPlatform::Linux => portable_cache.clone(),
150 HostPlatform::Macos => home.join("Library").join("Caches"),
151 };
152 let mut candidates = vec![
153 portable_cache.join("tldr"),
154 native_cache.join("tlrc"),
155 portable_cache.join("tlrc"),
156 native_cache.join("tealdeer").join("tldr-pages"),
157 portable_cache.join("tealdeer").join("tldr-pages"),
158 home.join(".tldrc").join("tldr"),
161 home.join(".tldr").join("cache"),
164 home.join(".tldr"),
165 ];
166
167 if let Some(value) = environment.get("XDG_DATA_DIRS") {
168 candidates.extend(
169 env::split_paths(OsStr::new(value))
170 .filter(|path| !path.as_os_str().is_empty())
171 .map(|path| path.join("tldr")),
172 );
173 } else {
174 candidates.extend(
175 ["/usr/local/share", "/usr/share"]
176 .into_iter()
177 .map(|path| PathBuf::from(path).join("tldr")),
178 );
179 }
180 Ok(deduplicate_paths(candidates))
181}
182
183pub fn get_tldr_read_cache_dirs(
189 environment: &BTreeMap<String, String>,
190 platform: HostPlatform,
191 tldr_installed: bool,
192) -> Result<Vec<PathBuf>, TldrCacheError> {
193 if environment.contains_key("MANT_TLDR_DIR") {
194 return get_tldr_cache_dir(environment, platform).map(|path| vec![path]);
195 }
196 if tldr_installed {
197 get_system_tldr_cache_dirs(environment, platform)
198 } else {
199 get_tldr_cache_dir(environment, platform).map(|path| vec![path])
200 }
201}
202
203#[must_use]
205pub fn get_tldr_languages(environment: &BTreeMap<String, String>) -> Vec<String> {
206 let mut languages = Vec::new();
207 if environment
208 .get("LANG")
209 .is_some_and(|lang| !matches!(lang.as_str(), "C" | "POSIX"))
210 {
211 if let Some(language) = environment.get("LANGUAGE") {
212 for locale in language.split(':') {
213 languages.extend(normalize_locale(locale));
214 }
215 }
216 if let Some(locale) = environment.get("LANG") {
217 languages.extend(normalize_locale(locale));
218 }
219 }
220 languages.push("en".to_owned());
221 deduplicate_strings(languages)
222}
223
224#[must_use]
226pub fn get_tldr_platforms(platform: HostPlatform) -> Vec<String> {
227 let mut platforms = match platform {
228 HostPlatform::Linux => vec!["linux".to_owned()],
229 HostPlatform::Macos => vec!["osx".to_owned(), "macos".to_owned()],
230 };
231 platforms.extend(ALL_PLATFORMS.iter().map(ToString::to_string));
232 deduplicate_strings(platforms)
233}
234
235#[must_use]
237pub fn normalize_tldr_topic(topic: &str) -> String {
238 topic
239 .trim()
240 .to_lowercase()
241 .split_whitespace()
242 .collect::<Vec<_>>()
243 .join("-")
244}
245
246fn is_safe_page_name(page_name: &str) -> bool {
254 let mut components = Path::new(page_name).components();
255 matches!(
256 (components.next(), components.next()),
257 (Some(std::path::Component::Normal(_)), None)
258 )
259}
260
261pub fn read_cached_tldr_page(topic: &str) -> Result<Option<TldrDocument>, TldrCacheError> {
267 let environment = env::vars().collect::<BTreeMap<_, _>>();
268 let platform = HostPlatform::current()?;
269 let cache_dirs = get_tldr_read_cache_dirs(
270 &environment,
271 platform,
272 find_executable("tldr", &environment).is_some(),
273 )?;
274 read_cached_tldr_page_with(
275 topic,
276 &cache_dirs,
277 &get_tldr_languages(&environment),
278 &get_tldr_platforms(platform),
279 &SystemFileReader,
280 )
281}
282
283trait TldrFileReader {
284 fn is_file(&self, path: &Path) -> bool;
285 fn read_to_string(&self, path: &Path) -> io::Result<String>;
286}
287
288struct SystemFileReader;
289
290impl TldrFileReader for SystemFileReader {
291 fn is_file(&self, path: &Path) -> bool {
292 path.is_file()
293 }
294
295 fn read_to_string(&self, path: &Path) -> io::Result<String> {
296 fs::read_to_string(path)
297 }
298}
299
300fn read_cached_tldr_page_with(
301 topic: &str,
302 cache_dirs: &[PathBuf],
303 languages: &[String],
304 platforms: &[String],
305 files: &dyn TldrFileReader,
306) -> Result<Option<TldrDocument>, TldrCacheError> {
307 let page_name = normalize_tldr_topic(topic);
308 if page_name.is_empty() || !is_safe_page_name(&page_name) {
309 return Ok(None);
310 }
311
312 for platform in platforms {
314 for language in languages {
315 let page_directories = if language == "en" {
316 vec!["pages".to_owned(), "pages.en".to_owned()]
317 } else {
318 vec![format!("pages.{language}")]
319 };
320 for cache_dir in cache_dirs {
321 for pages in &page_directories {
322 let source_path = cache_dir
323 .join(pages)
324 .join(platform)
325 .join(format!("{page_name}.md"));
326 if !files.is_file(&source_path) {
327 continue;
328 }
329 let markdown = files.read_to_string(&source_path).map_err(|source| {
330 TldrCacheError::Read {
331 path: source_path.clone(),
332 source,
333 }
334 })?;
335 let page = parse_tldr_page(
336 &markdown,
337 TldrPageLocation {
338 platform: platform.clone(),
339 language: language.clone(),
340 source_path: source_path.to_string_lossy().into_owned(),
341 },
342 )
343 .map_err(|source| TldrCacheError::Parse {
344 path: source_path,
345 source,
346 })?;
347 return Ok(Some(page));
348 }
349 }
350 }
351 }
352 Ok(None)
353}
354
355fn home_dir(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
356 environment
357 .get("HOME")
358 .filter(|home| !home.is_empty())
359 .map(PathBuf::from)
360 .ok_or(TldrCacheError::MissingHomeDirectory)
361}
362
363fn normalize_locale(locale: &str) -> Vec<String> {
364 let normalized = locale
365 .split('.')
366 .next()
367 .unwrap_or_default()
368 .replace('-', "_");
369 if normalized.is_empty() || matches!(normalized.as_str(), "C" | "POSIX") {
370 return Vec::new();
371 }
372 let language = normalized.split('_').next().unwrap_or_default().to_owned();
373 if normalized == language {
374 vec![language]
375 } else {
376 vec![normalized, language]
377 }
378}
379
380fn find_executable(name: &str, environment: &BTreeMap<String, String>) -> Option<PathBuf> {
381 let path = environment.get("PATH")?;
382 env::split_paths(OsStr::new(path))
383 .map(|directory| directory.join(name))
384 .find(|candidate| {
385 candidate.metadata().is_ok_and(|metadata| {
386 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
387 })
388 })
389}
390
391fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
392 let mut seen = HashSet::new();
393 paths
394 .into_iter()
395 .filter(|path| seen.insert(path.clone()))
396 .collect()
397}
398
399fn deduplicate_strings(values: Vec<String>) -> Vec<String> {
400 let mut seen = HashSet::new();
401 values
402 .into_iter()
403 .filter(|value| seen.insert(value.clone()))
404 .collect()
405}
406
407#[cfg(test)]
408mod tests {
409 use std::{
410 collections::{BTreeMap, HashMap},
411 io,
412 path::{Path, PathBuf},
413 };
414
415 use super::{
416 HostPlatform, TldrFileReader, get_system_tldr_cache_dirs, get_tldr_cache_dir,
417 get_tldr_languages, get_tldr_platforms, get_tldr_read_cache_dirs, normalize_tldr_topic,
418 read_cached_tldr_page_with,
419 };
420
421 const PAGE: &str = "# tar\n\n> Archiving utility.\n\n- List: `tar --list`\n";
422
423 #[derive(Default)]
424 struct MemoryFiles {
425 files: HashMap<PathBuf, String>,
426 }
427
428 impl TldrFileReader for MemoryFiles {
429 fn is_file(&self, path: &Path) -> bool {
430 self.files.contains_key(path)
431 }
432
433 fn read_to_string(&self, path: &Path) -> io::Result<String> {
434 self.files
435 .get(path)
436 .cloned()
437 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "memory fixture is missing"))
438 }
439 }
440
441 fn env(values: &[(&str, &str)]) -> BTreeMap<String, String> {
442 values
443 .iter()
444 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
445 .collect()
446 }
447
448 #[test]
449 fn resolves_mant_and_installed_client_cache_conventions() {
450 let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
451 assert_eq!(
452 get_tldr_cache_dir(&environment, HostPlatform::Linux).expect("cache dir"),
453 PathBuf::from("/cache/mant/tldr-pages")
454 );
455 assert_eq!(
456 get_tldr_cache_dir(&environment, HostPlatform::Macos).expect("cache dir"),
457 PathBuf::from("/home/test/Library/Caches/mant/tldr-pages")
458 );
459 assert_eq!(
460 get_system_tldr_cache_dirs(&environment, HostPlatform::Linux).expect("system caches"),
461 [
462 "/cache/tldr",
463 "/cache/tlrc",
464 "/cache/tealdeer/tldr-pages",
465 "/home/test/.tldrc/tldr",
466 "/home/test/.tldr/cache",
467 "/home/test/.tldr",
468 "/usr/local/share/tldr",
469 "/usr/share/tldr",
470 ]
471 .map(PathBuf::from)
472 );
473 assert_eq!(
474 get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, false)
475 .expect("fallback cache"),
476 [PathBuf::from("/cache/mant/tldr-pages")]
477 );
478 }
479
480 #[test]
481 fn reads_homebrew_c_client_and_node_client_cache_layouts_on_macos() {
482 let environment = env(&[("HOME", "/Users/test")]);
483 let cache_dirs = get_system_tldr_cache_dirs(&environment, HostPlatform::Macos)
484 .expect("macOS client caches");
485
486 for source in [
487 PathBuf::from("/Users/test/.tldrc/tldr/pages/common/tar.md"),
488 PathBuf::from("/Users/test/.tldr/cache/pages/common/tar.md"),
489 ] {
490 let files = MemoryFiles {
491 files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
492 };
493 let page = read_cached_tldr_page_with(
494 "tar",
495 &cache_dirs,
496 &["en".to_owned()],
497 &["osx".to_owned(), "common".to_owned()],
498 &files,
499 )
500 .expect("cache read")
501 .expect("page");
502
503 assert_eq!(page.source_path, source.to_string_lossy());
504 }
505 }
506
507 #[test]
508 fn explicit_cache_is_independent_from_an_installed_client() {
509 let environment = env(&[("HOME", "/home/test"), ("MANT_TLDR_DIR", "/custom/tldr")]);
510 assert_eq!(
511 get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
512 .expect("explicit cache"),
513 [PathBuf::from("/custom/tldr")]
514 );
515 }
516
517 #[test]
518 fn normalizes_topic_locale_and_platform_priority() {
519 let environment = env(&[("LANG", "pt_BR.UTF-8"), ("LANGUAGE", "zh_TW:pt_BR")]);
520 assert_eq!(
521 get_tldr_languages(&environment),
522 ["zh_TW", "zh", "pt_BR", "pt", "en"]
523 );
524 assert_eq!(
525 &get_tldr_platforms(HostPlatform::Linux)[..3],
526 ["linux", "common", "osx"]
527 );
528 assert_eq!(normalize_tldr_topic(" Git Commit "), "git-commit");
529 }
530
531 #[test]
532 fn host_platform_precedes_a_translated_common_page() {
533 let root = PathBuf::from("/cache");
534 let english_linux = root.join("pages/linux/tar.md");
535 let translated_common = root.join("pages.zh/common/tar.md");
536 let files = MemoryFiles {
537 files: [
538 (english_linux.clone(), PAGE.to_owned()),
539 (translated_common, PAGE.to_owned()),
540 ]
541 .into_iter()
542 .collect(),
543 };
544 let page = read_cached_tldr_page_with(
545 "tar",
546 &[root],
547 &["zh".to_owned(), "en".to_owned()],
548 &["linux".to_owned(), "common".to_owned()],
549 &files,
550 )
551 .expect("cache read")
552 .expect("page");
553 assert_eq!(page.source_path, english_linux.to_string_lossy());
554 assert_eq!(page.language, "en");
555 assert_eq!(page.platform, "linux");
556 }
557
558 #[test]
559 fn reads_pages_dot_en_layout_after_repository_layout() {
560 let root = PathBuf::from("/cache/tlrc");
561 let source = root.join("pages.en/linux/tar.md");
562 let files = MemoryFiles {
563 files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
564 };
565 let page = read_cached_tldr_page_with(
566 "tar",
567 &[root],
568 &["en".to_owned()],
569 &["linux".to_owned()],
570 &files,
571 )
572 .expect("cache read")
573 .expect("page");
574 assert_eq!(page.source_path, source.to_string_lossy());
575 }
576
577 #[test]
578 fn refuses_topics_that_escape_the_platform_page_directory() {
579 let root = PathBuf::from("/cache");
580 let escaped = PathBuf::from("/etc/hostname.md");
582 let files = MemoryFiles {
583 files: [(escaped, PAGE.to_owned())].into_iter().collect(),
584 };
585
586 for topic in ["../../../../etc/hostname", "/etc/hostname", "..", "a/b"] {
587 let result = read_cached_tldr_page_with(
588 topic,
589 std::slice::from_ref(&root),
590 &["en".to_owned()],
591 &["linux".to_owned()],
592 &files,
593 )
594 .expect("cache read must not error");
595 assert!(
596 result.is_none(),
597 "traversal topic {topic:?} must not resolve a page"
598 );
599 }
600 }
601
602 #[test]
603 fn only_single_ordinary_components_are_safe_page_names() {
604 assert!(super::is_safe_page_name("tar"));
605 assert!(super::is_safe_page_name("git-commit"));
606 assert!(!super::is_safe_page_name("../etc/passwd"));
607 assert!(!super::is_safe_page_name("/etc/passwd"));
608 assert!(!super::is_safe_page_name(".."));
609 assert!(!super::is_safe_page_name("a/b"));
610 }
611}