1use std::ffi::{OsStr, OsString};
4use std::os::windows::ffi::OsStrExt;
5use std::path::{Path, PathBuf};
6use std::thread;
7
8use windows::core::{Interface, PCWSTR};
9use windows::Win32::System::Com::{
10 CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile, CLSCTX_INPROC_SERVER,
11 COINIT_APARTMENTTHREADED,
12};
13use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
14
15use crate::error::{Error, Result};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ShortcutIcon {
20 pub path: PathBuf,
22 pub index: i32,
24}
25
26impl ShortcutIcon {
27 pub fn new(path: impl Into<PathBuf>, index: i32) -> Self {
29 Self {
30 path: path.into(),
31 index,
32 }
33 }
34}
35
36#[derive(Clone, Debug, Default, Eq, PartialEq)]
38pub struct ShortcutOptions {
39 pub arguments: Vec<OsString>,
41 pub working_directory: Option<PathBuf>,
43 pub icon: Option<ShortcutIcon>,
45 pub description: Option<String>,
47}
48
49impl ShortcutOptions {
50 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn arguments<I, S>(mut self, arguments: I) -> Self
57 where
58 I: IntoIterator<Item = S>,
59 S: Into<OsString>,
60 {
61 self.arguments = arguments.into_iter().map(Into::into).collect();
62 self
63 }
64
65 pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
67 self.arguments.push(argument.into());
68 self
69 }
70
71 pub fn working_directory(mut self, path: impl Into<PathBuf>) -> Self {
73 self.working_directory = Some(path.into());
74 self
75 }
76
77 pub fn icon(mut self, path: impl Into<PathBuf>, index: i32) -> Self {
79 self.icon = Some(ShortcutIcon::new(path, index));
80 self
81 }
82
83 pub fn description(mut self, description: impl Into<String>) -> Self {
85 self.description = Some(description.into());
86 self
87 }
88}
89
90#[derive(Clone, Debug)]
91struct ShortcutRequest {
92 shortcut_path: PathBuf,
93 target_path: PathBuf,
94 options: ShortcutOptions,
95}
96
97struct ComApartment;
98
99impl ComApartment {
100 fn initialize_sta() -> Result<Self> {
101 let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
102
103 if result.is_ok() {
104 Ok(Self)
105 } else {
106 Err(Error::WindowsApi {
107 context: "CoInitializeEx",
108 code: result.0,
109 })
110 }
111 }
112}
113
114impl Drop for ComApartment {
115 fn drop(&mut self) {
116 unsafe {
117 CoUninitialize();
118 }
119 }
120}
121
122fn to_wide_os(value: &OsStr) -> Vec<u16> {
123 value.encode_wide().chain(std::iter::once(0)).collect()
124}
125
126fn to_wide_str(value: &str) -> Vec<u16> {
127 OsStr::new(value)
128 .encode_wide()
129 .chain(std::iter::once(0))
130 .collect()
131}
132
133fn quote_arg(arg: &OsStr) -> String {
134 let text = arg.to_string_lossy();
135 let mut quoted = String::with_capacity(text.len() + 2);
136 let mut trailing_backslashes = 0usize;
137
138 quoted.push('"');
139
140 for ch in text.chars() {
141 match ch {
142 '\\' => trailing_backslashes += 1,
143 '"' => {
144 for _ in 0..(trailing_backslashes * 2 + 1) {
145 quoted.push('\\');
146 }
147 quoted.push('"');
148 trailing_backslashes = 0;
149 }
150 _ => {
151 for _ in 0..trailing_backslashes {
152 quoted.push('\\');
153 }
154 quoted.push(ch);
155 trailing_backslashes = 0;
156 }
157 }
158 }
159
160 for _ in 0..(trailing_backslashes * 2) {
161 quoted.push('\\');
162 }
163 quoted.push('"');
164
165 quoted
166}
167
168fn join_args_for_shortcut(args: &[OsString]) -> String {
169 args.iter()
170 .map(|arg| quote_arg(arg.as_os_str()))
171 .collect::<Vec<_>>()
172 .join(" ")
173}
174
175fn os_str_contains_nul(value: &OsStr) -> bool {
176 value.encode_wide().any(|unit| unit == 0)
177}
178
179fn path_contains_nul(path: &Path) -> bool {
180 os_str_contains_nul(path.as_os_str())
181}
182
183fn has_extension(path: &Path, expected: &str) -> bool {
184 path.extension()
185 .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected))
186 .unwrap_or(false)
187}
188
189fn validate_output_path(path: &Path, extension: &str, label: &'static str) -> Result<()> {
190 if path.as_os_str().is_empty() {
191 return Err(Error::InvalidInput(label));
192 }
193
194 if path_contains_nul(path) {
195 return Err(Error::InvalidInput(
196 "shortcut path cannot contain NUL bytes",
197 ));
198 }
199
200 if !path.is_absolute() {
201 return Err(Error::PathNotAbsolute);
202 }
203
204 if !has_extension(path, extension) {
205 return Err(Error::InvalidInput(match extension {
206 "lnk" => "shortcut path must use .lnk extension",
207 "url" => "shortcut path must use .url extension",
208 _ => "shortcut path has an unsupported extension",
209 }));
210 }
211
212 let parent = path
213 .parent()
214 .filter(|parent| !parent.as_os_str().is_empty())
215 .ok_or(Error::InvalidInput(
216 "shortcut path must have a parent directory",
217 ))?;
218
219 if !parent.exists() {
220 return Err(Error::PathDoesNotExist);
221 }
222
223 Ok(())
224}
225
226fn validate_existing_absolute_path(
227 path: &Path,
228 empty_message: &'static str,
229 nul_message: &'static str,
230) -> Result<()> {
231 if path.as_os_str().is_empty() {
232 return Err(Error::InvalidInput(empty_message));
233 }
234
235 if path_contains_nul(path) {
236 return Err(Error::InvalidInput(nul_message));
237 }
238
239 if !path.is_absolute() {
240 return Err(Error::PathNotAbsolute);
241 }
242
243 if !path.exists() {
244 return Err(Error::PathDoesNotExist);
245 }
246
247 Ok(())
248}
249
250fn validate_options(options: &ShortcutOptions) -> Result<()> {
251 if options
252 .arguments
253 .iter()
254 .any(|arg| os_str_contains_nul(arg.as_os_str()))
255 {
256 return Err(Error::InvalidInput(
257 "shortcut arguments cannot contain NUL bytes",
258 ));
259 }
260
261 if let Some(description) = &options.description {
262 if description.contains('\0') {
263 return Err(Error::InvalidInput(
264 "shortcut description cannot contain NUL bytes",
265 ));
266 }
267 }
268
269 if let Some(working_directory) = &options.working_directory {
270 validate_existing_absolute_path(
271 working_directory,
272 "working_directory cannot be empty",
273 "working_directory cannot contain NUL bytes",
274 )?;
275
276 if !working_directory.is_dir() {
277 return Err(Error::InvalidInput("working_directory must be a directory"));
278 }
279 }
280
281 if let Some(icon) = &options.icon {
282 validate_existing_absolute_path(
283 &icon.path,
284 "icon path cannot be empty",
285 "icon path cannot contain NUL bytes",
286 )?;
287 }
288
289 Ok(())
290}
291
292fn validate_url(url: &str) -> Result<&str> {
293 let trimmed = url.trim();
294
295 if trimmed.is_empty() {
296 return Err(Error::InvalidInput("url cannot be empty"));
297 }
298
299 if trimmed.contains('\0') {
300 return Err(Error::InvalidInput("url cannot contain NUL bytes"));
301 }
302
303 if trimmed.contains('\r') || trimmed.contains('\n') {
304 return Err(Error::InvalidInput("url cannot contain line breaks"));
305 }
306
307 Ok(trimmed)
308}
309
310fn create_shortcut_in_sta(request: ShortcutRequest) -> Result<()> {
311 let _com = ComApartment::initialize_sta()?;
312 let link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
313 .map_err(|err| Error::WindowsApi {
314 context: "CoCreateInstance(ShellLink)",
315 code: err.code().0,
316 })?;
317
318 let target_w = to_wide_os(request.target_path.as_os_str());
319 unsafe { link.SetPath(PCWSTR(target_w.as_ptr())) }.map_err(|err| Error::WindowsApi {
320 context: "IShellLinkW::SetPath",
321 code: err.code().0,
322 })?;
323
324 if !request.options.arguments.is_empty() {
325 let arguments = join_args_for_shortcut(&request.options.arguments);
326 let arguments_w = to_wide_str(&arguments);
327 unsafe { link.SetArguments(PCWSTR(arguments_w.as_ptr())) }.map_err(|err| {
328 Error::WindowsApi {
329 context: "IShellLinkW::SetArguments",
330 code: err.code().0,
331 }
332 })?;
333 }
334
335 if let Some(working_directory) = &request.options.working_directory {
336 let working_directory_w = to_wide_os(working_directory.as_os_str());
337 unsafe { link.SetWorkingDirectory(PCWSTR(working_directory_w.as_ptr())) }.map_err(
338 |err| Error::WindowsApi {
339 context: "IShellLinkW::SetWorkingDirectory",
340 code: err.code().0,
341 },
342 )?;
343 }
344
345 if let Some(description) = &request.options.description {
346 let description_w = to_wide_str(description);
347 unsafe { link.SetDescription(PCWSTR(description_w.as_ptr())) }.map_err(|err| {
348 Error::WindowsApi {
349 context: "IShellLinkW::SetDescription",
350 code: err.code().0,
351 }
352 })?;
353 }
354
355 if let Some(icon) = &request.options.icon {
356 let icon_w = to_wide_os(icon.path.as_os_str());
357 unsafe { link.SetIconLocation(PCWSTR(icon_w.as_ptr()), icon.index) }.map_err(|err| {
358 Error::WindowsApi {
359 context: "IShellLinkW::SetIconLocation",
360 code: err.code().0,
361 }
362 })?;
363 }
364
365 let persist: IPersistFile = link.cast().map_err(|err| Error::WindowsApi {
366 context: "IShellLinkW::cast(IPersistFile)",
367 code: err.code().0,
368 })?;
369 let shortcut_w = to_wide_os(request.shortcut_path.as_os_str());
370
371 unsafe { persist.Save(PCWSTR(shortcut_w.as_ptr()), true) }.map_err(|err| Error::WindowsApi {
372 context: "IPersistFile::Save",
373 code: err.code().0,
374 })
375}
376
377fn run_in_shortcut_sta<T, F>(work: F) -> Result<T>
378where
379 T: Send + 'static,
380 F: FnOnce() -> Result<T> + Send + 'static,
381{
382 match thread::spawn(work).join() {
383 Ok(result) => result,
384 Err(_) => Err(Error::Unsupported("shortcut STA worker thread panicked")),
385 }
386}
387
388pub fn create_shortcut(
418 shortcut_path: impl AsRef<Path>,
419 target_path: impl AsRef<Path>,
420 options: &ShortcutOptions,
421) -> Result<()> {
422 let shortcut_path = shortcut_path.as_ref();
423 let target_path = target_path.as_ref();
424
425 validate_output_path(shortcut_path, "lnk", "shortcut path cannot be empty")?;
426 validate_existing_absolute_path(
427 target_path,
428 "target path cannot be empty",
429 "target path cannot contain NUL bytes",
430 )?;
431 validate_options(options)?;
432
433 let request = ShortcutRequest {
434 shortcut_path: shortcut_path.to_path_buf(),
435 target_path: target_path.to_path_buf(),
436 options: options.clone(),
437 };
438
439 run_in_shortcut_sta(move || create_shortcut_in_sta(request))
440}
441
442pub fn create_url_shortcut(shortcut_path: impl AsRef<Path>, url: &str) -> Result<()> {
471 let shortcut_path = shortcut_path.as_ref();
472 let url = validate_url(url)?;
473
474 validate_output_path(shortcut_path, "url", "shortcut path cannot be empty")?;
475
476 let body = format!("[InternetShortcut]\r\nURL={url}\r\n");
477 std::fs::write(shortcut_path, body)?;
478
479 Ok(())
480}
481
482#[cfg(test)]
483mod tests {
484 use super::{
485 create_url_shortcut, join_args_for_shortcut, validate_output_path, validate_url,
486 ShortcutOptions,
487 };
488 use std::ffi::OsString;
489
490 #[test]
491 fn shortcut_options_builder_sets_values() {
492 let options = ShortcutOptions::new()
493 .argument("--help")
494 .working_directory(r"C:\Windows")
495 .icon(r"C:\Windows\notepad.exe", 0)
496 .description("Demo shortcut");
497
498 assert_eq!(options.arguments, [OsString::from("--help")]);
499 assert_eq!(options.description.as_deref(), Some("Demo shortcut"));
500 assert!(options.working_directory.is_some());
501 assert!(options.icon.is_some());
502 }
503
504 #[test]
505 fn join_args_quotes_each_argument() {
506 let args = [OsString::from("alpha"), OsString::from("two words")];
507 assert_eq!(join_args_for_shortcut(&args), "\"alpha\" \"two words\"");
508 }
509
510 #[test]
511 fn validate_url_trims_surrounding_whitespace() {
512 assert_eq!(
513 validate_url(" https://example.com/docs ").unwrap(),
514 "https://example.com/docs"
515 );
516 }
517
518 #[test]
519 fn validate_url_rejects_line_breaks() {
520 let result = validate_url("https://example.com/\r\nIconFile=bad.ico");
521 assert!(matches!(
522 result,
523 Err(crate::Error::InvalidInput("url cannot contain line breaks"))
524 ));
525 }
526
527 #[test]
528 fn validate_output_path_rejects_relative_paths() {
529 let result = validate_output_path(
530 std::path::Path::new("demo.lnk"),
531 "lnk",
532 "shortcut path cannot be empty",
533 );
534 assert!(matches!(result, Err(crate::Error::PathNotAbsolute)));
535 }
536
537 #[test]
538 fn validate_output_path_rejects_wrong_extension() {
539 let path = std::env::temp_dir().join("demo.txt");
540 let result = validate_output_path(&path, "lnk", "shortcut path cannot be empty");
541 assert!(matches!(
542 result,
543 Err(crate::Error::InvalidInput(
544 "shortcut path must use .lnk extension"
545 ))
546 ));
547 }
548
549 #[test]
550 fn create_url_shortcut_writes_url_file() {
551 let path = std::env::temp_dir().join(format!(
552 "win-desktop-utils-url-shortcut-test-{}.url",
553 std::process::id()
554 ));
555 let _ = std::fs::remove_file(&path);
556
557 create_url_shortcut(&path, " https://example.com/docs ").unwrap();
558
559 let body = std::fs::read_to_string(&path).unwrap();
560 assert_eq!(
561 body,
562 "[InternetShortcut]\r\nURL=https://example.com/docs\r\n"
563 );
564
565 std::fs::remove_file(path).unwrap();
566 }
567}