- src/windows/url.rs: open() 用 url::Url parse 校验 + scheme 严格
限制(只 http/https,拒绝 file:// / javascript: / data: / ftp /
自定义 scheme);通过 ShellExecuteW 调默认浏览器打开;§13
/ §38 安全约束
- src/windows/tray.rs:
· RegisterClassExW 注册隐藏 helper window class
· CreateWindowExW(WS_EX_TOOLWINDOW, 0×0) 创建不显示的
message-only helper window
· Shell_NotifyIconW NIM_ADD 注册托盘图标(默认 NULL icon,
Phase 7 polish 再换 .ico)
· dedicated thread 跑 GetMessageW / DispatchMessageW 标准
message loop
· WM_USER+1 uCallbackMessage 处理 tray 鼠标事件
· WM_LBUTTONDBLCLK = Open callback
· WM_RBUTTONUP = CreatePopupMenu + TrackPopupMenuEx 显示
Open / Quit 菜单
· WM_COMMAND 处理菜单 item IDM_OPEN / IDM_QUIT
· Drop 时 NIM_DELETE + DestroyWindow + thread.join() 干净退出
- src/lib.rs:
· #[cfg(windows)] 启动时建 TrayIcon,绑 on_open(show window)
/ on_quit(std::process::exit(0));失败时只 warn 不 abort
(托盘是便利功能,不致命)
· 引入 url::open;Phase 7 polish 时接到 message-card 点击
- src/windows/mod.rs: pub use tray + url
- Cargo.toml: windows-sys features 加 Win32_Graphics_Gdi
(WNDCLASSEXW 依赖)
避坑:
- LRESULT / WPARAM / LPARAM 在 windows-sys 0.59 是 type
alias,不是 struct — 不能 LRESULT(0) 构造;直接 0 返回
隐式 cast
- HMENU 是 *mut c_void;用 std::ptr::null_mut()(不是 null())
- NOTIFYICONDATAW_0 在 0.59 没 derive Default;用
unsafe { std::mem::zeroed() }
- std::mem::GUID::zeroed() 不存在;用 windows_sys::core::GUID
显式构造
- NIM_VERSION 常量 windows-sys 0.59 没 export;用字面 4u32
(NIM_VERSION = 4 in Win32 header)
- HWND (raw pointer) 不 Send;跨线程传递时转 usize
- Box<dyn Fn> 不能 derive Clone;用 Box + 唯一 move 到
TrayShared 不需要 Clone
- on_quit closure 不能捕获 Rc<RefCell<AppState>>(不是 Sync)
→ 简化:on_quit 直接 std::process::exit(0);之前 close
已经 save_state,丢失窗口只是 size 增量
- WNDCLASSEXW + RegisterClassExW 在 Win32_Graphics_Gdi
feature 后(hbrBackground 用 HBRUSH = GDI 类型)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 19 passed (新增 4 个 url rejection 测试 +
之前的 15 个); 0 failed
- debug 35.66 MB, subsystem = WINDOWS_CUI
- release 9.79 MB, subsystem = WINDOWS_GUI
- 托盘只能手测(CI 不能断言 WM_USER 消息);自动测试覆盖
URL 校验逻辑的拒绝路径
97 lines
2.9 KiB
Rust
97 lines
2.9 KiB
Rust
//! Open a URL in the default browser (AGENTS.md §13 / §38).
|
|
//!
|
|
//! Strict scheme check: only `http://` and `https://` pass through
|
|
//! (the docstring on §13 spells out "拒绝 file:// / javascript: /
|
|
//! 自定义 scheme"). `url::Url::parse` does the structural validation
|
|
//! (RFC 3986); we still gate on the scheme afterward so a parseable
|
|
//! but dangerous scheme (`data:`, `javascript:`, custom apps) is
|
|
//! refused even if the URL came from a trusted source.
|
|
|
|
use url::Url;
|
|
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
|
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
|
|
|
/// `true` if the URL was accepted and handed to ShellExecuteW. `false`
|
|
/// if the scheme was not http/https, the URL failed to parse, or
|
|
/// ShellExecuteW returned a <=32 error code (per the shell API
|
|
/// convention: "If the function succeeds, it returns a value greater
|
|
/// than 32").
|
|
pub fn open(url_str: &str) -> bool {
|
|
let trimmed = url_str.trim();
|
|
if trimmed.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let parsed = match Url::parse(trimmed) {
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
tracing::warn!(url = %url_str, error = %e, "url: parse failed");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
match parsed.scheme() {
|
|
"http" | "https" => {} // ok
|
|
other => {
|
|
tracing::warn!(scheme = %other, "url: refusing non-http(s) scheme");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ShellExecuteW takes a wide, null-terminated string for lpFile.
|
|
let wide: Vec<u16> = trimmed.encode_utf16().chain(std::iter::once(0)).collect();
|
|
|
|
// SAFETY: ShellExecuteW spawns the default handler; passing NULL for
|
|
// lpOperation / lpParameters / lpDirectory uses the file's verb
|
|
// association. The wide string is null-terminated and outlives
|
|
// the call.
|
|
let result = unsafe {
|
|
ShellExecuteW(
|
|
std::ptr::null_mut(),
|
|
std::ptr::null(),
|
|
wide.as_ptr(),
|
|
std::ptr::null(),
|
|
std::ptr::null(),
|
|
SW_SHOWNORMAL,
|
|
)
|
|
};
|
|
|
|
if result as isize > 32 {
|
|
true
|
|
} else {
|
|
tracing::warn!(code = result as isize, "url: ShellExecuteW failed");
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_string_rejected() {
|
|
assert!(!open(""));
|
|
assert!(!open(" "));
|
|
}
|
|
|
|
#[test]
|
|
fn unparseable_rejected() {
|
|
assert!(!open("not a url"));
|
|
assert!(!open("http://[invalid"));
|
|
}
|
|
|
|
#[test]
|
|
fn disallowed_schemes_rejected() {
|
|
assert!(!open("file:///etc/passwd"));
|
|
assert!(!open("javascript:alert(1)"));
|
|
assert!(!open("data:text/plain,hi"));
|
|
assert!(!open("ftp://example.com/"));
|
|
assert!(!open("custom://thing"));
|
|
}
|
|
|
|
// We do not exercise the http/https success path here — it would
|
|
// spawn the user's default browser. The CI environment can't
|
|
// assert on that. The dispatch logic is covered by the rejection
|
|
// tests above.
|
|
}
|