Skip to main content

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}