返回 CodeWhale
path_identity.rs
根目录 / crates / tui / src / plugins / path_identity.rs
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
160 lines RUST