use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::config;
#[derive(Clone, Debug, Default)]
pub struct Tools {
pub yt_dlp: Option<PathBuf>,
pub mpv: Option<PathBuf>,
pub deno: Option<PathBuf>,
}
#[cfg(unix)]
const EXTRA_DIRS: [&str; 4] = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/run/current-system/sw/bin",
];
#[cfg(not(unix))]
const EXTRA_DIRS: [&str; 0] = [];
impl Tools {
pub fn find(yt_dlp: Option<&Path>, mpv: Option<&Path>, deno: Option<&Path>) -> Self {
let pick = |set: Option<&Path>, var: &str, name: &str| {
let set = config::var(var)
.map(PathBuf::from)
.or(set.map(Path::to_path_buf));
match set {
Some(path) => usable(&path).then_some(path),
None => on_path(name),
}
};
Self {
yt_dlp: pick(yt_dlp, "TT_YT_DLP", "yt-dlp"),
mpv: pick(mpv, "TT_MPV", "mpv"),
deno: pick(deno, "TT_DENO", "deno"),
}
}
pub fn missing(&self) -> Option<String> {
let missing: Vec<&str> = [
("yt-dlp", self.yt_dlp.is_none()),
("deno", self.deno.is_none()),
("mpv", self.mpv.is_none()),
]
.into_iter()
.filter_map(|(name, gone)| gone.then_some(name))
.collect();
if missing.is_empty() {
return None;
}
let list = missing.join(" ");
let how = if cfg!(target_os = "macos") {
format!("brew install {list}")
} else if cfg!(windows) {
let ids: Vec<&str> = missing
.iter()
.map(|name| match *name {
"yt-dlp" => "yt-dlp.yt-dlp",
"deno" => "DenoLand.Deno",
_ => "shinchiro.mpv",
})
.collect();
format!("winget install {}", ids.join(" "))
} else {
format!("install {list} with your package manager")
};
Some(format!("Not found: {list}. {how}"))
}
}
fn usable(path: &Path) -> bool {
if !path.is_absolute() || config::on_another_machine(path) {
return false;
}
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
meta.is_file()
}
pub fn on_path(name: &str) -> Option<PathBuf> {
let exe = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
};
let path = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path)
.chain(EXTRA_DIRS.iter().map(PathBuf::from))
.filter(|dir| dir.is_absolute() && !config::on_another_machine(dir))
.map(|dir| dir.join(&exe))
.find(|candidate| usable(candidate))
}
#[cfg(unix)]
pub fn absolute_path_entries(path: Option<OsString>) -> Option<OsString> {
let kept: Vec<PathBuf> = std::env::split_paths(&path?)
.filter(|dir| dir.is_absolute() && !config::on_another_machine(dir))
.collect();
if kept.is_empty() {
return None;
}
std::env::join_paths(kept).ok()
}
const KEPT: [&str; 26] = [
"HOME",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TMPDIR",
"TZ",
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
"PULSE_SERVER",
"SYSTEMROOT",
"WINDIR",
"TEMP",
"TMP",
"USERPROFILE",
"LOCALAPPDATA",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NIX_SSL_CERT_FILE",
"NO_PROXY",
"no_proxy",
];
pub fn child_env(tools: &Tools) -> Vec<(OsString, OsString)> {
let proxy = crate::proxy::get().map(|p| p.url.as_str());
child_env_from(tools, |key| std::env::var_os(key), proxy)
}
fn child_env_from(
tools: &Tools,
var: impl Fn(&str) -> Option<OsString>,
proxy: Option<&str>,
) -> Vec<(OsString, OsString)> {
let mut env: Vec<(OsString, OsString)> = KEPT
.iter()
.filter_map(|&key| Some((key.into(), var(key)?)))
.collect();
if let Some(proxy) = proxy {
env.extend(crate::proxy::NAMES.map(|name| (name.into(), proxy.into())));
}
let mut dirs: Vec<PathBuf> = [&tools.yt_dlp, &tools.mpv, &tools.deno]
.into_iter()
.flatten()
.filter_map(|tool| Some(tool.parent()?.to_path_buf()))
.collect();
#[cfg(unix)]
dirs.extend(["/usr/bin", "/bin", "/usr/sbin", "/sbin"].map(PathBuf::from));
#[cfg(windows)]
if let Some(root) = var("SYSTEMROOT") {
dirs.push(PathBuf::from(&root).join("System32"));
dirs.push(PathBuf::from(root));
}
dirs.dedup();
if !dirs.is_empty()
&& let Ok(path) = std::env::join_paths(dirs)
{
env.push(("PATH".into(), path));
}
for (key, value) in [
("PYTHONNOUSERSITE", "1"),
("PYTHONDONTWRITEBYTECODE", "1"),
("PYTHONUTF8", "1"),
("YTDLP_NO_PLUGINS", "1"),
("DENO_NO_UPDATE_CHECK", "1"),
("NoDefaultCurrentDirectoryInExePath", "1"),
] {
env.push((key.into(), value.into()));
}
env
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn path_keeps_only_absolute_folders() {
let clean = absolute_path_entries(Some(":.:bin:/usr/bin::/bin://host/x:".into()));
assert_eq!(clean, Some("/usr/bin:/bin".into()));
assert_eq!(absolute_path_entries(Some(".:bin:".into())), None);
assert_eq!(absolute_path_entries(None), None);
}
#[test]
fn relative_and_remote_paths_are_never_used() {
assert!(!usable(Path::new("yt-dlp")));
assert!(!usable(Path::new("./yt-dlp")));
assert!(!usable(Path::new("//server/share/yt-dlp")));
}
#[test]
fn children_get_no_python_path_or_preloads_and_only_the_chosen_proxy() {
let set = |key: &str| -> Option<OsString> {
match key {
"PYTHONPATH" | "LD_PRELOAD" | "DYLD_INSERT_LIBRARIES" | "YTDLP_CONFIG" => {
Some("/nonexistent/evil".into())
}
"HOME" | "SSL_CERT_FILE" | "NO_PROXY" => Some("kept".into()),
"ALL_PROXY" => Some("socks5h://elsewhere:9050".into()),
"PATH" => Some(".:relative:/usr/bin".into()),
_ => None,
}
};
let env = child_env_from(&Tools::default(), set, Some("http://proxy.example:3128"));
for name in crate::proxy::NAMES {
let value = env.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone());
assert_eq!(value, Some("http://proxy.example:3128".into()), "{name}");
}
assert!(
child_env_from(&Tools::default(), set, None)
.iter()
.all(|(k, _)| !crate::proxy::NAMES.contains(&k.to_str().unwrap())),
"no proxy chosen: none passed on"
);
let keys: Vec<String> = env
.iter()
.map(|(k, _)| k.to_string_lossy().into_owned())
.collect();
for dropped in [
"PYTHONPATH",
"LD_PRELOAD",
"DYLD_INSERT_LIBRARIES",
"YTDLP_CONFIG",
] {
assert!(!keys.contains(&dropped.to_string()), "{dropped}");
}
for kept in ["NO_PROXY", "HOME", "SSL_CERT_FILE", "PYTHONNOUSERSITE"] {
assert!(keys.contains(&kept.to_string()), "{kept}");
}
let path = env
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.clone());
for dir in path.iter().flat_map(std::env::split_paths) {
assert!(dir.is_absolute(), "the caller's PATH isn't used: {dir:?}");
}
}
}