返回 CodeWhale
external_credentials.rs
根目录 / crates / tui / src / external_credentials.rs
1 //! Capability-gated I/O for credentials owned by another CLI.
2 //!
3 //! Every external open/read stays behind an opaque grant. Consumption opens
4 //! one absolute regular file through a no-follow traversal, validates that
5 //! same handle, and reads a bounded payload from it. This prevents a consented
6 //! path from being redirected through a leaf or parent symlink/reparse point
7 //! and avoids the old exists-then-read race.
8
9 use std::fs::File;
10 use std::io::{self, Read};
11 use std::path::Path;
12
13 use anyhow::{Context, Result, bail};
14 use codewhale_config::ExternalCredentialReadGrant;
15
16 /// Credential JSON is expected to be tiny. Bound reads so a replaced regular
17 /// file cannot turn read-only consent into unbounded memory consumption.
18 const MAX_EXTERNAL_CREDENTIAL_BYTES: u64 = 1024 * 1024;
19
20 #[cfg(all(test, unix))]
21 thread_local! {
22 static BEFORE_LEAF_OPEN_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
23 std::cell::RefCell::new(None);
24 }
25
26 #[cfg(test)]
27 thread_local! {
28 /// Per-test-thread real sink counters. Keeping the trap thread-local makes
29 /// parallel tests unable to contaminate one another while still counting
30 /// the exact production functions reached by the code under test.
31 static SIDE_EFFECT_TRAP: std::cell::Cell<[usize; 5]> = const {
32 std::cell::Cell::new([0; 5])
33 };
34 }
35
36 #[cfg(test)]
37 fn increment_side_effect(index: usize) {
38 SIDE_EFFECT_TRAP.with(|trap| {
39 let mut counts = trap.get();
40 counts[index] += 1;
41 trap.set(counts);
42 });
43 }
44
45 /// Open and read the exact granted file once. Missing files are reported as
46 /// `Ok(None)`; every other unsafe or malformed filesystem shape fails closed.
47 pub(crate) fn read_to_string(grant: &ExternalCredentialReadGrant) -> Result<Option<String>> {
48 #[cfg(test)]
49 increment_side_effect(0);
50
51 let mut file = match open_secure_regular_file(grant.path(), false) {
52 Ok(file) => file,
53 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
54 Err(error) => {
55 return Err(error).with_context(|| {
56 format!(
57 "securely opening external {} credential file {}",
58 grant.source().as_str(),
59 codewhale_config::quote_os_path(grant.path())
60 )
61 });
62 }
63 };
64
65 #[cfg(test)]
66 increment_side_effect(1);
67
68 let mut bytes = Vec::new();
69 file.by_ref()
70 .take(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
71 .read_to_end(&mut bytes)
72 .with_context(|| {
73 format!(
74 "reading external {} credential file {}",
75 grant.source().as_str(),
76 codewhale_config::quote_os_path(grant.path())
77 )
78 })?;
79 if bytes.len() as u64 > MAX_EXTERNAL_CREDENTIAL_BYTES {
80 bail!(
81 "external {} credential file {} exceeds the {} byte safety limit",
82 grant.source().as_str(),
83 codewhale_config::quote_os_path(grant.path()),
84 MAX_EXTERNAL_CREDENTIAL_BYTES
85 );
86 }
87 let contents = String::from_utf8(bytes).with_context(|| {
88 format!(
89 "external {} credential file {} is not valid UTF-8",
90 grant.source().as_str(),
91 codewhale_config::quote_os_path(grant.path())
92 )
93 })?;
94 Ok(Some(contents))
95 }
96
97 /// Read one Codewhale-owned credential file through the same no-follow,
98 /// bounded I/O boundary used for external grants. On Unix the opened handle
99 /// must belong to the effective user and have no group/other permission bits.
100 /// The caller is responsible for constraining `path` to a validated basename
101 /// below Codewhale's credentials directory before invoking this function.
102 pub(crate) fn read_codewhale_owned_to_string(path: &Path) -> Result<Option<String>> {
103 let mut file = match open_secure_regular_file(path, true) {
104 Ok(file) => file,
105 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
106 Err(error) => {
107 return Err(error).with_context(|| {
108 format!(
109 "securely opening Codewhale-owned credential file {}",
110 codewhale_config::quote_os_path(path)
111 )
112 });
113 }
114 };
115 let mut bytes = Vec::new();
116 file.by_ref()
117 .take(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
118 .read_to_end(&mut bytes)
119 .with_context(|| {
120 format!(
121 "reading Codewhale-owned credential file {}",
122 codewhale_config::quote_os_path(path)
123 )
124 })?;
125 if bytes.len() as u64 > MAX_EXTERNAL_CREDENTIAL_BYTES {
126 bail!(
127 "Codewhale-owned credential file {} exceeds the {} byte safety limit",
128 codewhale_config::quote_os_path(path),
129 MAX_EXTERNAL_CREDENTIAL_BYTES
130 );
131 }
132 String::from_utf8(bytes).map(Some).with_context(|| {
133 format!(
134 "Codewhale-owned credential file {} is not valid UTF-8",
135 codewhale_config::quote_os_path(path)
136 )
137 })
138 }
139
140 #[cfg(unix)]
141 fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result<File> {
142 use std::ffi::CString;
143 use std::os::fd::FromRawFd;
144 use std::os::unix::ffi::OsStrExt;
145 use std::path::Component;
146
147 if !path.is_absolute() {
148 return Err(io::Error::new(
149 io::ErrorKind::InvalidInput,
150 "external credential path must be absolute",
151 ));
152 }
153
154 let root = CString::new("/").expect("static root contains no NUL");
155 // SAFETY: `root` is a valid C string and flags require no variadic mode.
156 let root_fd = unsafe {
157 libc::open(
158 root.as_ptr(),
159 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
160 )
161 };
162 if root_fd < 0 {
163 return Err(io::Error::last_os_error());
164 }
165 // SAFETY: `root_fd` is newly owned after the successful `open`.
166 let mut current = unsafe { File::from_raw_fd(root_fd) };
167 let mut normals = path
168 .components()
169 .filter_map(|component| match component {
170 Component::Normal(part) => Some(Ok(part)),
171 Component::RootDir => None,
172 Component::Prefix(_) | Component::CurDir | Component::ParentDir => {
173 Some(Err(io::Error::new(
174 io::ErrorKind::InvalidInput,
175 "external credential path must be lexically normalized",
176 )))
177 }
178 })
179 .peekable();
180
181 let mut opened_leaf = false;
182 while let Some(component) = normals.next() {
183 let component = component?;
184 let component = CString::new(component.as_bytes()).map_err(|_| {
185 io::Error::new(
186 io::ErrorKind::InvalidInput,
187 "external credential path contains a NUL byte",
188 )
189 })?;
190 let leaf = normals.peek().is_none();
191 #[cfg(test)]
192 if leaf {
193 BEFORE_LEAF_OPEN_HOOK.with(|hook| {
194 if let Some(hook) = hook.borrow_mut().take() {
195 hook();
196 }
197 });
198 }
199 let flags = if leaf {
200 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK
201 } else {
202 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY
203 };
204 use std::os::fd::AsRawFd;
205 // SAFETY: the directory fd and component C string are valid for this
206 // call and flags require no variadic mode.
207 let fd = unsafe { libc::openat(current.as_raw_fd(), component.as_ptr(), flags) };
208 if fd < 0 {
209 return Err(io::Error::last_os_error());
210 }
211 // SAFETY: `fd` is newly owned after the successful `openat`.
212 current = unsafe { File::from_raw_fd(fd) };
213 opened_leaf = leaf;
214 }
215
216 if !opened_leaf {
217 return Err(io::Error::new(
218 io::ErrorKind::InvalidInput,
219 "external credential path must name a file",
220 ));
221 }
222 let metadata = current.metadata()?;
223 if !metadata.file_type().is_file() {
224 return Err(io::Error::new(
225 io::ErrorKind::InvalidInput,
226 "external credential path must name a regular file",
227 ));
228 }
229 if require_owner_only {
230 use std::os::unix::fs::MetadataExt as _;
231 // SAFETY: geteuid(2) dereferences no pointers.
232 if metadata.uid() != unsafe { libc::geteuid() }
233 || metadata.mode() & 0o077 != 0
234 || metadata.nlink() != 1
235 {
236 return Err(io::Error::new(
237 io::ErrorKind::PermissionDenied,
238 "Codewhale-owned credential file must be singly linked, owned by this user, and mode 0600 or stricter",
239 ));
240 }
241 }
242 Ok(current)
243 }
244
245 #[cfg(windows)]
246 fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result<File> {
247 use std::ffi::OsString;
248 use std::os::windows::ffi::OsStringExt;
249 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
250 use std::os::windows::io::AsRawHandle;
251 use std::path::Component;
252 use windows_sys::Win32::Storage::FileSystem::{
253 FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_OPENED,
254 GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
255 };
256
257 if !path.is_absolute()
258 || path
259 .components()
260 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
261 {
262 return Err(io::Error::new(
263 io::ErrorKind::InvalidInput,
264 "external credential path must be absolute and lexically normalized",
265 ));
266 }
267
268 // Reject every reparse-point component before the final open. The final
269 // handle is opened as the reparse point itself, checked again, and its
270 // kernel-resolved path is compared below. A second component pass catches
271 // replacement during the open window.
272 reject_windows_reparse_components(path)?;
273 let file = std::fs::OpenOptions::new()
274 .read(true)
275 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
276 .open(path)?;
277 let metadata = file.metadata()?;
278 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
279 || !metadata.file_type().is_file()
280 {
281 return Err(io::Error::new(
282 io::ErrorKind::InvalidInput,
283 "external credential path must name a non-reparse regular file",
284 ));
285 }
286 reject_windows_reparse_components(path)?;
287
288 let handle = file.as_raw_handle();
289 // Compare the spelling Windows actually opened rather than asking it to
290 // expand the path into its normalized long form. A valid caller path can
291 // contain an 8.3 component such as `RUNNER~1`; normalizing only the handle
292 // side would make that exact path look redirected. FILE_NAME_OPENED keeps
293 // the comparison handle-relative while the pre/post component checks above
294 // continue to reject reparse points and swaps.
295 let flags = FILE_NAME_OPENED | VOLUME_NAME_DOS;
296 // SAFETY: the handle remains owned by `file`; null output asks Windows for
297 // the required UTF-16 buffer length.
298 let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
299 if needed == 0 {
300 return Err(io::Error::last_os_error());
301 }
302 let mut buffer = vec![0u16; needed as usize + 1];
303 // SAFETY: `buffer` is writable for its declared length and `handle` is
304 // valid for the duration of the call.
305 let written = unsafe {
306 GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
307 };
308 if written == 0 || written as usize >= buffer.len() {
309 return Err(io::Error::last_os_error());
310 }
311 let final_path = OsString::from_wide(&buffer[..written as usize]);
312 let actual = normalize_windows_path_for_comparison(Path::new(&final_path))?;
313 let expected = normalize_windows_path_for_comparison(path)?;
314 if actual != expected {
315 return Err(io::Error::new(
316 io::ErrorKind::PermissionDenied,
317 "external credential path was redirected while opening",
318 ));
319 }
320 if require_owner_only {
321 use windows_sys::Win32::Storage::FileSystem::{
322 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
323 };
324 let mut information = BY_HANDLE_FILE_INFORMATION::default();
325 // SAFETY: the opened credential handle and output pointer remain valid
326 // for the duration of the call.
327 if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
328 return Err(io::Error::last_os_error());
329 }
330 if information.nNumberOfLinks != 1 {
331 return Err(io::Error::new(
332 io::ErrorKind::PermissionDenied,
333 "Codewhale-owned credential file must be singly linked",
334 ));
335 }
336 verify_windows_owner_only_handle(handle)?;
337 }
338 Ok(file)
339 }
340
341 /// Normalize a Windows path without replacement characters. Unpaired UTF-16
342 /// is rejected so two distinct paths can never compare equal after a lossy
343 /// conversion. This is intentionally stricter than filesystem display.
344 #[cfg(windows)]
345 fn normalize_windows_path_for_comparison(path: &Path) -> io::Result<String> {
346 let text = path.to_str().ok_or_else(|| {
347 io::Error::new(
348 io::ErrorKind::PermissionDenied,
349 "credential path contains invalid Unicode and cannot be compared safely",
350 )
351 })?;
352 let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
353 let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
354 || without_device_prefix.to_string(),
355 |rest| format!(r"\\{rest}"),
356 );
357 Ok(normalized_prefix
358 .replace('/', "\\")
359 .trim_end_matches('\\')
360 .to_lowercase())
361 }
362
363 /// Apply a protected DACL granting only the current Windows user full access.
364 /// Directories propagate that owner-only policy to newly staged generations.
365 #[cfg(all(windows, test))]
366 pub(crate) fn secure_codewhale_owned_windows_path(
367 path: &Path,
368 inherit_to_children: bool,
369 ) -> io::Result<()> {
370 use std::os::windows::ffi::OsStrExt as _;
371 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
372 use windows_sys::Win32::Security::Authorization::{
373 EXPLICIT_ACCESS_W, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetNamedSecurityInfoW,
374 TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
375 };
376 use windows_sys::Win32::Security::{
377 DACL_SECURITY_INFORMATION, NO_INHERITANCE, OWNER_SECURITY_INFORMATION,
378 PROTECTED_DACL_SECURITY_INFORMATION, SUB_CONTAINERS_AND_OBJECTS_INHERIT,
379 };
380 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
381
382 let user = CurrentWindowsUser::open()?;
383 let entry = EXPLICIT_ACCESS_W {
384 grfAccessPermissions: FILE_ALL_ACCESS,
385 grfAccessMode: SET_ACCESS,
386 grfInheritance: if inherit_to_children {
387 SUB_CONTAINERS_AND_OBJECTS_INHERIT
388 } else {
389 NO_INHERITANCE
390 },
391 Trustee: TRUSTEE_W {
392 pMultipleTrustee: std::ptr::null_mut(),
393 MultipleTrusteeOperation: 0,
394 TrusteeForm: TRUSTEE_IS_SID,
395 TrusteeType: TRUSTEE_IS_USER,
396 ptstrName: user.sid().cast::<u16>(),
397 },
398 };
399 let mut acl = std::ptr::null_mut();
400 // SAFETY: `entry` and the returned ACL stay live through the security-info
401 // update; the ACL is released with LocalFree below.
402 let result = unsafe { SetEntriesInAclW(1, &entry, std::ptr::null(), &mut acl) };
403 if result != ERROR_SUCCESS {
404 return Err(io::Error::from_raw_os_error(result as i32));
405 }
406 let _acl = WindowsLocalAllocation(acl.cast());
407 let wide: Vec<u16> = path.as_os_str().encode_wide().chain([0]).collect();
408 // SAFETY: `wide` is NUL terminated and `acl` remains allocated for the
409 // duration of this call. Owner and DACL are applied together to match the
410 // production current-user-only verifier.
411 let result = unsafe {
412 SetNamedSecurityInfoW(
413 wide.as_ptr(),
414 SE_FILE_OBJECT,
415 OWNER_SECURITY_INFORMATION
416 | DACL_SECURITY_INFORMATION
417 | PROTECTED_DACL_SECURITY_INFORMATION,
418 user.sid(),
419 std::ptr::null_mut(),
420 acl,
421 std::ptr::null(),
422 )
423 };
424 if result != ERROR_SUCCESS {
425 return Err(io::Error::from_raw_os_error(result as i32));
426 }
427 Ok(())
428 }
429
430 #[cfg(windows)]
431 fn verify_windows_owner_only_handle(
432 handle: windows_sys::Win32::Foundation::HANDLE,
433 ) -> io::Result<()> {
434 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
435 use windows_sys::Win32::Security::Authorization::{
436 EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
437 SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
438 };
439 use windows_sys::Win32::Security::{
440 ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
441 PSID,
442 };
443 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
444
445 let user = CurrentWindowsUser::open()?;
446 let mut owner: PSID = std::ptr::null_mut();
447 let mut dacl: *mut ACL = std::ptr::null_mut();
448 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
449 // SAFETY: the opened file handle remains valid and all output pointers are
450 // writable. Windows allocates `descriptor`, released below.
451 let result = unsafe {
452 GetSecurityInfo(
453 handle,
454 SE_FILE_OBJECT,
455 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
456 &mut owner,
457 std::ptr::null_mut(),
458 &mut dacl,
459 std::ptr::null_mut(),
460 &mut descriptor,
461 )
462 };
463 if result != ERROR_SUCCESS {
464 return Err(io::Error::from_raw_os_error(result as i32));
465 }
466 let _descriptor = WindowsLocalAllocation(descriptor.cast());
467 // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`.
468 if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 {
469 return Err(io::Error::new(
470 io::ErrorKind::PermissionDenied,
471 "Codewhale-owned credential file owner is not the current user",
472 ));
473 }
474 if dacl.is_null() {
475 return Err(io::Error::new(
476 io::ErrorKind::PermissionDenied,
477 "Codewhale-owned credential file must have an owner-only DACL",
478 ));
479 }
480 let mut count = 0;
481 let mut entries: *mut EXPLICIT_ACCESS_W = std::ptr::null_mut();
482 // SAFETY: `dacl` is owned by the live security descriptor; Windows
483 // allocates the returned entries, released below.
484 let result = unsafe { GetExplicitEntriesFromAclW(dacl, &mut count, &mut entries) };
485 if result != ERROR_SUCCESS {
486 return Err(io::Error::from_raw_os_error(result as i32));
487 }
488 let _entries = WindowsLocalAllocation(entries.cast());
489 if count != 1 || entries.is_null() {
490 return Err(io::Error::new(
491 io::ErrorKind::PermissionDenied,
492 "Codewhale-owned credential file DACL must grant only one user",
493 ));
494 }
495 // SAFETY: `count == 1` proves the first returned entry is initialized.
496 let entry = unsafe { &*entries };
497 let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
498 // SAFETY: form and null checked in this expression; sid owned by `user`.
499 let current_user_only = entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
500 && !trustee_sid.is_null()
501 && unsafe { EqualSid(trustee_sid, user.sid()) } != 0
502 && matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
503 && entry.grfAccessPermissions == FILE_ALL_ACCESS;
504 if !current_user_only {
505 return Err(io::Error::new(
506 io::ErrorKind::PermissionDenied,
507 "Codewhale-owned credential file DACL is not current-user-only",
508 ));
509 }
510 Ok(())
511 }
512
513 #[cfg(windows)]
514 struct CurrentWindowsUser {
515 token: windows_sys::Win32::Foundation::HANDLE,
516 token_info: Vec<usize>,
517 }
518
519 #[cfg(windows)]
520 impl CurrentWindowsUser {
521 fn open() -> io::Result<Self> {
522 use windows_sys::Win32::Foundation::{GetLastError, HANDLE};
523 use windows_sys::Win32::Security::{
524 GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
525 };
526 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
527
528 let mut token: HANDLE = std::ptr::null_mut();
529 // SAFETY: the pseudo-process handle is valid and `token` is writable.
530 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
531 return Err(io::Error::last_os_error());
532 }
533 let mut needed = 0;
534 // SAFETY: the null buffer/zero length call obtains the required size.
535 let _ =
536 unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
537 if needed == 0 {
538 // SAFETY: reads thread-local error state only.
539 let error = io::Error::from_raw_os_error(unsafe { GetLastError() } as i32);
540 // SAFETY: `token` is owned here and not stored on this path.
541 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
542 return Err(error);
543 }
544 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
545 let mut token_info = vec![0usize; words];
546 // SAFETY: the word buffer is aligned and contains at least `needed`
547 // writable bytes; `token` remains open.
548 if unsafe {
549 GetTokenInformation(
550 token,
551 TokenUser,
552 token_info.as_mut_ptr().cast(),
553 needed,
554 &mut needed,
555 )
556 } == 0
557 {
558 let error = io::Error::last_os_error();
559 // SAFETY: `token` is owned here and not stored on this path.
560 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
561 return Err(error);
562 }
563 // SAFETY: initialized by GetTokenInformation; buffer outlives use.
564 let user = unsafe { &*token_info.as_ptr().cast::<TOKEN_USER>() };
565 if user.User.Sid.is_null() {
566 // SAFETY: `token` is owned here and not stored on this path.
567 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
568 return Err(io::Error::new(
569 io::ErrorKind::InvalidData,
570 "current Windows user token has no SID",
571 ));
572 }
573 Ok(Self { token, token_info })
574 }
575
576 fn sid(&self) -> windows_sys::Win32::Security::PSID {
577 use windows_sys::Win32::Security::TOKEN_USER;
578 // SAFETY: `token_info` is aligned, initialized by GetTokenInformation,
579 // and remains owned by `self` while the returned SID is used.
580 unsafe { (*self.token_info.as_ptr().cast::<TOKEN_USER>()).User.Sid }
581 }
582 }
583
584 #[cfg(windows)]
585 impl Drop for CurrentWindowsUser {
586 fn drop(&mut self) {
587 // SAFETY: `token` is owned by this guard and closed exactly once.
588 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.token) };
589 }
590 }
591
592 #[cfg(windows)]
593 struct WindowsLocalAllocation(*mut core::ffi::c_void);
594
595 #[cfg(windows)]
596 impl Drop for WindowsLocalAllocation {
597 fn drop(&mut self) {
598 if !self.0.is_null() {
599 // SAFETY: Windows returned this allocation to a caller documented
600 // to release it with LocalFree; the guard frees it exactly once.
601 unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) };
602 }
603 }
604 }
605
606 #[cfg(windows)]
607 fn reject_windows_reparse_components(path: &Path) -> io::Result<()> {
608 use std::os::windows::fs::MetadataExt;
609 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
610
611 let mut current = std::path::PathBuf::new();
612 for component in path.components() {
613 current.push(component.as_os_str());
614 if matches!(
615 component,
616 std::path::Component::Prefix(_) | std::path::Component::RootDir
617 ) {
618 continue;
619 }
620 let metadata = std::fs::symlink_metadata(&current)?;
621 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
622 return Err(io::Error::new(
623 io::ErrorKind::PermissionDenied,
624 format!(
625 "external credential path contains reparse point {}",
626 codewhale_config::quote_os_path(&current)
627 ),
628 ));
629 }
630 }
631 Ok(())
632 }
633
634 #[cfg(not(any(unix, windows)))]
635 fn open_secure_regular_file(_path: &Path, _require_owner_only: bool) -> io::Result<File> {
636 Err(io::Error::new(
637 io::ErrorKind::Unsupported,
638 "secure external credential reads are unsupported on this platform",
639 ))
640 }
641
642 #[cfg(test)]
643 pub(crate) fn reset_side_effect_trap() {
644 SIDE_EFFECT_TRAP.with(|trap| trap.set([0; 5]));
645 }
646
647 #[cfg(test)]
648 #[must_use]
649 pub(crate) fn side_effect_trap_counts() -> (usize, usize) {
650 SIDE_EFFECT_TRAP.with(|trap| {
651 let counts = trap.get();
652 (counts[0], counts[1])
653 })
654 }
655
656 #[cfg(test)]
657 #[must_use]
658 pub(crate) fn complete_side_effect_trap_counts() -> (usize, usize, usize, usize, usize) {
659 SIDE_EFFECT_TRAP.with(|trap| {
660 let counts = trap.get();
661 (counts[0], counts[1], counts[2], counts[3], counts[4])
662 })
663 }
664
665 #[cfg(test)]
666 pub(crate) fn record_owned_credential_write() {
667 increment_side_effect(2);
668 }
669
670 #[cfg(test)]
671 pub(crate) fn record_oauth_refresh() {
672 increment_side_effect(3);
673 }
674
675 #[cfg(test)]
676 pub(crate) fn record_oauth_network() {
677 increment_side_effect(4);
678 }
679
680 #[cfg(test)]
681 mod tests {
682 use super::*;
683 use codewhale_config::{ExternalCredentialConsentToml, ExternalCredentialSource, ProviderKind};
684
685 fn grant(path: &Path) -> ExternalCredentialReadGrant {
686 ExternalCredentialConsentToml::read_only(
687 ProviderKind::OpenaiCodex,
688 ExternalCredentialSource::CodexCli,
689 path.to_path_buf(),
690 )
691 .read_grant(
692 ProviderKind::OpenaiCodex,
693 ExternalCredentialSource::CodexCli,
694 path,
695 )
696 .expect("test grant")
697 }
698
699 #[test]
700 fn secure_read_accepts_one_bounded_regular_file() {
701 let _env = crate::test_support::lock_test_env();
702 let dir = tempfile::tempdir().expect("tempdir");
703 let path = dir
704 .path()
705 .canonicalize()
706 .expect("canonical temp root")
707 .join("auth.json");
708 std::fs::write(&path, "{\"token\":\"ok\"}").expect("fixture");
709 assert_eq!(
710 read_to_string(&grant(&path))
711 .expect("secure read")
712 .as_deref(),
713 Some("{\"token\":\"ok\"}")
714 );
715 }
716
717 #[cfg(unix)]
718 #[test]
719 fn secure_read_rejects_leaf_and_parent_symlinks_and_non_regular_files() {
720 let _env = crate::test_support::lock_test_env();
721 use std::os::unix::fs::symlink;
722
723 let dir = tempfile::tempdir().expect("tempdir");
724 let root = dir.path().canonicalize().expect("canonical temp root");
725 let real_dir = root.join("real");
726 std::fs::create_dir(&real_dir).expect("real dir");
727 let real = real_dir.join("auth.json");
728 std::fs::write(&real, "secret").expect("fixture");
729
730 let leaf = root.join("leaf.json");
731 symlink(&real, &leaf).expect("leaf symlink");
732 assert!(read_to_string(&grant(&leaf)).is_err());
733
734 let parent = root.join("linked-parent");
735 symlink(&real_dir, &parent).expect("parent symlink");
736 assert!(read_to_string(&grant(&parent.join("auth.json"))).is_err());
737
738 assert!(read_to_string(&grant(&real_dir)).is_err());
739 }
740
741 #[cfg(unix)]
742 #[test]
743 fn secure_read_rejects_a_leaf_swapped_after_grant_before_open() {
744 let _env = crate::test_support::lock_test_env();
745 use std::os::unix::fs::symlink;
746
747 let dir = tempfile::tempdir().expect("tempdir");
748 let root = dir.path().canonicalize().expect("canonical temp root");
749 let path = root.join("auth.json");
750 let moved = root.join("auth-before-swap.json");
751 let attacker = root.join("attacker.json");
752 std::fs::write(&path, "owner-a").expect("owner fixture");
753 std::fs::write(&attacker, "attacker").expect("attacker fixture");
754 let grant = grant(&path);
755 let hook_path = path.clone();
756 BEFORE_LEAF_OPEN_HOOK.with(|hook| {
757 *hook.borrow_mut() = Some(Box::new(move || {
758 std::fs::rename(&hook_path, &moved).expect("move original");
759 symlink(&attacker, &hook_path).expect("swap leaf to symlink");
760 }));
761 });
762 assert!(
763 read_to_string(&grant).is_err(),
764 "a swap to a symlink must fail before any bytes are read"
765 );
766 }
767
768 #[test]
769 fn secure_read_rejects_oversized_regular_file() {
770 let _env = crate::test_support::lock_test_env();
771 let dir = tempfile::tempdir().expect("tempdir");
772 let path = dir
773 .path()
774 .canonicalize()
775 .expect("canonical temp root")
776 .join("oversized.json");
777 let file = File::create(&path).expect("fixture");
778 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
779 .expect("oversize fixture");
780 let error = read_to_string(&grant(&path)).expect_err("oversized file");
781 assert!(error.to_string().contains("safety limit"), "{error:#}");
782 }
783
784 #[cfg(unix)]
785 #[test]
786 fn owned_read_requires_owner_only_regular_file_and_never_follows_symlinks() {
787 use std::os::unix::fs::{PermissionsExt as _, symlink};
788
789 let _env = crate::test_support::lock_test_env();
790 let dir = tempfile::tempdir().expect("tempdir");
791 let root = dir.path().canonicalize().expect("canonical temp root");
792 let path = root.join("owned.json");
793 std::fs::write(&path, "owned-secret").expect("fixture");
794 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
795 .expect("loose mode");
796 assert!(
797 read_codewhale_owned_to_string(&path).is_err(),
798 "group/other-readable owned credentials must fail closed"
799 );
800
801 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
802 .expect("owner-only mode");
803 assert_eq!(
804 read_codewhale_owned_to_string(&path)
805 .expect("secure owned read")
806 .as_deref(),
807 Some("owned-secret")
808 );
809
810 let hardlink = root.join("owned-hardlink.json");
811 std::fs::hard_link(&path, &hardlink).expect("hardlink fixture");
812 assert!(
813 read_codewhale_owned_to_string(&path).is_err(),
814 "owned reads must reject multiply-linked files"
815 );
816 std::fs::remove_file(hardlink).expect("remove hardlink fixture");
817
818 let link = root.join("owned-link.json");
819 symlink(&path, &link).expect("symlink");
820 assert!(read_codewhale_owned_to_string(&link).is_err());
821 }
822
823 #[cfg(unix)]
824 #[test]
825 fn owned_read_is_bounded() {
826 use std::os::unix::fs::PermissionsExt as _;
827
828 let _env = crate::test_support::lock_test_env();
829 let dir = tempfile::tempdir().expect("tempdir");
830 let path = dir
831 .path()
832 .canonicalize()
833 .unwrap()
834 .join("oversized-owned.json");
835 let file = File::create(&path).expect("fixture");
836 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1).unwrap();
837 file.set_permissions(std::fs::Permissions::from_mode(0o600))
838 .unwrap();
839 let error = read_codewhale_owned_to_string(&path).expect_err("oversized owned file");
840 assert!(error.to_string().contains("safety limit"), "{error:#}");
841 }
842
843 #[cfg(windows)]
844 fn secured_owned_windows_fixture(contents: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
845 let dir = tempfile::tempdir().expect("tempdir");
846 secure_codewhale_owned_windows_path(dir.path(), true).expect("owner-only directory");
847 let path = dir.path().join("owned.json");
848 std::fs::write(&path, contents).expect("fixture");
849 secure_codewhale_owned_windows_path(&path, false).expect("owner-only file");
850 (dir, path)
851 }
852
853 #[cfg(windows)]
854 #[test]
855 fn owned_read_accepts_current_user_only_dacl_with_opened_path_spelling() {
856 let _env = crate::test_support::lock_test_env();
857 let (_dir, path) = secured_owned_windows_fixture(b"owned-secret");
858 assert_eq!(
859 read_codewhale_owned_to_string(&path)
860 .expect("secure owned read")
861 .as_deref(),
862 Some("owned-secret")
863 );
864 }
865
866 #[cfg(windows)]
867 #[test]
868 fn owned_read_rejects_hardlinks_on_windows() {
869 let _env = crate::test_support::lock_test_env();
870 let (dir, path) = secured_owned_windows_fixture(b"owned-secret");
871 let hardlink = dir.path().join("owned-hardlink.json");
872 std::fs::hard_link(&path, &hardlink).expect("hardlink fixture");
873 assert!(
874 read_codewhale_owned_to_string(&path).is_err(),
875 "owned reads must reject multiply-linked files"
876 );
877 std::fs::remove_file(hardlink).expect("remove hardlink fixture");
878 }
879
880 #[cfg(windows)]
881 #[test]
882 fn owned_read_is_bounded_on_windows() {
883 let _env = crate::test_support::lock_test_env();
884 let (_dir, path) = secured_owned_windows_fixture(b"owned-secret");
885 let file = File::options()
886 .write(true)
887 .open(&path)
888 .expect("reopen fixture");
889 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
890 .expect("oversize fixture");
891 let error = read_codewhale_owned_to_string(&path).expect_err("oversized owned file");
892 assert!(error.to_string().contains("safety limit"), "{error:#}");
893 }
894
895 #[cfg(windows)]
896 #[test]
897 fn owned_read_rejects_leaf_reparse_points_on_windows() {
898 let _env = crate::test_support::lock_test_env();
899 let (dir, path) = secured_owned_windows_fixture(b"owned-secret");
900 let link = dir.path().join("owned-link.json");
901 if std::os::windows::fs::symlink_file(&path, &link).is_ok() {
902 assert!(
903 read_codewhale_owned_to_string(&link).is_err(),
904 "owned reads must reject leaf reparse points"
905 );
906 }
907 }
908
909 #[cfg(windows)]
910 #[test]
911 fn windows_handle_path_comparison_is_lossless_and_fails_closed() {
912 use std::ffi::OsString;
913 use std::os::windows::ffi::OsStringExt as _;
914 use std::path::PathBuf;
915
916 let expected = PathBuf::from(r"C:\Users\Alice\credential.json");
917 let kernel = PathBuf::from(r"\\?\C:\Users\Alice\credential.json");
918 assert_eq!(
919 normalize_windows_path_for_comparison(&expected).unwrap(),
920 normalize_windows_path_for_comparison(&kernel).unwrap()
921 );
922 assert_eq!(
923 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\A\credential.json"))
924 .unwrap(),
925 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\a\credential.json"))
926 .unwrap(),
927 "Windows credential path identity must compare case-insensitively"
928 );
929
930 let invalid = PathBuf::from(OsString::from_wide(&[
931 b'C' as u16,
932 b':' as u16,
933 b'\\' as u16,
934 0xd800,
935 ]));
936 assert!(normalize_windows_path_for_comparison(&invalid).is_err());
937 }
938 }
939
939 lines RUST