prikk_ffi/lib.rs
1//! Prikk's sole FFI surface (DC-96 Windows Anchor Identity). Every future FFI need lands here
2//! rather than motivating a second entry in `UNSAFE_EXEMPT_CRATES`, which DC-90 forbids anyway --
3//! see that gate's own module doc for why at most one workspace crate may hold this exemption.
4//! Compiles to nothing on non-Windows targets: there is no FFI need there today, and this crate
5//! exists to hold the one that does, not to anticipate ones that don't yet.
6
7/// Identity of an open filesystem object on Windows -- the value that confirms an anchor handle
8/// still refers to the object it was bound to (DC-96). Meaningful only within one boot on one
9/// volume -- never derive an object id, a container path, or any on-disk artifact from it, and
10/// never persist it past the process that read it.
11///
12/// **Two variants, not one uniform 128-bit shape padded from the 64-bit fallback -- deliberately**
13/// (DC-99 Stage 2). `microsoft/STL`'s own `_Get_file_id_by_handle` (`stl/src/filesystem.cpp`,
14/// backing `std::filesystem::equivalent`) zero-pads the 64-bit index into `FILE_ID_INFO`'s 128-bit
15/// shape and compares both forms with one `memcmp`. That is safe for *their* use: two handles
16/// opened and compared within one call, so a filesystem lacking `FileIdInfo` produces the same
17/// zero-padded shape on both sides. **It is not safe for this crate's use**: `WindowsAuthority`
18/// captures an identity once (`bind`) and compares it against one re-derived later, at walk time --
19/// a stored, zero-padded 64-bit value could compare byte-equal to some other file's genuine 128-bit
20/// id that happens to end in eight zero bytes. Astronomically unlikely, but not structurally
21/// prevented by a uniform shape, and "correct for STL's own use case" is not the same claim as
22/// "correct for this one" (DC-99 stage-2-investigation-ruling-v1 §1). The enum below makes a
23/// cross-form comparison **impossible**, not merely unlikely: a derived `PartialEq` compares the
24/// discriminant first, so an `Id128` and an `Id64` never compare equal regardless of what bytes
25/// either one holds. If a future edit "simplifies" this back to one padded shape, it is
26/// reintroducing exactly the risk this comment -- and the ruling it records -- exists to name.
27#[cfg(windows)]
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum FileIdentity {
30 /// `GetFileInformationByHandleEx(FileIdInfo)` -- NTFS, ReFS.
31 Id128 {
32 /// `FILE_ID_INFO::VolumeSerialNumber`.
33 volume_serial_number: u64,
34 /// `FILE_ID_INFO::FileId`'s 16-byte identifier, opaque and directly comparable as bytes --
35 /// no reserved or unstable portion (confirmed against `microsoft/STL`'s own `memcmp`-based
36 /// comparison of the whole struct).
37 file_id: [u8; 16],
38 },
39 /// `GetFileInformationByHandle`'s 64-bit file index -- the fallback for filesystems that
40 /// refuse `FileIdInfo` (FAT/exFAT, some network filesystems). Triggered only by
41 /// `ERROR_NOT_SUPPORTED` or `ERROR_INVALID_PARAMETER` from the primary call, the same two
42 /// codes `microsoft/STL`'s own fallback branches on -- any other failure is a real error,
43 /// propagated rather than silently downgraded to this variant.
44 Id64 {
45 /// `BY_HANDLE_FILE_INFORMATION::dwVolumeSerialNumber`.
46 volume_serial_number: u32,
47 /// `BY_HANDLE_FILE_INFORMATION`'s `nFileIndexHigh`/`nFileIndexLow`, combined -- not
48 /// guaranteed unique on ReFS (Microsoft's own documentation), which is exactly why the
49 /// 128-bit form above is preferred whenever the filesystem supports it.
50 file_index: u64,
51 },
52}
53
54#[cfg(all(test, windows))]
55std::thread_local! {
56 static FORCE_FALLBACK_ONCE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
57}
58
59/// Test-only: force the very next `identity_of` call on this thread to take the 64-bit fallback
60/// path, as if the primary call had failed with `ERROR_NOT_SUPPORTED` -- without a real
61/// unsupported-filesystem volume, which CI does not provision (DC-99 stage-2-investigation-ruling-v1
62/// §3). Consumed after one call, matching this workspace's other test-only failure-injection
63/// mechanism (`fsutil::anchored::failpoints`, `prikk-store`). **Observable, not merely forced**: the
64/// caller must assert the returned `FileIdentity` is the `Id64` variant -- if this override ever
65/// stopped working, `identity_of` would take the real primary path and return `Id128`, and that
66/// assertion would fail. There is no state in which the override silently forces nothing.
67#[cfg(all(test, windows))]
68pub fn force_identity_fallback_once() {
69 FORCE_FALLBACK_ONCE.with(|flag| flag.set(true));
70}
71
72#[cfg(windows)]
73fn should_force_identity_fallback() -> bool {
74 #[cfg(test)]
75 {
76 FORCE_FALLBACK_ONCE.with(|flag| flag.replace(false))
77 }
78 #[cfg(not(test))]
79 {
80 false
81 }
82}
83
84/// Read `file`'s identity from its already-open handle, preferring the 128-bit form
85/// (`GetFileInformationByHandleEx(FileIdInfo)`) and falling back to the 64-bit form
86/// (`GetFileInformationByHandle`) only when the OS itself reports the 128-bit form unsupported. The
87/// caller owns opening the handle, including share flags and reparse-point policy
88/// (`FILE_SHARE_DELETE`, `FILE_FLAG_OPEN_REPARSE_POINT`).
89#[cfg(windows)]
90pub fn identity_of(file: &std::fs::File) -> std::io::Result<FileIdentity> {
91 use std::os::windows::io::AsRawHandle;
92
93 use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED, HANDLE};
94 use windows_sys::Win32::Storage::FileSystem::{
95 BY_HANDLE_FILE_INFORMATION, FILE_ID_INFO, FileIdInfo, GetFileInformationByHandle,
96 GetFileInformationByHandleEx,
97 };
98
99 let handle: HANDLE = file.as_raw_handle();
100
101 if !should_force_identity_fallback() {
102 let mut id128 = FILE_ID_INFO::default();
103 // SAFETY: `handle` is a valid, open HANDLE for the duration of this call, borrowed from
104 // `file`. `id128` is `#[repr(C)]`, `Default`-initialized (windows-sys 0.61.2), and its size
105 // is passed explicitly as `dwBufferSize`, so a well-formed `&mut` pointer to it is always a
106 // valid, correctly-sized write target for what this call writes on success. On failure it
107 // writes nothing we read; the OS error is read via `std::io::Error::last_os_error`
108 // immediately after, before any other call can overwrite it.
109 let succeeded = unsafe {
110 GetFileInformationByHandleEx(
111 handle,
112 FileIdInfo,
113 (&raw mut id128).cast(),
114 u32::try_from(size_of::<FILE_ID_INFO>()).unwrap_or(u32::MAX),
115 )
116 };
117 if succeeded != 0 {
118 return Ok(FileIdentity::Id128 {
119 volume_serial_number: id128.VolumeSerialNumber,
120 file_id: id128.FileId.Identifier,
121 });
122 }
123 let error = std::io::Error::last_os_error();
124 match error.raw_os_error() {
125 Some(code)
126 if code == ERROR_NOT_SUPPORTED as i32 || code == ERROR_INVALID_PARAMETER as i32 => {
127 }
128 _ => return Err(error),
129 }
130 }
131
132 let mut info = BY_HANDLE_FILE_INFORMATION::default();
133 // SAFETY: same reasoning as the primary call above -- `handle` is still the same valid, open
134 // HANDLE, `info` is `#[repr(C)]` and `Default`-initialized, and the OS error is read
135 // immediately on failure, before any other call can overwrite it.
136 let succeeded = unsafe { GetFileInformationByHandle(handle, &raw mut info) };
137 if succeeded == 0 {
138 return Err(std::io::Error::last_os_error());
139 }
140 Ok(FileIdentity::Id64 {
141 volume_serial_number: info.dwVolumeSerialNumber,
142 file_index: (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
143 })
144}
145
146/// The current path of an already-open handle's object (`GetFinalPathNameByHandle`, Microsoft
147/// Learn). A directory handle follows its object across a rename -- re-deriving the path this way
148/// before a walk, rather than re-walking a path string captured earlier, is what lets Windows
149/// continue operating correctly against the object that was validated even after its directory
150/// entry has been renamed elsewhere (DC-96 implementation-ruling-v1 §4). Returned with the
151/// `VOLUME_NAME_DOS` / `FILE_NAME_NORMALIZED` flags (value `0`) -- the ordinary drive-letter path
152/// form every other function in this crate and its caller already expects, at the cost of the
153/// well-known `\\?\` extended-length prefix Windows adds to this form; `std::fs`/`CreateFileW`
154/// both accept it transparently.
155#[cfg(windows)]
156pub fn current_path_of(file: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
157 use std::ffi::OsString;
158 use std::os::windows::ffi::OsStringExt;
159 use std::os::windows::io::AsRawHandle;
160 use std::path::PathBuf;
161
162 use windows_sys::Win32::Foundation::HANDLE;
163 use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW;
164
165 let handle: HANDLE = file.as_raw_handle();
166 // Zero-initialized and grown by `Vec::resize`, never left partially uninitialized -- so
167 // `buffer.as_mut_ptr()` below is always a pointer to `buffer.len()` valid, initialized `u16`
168 // slots, regardless of how much of that capacity the call actually writes.
169 let mut buffer: Vec<u16> = vec![0; 512];
170 loop {
171 let capacity = u32::try_from(buffer.len()).unwrap_or(u32::MAX);
172 // SAFETY: `handle` is a valid, open HANDLE for the duration of this call, borrowed from
173 // `file`. `buffer.as_mut_ptr()` points to `capacity` initialized, writable `u16` slots
174 // (see above); the function writes at most `capacity` of them and returns the count
175 // actually written (success) or the count that would have been required (buffer too
176 // small) -- it never writes past what `capacity` promises is available. On failure
177 // (return value 0) it writes nothing we read; the OS error is read via
178 // `std::io::Error::last_os_error` immediately after, before any other call can clobber
179 // it.
180 let written =
181 unsafe { GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), capacity, 0) };
182 if written == 0 {
183 return Err(std::io::Error::last_os_error());
184 }
185 if written < capacity {
186 // Success: `written` is the length actually used, excluding the null terminator.
187 buffer.truncate(written as usize);
188 break;
189 }
190 // Too small: `written` is the required size, including the null terminator this time.
191 buffer.resize(written as usize, 0);
192 }
193 Ok(PathBuf::from(OsString::from_wide(&buffer)))
194}
195
196/// Best-effort, advisory liveness of a process id (DC-99, `prikk unlock`'s Windows primitive).
197/// `prikk-ffi` cannot depend on `prikk-store`, so this returns its own enum -- map to
198/// `unlock.rs::PidLiveness` at the call site, the same split `identity_of`/`current_path_of` already
199/// draw between this crate's raw Win32 answer and its caller's own vocabulary.
200#[cfg(windows)]
201#[derive(Clone, Copy, PartialEq, Eq, Debug)]
202pub enum ProcessLiveness {
203 /// A handle was opened and confirmed still running: `WaitForSingleObject` reports the handle
204 /// nonsignaled, or the open itself failed with `ERROR_ACCESS_DENIED` -- the kernel found a
205 /// process to check permissions against, the same reasoning Unix's `EPERM` branch already
206 /// applies (`unlock.rs::check_pid_liveness`).
207 Exists,
208 /// Positively established absence: `OpenProcess` failed with `ERROR_INVALID_PARAMETER` (no such
209 /// process), or a successfully opened handle's process has since terminated
210 /// (`WaitForSingleObject` reports the handle signaled).
211 DoesNotExist,
212 /// Neither established -- an unexpected error, an unanticipated wait result, or a degenerate PID
213 /// this function refuses to ask the OS about at all. Never authorization to clear anything; see
214 /// `PidLiveness`'s own doc at the call site for why a negative or unknown result is advisory
215 /// only.
216 Indeterminate,
217}
218
219/// PID 0 names the System Idle Process on Windows -- a real PID, but never a value a lock file's own
220/// `pid=` field can legitimately record for a user process. Rejected before the OS call rather than
221/// let it reach `OpenProcess`, so a corrupt or hand-edited lock file recording `pid=0` produces the
222/// same `Indeterminate` answer here that `rustix::process::Pid::from_raw(0)` already produces as
223/// `None` on Linux/macOS (`unlock.rs::check_pid_liveness`) -- not merely to avoid a syscall, but to
224/// keep the two platforms' answers equal for the same malformed input (DC-99
225/// stage-1-investigation-ruling-v1 §2: without this guard, `pid=0` reaches `DoesNotExist` on Windows
226/// and `Unknown` on Unix for the identical recorded value, and `DoesNotExist` is the one answer that
227/// can authorize clearing a lock).
228#[cfg(windows)]
229pub fn process_liveness(pid: u32) -> ProcessLiveness {
230 use windows_sys::Win32::Foundation::{
231 ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT,
232 };
233 use windows_sys::Win32::System::Threading::{
234 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
235 };
236
237 if pid == 0 {
238 return ProcessLiveness::Indeterminate;
239 }
240
241 // SAFETY: `OpenProcess` takes no pointer arguments; every argument is a plain integer, and the
242 // return value is either NULL (checked immediately below) or a `HANDLE` this function takes
243 // ownership of via `OwnedProcessHandle`. Requesting only `PROCESS_QUERY_LIMITED_INFORMATION |
244 // PROCESS_SYNCHRONIZE` (not a broader right) is deliberate -- it is queryable against a process
245 // this caller does not own, which is exactly the access-denied case this function must
246 // distinguish from genuine absence.
247 let handle: HANDLE = unsafe {
248 OpenProcess(
249 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
250 0,
251 pid,
252 )
253 };
254 if handle.is_null() {
255 // `std::io::Error::last_os_error` wraps `GetLastError`, read immediately after the call that
256 // can set it, matching this crate's other two functions -- no separate FFI import needed for
257 // it.
258 return match std::io::Error::last_os_error().raw_os_error() {
259 Some(code) if code == ERROR_INVALID_PARAMETER as i32 => ProcessLiveness::DoesNotExist,
260 Some(code) if code == ERROR_ACCESS_DENIED as i32 => ProcessLiveness::Exists,
261 _ => ProcessLiveness::Indeterminate,
262 };
263 }
264 let guard = OwnedProcessHandle(handle);
265
266 // SAFETY: `guard.0` is the valid, open HANDLE just obtained above, not yet closed (the guard
267 // closes it on drop, after this call returns and its result is captured). A zero timeout never
268 // blocks -- it reports the object's current state and returns immediately either way, so this
269 // cannot hang the command that calls it.
270 let waited = unsafe { WaitForSingleObject(guard.0, 0) };
271 match waited {
272 WAIT_TIMEOUT => ProcessLiveness::Exists,
273 WAIT_OBJECT_0 => ProcessLiveness::DoesNotExist,
274 _ => ProcessLiveness::Indeterminate,
275 }
276 // `guard` drops here on every path above, including the unwind path if a future edit adds a
277 // panicking call between the two `unsafe` blocks -- `CloseHandle` runs in `Drop`, not repeated at
278 // each branch, so there is exactly one place a handle leak could hide, and it is not a per-branch
279 // decision that could be forgotten in a new arm.
280}
281
282/// The `HANDLE` `OpenProcess` returns, owned. `windows-sys` provides no RAII wrapper for `HANDLE`
283/// (`identity_of`/`current_path_of` never own one -- they borrow from a `std::fs::File` that already
284/// manages its own closing); this is the one function in the crate that does, so it is the one place
285/// that needs its own `Drop`.
286#[cfg(windows)]
287struct OwnedProcessHandle(windows_sys::Win32::Foundation::HANDLE);
288
289#[cfg(windows)]
290impl Drop for OwnedProcessHandle {
291 fn drop(&mut self) {
292 // SAFETY: `self.0` is a valid, open HANDLE this struct exclusively owns (constructed only
293 // from a non-null `OpenProcess` success in `process_liveness`, never copied or shared) and
294 // has not been closed yet -- `Drop::drop` runs at most once per value. `CloseHandle`'s own
295 // return value is not checked: there is nothing a liveness check can safely do in response to
296 // a close failure, and a leaked handle here would be a real defect (DC-99 design-v1.md §
297 // "close the handle") but a checked-and-ignored return would not prevent one.
298 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
299 }
300}
301
302#[cfg(all(test, windows))]
303mod tests {
304 use super::{
305 FileIdentity, ProcessLiveness, current_path_of, force_identity_fallback_once, identity_of,
306 process_liveness,
307 };
308
309 /// The one case this advisory check can prove reliably: the current process's own PID, which is
310 /// definitely alive because it is the process running this assertion.
311 #[test]
312 fn process_liveness_of_the_current_process_is_exists() {
313 assert_eq!(
314 process_liveness(std::process::id()),
315 ProcessLiveness::Exists
316 );
317 }
318
319 /// DC-99 stage-1-investigation-ruling-v1 §2: PID 0 must not reach `DoesNotExist` -- it names the
320 /// System Idle Process, a real PID, and the same malformed lock-file value produces `Unknown` on
321 /// Linux/macOS (`Pid::from_raw(0)` returning `None`). `Indeterminate` here keeps the two
322 /// platforms' answers equal for the same degenerate input.
323 #[test]
324 fn process_liveness_of_pid_zero_is_indeterminate() {
325 assert_eq!(process_liveness(0), ProcessLiveness::Indeterminate);
326 }
327
328 /// A PID astronomically unlikely to name a real process on a test runner -- real Windows PIDs
329 /// stay far below this range in practice, the same "unlikely" reasoning
330 /// `unlock/tests.rs::a_lock_recording_a_nonexistent_pid_is_reported_as_not_appearing_to_run` uses
331 /// for its own `999999` on Linux/macOS. `OpenProcess` is expected to fail with
332 /// `ERROR_INVALID_PARAMETER` for it -- demonstrated by running this, not assumed from the API
333 /// contract (RFC criterion 1).
334 #[test]
335 fn process_liveness_of_an_implausible_pid_is_does_not_exist() {
336 assert_eq!(process_liveness(0x7FFF_FFFF), ProcessLiveness::DoesNotExist);
337 }
338
339 /// RFC criterion 3 / design-v1.md: an honest, Windows-reachable access-denied test, not one
340 /// asserted only by reading the API contract. PID 4 is the Windows System process by
341 /// long-standing OS convention, and `OpenProcess`'s own reference page states explicitly that
342 /// opening it "fails and the last error code is ERROR_ACCESS_DENIED because their access
343 /// restrictions prevent user-level code from opening them" -- regardless of which access right is
344 /// requested, unlike an ordinary process where a narrower right (`PROCESS_QUERY_LIMITED_INFORMATION`)
345 /// can succeed where a broader one fails.
346 #[test]
347 fn process_liveness_of_the_system_process_is_exists() {
348 assert_eq!(process_liveness(4), ProcessLiveness::Exists);
349 }
350
351 /// DC-96 design-v1.md §6.4: without this, a `FileIdentity` that never equals itself -- or
352 /// always equals everything -- would pass every other test in this increment silently. Exercises
353 /// the real primary path (DC-99 Stage 2): GitHub's `windows-latest` runner's drives are NTFS
354 /// (`stage-2-investigation-v1` §2), so this is expected to return the `Id128` variant without any
355 /// override, demonstrated below rather than assumed.
356 #[test]
357 fn identity_distinguishes_different_files_and_matches_the_same_one() -> std::io::Result<()> {
358 let directory = std::env::temp_dir();
359 let suffix = std::process::id();
360 let path_a = directory.join(format!("prikk-ffi-identity-test-a-{suffix}"));
361 let path_b = directory.join(format!("prikk-ffi-identity-test-b-{suffix}"));
362 std::fs::write(&path_a, b"a")?;
363 std::fs::write(&path_b, b"b")?;
364
365 let identity_a = identity_of(&std::fs::File::open(&path_a)?)?;
366 let identity_b = identity_of(&std::fs::File::open(&path_b)?)?;
367 let identity_a_reopened = identity_of(&std::fs::File::open(&path_a)?)?;
368
369 let _ = std::fs::remove_file(&path_a);
370 let _ = std::fs::remove_file(&path_b);
371
372 assert!(
373 matches!(identity_a, FileIdentity::Id128 { .. }),
374 "the CI runner's NTFS drives are expected to take the primary 128-bit path by default: \
375 {identity_a:?}"
376 );
377 assert_ne!(
378 identity_a, identity_b,
379 "two different files must not compare equal"
380 );
381 assert_eq!(
382 identity_a, identity_a_reopened,
383 "the same file, reopened, must compare equal"
384 );
385 Ok(())
386 }
387
388 /// DC-99 Stage 2's own negative control for the fallback path: `force_identity_fallback_once`
389 /// forces `identity_of` to skip the primary call and go straight to `GetFileInformationByHandle`,
390 /// against an ordinary NTFS file that would otherwise take the 128-bit path -- proving the
391 /// fallback's own construction is correct even though CI provisions no genuinely unsupported
392 /// filesystem to trigger it for real (`stage-2-investigation-ruling-v1` §3). Observable per that
393 /// ruling: every assertion below is on the *returned variant*, so if the override ever stopped
394 /// forcing anything, `identity_of` would silently take the primary path and return `Id128`
395 /// instead -- turning this test red, not green.
396 #[test]
397 fn identity_fallback_distinguishes_different_files_and_matches_the_same_one()
398 -> std::io::Result<()> {
399 let directory = std::env::temp_dir();
400 let suffix = std::process::id();
401 let path_a = directory.join(format!("prikk-ffi-identity-fallback-test-a-{suffix}"));
402 let path_b = directory.join(format!("prikk-ffi-identity-fallback-test-b-{suffix}"));
403 std::fs::write(&path_a, b"a")?;
404 std::fs::write(&path_b, b"b")?;
405
406 force_identity_fallback_once();
407 let identity_a = identity_of(&std::fs::File::open(&path_a)?)?;
408 force_identity_fallback_once();
409 let identity_b = identity_of(&std::fs::File::open(&path_b)?)?;
410 force_identity_fallback_once();
411 let identity_a_reopened = identity_of(&std::fs::File::open(&path_a)?)?;
412
413 let _ = std::fs::remove_file(&path_a);
414 let _ = std::fs::remove_file(&path_b);
415
416 assert!(
417 matches!(identity_a, FileIdentity::Id64 { .. }),
418 "the override must have forced the fallback branch, not the primary one: {identity_a:?}"
419 );
420 assert!(matches!(identity_b, FileIdentity::Id64 { .. }));
421 assert!(matches!(identity_a_reopened, FileIdentity::Id64 { .. }));
422 assert_ne!(
423 identity_a, identity_b,
424 "two different files must not compare equal, even via the fallback form"
425 );
426 assert_eq!(
427 identity_a, identity_a_reopened,
428 "the same file, reopened through the fallback form both times, must compare equal"
429 );
430 Ok(())
431 }
432
433 /// `force_identity_fallback_once` is consumed after exactly one call -- a second `identity_of`
434 /// call on the same thread must take the real primary path again, not stay forced. Without this,
435 /// the fallback test above could pass for the wrong reason: every call after the first one
436 /// forced would also return `Id64`, whether or not the override actually fired again.
437 #[test]
438 fn identity_fallback_override_is_consumed_after_one_call() -> std::io::Result<()> {
439 let path = std::env::temp_dir().join(format!(
440 "prikk-ffi-identity-fallback-once-test-{}",
441 std::process::id()
442 ));
443 std::fs::write(&path, b"x")?;
444
445 force_identity_fallback_once();
446 let forced = identity_of(&std::fs::File::open(&path)?)?;
447 let unforced = identity_of(&std::fs::File::open(&path)?)?;
448
449 let _ = std::fs::remove_file(&path);
450
451 assert!(matches!(forced, FileIdentity::Id64 { .. }));
452 assert!(
453 matches!(unforced, FileIdentity::Id128 { .. }),
454 "the override must not persist past its one call: {unforced:?}"
455 );
456 Ok(())
457 }
458
459 /// Guards the enum's own shape: if a future edit adds a field this derive doesn't cover, or
460 /// removes `PartialEq`, this fails to compile rather than silently comparing fewer fields than
461 /// intended.
462 #[test]
463 fn file_identity_is_copy_and_comparable() {
464 fn assert_bounds<T: Copy + PartialEq + Eq + std::fmt::Debug>() {}
465 assert_bounds::<FileIdentity>();
466 }
467
468 fn open_directory(path: &std::path::Path) -> std::io::Result<std::fs::File> {
469 use std::os::windows::fs::OpenOptionsExt;
470 // `FILE_FLAG_BACKUP_SEMANTICS` (`0x02000000`) -- required to obtain a directory handle via
471 // `CreateFile` at all (Microsoft Learn, `CreateFileA`, `dwFlagsAndAttributes`). The same
472 // constant `windows.rs` uses for the same reason; not re-exported from there to keep this
473 // crate's only dependency on its caller one-directional (this crate takes no dependency on
474 // `prikk-store`).
475 std::fs::OpenOptions::new()
476 .read(true)
477 .custom_flags(0x0200_0000)
478 .open(path)
479 }
480
481 /// DC-96 implementation-ruling-v1 §4: the mechanism the whole correction rests on. A retained
482 /// directory handle must keep resolving to its *current* path after the directory is renamed
483 /// out from under it -- this is what turns identity comparison from the sole mechanism
484 /// (detection only, and wrong per that ruling) into the secondary confirmation after a walk
485 /// that already starts from the right place (prevention).
486 #[test]
487 fn current_path_of_follows_the_handle_across_a_rename() -> std::io::Result<()> {
488 let temporary_root = std::env::temp_dir();
489 let suffix = std::process::id();
490 let original = temporary_root.join(format!("prikk-ffi-rename-test-original-{suffix}"));
491 let renamed = temporary_root.join(format!("prikk-ffi-rename-test-renamed-{suffix}"));
492 let _ = std::fs::remove_dir_all(&original);
493 let _ = std::fs::remove_dir_all(&renamed);
494 std::fs::create_dir(&original)?;
495
496 let handle = open_directory(&original)?;
497 let path_before = current_path_of(&handle)?;
498 let expected_before = std::fs::canonicalize(&original)?;
499
500 std::fs::rename(&original, &renamed)?;
501 let path_after = current_path_of(&handle)?;
502 let expected_after = std::fs::canonicalize(&renamed)?;
503
504 let _ = std::fs::remove_dir_all(&renamed);
505
506 assert_eq!(
507 path_before, expected_before,
508 "before any rename, the handle's path must match the directory it was opened from"
509 );
510 assert_eq!(
511 path_after, expected_after,
512 "after renaming the directory out from under the still-open handle, current_path_of \
513 must report the NEW path -- this is the whole mechanism DC-96 depends on"
514 );
515 assert_ne!(
516 path_before, path_after,
517 "the rename must actually have been observed, not silently ignored"
518 );
519 Ok(())
520 }
521}