|
| 1 | +//! Small native Windows helpers used to call OS services directly instead of |
| 2 | +//! routing through external shell helpers. |
| 3 | +
|
| 4 | +#[cfg(windows)] |
| 5 | +use std::io; |
| 6 | + |
| 7 | +/// Copilot CLI stores its OAuth credential in Windows Credential Manager under |
| 8 | +/// target names containing `copilot-cli`. This predicate is deliberately kept |
| 9 | +/// pure so the matching behavior is testable without touching a user's |
| 10 | +/// Credential Manager store. |
| 11 | +pub(crate) fn credential_target_matches_copilot(target: &str) -> bool { |
| 12 | + target.to_ascii_lowercase().contains("copilot-cli") |
| 13 | +} |
| 14 | + |
| 15 | +#[cfg(windows)] |
| 16 | +unsafe fn wide_ptr_to_string(ptr: *const u16) -> String { |
| 17 | + use std::ffi::OsString; |
| 18 | + use std::os::windows::ffi::OsStringExt; |
| 19 | + |
| 20 | + if ptr.is_null() { |
| 21 | + return String::new(); |
| 22 | + } |
| 23 | + let mut len = 0usize; |
| 24 | + while unsafe { *ptr.add(len) } != 0 { |
| 25 | + len += 1; |
| 26 | + } |
| 27 | + let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; |
| 28 | + OsString::from_wide(slice).to_string_lossy().into_owned() |
| 29 | +} |
| 30 | + |
| 31 | +#[cfg(windows)] |
| 32 | +struct CredentialArray(*mut *mut windows_sys::Win32::Security::Credentials::CREDENTIALW); |
| 33 | + |
| 34 | +#[cfg(windows)] |
| 35 | +impl Drop for CredentialArray { |
| 36 | + fn drop(&mut self) { |
| 37 | + unsafe { |
| 38 | + windows_sys::Win32::Security::Credentials::CredFree(self.0 as _); |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +/// Read-only Copilot credential presence check using the native Credential |
| 44 | +/// Manager API. We inspect target names only; the credential secret/blob is |
| 45 | +/// never read. |
| 46 | +#[cfg(windows)] |
| 47 | +pub(crate) fn copilot_credential_present() -> bool { |
| 48 | + use windows_sys::Win32::Security::Credentials::{CredEnumerateW, CREDENTIALW}; |
| 49 | + |
| 50 | + let mut count = 0u32; |
| 51 | + let mut credentials: *mut *mut CREDENTIALW = std::ptr::null_mut(); |
| 52 | + // Enumerate all targets and apply our own substring predicate to preserve |
| 53 | + // parity with the old shell-based substring probe. Copilot CLI has |
| 54 | + // used both prefix (`copilot-cli/...`) and suffix (`... .copilot-cli`) |
| 55 | + // target shapes; CredEnumerateW's filter is prefix-only and would miss the |
| 56 | + // suffix form. We still inspect target names only — never credential blobs. |
| 57 | + let ok = unsafe { CredEnumerateW(std::ptr::null(), 0, &mut count, &mut credentials) != 0 }; |
| 58 | + if !ok || credentials.is_null() || count == 0 { |
| 59 | + return false; |
| 60 | + } |
| 61 | + |
| 62 | + let _guard = CredentialArray(credentials); |
| 63 | + let entries = unsafe { std::slice::from_raw_parts(credentials, count as usize) }; |
| 64 | + entries.iter().any(|&cred| { |
| 65 | + if cred.is_null() { |
| 66 | + return false; |
| 67 | + } |
| 68 | + let target = unsafe { wide_ptr_to_string((*cred).TargetName) }; |
| 69 | + credential_target_matches_copilot(&target) |
| 70 | + }) |
| 71 | +} |
| 72 | + |
| 73 | +#[cfg(not(windows))] |
| 74 | +pub(crate) fn copilot_credential_present() -> bool { |
| 75 | + false |
| 76 | +} |
| 77 | + |
| 78 | +#[cfg(windows)] |
| 79 | +struct ClipboardGuard; |
| 80 | + |
| 81 | +#[cfg(windows)] |
| 82 | +impl ClipboardGuard { |
| 83 | + fn open() -> io::Result<Self> { |
| 84 | + use windows_sys::Win32::System::DataExchange::OpenClipboard; |
| 85 | + |
| 86 | + for _ in 0..10 { |
| 87 | + if unsafe { OpenClipboard(std::ptr::null_mut()) } != 0 { |
| 88 | + return Ok(Self); |
| 89 | + } |
| 90 | + std::thread::sleep(std::time::Duration::from_millis(10)); |
| 91 | + } |
| 92 | + Err(io::Error::last_os_error()) |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +#[cfg(windows)] |
| 97 | +impl Drop for ClipboardGuard { |
| 98 | + fn drop(&mut self) { |
| 99 | + unsafe { |
| 100 | + windows_sys::Win32::System::DataExchange::CloseClipboard(); |
| 101 | + } |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +/// Copy UTF-16 text to the Windows clipboard without spawning external helper |
| 106 | +/// processes or invoking a shell parser. |
| 107 | +#[cfg(windows)] |
| 108 | +pub(crate) fn copy_text_to_clipboard(text: &str) -> io::Result<()> { |
| 109 | + use windows_sys::Win32::Foundation::GlobalFree; |
| 110 | + use windows_sys::Win32::System::DataExchange::{EmptyClipboard, SetClipboardData}; |
| 111 | + use windows_sys::Win32::System::Memory::{ |
| 112 | + GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE, |
| 113 | + }; |
| 114 | + |
| 115 | + // CF_UNICODETEXT. windows-sys exposes it under Win32_System_Ole, but the |
| 116 | + // numeric clipboard format is stable and avoids pulling in Ole just for a |
| 117 | + // constant. |
| 118 | + const CF_UNICODETEXT: u32 = 13; |
| 119 | + |
| 120 | + let _guard = ClipboardGuard::open()?; |
| 121 | + let mut wide: Vec<u16> = text.encode_utf16().collect(); |
| 122 | + wide.push(0); |
| 123 | + let bytes = wide.len() * std::mem::size_of::<u16>(); |
| 124 | + |
| 125 | + unsafe { |
| 126 | + if EmptyClipboard() == 0 { |
| 127 | + return Err(io::Error::last_os_error()); |
| 128 | + } |
| 129 | + let handle = GlobalAlloc(GMEM_MOVEABLE, bytes); |
| 130 | + if handle.is_null() { |
| 131 | + return Err(io::Error::last_os_error()); |
| 132 | + } |
| 133 | + |
| 134 | + let ptr = GlobalLock(handle); |
| 135 | + if ptr.is_null() { |
| 136 | + GlobalFree(handle); |
| 137 | + return Err(io::Error::last_os_error()); |
| 138 | + } |
| 139 | + |
| 140 | + std::ptr::copy_nonoverlapping(wide.as_ptr(), ptr as *mut u16, wide.len()); |
| 141 | + GlobalUnlock(handle); |
| 142 | + |
| 143 | + // On success, SetClipboardData transfers ownership of `handle` to the |
| 144 | + // OS; on failure it remains ours and must be freed. |
| 145 | + if SetClipboardData(CF_UNICODETEXT, handle as _).is_null() { |
| 146 | + GlobalFree(handle); |
| 147 | + return Err(io::Error::last_os_error()); |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + Ok(()) |
| 152 | +} |
| 153 | + |
| 154 | +#[cfg(not(windows))] |
| 155 | +pub(crate) fn copy_text_to_clipboard(_text: &str) -> std::io::Result<()> { |
| 156 | + Err(std::io::Error::new( |
| 157 | + std::io::ErrorKind::Unsupported, |
| 158 | + "clipboard is only supported on Windows", |
| 159 | + )) |
| 160 | +} |
| 161 | + |
| 162 | +/// Open a URL with the user's default handler using ShellExecuteW instead of a |
| 163 | +/// shell wrapper. |
| 164 | +#[cfg(windows)] |
| 165 | +pub(crate) fn open_url_in_default_browser(url: &str) -> io::Result<()> { |
| 166 | + use windows_sys::Win32::UI::Shell::ShellExecuteW; |
| 167 | + |
| 168 | + let operation: Vec<u16> = "open".encode_utf16().chain(std::iter::once(0)).collect(); |
| 169 | + let file: Vec<u16> = url.encode_utf16().chain(std::iter::once(0)).collect(); |
| 170 | + let result = unsafe { |
| 171 | + ShellExecuteW( |
| 172 | + std::ptr::null_mut(), |
| 173 | + operation.as_ptr(), |
| 174 | + file.as_ptr(), |
| 175 | + std::ptr::null(), |
| 176 | + std::ptr::null(), |
| 177 | + 1, // SW_SHOWNORMAL |
| 178 | + ) |
| 179 | + }; |
| 180 | + |
| 181 | + let code = result as isize; |
| 182 | + if code <= 32 { |
| 183 | + Err(io::Error::new( |
| 184 | + io::ErrorKind::Other, |
| 185 | + format!("ShellExecuteW failed with code {code}"), |
| 186 | + )) |
| 187 | + } else { |
| 188 | + Ok(()) |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +#[cfg(not(windows))] |
| 193 | +pub(crate) fn open_url_in_default_browser(_url: &str) -> std::io::Result<()> { |
| 194 | + Err(std::io::Error::new( |
| 195 | + std::io::ErrorKind::Unsupported, |
| 196 | + "opening URLs is only supported on Windows", |
| 197 | + )) |
| 198 | +} |
| 199 | + |
| 200 | +#[cfg(test)] |
| 201 | +mod tests { |
| 202 | + use super::credential_target_matches_copilot; |
| 203 | + |
| 204 | + #[test] |
| 205 | + fn copilot_credential_match_accepts_known_target_shapes() { |
| 206 | + assert!(credential_target_matches_copilot("copilot-cli/https://github.com:user")); |
| 207 | + assert!(credential_target_matches_copilot("https://github.com:user.copilot-cli")); |
| 208 | + assert!(credential_target_matches_copilot("COPILOT-CLI/https://example.ghe.com:user")); |
| 209 | + } |
| 210 | + |
| 211 | + #[test] |
| 212 | + fn copilot_credential_match_rejects_unrelated_targets() { |
| 213 | + assert!(!credential_target_matches_copilot("")); |
| 214 | + assert!(!credential_target_matches_copilot("github.com:user")); |
| 215 | + assert!(!credential_target_matches_copilot("other-agent-cli")); |
| 216 | + } |
| 217 | +} |
0 commit comments