use anyhow::Result;
const CMD_UNSAFE: &[char] = &[
' ', '\t', ';', ',', '=', '"', '&', '|', '<', '>', '(', ')', '^', '%',
];
#[cfg_attr(not(windows), allow(dead_code))]
fn first_cmd_unsafe_char(p: &str) -> Option<char> {
p.chars().find(|c| CMD_UNSAFE.contains(c))
}
#[cfg(windows)]
pub(super) fn windows_bare_hook_command(resolved_path: &str, source: &str) -> Result<String> {
resolve_windows_command(resolved_path, source, short_path_windows)
}
#[cfg_attr(not(windows), allow(dead_code))]
fn resolve_windows_command(
path: &str,
source: &str,
short_path: impl FnOnce(&str) -> Option<String>,
) -> Result<String> {
if let Some(bad) = first_cmd_unsafe_char(source) {
anyhow::bail!(
"internal: hook source name {source:?} contains a cmd-unsafe character {bad:?}"
);
}
let Some(bad) = first_cmd_unsafe_char(path) else {
return Ok(format!("{path} --source {source}"));
};
if let Some(s) = short_path(path) {
if first_cmd_unsafe_char(&s).is_none() {
return Ok(format!("{s} --source {source}"));
}
}
anyhow::bail!(
"pixtuoid-hook is at a path containing {bad:?} ({path}) that the cmd.exe /C hook \
runner can't safely invoke, and no DOS 8.3 short name is available (8.3 \
generation is disabled on this volume). Install pixtuoid to a path of ordinary \
characters (e.g. %USERPROFILE%\\.cargo\\bin or the npm global prefix) and \
reconnect the target in pixtuoid's Connection panel. (Tracking: #195.)"
);
}
#[cfg(windows)]
fn short_path_windows(long: &str) -> Option<String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
let wide: Vec<u16> = std::ffi::OsStr::new(long)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let needed = unsafe { GetShortPathNameW(wide.as_ptr(), std::ptr::null_mut(), 0) };
if needed == 0 {
return None;
}
let mut buf = vec![0u16; needed as usize];
let written = unsafe { GetShortPathNameW(wide.as_ptr(), buf.as_mut_ptr(), needed) };
if written == 0 || written >= needed {
return None;
}
String::from_utf16(&buf[..written as usize]).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_command_is_bare_for_a_clean_path() {
let c = resolve_windows_command(r"C:\tools\pixtuoid-hook.exe", "codex", |_| {
panic!("short_path must NOT be called for a clean path")
});
assert_eq!(c.unwrap(), r"C:\tools\pixtuoid-hook.exe --source codex");
}
#[test]
fn windows_command_uses_8dot3_short_form_when_path_has_a_space() {
let c =
resolve_windows_command(r"C:\Program Files\x\pixtuoid-hook.exe", "reasonix", |_| {
Some(r"C:\PROGRA~1\x\PIXTUO~1.EXE".to_string())
});
assert_eq!(c.unwrap(), r"C:\PROGRA~1\x\PIXTUO~1.EXE --source reasonix");
}
#[test]
fn windows_command_rejects_when_8dot3_is_unavailable() {
let long = resolve_windows_command(r"C:\Program Files\x\h.exe", "codex", |p| {
Some(p.to_string())
});
assert!(long.is_err());
let none = resolve_windows_command(r"C:\a&b\h.exe", "codex", |_| None);
let err = none.unwrap_err().to_string();
assert!(
err.contains("cmd.exe") && err.contains("ordinary characters"),
"reject message must stay actionable: {err}"
);
}
#[test]
fn windows_command_treats_cmd_first_token_delimiters_as_unsafe() {
for path in [
r"C:\tools\a;b\pixtuoid-hook.exe",
r"C:\tools\a,b\pixtuoid-hook.exe",
r"C:\tools\a=b\pixtuoid-hook.exe",
"C:\\tools\\a\tb\\pixtuoid-hook.exe",
] {
let short = resolve_windows_command(path, "codex", |_| {
Some(r"C:\TOOLS\SHORT~1\PIXTUO~1.EXE".to_string())
});
assert_eq!(
short.unwrap(),
r"C:\TOOLS\SHORT~1\PIXTUO~1.EXE --source codex",
"{path:?} must substitute the 8.3 short name"
);
let rejected = resolve_windows_command(path, "codex", |_| None);
assert!(
rejected.is_err(),
"{path:?} must reject, never write a silently-truncating bare form"
);
}
}
#[test]
fn windows_command_rejects_a_cmd_unsafe_source() {
let c = resolve_windows_command(r"C:\tools\hook.exe", "co&dex", |_| {
panic!("must reject on source before touching the short-path resolver")
});
assert!(c.unwrap_err().to_string().contains("source name"));
}
#[cfg(windows)]
#[test]
fn short_path_windows_resolves_an_existing_dir() {
let tmp = std::env::temp_dir();
let got = short_path_windows(&tmp.to_string_lossy());
assert!(
got.is_some_and(|s| !s.is_empty()),
"GetShortPathNameW must resolve an existing dir to a non-empty path"
);
}
}