1use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use windows::core::{Interface, PCWSTR};
7use windows::Win32::System::Com::{CoCreateInstance, IPersistFile, CLSCTX_INPROC_SERVER};
8use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
9
10use crate::error::{Error, Result};
11use crate::win::{
12 join_quoted_args, os_str_contains_nul, path_contains_nul, run_in_sta, to_wide_os, to_wide_str,
13 ComApartment,
14};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ShortcutIcon {
28 pub path: PathBuf,
30 pub index: i32,
32}
33
34impl ShortcutIcon {
35 pub fn new(path: impl Into<PathBuf>, index: i32) -> Self {
37 Self {
38 path: path.into(),
39 index,
40 }
41 }
42}
43
44#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct ShortcutOptions {
62 pub arguments: Vec<OsString>,
64 pub working_directory: Option<PathBuf>,
66 pub icon: Option<ShortcutIcon>,
68 pub description: Option<String>,
70}
71
72impl ShortcutOptions {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn arguments<I, S>(mut self, arguments: I) -> Self
80 where
81 I: IntoIterator<Item = S>,
82 S: Into<OsString>,
83 {
84 self.arguments = arguments.into_iter().map(Into::into).collect();
85 self
86 }
87
88 pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
90 self.arguments.push(argument.into());
91 self
92 }
93
94 pub fn working_directory(mut self, path: impl Into<PathBuf>) -> Self {
96 self.working_directory = Some(path.into());
97 self
98 }
99
100 pub fn icon(mut self, path: impl Into<PathBuf>, index: i32) -> Self {
102 self.icon = Some(ShortcutIcon::new(path, index));
103 self
104 }
105
106 pub fn description(mut self, description: impl Into<String>) -> Self {
108 self.description = Some(description.into());
109 self
110 }
111}
112
113#[derive(Clone, Debug)]
114struct ShortcutRequest {
115 shortcut_path: PathBuf,
116 target_path: PathBuf,
117 options: ShortcutOptions,
118}
119
120fn join_args_for_shortcut(args: &[OsString]) -> String {
121 join_quoted_args(args)
122}
123
124fn has_extension(path: &Path, expected: &str) -> bool {
125 path.extension()
126 .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected))
127 .unwrap_or(false)
128}
129
130fn validate_output_path(path: &Path, extension: &str, label: &'static str) -> Result<()> {
131 if path.as_os_str().is_empty() {
132 return Err(Error::InvalidInput(label));
133 }
134
135 if path_contains_nul(path) {
136 return Err(Error::InvalidInput(
137 "shortcut path cannot contain NUL bytes",
138 ));
139 }
140
141 if !path.is_absolute() {
142 return Err(Error::PathNotAbsolute);
143 }
144
145 if !has_extension(path, extension) {
146 return Err(Error::InvalidInput(match extension {
147 "lnk" => "shortcut path must use .lnk extension",
148 "url" => "shortcut path must use .url extension",
149 _ => "shortcut path has an unsupported extension",
150 }));
151 }
152
153 let parent = path
154 .parent()
155 .filter(|parent| !parent.as_os_str().is_empty())
156 .ok_or(Error::InvalidInput(
157 "shortcut path must have a parent directory",
158 ))?;
159
160 if !parent.exists() {
161 return Err(Error::PathDoesNotExist);
162 }
163
164 Ok(())
165}
166
167fn validate_existing_absolute_path(
168 path: &Path,
169 empty_message: &'static str,
170 nul_message: &'static str,
171) -> Result<()> {
172 if path.as_os_str().is_empty() {
173 return Err(Error::InvalidInput(empty_message));
174 }
175
176 if path_contains_nul(path) {
177 return Err(Error::InvalidInput(nul_message));
178 }
179
180 if !path.is_absolute() {
181 return Err(Error::PathNotAbsolute);
182 }
183
184 if !path.exists() {
185 return Err(Error::PathDoesNotExist);
186 }
187
188 Ok(())
189}
190
191fn validate_options(options: &ShortcutOptions) -> Result<()> {
192 if options
193 .arguments
194 .iter()
195 .any(|arg| os_str_contains_nul(arg.as_os_str()))
196 {
197 return Err(Error::InvalidInput(
198 "shortcut arguments cannot contain NUL bytes",
199 ));
200 }
201
202 if let Some(description) = &options.description {
203 if description.contains('\0') {
204 return Err(Error::InvalidInput(
205 "shortcut description cannot contain NUL bytes",
206 ));
207 }
208 }
209
210 if let Some(working_directory) = &options.working_directory {
211 validate_existing_absolute_path(
212 working_directory,
213 "working_directory cannot be empty",
214 "working_directory cannot contain NUL bytes",
215 )?;
216
217 if !working_directory.is_dir() {
218 return Err(Error::InvalidInput("working_directory must be a directory"));
219 }
220 }
221
222 if let Some(icon) = &options.icon {
223 validate_existing_absolute_path(
224 &icon.path,
225 "icon path cannot be empty",
226 "icon path cannot contain NUL bytes",
227 )?;
228 }
229
230 Ok(())
231}
232
233fn validate_url(url: &str) -> Result<&str> {
234 let trimmed = url.trim();
235
236 if trimmed.is_empty() {
237 return Err(Error::InvalidInput("url cannot be empty"));
238 }
239
240 if trimmed.contains('\0') {
241 return Err(Error::InvalidInput("url cannot contain NUL bytes"));
242 }
243
244 if trimmed.contains('\r') || trimmed.contains('\n') {
245 return Err(Error::InvalidInput("url cannot contain line breaks"));
246 }
247
248 Ok(trimmed)
249}
250
251fn create_shortcut_in_sta(request: ShortcutRequest) -> Result<()> {
252 let _com = ComApartment::initialize_sta("CoInitializeEx")?;
253 let link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
254 .map_err(|err| Error::WindowsApi {
255 context: "CoCreateInstance(ShellLink)",
256 code: err.code().0,
257 })?;
258
259 let target_w = to_wide_os(request.target_path.as_os_str());
260 unsafe { link.SetPath(PCWSTR(target_w.as_ptr())) }.map_err(|err| Error::WindowsApi {
261 context: "IShellLinkW::SetPath",
262 code: err.code().0,
263 })?;
264
265 if !request.options.arguments.is_empty() {
266 let arguments = join_args_for_shortcut(&request.options.arguments);
267 let arguments_w = to_wide_str(&arguments);
268 unsafe { link.SetArguments(PCWSTR(arguments_w.as_ptr())) }.map_err(|err| {
269 Error::WindowsApi {
270 context: "IShellLinkW::SetArguments",
271 code: err.code().0,
272 }
273 })?;
274 }
275
276 if let Some(working_directory) = &request.options.working_directory {
277 let working_directory_w = to_wide_os(working_directory.as_os_str());
278 unsafe { link.SetWorkingDirectory(PCWSTR(working_directory_w.as_ptr())) }.map_err(
279 |err| Error::WindowsApi {
280 context: "IShellLinkW::SetWorkingDirectory",
281 code: err.code().0,
282 },
283 )?;
284 }
285
286 if let Some(description) = &request.options.description {
287 let description_w = to_wide_str(description);
288 unsafe { link.SetDescription(PCWSTR(description_w.as_ptr())) }.map_err(|err| {
289 Error::WindowsApi {
290 context: "IShellLinkW::SetDescription",
291 code: err.code().0,
292 }
293 })?;
294 }
295
296 if let Some(icon) = &request.options.icon {
297 let icon_w = to_wide_os(icon.path.as_os_str());
298 unsafe { link.SetIconLocation(PCWSTR(icon_w.as_ptr()), icon.index) }.map_err(|err| {
299 Error::WindowsApi {
300 context: "IShellLinkW::SetIconLocation",
301 code: err.code().0,
302 }
303 })?;
304 }
305
306 let persist: IPersistFile = link.cast().map_err(|err| Error::WindowsApi {
307 context: "IShellLinkW::cast(IPersistFile)",
308 code: err.code().0,
309 })?;
310 let shortcut_w = to_wide_os(request.shortcut_path.as_os_str());
311
312 unsafe { persist.Save(PCWSTR(shortcut_w.as_ptr()), true) }.map_err(|err| Error::WindowsApi {
313 context: "IPersistFile::Save",
314 code: err.code().0,
315 })
316}
317
318fn run_in_shortcut_sta<T, F>(work: F) -> Result<T>
319where
320 T: Send + 'static,
321 F: FnOnce() -> Result<T> + Send + 'static,
322{
323 run_in_sta("shortcut STA worker thread panicked", work)
324}
325
326pub fn create_shortcut(
356 shortcut_path: impl AsRef<Path>,
357 target_path: impl AsRef<Path>,
358 options: &ShortcutOptions,
359) -> Result<()> {
360 let shortcut_path = shortcut_path.as_ref();
361 let target_path = target_path.as_ref();
362
363 validate_output_path(shortcut_path, "lnk", "shortcut path cannot be empty")?;
364 validate_existing_absolute_path(
365 target_path,
366 "target path cannot be empty",
367 "target path cannot contain NUL bytes",
368 )?;
369 validate_options(options)?;
370
371 let request = ShortcutRequest {
372 shortcut_path: shortcut_path.to_path_buf(),
373 target_path: target_path.to_path_buf(),
374 options: options.clone(),
375 };
376
377 run_in_shortcut_sta(move || create_shortcut_in_sta(request))
378}
379
380pub fn create_url_shortcut(shortcut_path: impl AsRef<Path>, url: &str) -> Result<()> {
409 let shortcut_path = shortcut_path.as_ref();
410 let url = validate_url(url)?;
411
412 validate_output_path(shortcut_path, "url", "shortcut path cannot be empty")?;
413
414 let body = format!("[InternetShortcut]\r\nURL={url}\r\n");
415 std::fs::write(shortcut_path, body)?;
416
417 Ok(())
418}
419
420#[cfg(test)]
421mod tests {
422 use super::{
423 create_url_shortcut, join_args_for_shortcut, validate_output_path, validate_url,
424 ShortcutOptions,
425 };
426 use std::ffi::OsString;
427
428 #[test]
429 fn shortcut_options_builder_sets_values() {
430 let options = ShortcutOptions::new()
431 .argument("--help")
432 .working_directory(r"C:\Windows")
433 .icon(r"C:\Windows\notepad.exe", 0)
434 .description("Demo shortcut");
435
436 assert_eq!(options.arguments, [OsString::from("--help")]);
437 assert_eq!(options.description.as_deref(), Some("Demo shortcut"));
438 assert!(options.working_directory.is_some());
439 assert!(options.icon.is_some());
440 }
441
442 #[test]
443 fn join_args_quotes_each_argument() {
444 let args = [OsString::from("alpha"), OsString::from("two words")];
445 assert_eq!(join_args_for_shortcut(&args), "\"alpha\" \"two words\"");
446 }
447
448 #[test]
449 fn validate_url_trims_surrounding_whitespace() {
450 assert_eq!(
451 validate_url(" https://example.com/docs ").unwrap(),
452 "https://example.com/docs"
453 );
454 }
455
456 #[test]
457 fn validate_url_rejects_line_breaks() {
458 let result = validate_url("https://example.com/\r\nIconFile=bad.ico");
459 assert!(matches!(
460 result,
461 Err(crate::Error::InvalidInput("url cannot contain line breaks"))
462 ));
463 }
464
465 #[test]
466 fn validate_output_path_rejects_relative_paths() {
467 let result = validate_output_path(
468 std::path::Path::new("demo.lnk"),
469 "lnk",
470 "shortcut path cannot be empty",
471 );
472 assert!(matches!(result, Err(crate::Error::PathNotAbsolute)));
473 }
474
475 #[test]
476 fn validate_output_path_rejects_wrong_extension() {
477 let path = std::env::temp_dir().join("demo.txt");
478 let result = validate_output_path(&path, "lnk", "shortcut path cannot be empty");
479 assert!(matches!(
480 result,
481 Err(crate::Error::InvalidInput(
482 "shortcut path must use .lnk extension"
483 ))
484 ));
485 }
486
487 #[test]
488 fn create_url_shortcut_writes_url_file() {
489 let path = std::env::temp_dir().join(format!(
490 "win-desktop-utils-url-shortcut-test-{}.url",
491 std::process::id()
492 ));
493 let _ = std::fs::remove_file(&path);
494
495 create_url_shortcut(&path, " https://example.com/docs ").unwrap();
496
497 let body = std::fs::read_to_string(&path).unwrap();
498 assert_eq!(
499 body,
500 "[InternetShortcut]\r\nURL=https://example.com/docs\r\n"
501 );
502
503 std::fs::remove_file(path).unwrap();
504 }
505}