Skip to content

Commit 86c27a1

Browse files
committed
Initial commit
0 parents  commit 86c27a1

16 files changed

Lines changed: 1041 additions & 0 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/target
2+
Cargo.lock
3+
.metadata
4+
.vscode

Cargo.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "lnks"
3+
version = "0.1.0"
4+
description = "A library for reading and writing windows shortcuts (.lnk)."
5+
repository = "https://github.com/OpenByteDev/lnks"
6+
documentation = "https://docs.rs/lnks"
7+
license = "MIT"
8+
authors = ["OpenByte <development.openbyte@gmail.com>"]
9+
edition = "2024"
10+
categories = ["api-bindings", "filesystem", "os::windows-apis"]
11+
keywords = ["lnk", "windows", "shell-link", "parser"]
12+
13+
[dependencies]
14+
windows = { version = "0.62", features = [
15+
"std",
16+
"Win32_System_Com",
17+
"Win32_UI_Shell_Common",
18+
"Win32_Storage_FileSystem",
19+
"Win32_UI_WindowsAndMessaging"
20+
], default-features = false }
21+
widestring = { version = "1.2", features = ["std"], default-features = false }
22+
num_enum = { version = "0.7", default-features = false }
23+
thiserror = { version = "2.0", default-features = false }
24+
enumflags2 = { version = "0.7", default-features = false }
25+
26+
[dev-dependencies]
27+
dunce = { version = "1", default-features = false }
28+
uuid = { version = "1", features = ["v4"], default-features = false }

