use crate::{EditorModel, MainWindow, gui::show_toast};
use slint::ComponentHandle;
use std::{
ffi::OsStr,
io,
path::{Path, PathBuf},
process::Command,
};
use tracing::{info, warn};
pub(super) fn user_manual_candidates(exe_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
vec![
exe_dir.join("docs/manual/README.md"),
exe_dir.join("../docs/manual/README.md"),
exe_dir.join("../../apps/indicatrix-cut/docs/manual/README.md"),
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md"),
]
}
fn locate_user_manual() -> Option<std::path::PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf))?;
user_manual_candidates(&exe_dir)
.into_iter()
.find(|candidate| candidate.is_file())
}
pub(super) fn locate_manual_folder() -> Option<PathBuf> {
locate_user_manual().and_then(|readme| readme.parent().map(Path::to_path_buf))
}
pub(super) fn setup_user_manual_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.on_open_user_manual(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if let Err(message) = super::help::open_topic(&ui, super::help::topics::CONTENTS) {
warn!("{message}");
show_toast(&ui, &message, "error");
}
});
}
pub(super) fn open_external_url(url: &str) -> Result<(), String> {
let launch_url = validated_url(url).inspect_err(|_| {
warn!("Refused to open the link {url:?}");
})?;
info!("Opening URL: {launch_url}");
launch(OsStr::new(&launch_url)).map_err(|e| format!("Could not open {launch_url}: {e}"))
}
pub(super) fn open_local_path(path: &Path) -> Result<(), String> {
let path = validated_local_path(path)?;
launch(path.as_os_str()).map_err(|e| format!("Could not open {}: {e}", path.display()))
}
fn validated_url(url: &str) -> Result<String, String> {
let url = url.trim();
if url.chars().any(char::is_control) {
return Err("Refused to open a link that contains control characters.".to_owned());
}
let is_web_link = url.split_once("://").is_some_and(|(scheme, rest)| {
(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& !rest.is_empty()
});
if !is_web_link {
return Err("Refused to open a link that is not an http or https address.".to_owned());
}
Ok(url.replace(' ', "%20").replace('"', "%22"))
}
fn validated_local_path(path: &Path) -> Result<PathBuf, String> {
if !path.exists() {
return Err(format!("{} does not exist.", path.display()));
}
Ok(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()))
}
fn opener_command(target: &OsStr) -> Command {
#[cfg(target_os = "windows")]
{
let mut command = Command::new("rundll32");
command.arg("url.dll,FileProtocolHandler").arg(target);
command
}
#[cfg(target_os = "macos")]
{
let mut command = Command::new("open");
command.arg(target);
command
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let mut command = Command::new("xdg-open");
command.arg(target);
command
}
}
fn launch(target: &OsStr) -> io::Result<()> {
let mut child = opener_command(target).spawn()?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
pub(super) fn setup_reveal_last_saved_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.global::<EditorModel>()
.on_reveal_last_saved_path(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let saved = ui.global::<EditorModel>().get_last_saved_path();
if saved.is_empty() {
return;
}
let path = std::path::PathBuf::from(saved.as_str());
let target = path.parent().unwrap_or(&path);
if let Err(message) = open_local_path(target) {
warn!("{message}");
show_toast(&ui, &message, "error");
}
});
}
#[cfg(test)]
mod tests {
use super::{
locate_manual_folder, opener_command, user_manual_candidates, validated_local_path,
validated_url,
};
use std::{ffi::OsStr, path::Path};
#[test]
fn the_manual_folder_is_found_in_a_checkout() {
let folder = locate_manual_folder().expect("the checkout's manual folder");
assert!(folder.join("README.md").is_file());
}
#[test]
fn user_manual_candidates_are_relative_to_exe_dir_in_order() {
let exe_dir = std::path::Path::new("/opt/indicatrix-cut/bin");
let candidates = user_manual_candidates(exe_dir);
assert_eq!(
candidates[0],
std::path::Path::new("/opt/indicatrix-cut/bin/docs/manual/README.md")
);
assert_eq!(
candidates[1],
std::path::Path::new("/opt/indicatrix-cut/bin/../docs/manual/README.md")
);
assert_eq!(
candidates[2],
std::path::Path::new(
"/opt/indicatrix-cut/bin/../../apps/indicatrix-cut/docs/manual/README.md"
)
);
assert_eq!(
candidates[3],
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md")
);
assert_eq!(candidates.len(), 4);
}
#[test]
fn an_http_url_with_shell_metacharacters_is_accepted_verbatim() {
for url in [
"http://example.com/?a=1&calc",
"https://example.com/path?x=1&y=2|3^4%5",
"https://example.com/?a&echo%20pwned>%TEMP%\\x",
] {
assert_eq!(validated_url(url).as_deref(), Ok(url));
}
}
#[test]
fn the_scheme_check_ignores_case_and_surrounding_whitespace() {
assert_eq!(
validated_url(" HTTPS://Example.com/a \n").as_deref(),
Ok("HTTPS://Example.com/a")
);
}
#[test]
fn spaces_and_quotes_are_percent_encoded() {
assert_eq!(
validated_url("http://example.com/a b\"c").as_deref(),
Ok("http://example.com/a%20b%22c")
);
}
#[test]
fn anything_but_an_http_or_https_url_is_refused() {
for refused in [
"",
" ",
"http://",
"https://",
"example.com",
"ftp://example.com/file",
"file:///C:/Windows/System32/calc.exe",
"javascript:alert(1)",
"calc.exe",
"C:\\Windows\\System32\\calc.exe",
"\\\\server\\share\\tool.exe",
"/usr/bin/xterm",
"ms-settings:network",
] {
assert!(
validated_url(refused).is_err(),
"{refused:?} must be refused"
);
}
}
#[test]
fn a_url_with_control_characters_is_refused() {
for refused in [
"http://example.com/\nnext",
"http://example.com/\r\ncalc",
"http://example.com/\0",
"http://example.com/\tx",
"http://example.com/\u{1b}[0m",
] {
assert!(
validated_url(refused).is_err(),
"{refused:?} must be refused"
);
}
}
#[test]
fn the_opener_receives_the_url_as_one_argument_and_no_shell_is_involved() {
let url = validated_url("http://x/?a=1&calc|echo pwned>%TEMP%\\x").unwrap();
let command = opener_command(OsStr::new(&url));
let program = Path::new(command.get_program())
.file_stem()
.map(|stem| stem.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
assert!(
!matches!(
program.as_str(),
"cmd" | "sh" | "bash" | "zsh" | "powershell" | "pwsh"
),
"the opener must not be a shell: {program}"
);
let args: Vec<&OsStr> = command.get_args().collect();
assert_eq!(args.last().copied(), Some(OsStr::new(&url)));
assert_eq!(
args.iter()
.filter(|arg| arg.to_string_lossy().contains('&'))
.count(),
1,
"the URL must not be split across arguments"
);
}
#[test]
fn a_local_path_must_exist() {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
let accepted = validated_local_path(here).unwrap();
assert!(accepted.is_absolute());
assert!(validated_local_path(&here.join("no-such-file-for-the-opener")).is_err());
}
}