use std::path::{Path, PathBuf};
use crate::join::clean;
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub(crate) const MAX_FILE_BYTES: u64 = 64 * 1024;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct UserDirs {
pub documents: Option<PathBuf>,
pub desktop: Option<PathBuf>,
pub download: Option<PathBuf>,
pub pictures: Option<PathBuf>,
pub music: Option<PathBuf>,
pub videos: Option<PathBuf>,
}
pub(crate) fn parse(contents: &str, home: &Path) -> UserDirs {
let mut dirs = UserDirs::default();
for line in contents.lines() {
let Some((key, value)) = split_assignment(line) else {
continue;
};
let slot = match key {
"XDG_DOCUMENTS_DIR" => &mut dirs.documents,
"XDG_DESKTOP_DIR" => &mut dirs.desktop,
"XDG_DOWNLOAD_DIR" => &mut dirs.download,
"XDG_PICTURES_DIR" => &mut dirs.pictures,
"XDG_MUSIC_DIR" => &mut dirs.music,
"XDG_VIDEOS_DIR" => &mut dirs.videos,
_ => continue,
};
if let Some(path) = resolve_value(value, home) {
*slot = Some(path);
}
}
dirs
}
fn split_assignment(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (key, rest) = line.split_once('=')?;
let rest = rest.trim();
let value = rest.strip_prefix('"')?.strip_suffix('"')?;
Some((key.trim(), value))
}
fn resolve_value(value: &str, home: &Path) -> Option<PathBuf> {
if value.contains(['\0', '\n', '"', '`']) {
return None;
}
let path = if let Some(rest) = value.strip_prefix("$HOME") {
if !(rest.is_empty() || rest.starts_with('/')) {
return None;
}
let rest = rest.trim_start_matches('/');
if rest.contains('$') {
return None;
}
clean(home.join(rest))
} else if value.starts_with('/') && !value.contains('$') {
clean(value)
} else {
return None;
};
if path == clean(home) {
return None;
}
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> PathBuf {
PathBuf::from("/home/u")
}
fn h(rel: &str) -> PathBuf {
clean(home().join(rel))
}
#[test]
fn test_parse_standard_file() {
let file = r#"
# This file is written by xdg-user-dirs-update
XDG_DESKTOP_DIR="$HOME/Desktop"
XDG_DOWNLOAD_DIR="$HOME/Downloads"
XDG_DOCUMENTS_DIR="$HOME/Dokumente"
XDG_MUSIC_DIR="/mnt/media/music"
XDG_PICTURES_DIR="$HOME/Pictures"
XDG_VIDEOS_DIR="$HOME/Videos"
XDG_TEMPLATES_DIR="$HOME/Templates"
"#;
let d = parse(file, &home());
assert_eq!(d.documents, Some(h("Dokumente")));
assert_eq!(d.desktop, Some(h("Desktop")));
assert_eq!(d.download, Some(h("Downloads")));
assert_eq!(d.music, Some(clean("/mnt/media/music")));
assert_eq!(d.pictures, Some(h("Pictures")));
assert_eq!(d.videos, Some(h("Videos")));
}
#[test]
fn test_parse_disabled_folder_pointing_at_home_is_ignored() {
let d = parse(
"XDG_DOCUMENTS_DIR=\"$HOME/\"\nXDG_DESKTOP_DIR=\"$HOME\"",
&home(),
);
assert_eq!(d.documents, None);
assert_eq!(d.desktop, None);
}
#[test]
fn test_parse_rejects_relative_unquoted_and_expansions() {
let file = "XDG_DOCUMENTS_DIR=\"Documents\"\n\
XDG_DESKTOP_DIR=$HOME/Desktop\n\
XDG_DOWNLOAD_DIR=\"$HOME/$(rm -rf ~)\"\n\
XDG_MUSIC_DIR=\"$HOMEBREW/music\"\n\
XDG_PICTURES_DIR=\"/a/$USER/b\"\n\
XDG_VIDEOS_DIR=\"`id`\"";
assert_eq!(parse(file, &home()), UserDirs::default());
}
#[test]
fn test_parse_cleans_traversal_in_values() {
let d = parse("XDG_DOCUMENTS_DIR=\"$HOME/a/../docs//\"", &home());
assert_eq!(d.documents, Some(h("docs")));
}
#[test]
fn test_parse_tolerates_whitespace_and_comments() {
let d = parse(
" # comment\n XDG_DOCUMENTS_DIR = \"$HOME/D\" \n\n",
&home(),
);
assert_eq!(d.documents, Some(h("D")));
}
#[test]
fn test_parse_later_valid_entry_wins() {
let d = parse(
"XDG_DOCUMENTS_DIR=\"$HOME/A\"\nXDG_DOCUMENTS_DIR=\"$HOME/B\"",
&home(),
);
assert_eq!(d.documents, Some(h("B")));
}
#[test]
fn test_parse_garbage_input_yields_nothing() {
let d = parse("\0\0=\"\"\n=\n\"\"\nXDG_DOCUMENTS_DIR=\"\0\"", &home());
assert_eq!(d, UserDirs::default());
}
}