Files
Starlight_Launcher/packages/app-lib/src/util/resolve_shortcut.rs
T

352 lines
11 KiB
Rust

//! Resolve shortcuts, symlinks, and application bundles to their target paths.
//!
//! Supports:
//! - Windows `.lnk` files via COM `IShellLinkW`
//! - macOS `.app` bundles via `Contents/Info.plist`
//! - Linux `.desktop` files
//! - Symlinks on all platforms
use std::path::{Path, PathBuf};
use tracing::warn;
/// Maximum recursion depth to prevent infinite loops.
#[allow(dead_code)]
const MAX_DEPTH: u32 = 3;
/// Resolve a shortcut, symlink, or application bundle to its target path.
///
/// Returns `None` when the path is not a recognised shortcut type, on any
/// error, or when `max_depth` reaches 0 (recursion guard).
pub fn resolve_shortcut(path: &Path, max_depth: u32) -> Option<PathBuf> {
if max_depth == 0 {
warn!(
"Shortcut resolution exceeded max depth for: {}",
path.display()
);
return None;
}
// Always try symlink resolution first (platform-independent).
if let Ok(target) = std::fs::read_link(path) {
if target.is_absolute() {
// Resolve symlink chains recursively.
return resolve_shortcut(&target, max_depth - 1).or(Some(target));
} else {
// Relative symlink: resolve relative to the symlink's parent.
let absolute = if let Some(parent) = path.parent() {
parent.join(&target)
} else {
target
};
return resolve_shortcut(&absolute, max_depth - 1)
.or(Some(absolute));
}
}
// Platform-specific shortcut resolution.
#[cfg(windows)]
{
if let Some(resolved) = resolve_windows_lnk(path, max_depth) {
return Some(resolved);
}
}
#[cfg(target_os = "macos")]
{
if let Some(resolved) = resolve_macos_app(path, max_depth) {
return Some(resolved);
}
}
#[cfg(target_os = "linux")]
{
if let Some(resolved) = resolve_linux_desktop(path, max_depth) {
return Some(resolved);
}
}
None
}
// ─── Windows .lnk resolution ───────────────────────────────────────────────
#[cfg(windows)]
fn resolve_windows_lnk(path: &Path, _max_depth: u32) -> Option<PathBuf> {
let ext = path.extension()?;
if !ext.eq_ignore_ascii_case("lnk") {
return None;
}
use std::os::windows::ffi::OsStrExt;
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
CoCreateInstance, CoInitializeEx, IPersistFile, STGM,
};
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
use windows::core::{Interface, PCWSTR};
// SAFETY: COM is initialised for this thread. The guard ensures
// `CoUninitialize` is called exactly once when we're done.
unsafe {
let init = CoInitializeEx(
None,
COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE,
);
if init.is_err() {
return None;
}
let _guard = ComGuard;
let shortcut: IShellLinkW =
CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).ok()?;
let persist: IPersistFile = shortcut.cast().ok()?;
let wide_path: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
persist
.Load(PCWSTR::from_raw(wide_path.as_ptr()), STGM(0))
.ok()?;
let mut buf = [0u16; 1024];
shortcut.GetPath(&mut buf, std::ptr::null_mut(), 0).ok()?;
// Find the null terminator.
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let target = String::from_utf16(&buf[..len]).ok()?;
Some(PathBuf::from(&target))
}
}
#[cfg(windows)]
use windows::Win32::System::Com::CoUninitialize;
#[cfg(windows)]
struct ComGuard;
#[cfg(windows)]
impl Drop for ComGuard {
fn drop(&mut self) {
// SAFETY: COM was initialised in the current thread before this guard
// was created.
unsafe {
CoUninitialize();
}
}
}
// ─── macOS .app bundle resolution ──────────────────────────────────────────
#[cfg(target_os = "macos")]
fn resolve_macos_app(path: &Path, max_depth: u32) -> Option<PathBuf> {
// Must be a directory ending in .app.
if !path.is_dir() {
return None;
}
let ext = path.extension()?;
if !ext.eq_ignore_ascii_case("app") {
return None;
}
let plist_path = path.join("Contents").join("Info.plist");
if !plist_path.exists() {
return None;
}
// Parse the XML plist with quick_xml to find CFBundleExecutable.
let content = std::fs::read_to_string(&plist_path).ok()?;
let executable = parse_cfbundle_executable(&content)?;
let executable_path = path.join("Contents").join("MacOS").join(executable);
if executable_path.exists() {
resolve_shortcut(&executable_path, max_depth - 1)
.or(Some(executable_path))
} else {
None
}
}
/// Naive XML parser for extracting `CFBundleExecutable` from a plist.
///
/// Uses simple string matching rather than a full XML parser to avoid adding
/// a dependency. The plist is well-known enough that this is reliable.
#[cfg(target_os = "macos")]
fn parse_cfbundle_executable(content: &str) -> Option<String> {
// Look for: <key>CFBundleExecutable</key> followed by <string>value</string>
let key_marker = "<key>CFBundleExecutable</key>";
let key_pos = content.find(key_marker)?;
let after_key = &content[key_pos + key_marker.len()..];
let string_start = after_key.find("<string>")?;
let value_start = string_start + "<string>".len();
let value_end = after_key[value_start..].find("</string>")?;
Some(
after_key[value_start..value_start + value_end]
.trim()
.to_string(),
)
}
// ─── Linux .desktop file resolution ────────────────────────────────────────
#[cfg(target_os = "linux")]
fn resolve_linux_desktop(path: &Path, _max_depth: u32) -> Option<PathBuf> {
let ext = path.extension()?;
if !ext.eq_ignore_ascii_case("desktop") {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
parse_desktop_entry(&content)
}
#[cfg(target_os = "linux")]
fn parse_desktop_entry(content: &str) -> Option<PathBuf> {
let mut in_desktop_entry = false;
let mut exec_line: Option<&str> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[Desktop Entry]" {
in_desktop_entry = true;
continue;
}
if !in_desktop_entry {
continue;
}
if trimmed.starts_with('[') {
// Reached next section — stop looking.
break;
}
if let Some(value) = trimmed.strip_prefix("Exec=") {
exec_line = Some(value);
}
}
let exec = exec_line?;
// Parse the Exec value: extract the first token that is not a parameter
// placeholder like %f, %F, %u, %U, %k, %c.
let tokens = shlex::split(exec);
let binary =
tokens
.iter()
.flat_map(|tokens| tokens.iter())
.find(|token| {
!token.starts_with('%')
&& !token.starts_with('-')
&& !token.starts_with("--")
})?;
let binary_path = if binary.contains('/') {
PathBuf::from(binary)
} else {
// Look up in PATH.
std::env::var_os("PATH")
.iter()
.flat_map(|p| std::env::split_paths(p))
.map(|dir| dir.join(binary))
.find(|p| p.exists())?
};
Some(binary_path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_symlink_resolution() {
let dir = tempdir().expect("temp dir");
let target = dir.path().join("real_file.txt");
let link = dir.path().join("link.txt");
fs::write(&target, "hello").expect("write target");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let resolved = resolve_shortcut(&link, MAX_DEPTH);
assert!(resolved.is_some());
assert_eq!(
resolved.unwrap().canonicalize().ok(),
Some(target.canonicalize().ok()).flatten()
);
}
#[cfg(windows)]
{
// On Windows, try junction or symlink if available.
let _ = std::os::windows::fs::symlink_file(&target, &link);
let resolved = resolve_shortcut(&link, MAX_DEPTH);
// symlink creation may fail on some Windows configs — accept either outcome.
if link.exists() {
assert!(resolved.is_some());
}
}
}
#[cfg(unix)]
#[test]
fn test_broken_symlink() {
let dir = tempdir().expect("temp dir");
let link = dir.path().join("broken_link");
std::os::unix::fs::symlink("/nonexistent/path", &link)
.expect("symlink");
let resolved = resolve_shortcut(&link, MAX_DEPTH);
// `read_link` succeeds but the target doesn't exist — resolve_shortcut
// still returns the target path from read_link.
assert!(resolved.is_some());
}
#[test]
fn test_max_depth_zero() {
let resolved = resolve_shortcut(Path::new("/some/path"), 0);
assert!(resolved.is_none());
}
#[test]
fn test_regular_file_no_shortcut() {
let dir = tempdir().expect("temp dir");
let file = dir.path().join("regular.txt");
fs::write(&file, "data").expect("write");
let resolved = resolve_shortcut(&file, MAX_DEPTH);
// Not a symlink, not a platform shortcut — should return None.
assert!(resolved.is_none());
}
#[cfg(unix)]
#[test]
fn test_relative_symlink() {
let dir = tempdir().expect("temp dir");
let target = dir.path().join("target.txt");
fs::write(&target, "hello").expect("write target");
let link = dir.path().join("link.txt");
// Create a relative symlink.
std::os::unix::fs::symlink("target.txt", &link).expect("symlink");
let resolved = resolve_shortcut(&link, MAX_DEPTH);
assert!(resolved.is_some());
let resolved = resolved.unwrap();
assert!(resolved.exists());
}
#[test]
fn test_not_a_symlink_directory() {
let dir = tempdir().expect("temp dir");
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).expect("create dir");
let resolved = resolve_shortcut(&subdir, MAX_DEPTH);
// A regular directory without any shortcut marker → None.
assert!(resolved.is_none());
}
}