| 1 | use std::path::Path; |
| 2 | |
| 3 | use sha2::Digest; |
| 4 | |
| 5 | /// Return true for every pathname indirection the plugin boundary rejects. |
| 6 | /// |
| 7 | /// `FileType::is_symlink` is insufficient on Windows: junctions, mount points, |
| 8 | /// and other name-surrogate objects carry `FILE_ATTRIBUTE_REPARSE_POINT` |
| 9 | /// without necessarily using the symbolic-link reparse tag. Keep this one |
| 10 | /// predicate shared by discovery, manifest validation, staging, and ACL |
| 11 | /// hardening so no surface silently follows a broader class than another. |
| 12 | #[cfg(windows)] |
| 13 | pub(crate) fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { |
| 14 | use std::os::windows::fs::MetadataExt as _; |
| 15 | |
| 16 | metadata.file_type().is_symlink() || metadata.file_attributes() & 0x0000_0400 != 0 |
| 17 | } |
| 18 | |
| 19 | #[cfg(not(windows))] |
| 20 | pub(crate) fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { |
| 21 | metadata.file_type().is_symlink() |
| 22 | } |
| 23 | |
| 24 | #[cfg(windows)] |
| 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 26 | pub(crate) struct WindowsFileIdentity { |
| 27 | pub(crate) volume: u32, |
| 28 | pub(crate) index: u64, |
| 29 | pub(crate) links: u32, |
| 30 | pub(crate) attributes: u32, |
| 31 | } |
| 32 | |
| 33 | /// Query stable handle-relative Windows identity without relying on Rust's |
| 34 | /// still-unstable `windows_by_handle` metadata extensions. |
| 35 | #[cfg(windows)] |
| 36 | pub(crate) fn windows_file_identity(file: &std::fs::File) -> std::io::Result<WindowsFileIdentity> { |
| 37 | use std::os::windows::io::AsRawHandle as _; |
| 38 | use windows::Win32::Foundation::HANDLE; |
| 39 | use windows::Win32::Storage::FileSystem::{ |
| 40 | BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, |
| 41 | }; |
| 42 | |
| 43 | let mut information = BY_HANDLE_FILE_INFORMATION::default(); |
| 44 | // SAFETY: `file` retains a valid handle and `information` points to live, |
| 45 | // writable storage for the duration of the call. |
| 46 | unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information) } |
| 47 | .map_err(std::io::Error::other)?; |
| 48 | Ok(WindowsFileIdentity { |
| 49 | volume: information.dwVolumeSerialNumber, |
| 50 | index: (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow), |
| 51 | links: information.nNumberOfLinks, |
| 52 | attributes: information.dwFileAttributes, |
| 53 | }) |
| 54 | } |
| 55 | |
| 56 | /// Add a lossless, platform-scoped OS path to a digest. |
| 57 | /// |
| 58 | /// Plugin identity must never pass through Unicode replacement. Unix paths |
| 59 | /// are byte strings and Windows paths are UTF-16 strings; two distinct native |
| 60 | /// paths can therefore have the same `to_string_lossy()` representation. The |
| 61 | /// framing below also prevents a future platform/domain change from silently |
| 62 | /// reusing an existing trust receipt. |
| 63 | pub(crate) fn hash_os_path(hasher: &mut impl Digest, domain: &'static [u8], path: &Path) { |
| 64 | hasher.update(b"codewhale-os-path-v1\0"); |
| 65 | hasher.update((domain.len() as u64).to_le_bytes()); |
| 66 | hasher.update(domain); |
| 67 | |
| 68 | #[cfg(unix)] |
| 69 | { |
| 70 | use std::os::unix::ffi::OsStrExt as _; |
| 71 | |
| 72 | let bytes = path.as_os_str().as_bytes(); |
| 73 | hasher.update(b"unix-bytes\0"); |
| 74 | hasher.update((bytes.len() as u64).to_le_bytes()); |
| 75 | hasher.update(bytes); |
| 76 | } |
| 77 | |
| 78 | #[cfg(windows)] |
| 79 | { |
| 80 | use std::os::windows::ffi::OsStrExt as _; |
| 81 | |
| 82 | let units = path.as_os_str().encode_wide().collect::<Vec<_>>(); |
| 83 | hasher.update(b"windows-utf16le\0"); |
| 84 | hasher.update((units.len() as u64).to_le_bytes()); |
| 85 | for unit in units { |
| 86 | hasher.update(unit.to_le_bytes()); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | #[cfg(all(not(unix), not(windows)))] |
| 91 | { |
| 92 | // `as_encoded_bytes` is lossless for the platform's `OsStr` |
| 93 | // representation within one Rust implementation. Keep this fallback |
| 94 | // separately tagged so receipts can never cross into Unix/Windows. |
| 95 | let bytes = path.as_os_str().as_encoded_bytes(); |
| 96 | hasher.update(b"rust-osstr-encoded\0"); |
| 97 | hasher.update((bytes.len() as u64).to_le_bytes()); |
| 98 | hasher.update(bytes); |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | #[cfg(test)] |
| 103 | mod tests { |
| 104 | use super::*; |
| 105 | use sha2::Sha256; |
| 106 | |
| 107 | fn digest(path: &Path) -> Vec<u8> { |
| 108 | let mut hasher = Sha256::new(); |
| 109 | hash_os_path(&mut hasher, b"test-domain", path); |
| 110 | hasher.finalize().to_vec() |
| 111 | } |
| 112 | |
| 113 | #[cfg(windows)] |
| 114 | #[test] |
| 115 | fn junctions_are_reparse_points_even_when_not_symbolic_links() { |
| 116 | let directory = tempfile::tempdir().unwrap(); |
| 117 | let target = directory.path().join("target"); |
| 118 | let junction = directory.path().join("junction"); |
| 119 | std::fs::create_dir(&target).unwrap(); |
| 120 | let output = std::process::Command::new("cmd") |
| 121 | .args(["/C", "mklink", "/J"]) |
| 122 | .arg(&junction) |
| 123 | .arg(&target) |
| 124 | .output() |
| 125 | .expect("invoke Windows junction creation"); |
| 126 | assert!( |
| 127 | output.status.success(), |
| 128 | "failed to create junction: {}", |
| 129 | String::from_utf8_lossy(&output.stderr) |
| 130 | ); |
| 131 | |
| 132 | let metadata = std::fs::symlink_metadata(&junction).unwrap(); |
| 133 | assert!(metadata_is_link_or_reparse(&metadata)); |
| 134 | } |
| 135 | |
| 136 | #[cfg(unix)] |
| 137 | #[test] |
| 138 | fn invalid_unicode_paths_do_not_collapse_to_replacement_text() { |
| 139 | use std::ffi::OsString; |
| 140 | use std::os::unix::ffi::OsStringExt as _; |
| 141 | |
| 142 | let first = OsString::from_vec(vec![b'a', 0xff]); |
| 143 | let second = OsString::from_vec(vec![b'a', 0xfe]); |
| 144 | assert_eq!(first.to_string_lossy(), second.to_string_lossy()); |
| 145 | assert_ne!(digest(Path::new(&first)), digest(Path::new(&second))); |
| 146 | } |
| 147 | |
| 148 | #[cfg(windows)] |
| 149 | #[test] |
| 150 | fn unpaired_utf16_paths_do_not_collapse_to_replacement_text() { |
| 151 | use std::ffi::OsString; |
| 152 | use std::os::windows::ffi::OsStringExt as _; |
| 153 | |
| 154 | let first = OsString::from_wide(&[b'a' as u16, 0xd800]); |
| 155 | let second = OsString::from_wide(&[b'a' as u16, 0xd801]); |
| 156 | assert_eq!(first.to_string_lossy(), second.to_string_lossy()); |
| 157 | assert_ne!(digest(Path::new(&first)), digest(Path::new(&second))); |
| 158 | } |
| 159 | } |
| 160 |