LICENSE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) OpenByte <development.openbyte@gmail.com>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# lnks
2+
3+
[![CI](https://github.com/OpenByteDev/lnks/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenByteDev/lnks/actions/workflows/ci.yml) [![crates.io](https://img.shields.io/crates/v/lnks.svg)](https://crates.io/crates/lnks) [![Documentation](https://docs.rs/lnks/badge.svg)](https://docs.rs/lnks) [![dependency status](https://deps.rs/repo/github/openbytedev/lnks/status.svg)](https://deps.rs/repo/github/openbytedev/lnks) [![MIT](https://img.shields.io/crates/l/lnks.svg)](https://github.com/OpenByteDev/lnks/blob/master/LICENSE)
4+
5+
`lnks` provides a high-level API for reading and writing Windows `.lnk` (Shell Link) files.
6+
It wraps the COM-based Shell APIs `IShellLinkW` and `IPersistFile`, including support for reading and toggling the undocumented "Run as administrator" flag.
7+
8+
## Examples
9+
10+
### Load an existing shortcut
11+
```rust
12+
let path = Path::new(r"C:\Users\Public\Desktop\Notepad.lnk");
13+
let shortcut = lnks::Shortcut::load(path).unwrap();
14+
```
15+
16+
### Create and save a new shortcut to Notepad
17+
```rust
18+
let mut shortcut = lnks::Shortcut::new(r"C:\Windows\System32\notepad.exe");
19+
shortcut.arguments = Some(r"C:\Windows\win.ini".to_string());
20+
let out = Path::new(r"C:\Users\Public\Desktop\Notepad.lnk");
21+
shortcut.save(out).unwrap();
22+
```
23+
24+
## License
25+
Licensed under the MIT license ([LICENSE](https://github.com/OpenByteDev/lnks/blob/master/LICENSE) or http://opensource.org/licenses/MIT)

src/buf_utils.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use crate::Result;
2+
use std::path::PathBuf;
3+
use widestring::U16CString;
4+
use windows::Win32::Foundation::MAX_PATH;
5+
6+
const INITIAL_CAPACITY: usize = MAX_PATH as _;
7+
const MAX_CAPACITY: usize = 4 * 1024;
8+
9+
pub(crate) fn com_get_with_growth(
10+
mut f: impl FnMut(&mut [u16]) -> Result<()>,
11+
) -> Result<U16CString> {
12+
let mut buf = vec![0u16; INITIAL_CAPACITY];
13+
14+
loop {
15+
let res = f(&mut buf);
16+
17+
// COM APIs do not consitently return an error and just truncate the output
18+
// but still include the null terminator.
19+
// Thus we just check if the second to last element is null to guess whether
20+
// the output was truncated.
21+
if buf[0] != 0 && buf[buf.len() - 2] != 0 {
22+
let new_capacity = buf.capacity().saturating_mul(2);
23+
if new_capacity <= MAX_CAPACITY {
24+
buf.resize(new_capacity, 0);
25+
continue;
26+
}
27+
}
28+
29+
return res.map(|()| U16CString::from_vec_truncate(buf));
30+
}
31+
}
32+
33+
pub(crate) fn com_get_string(f: impl FnMut(&mut [u16]) -> Result<()>) -> Result<String> {
34+
com_get_with_growth(f)?
35+
.to_string()
36+
.map_err(crate::Error::Utf16)
37+
}
38+
39+
pub(crate) fn com_get_optional_string(
40+
f: impl FnMut(&mut [u16]) -> Result<()>,
41+
) -> Result<Option<String>> {
42+
let s = com_get_string(f)?;
43+
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
44+
}
45+
46+
#[allow(dead_code)]
47+
pub(crate) fn com_get_path(f: impl FnMut(&mut [u16]) -> Result<()>) -> Result<PathBuf> {
48+
let str = com_get_with_growth(f)?;
49+
Ok(PathBuf::from(str.to_os_string()))
50+
}
51+
52+
pub(crate) fn com_get_optional_path(
53+
f: impl FnMut(&mut [u16]) -> Result<()>,
54+
) -> Result<Option<PathBuf>> {
55+
let s = com_get_with_growth(f)?;
56+
if s.is_empty() {
57+
Ok(None)
58+
} else {
59+
Ok(Some(PathBuf::from(s.to_os_string())))
60+
}
61+
}

src/com.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use std::cell::Cell;
2+
use std::fmt;
3+
use std::marker::PhantomData;
4+
use std::rc::Rc;
5+
6+
use windows::Win32::System::Com::{
7+
COINIT, COINIT_APARTMENTTHREADED, COINIT_MULTITHREADED, CoInitializeEx, CoUninitialize,
8+
};
9+
use windows::core::HRESULT;
10+
11+
/// COM apartment model.
12+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
13+
pub enum Type {
14+
/// Single-Threaded Apartment (STA)
15+
Sta,
16+
/// Multi-Threaded Apartment (MTA)
17+
Mta,
18+
}
19+
20+
impl Type {
21+
#[must_use]
22+
pub fn as_raw(&self) -> COINIT {
23+
match self {
24+
Type::Sta => COINIT_APARTMENTTHREADED,
25+
Type::Mta => COINIT_MULTITHREADED,
26+
}
27+
}
28+
29+
#[must_use]
30+
pub fn as_code(&self) -> i32 {
31+
self.as_raw().0
32+
}
33+
}
34+
35+
/// HRESULT returned when COM is already initialized on the current thread with a
36+
/// different apartment model than requested.
37+
///
38+
/// In this situation COM remains usable, but the requested model is not applied.
39+
const RPC_E_CHANGED_MODE: HRESULT = HRESULT(0x8001_0106_u32.cast_signed());
40+
41+
thread_local! {
42+
/// Per-thread flag that stores whether COM has been initialized.
43+
static COM_INIT: Cell<bool> = const { Cell::new(false) };
44+
}
45+
46+
/// Result of a COM initialization attempt.
47+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48+
pub enum Initialization {
49+
/// COM was successfully initialized by this call.
50+
Success,
51+
/// COM was already initialized on this thread, either:
52+
/// - by a previous call to `initialize_as`
53+
/// - by external code
54+
AlreadyInitialized,
55+
}
56+
57+
/// Initializes COM for the current thread.
58+
/// This is done automatically when reading or writing shortcuts (as STA).
59+
pub fn initialize_as(kind: Type) -> Result<Initialization, Error> {
60+
COM_INIT.with(|state| {
61+
if state.get() {
62+
return Ok(Initialization::AlreadyInitialized);
63+
}
64+
65+
let coinit: COINIT = match kind {
66+
Type::Sta => COINIT_APARTMENTTHREADED,
67+
Type::Mta => COINIT_MULTITHREADED,
68+
};
69+
70+
let hresult = unsafe { CoInitializeEx(None, coinit) };
71+
72+
match hresult {
73+
HRESULT(0) => {
74+
state.set(true);
75+
Ok(Initialization::Success)
76+
}
77+
HRESULT(1) | RPC_E_CHANGED_MODE => {
78+
state.set(true);
79+
Ok(Initialization::AlreadyInitialized)
80+
}
81+
_ => Err(windows::core::Error::from_hresult(hresult).context(None, "CoInitializeEx")),
82+
}
83+
})
84+
}
85+
86+
pub(crate) fn ensure_initialized() -> crate::Result<()> {
87+
match initialize_as(Type::Sta) {
88+
Ok(_) => Ok(()),
89+
Err(err) => Err(crate::Error::Com(err)),
90+
}
91+
}
92+
93+
/// Uninitialize COM for the current thread.
94+
pub fn uninitialize() {
95+
COM_INIT.with(|state| {
96+
if state.get() {
97+
unsafe { CoUninitialize() };
98+
}
99+
});
100+
}
101+
102+
/// RAII wrapper around [`initialize_as`] / [`uninitialize`].
103+
#[derive(Debug)]
104+
pub struct Session {
105+
uninit_on_drop: bool,
106+
_no_send: PhantomData<Rc<()>>,
107+
}
108+
109+
impl Session {
110+
/// Initializes COM for the current thread and returns a guard that will call
111+
/// [`uninitialize`] on drop if COM was not already initialized.
112+
pub fn new(kind: Type) -> Result<Self, Error> {
113+
let init = initialize_as(kind)?;
114+
Ok(Self {
115+
uninit_on_drop: init == Initialization::Success,
116+
_no_send: PhantomData,
117+
})
118+
}
119+
}
120+
121+
impl Drop for Session {
122+
fn drop(&mut self) {
123+
if self.uninit_on_drop {
124+
uninitialize();
125+
}
126+
}
127+
}
128+
129+
/// Identifies a COM method.
130+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
131+
pub struct MethodInfo<'a> {
132+
/// Optional COM interface name (e.g. `IShellLinkW`).
133+
pub interface: Option<&'a str>,
134+
/// COM method name (e.g. `GetPath`).
135+
pub method: &'a str,
136+
}
137+
138+
impl fmt::Display for MethodInfo<'_> {
139+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140+
match self.interface {
141+
Some(interface) => write!(f, "{}::{}", interface, self.method),
142+
None => write!(f, "{}", self.method),
143+
}
144+
}
145+
}
146+
147+
/// Windows / COM error annotated with the COM method that failed.
148+
///
149+
/// This type exists to add semantic context to `windows::core::Error`
150+
/// without losing the original error as the source.
151+
#[derive(Debug, thiserror::Error)]
152+
#[error("COM error from {method}: {source}")]
153+
pub struct Error {
154+
/// The COM method that produced the error.
155+
pub method: MethodInfo<'static>,
156+
157+
/// The underlying Windows error.
158+
#[source]
159+
pub source: windows::core::Error,
160+
}
161+
162+
pub(crate) trait ComErrorExt {
163+
fn context(self, interface: Option<&'static str>, method: &'static str) -> Error;
164+
}
165+
166+
impl ComErrorExt for windows::core::Error {
167+
fn context(self, interface: Option<&'static str>, method: &'static str) -> Error {
168+
Error {
169+
method: MethodInfo { interface, method },
170+
source: self,
171+
}
172+
}
173+
}
174+
175+
pub(crate) trait ComResultExt<T> {
176+
fn context(self, interface: Option<&'static str>, method: &'static str) -> crate::Result<T>;
177+
}
178+
179+
impl<T> ComResultExt<T> for windows::core::Result<T> {
180+
fn context(self, interface: Option<&'static str>, method: &'static str) -> crate::Result<T> {
181+
self.map_err(|source| crate::Error::Com(source.context(interface, method)))
182+
}
183+
}

src/error.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use thiserror::Error;
2+
3+
use crate::{com, runas};
4+
5+
/// Top-level error type for this crate.
6+
#[derive(Debug, Error)]
7+
pub enum Error {
8+
/// A Windows / COM API returned an error.
9+
#[error("COM error: {0}")]
10+
Com(
11+
#[from]
12+
#[source]
13+
com::Error,
14+
),
15+
16+
/// Filesystem IO failed.
17+
#[error("Filesystem error: {0}")]
18+
Io(
19+
#[from]
20+
#[source]
21+
std::io::Error,
22+
),
23+
24+
/// UTF-16 conversion failed.
25+
#[error("UTF-16 conversion error: {0}")]
26+
Utf16(
27+
#[from]
28+
#[source]
29+
widestring::error::Utf16Error,
30+
),
31+
32+
/// The input contained an interior NUL.
33+
#[error("string/path contains NUL: {0}")]
34+
ContainsNul(
35+
#[from]
36+
#[source]
37+
widestring::error::ContainsNul<u16>,
38+
),
39+
40+
/// Failed to get or set `runas` flag.
41+
#[error("failed to get/set runas flag: {0}")]
42+
RunAsFlag(
43+
#[from]
44+
#[source]
45+
runas::Error,
46+
),
47+
}
48+
49+
/// Result alias for this crates error type.
50+
pub type Result<T> = std::result::Result<T, Error>;

0 commit comments

Comments
 (0)