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 if !parent.is_dir() {
165 return Err(Error::InvalidInput(
166 "shortcut path parent must be a directory",
167 ));
168 }
169
170 Ok(())
171}
172
173fn validate_existing_absolute_path(
174 path: &Path,
175 empty_message: &'static str,
176 nul_message: &'static str,
177) -> Result<()> {
178 if path.as_os_str().is_empty() {
179 return Err(Error::InvalidInput(empty_message));
180 }
181
182 if path_contains_nul(path) {
183 return Err(Error::InvalidInput(nul_message));
184 }
185
186 if !path.is_absolute() {
187 return Err(Error::PathNotAbsolute);
188 }
189
190 if !path.exists() {
191 return Err(Error::PathDoesNotExist);
192 }
193
194 Ok(())
195}
196
197fn validate_options(options: &ShortcutOptions) -> Result<()> {
198 if options
199 .arguments
200 .iter()
201 .any(|arg| os_str_contains_nul(arg.as_os_str()))
202 {
203 return Err(Error::InvalidInput(
204 "shortcut arguments cannot contain NUL bytes",
205 ));
206 }
207
208 if let Some(description) = &options.description {
209 if description.contains('\0') {
210 return Err(Error::InvalidInput(
211 "shortcut description cannot contain NUL bytes",
212 ));
213 }
214 }
215
216 if let Some(working_directory) = &options.working_directory {
217 validate_existing_absolute_path(
218 working_directory,
219 "working_directory cannot be empty",
220 "working_directory cannot contain NUL bytes",
221 )?;
222
223 if !working_directory.is_dir() {
224 return Err(Error::InvalidInput("working_directory must be a directory"));
225 }
226 }
227
228 if let Some(icon) = &options.icon {
229 validate_existing_absolute_path(
230 &icon.path,
231 "icon path cannot be empty",
232 "icon path cannot contain NUL bytes",
233 )?;
234 }
235
236 Ok(())
237}
238
239fn validate_url(url: &str) -> Result<&str> {
240 let trimmed = url.trim();
241
242 if trimmed.is_empty() {
243 return Err(Error::InvalidInput("url cannot be empty"));
244 }
245
246 if trimmed.contains('\0') {
247 return Err(Error::InvalidInput("url cannot contain NUL bytes"));
248 }
249
250 if trimmed.contains('\r') || trimmed.contains('\n') {
251 return Err(Error::InvalidInput("url cannot contain line breaks"));
252 }
253
254 Ok(trimmed)
255}
256
257fn create_shortcut_in_sta(request: ShortcutRequest) -> Result<()> {
258 let _com = ComApartment::initialize_sta("CoInitializeEx")?;
259 let link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
260 .map_err(|err| Error::WindowsApi {
261 context: "CoCreateInstance(ShellLink)",
262 code: err.code().0,
263 })?;
264
265 let target_w = to_wide_os(request.target_path.as_os_str());
266 unsafe { link.SetPath(PCWSTR(target_w.as_ptr())) }.map_err(|err| Error::WindowsApi {
267 context: "IShellLinkW::SetPath",
268 code: err.code().0,
269 })?;
270
271 if !request.options.arguments.is_empty() {
272 let arguments = join_args_for_shortcut(&request.options.arguments);
273 let arguments_w = to_wide_str(&arguments);
274 unsafe { link.SetArguments(PCWSTR(arguments_w.as_ptr())) }.map_err(|err| {
275 Error::WindowsApi {
276 context: "IShellLinkW::SetArguments",
277 code: err.code().0,
278 }
279 })?;
280 }
281
282 if let Some(working_directory) = &request.options.working_directory {
283 let working_directory_w = to_wide_os(working_directory.as_os_str());
284 unsafe { link.SetWorkingDirectory(PCWSTR(working_directory_w.as_ptr())) }.map_err(
285 |err| Error::WindowsApi {
286 context: "IShellLinkW::SetWorkingDirectory",
287 code: err.code().0,
288 },
289 )?;
290 }
291
292 if let Some(description) = &request.options.description {
293 let description_w = to_wide_str(description);
294 unsafe { link.SetDescription(PCWSTR(description_w.as_ptr())) }.map_err(|err| {
295 Error::WindowsApi {
296 context: "IShellLinkW::SetDescription",
297 code: err.code().0,
298 }
299 })?;
300 }
301
302 if let Some(icon) = &request.options.icon {
303 let icon_w = to_wide_os(icon.path.as_os_str());
304 unsafe { link.SetIconLocation(PCWSTR(icon_w.as_ptr()), icon.index) }.map_err(|err| {
305 Error::WindowsApi {
306 context: "IShellLinkW::SetIconLocation",
307 code: err.code().0,
308 }
309 })?;
310 }
311
312 let persist: IPersistFile = link.cast().map_err(|err| Error::WindowsApi {
313 context: "IShellLinkW::cast(IPersistFile)",
314 code: err.code().0,
315 })?;
316 let shortcut_w = to_wide_os(request.shortcut_path.as_os_str());
317
318 unsafe { persist.Save(PCWSTR(shortcut_w.as_ptr()), true) }.map_err(|err| Error::WindowsApi {
319 context: "IPersistFile::Save",
320 code: err.code().0,
321 })
322}
323
324fn run_in_shortcut_sta<T, F>(work: F) -> Result<T>
325where
326 T: Send + 'static,
327 F: FnOnce() -> Result<T> + Send + 'static,
328{
329 run_in_sta("shortcut STA worker thread panicked", work)
330}
331
332pub fn create_shortcut(
362 shortcut_path: impl AsRef<Path>,
363 target_path: impl AsRef<Path>,
364 options: &ShortcutOptions,
365) -> Result<()> {
366 let shortcut_path = shortcut_path.as_ref();
367 let target_path = target_path.as_ref();
368
369 validate_output_path(shortcut_path, "lnk", "shortcut path cannot be empty")?;
370 validate_existing_absolute_path(
371 target_path,
372 "target path cannot be empty",
373 "target path cannot contain NUL bytes",
374 )?;
375 validate_options(options)?;
376
377 let request = ShortcutRequest {
378 shortcut_path: shortcut_path.to_path_buf(),
379 target_path: target_path.to_path_buf(),
380 options: options.clone(),
381 };
382
383 run_in_shortcut_sta(move || create_shortcut_in_sta(request))
384}
385
386pub fn create_url_shortcut(shortcut_path: impl AsRef<Path>, url: &str) -> Result<()> {
415 let shortcut_path = shortcut_path.as_ref();
416 let url = validate_url(url)?;
417
418 validate_output_path(shortcut_path, "url", "shortcut path cannot be empty")?;
419
420 let body = format!("[InternetShortcut]\r\nURL={url}\r\n");
421 std::fs::write(shortcut_path, body)?;
422
423 Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428 use super::{
429 create_url_shortcut, join_args_for_shortcut, validate_output_path, validate_url,
430 ShortcutOptions,
431 };
432 use std::ffi::OsString;
433
434 #[test]
435 fn shortcut_options_builder_sets_values() {
436 let options = ShortcutOptions::new()
437 .argument("--help")
438 .working_directory(r"C:\Windows")
439 .icon(r"C:\Windows\notepad.exe", 0)
440 .description("Demo shortcut");
441
442 assert_eq!(options.arguments, [OsString::from("--help")]);
443 assert_eq!(options.description.as_deref(), Some("Demo shortcut"));
444 assert!(options.working_directory.is_some());
445 assert!(options.icon.is_some());
446 }
447
448 #[test]
449 fn join_args_quotes_each_argument() {
450 let args = [OsString::from("alpha"), OsString::from("two words")];
451 assert_eq!(join_args_for_shortcut(&args), "\"alpha\" \"two words\"");
452 }
453
454 #[test]
455 fn validate_url_trims_surrounding_whitespace() {
456 assert_eq!(
457 validate_url(" https://example.com/docs ").unwrap(),
458 "https://example.com/docs"
459 );
460 }
461
462 #[test]
463 fn validate_url_rejects_line_breaks() {
464 let result = validate_url("https://example.com/\r\nIconFile=bad.ico");
465 assert!(matches!(
466 result,
467 Err(crate::Error::InvalidInput("url cannot contain line breaks"))
468 ));
469 }
470
471 #[test]
472 fn validate_output_path_rejects_relative_paths() {
473 let result = validate_output_path(
474 std::path::Path::new("demo.lnk"),
475 "lnk",
476 "shortcut path cannot be empty",
477 );
478 assert!(matches!(result, Err(crate::Error::PathNotAbsolute)));
479 }
480
481 #[test]
482 fn validate_output_path_rejects_wrong_extension() {
483 let path = std::env::temp_dir().join("demo.txt");
484 let result = validate_output_path(&path, "lnk", "shortcut path cannot be empty");
485 assert!(matches!(
486 result,
487 Err(crate::Error::InvalidInput(
488 "shortcut path must use .lnk extension"
489 ))
490 ));
491 }
492
493 #[test]
494 fn create_url_shortcut_writes_url_file() {
495 let path = std::env::temp_dir().join(format!(
496 "win-desktop-utils-url-shortcut-test-{}.url",
497 std::process::id()
498 ));
499 let _ = std::fs::remove_file(&path);
500
501 create_url_shortcut(&path, " https://example.com/docs ").unwrap();
502
503 let body = std::fs::read_to_string(&path).unwrap();
504 assert_eq!(
505 body,
506 "[InternetShortcut]\r\nURL=https://example.com/docs\r\n"
507 );
508
509 std::fs::remove_file(path).unwrap();
510 }
511}