slipcase_open/identity.rs
1//! What makes two paths the same file.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 8 keys the live session table on file identity rather than on a
7//! path, and the reason is the case §7 already warns about: a container
8//! reachable under two hard links is two paths and one file. Canonicalising
9//! resolves symbolic links and does nothing about that, so a table keyed on the
10//! canonical path would open two sessions on one container, both repacking it,
11//! the second write-back overwriting the first with nothing said.
12//!
13//! **Where the filesystem will not answer, the path is the answer.** Some
14//! network mounts report a zero or unstable file index, and a key built from
15//! one would make every lookup a miss — which is the failure that opens the
16//! second session. Falling back to the canonical path narrows the guarantee to
17//! what canonicalising gives, and says so in the type rather than pretending.
18
19use std::fmt;
20use std::io;
21use std::path::{Path, PathBuf};
22
23/// What the filesystem says this file is.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum Identity {
26 /// The file itself, as the filesystem numbers it. Two hard links to one
27 /// inode compare equal here, which is the point.
28 File {
29 /// Device on Unix, volume serial number on Windows.
30 volume: u64,
31 /// Inode on Unix, file index on Windows.
32 file: u64,
33 },
34 /// The filesystem would not give a stable number, so this is the
35 /// canonicalised path and the narrower guarantee that comes with it.
36 Path(PathBuf),
37}
38
39impl fmt::Display for Identity {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::File { volume, file } => write!(f, "{volume:x}:{file:x}"),
43 Self::Path(p) => write!(f, "{}", p.display()),
44 }
45 }
46}
47
48/// What the filesystem says about the file at `path`.
49///
50/// # Errors
51///
52/// Where the path cannot be resolved or the file cannot be stat'd. A container
53/// that is not there has no identity, which is a different answer from two
54/// containers not matching.
55pub fn of(path: &Path) -> io::Result<Identity> {
56 // Resolved first, so that the fallback arm is a canonical path rather than
57 // whatever the caller happened to type, and so a symbolic link is the file
58 // it points at on both arms.
59 let real = std::fs::canonicalize(path)?;
60 let meta = std::fs::metadata(&real)?;
61 Ok(
62 numbers(&real, &meta).map_or(Identity::Path(real), |(volume, file)| Identity::File {
63 volume,
64 file,
65 }),
66 )
67}
68
69/// The device and inode, where they are worth anything.
70///
71/// A zero inode is the tell that nothing real is behind the number. Some
72/// network filesystems report one for every file, and a key that collapses
73/// every container on such a mount into one entry is worse than no key at all —
74/// it would refuse to open the second container the user asked for, believing
75/// it already had it.
76#[cfg(unix)]
77fn numbers(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64)> {
78 use std::os::unix::fs::MetadataExt as _;
79 match (meta.dev(), meta.ino()) {
80 (_, 0) => None,
81 (dev, ino) => Some((dev, ino)),
82 }
83}
84
85/// The volume serial number and the file index, through
86/// `GetFileInformationByHandle`.
87///
88/// Concept 8 names that pair and this answered `None` until Phase 4, so Windows
89/// had no hard-link arm — which is the case the whole module exists for.
90/// `std::os::windows::fs::MetadataExt` exposes both and has kept them behind the
91/// unstable `windows_by_handle` feature since 2019, and this crate builds on
92/// stable, so the obvious implementation was never available.
93///
94/// **`same-file` was the other route PLAN.md named, and it does not fit.** Its
95/// `Handle` keys on exactly this pair and compares on it, but `key` and the
96/// `Key` type behind it are both private with no accessor, and a `Handle` holds
97/// the file open for as long as it lives. [`Identity`] is owned data held in the
98/// session table across a whole session and printed by `Display`, so the two
99/// numbers have to come out. Measured by reading `same-file-1.0.6/src/win.rs`.
100/// That leaves the call itself, and the `deny` `Cargo.toml` now carries in place
101/// of `forbid` so that this one module can lift it.
102///
103/// **Opened for its attributes and nothing more.** `FILE_READ_ATTRIBUTES` is
104/// what the call needs and the least it can ask for, and the share mode is
105/// `std`'s default of read, write and delete. Nothing here may stand in the way
106/// of the application editing its content file, and measured on 2026-09-01: a
107/// container already held open by another handle for reading and writing still
108/// answers.
109///
110/// A zero index is the tell the Unix arm reads from a zero inode, and gets the
111/// same answer — no stable number, so the caller falls back to the canonicalised
112/// path and the narrower guarantee that comes with it.
113#[cfg(windows)]
114#[allow(unsafe_code)]
115fn numbers(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64)> {
116 use std::os::windows::fs::OpenOptionsExt as _;
117 use std::os::windows::io::AsRawHandle as _;
118 use windows_sys::Win32::Storage::FileSystem::{
119 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_READ_ATTRIBUTES,
120 };
121
122 let file = std::fs::OpenOptions::new()
123 .access_mode(FILE_READ_ATTRIBUTES)
124 .open(path)
125 .ok()?;
126 let mut info = BY_HANDLE_FILE_INFORMATION::default();
127 // SAFETY: the handle is open and owned by `file`, which outlives the call;
128 // `info` is a live, correctly typed allocation the callee only writes. The
129 // return value is checked before anything in `info` is read.
130 let got = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &raw mut info) };
131 if got == 0 {
132 return None;
133 }
134 let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow);
135 match (u64::from(info.dwVolumeSerialNumber), index) {
136 (_, 0) => None,
137 pair => Some(pair),
138 }
139}
140
141#[cfg(not(any(unix, windows)))]
142fn numbers(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64)> {
143 None
144}
145
146#[cfg(test)]
147mod tests {
148 use super::of;
149 use std::fs;
150
151 #[test]
152 fn a_file_is_itself() {
153 let tmp = tempfile::tempdir().unwrap();
154 let p = tmp.path().join("report.slpc");
155 fs::write(&p, b"x").unwrap();
156 assert_eq!(of(&p).unwrap(), of(&p).unwrap());
157 }
158
159 #[test]
160 fn two_files_are_not_each_other() {
161 let tmp = tempfile::tempdir().unwrap();
162 let a = tmp.path().join("a.slpc");
163 let b = tmp.path().join("b.slpc");
164 fs::write(&a, b"x").unwrap();
165 fs::write(&b, b"x").unwrap();
166 assert_ne!(of(&a).unwrap(), of(&b).unwrap());
167 }
168
169 #[test]
170 fn a_relative_path_and_an_absolute_one_are_the_same_file() {
171 let tmp = tempfile::tempdir().unwrap();
172 let p = tmp.path().join("report.slpc");
173 fs::write(&p, b"x").unwrap();
174
175 let previous = std::env::current_dir().unwrap();
176 std::env::set_current_dir(tmp.path()).unwrap();
177 let relative = of(std::path::Path::new("report.slpc"));
178 std::env::set_current_dir(previous).unwrap();
179
180 assert_eq!(relative.unwrap(), of(&p).unwrap());
181 }
182
183 #[cfg(unix)]
184 #[test]
185 fn a_symbolic_link_is_the_file_it_points_at() {
186 let tmp = tempfile::tempdir().unwrap();
187 let real = tmp.path().join("report.slpc");
188 let link = tmp.path().join("link.slpc");
189 fs::write(&real, b"x").unwrap();
190 std::os::unix::fs::symlink(&real, &link).unwrap();
191 assert_eq!(of(&link).unwrap(), of(&real).unwrap());
192 }
193
194 #[test]
195 fn two_hard_links_are_one_container() {
196 // The case a canonicalised path cannot see, and the reason concept 8
197 // keys on identity. Both names resolve to themselves and differ, and
198 // both are the same file.
199 //
200 // Not `cfg(unix)` since Phase 4. This is the test the Windows arm was
201 // written for, and gating it there would have left the arm asserting
202 // nothing on the platform it was added for.
203 let tmp = tempfile::tempdir().unwrap();
204 let a = tmp.path().join("a.slpc");
205 let b = tmp.path().join("b.slpc");
206 fs::write(&a, b"x").unwrap();
207 fs::hard_link(&a, &b).unwrap();
208
209 assert_ne!(fs::canonicalize(&a).unwrap(), fs::canonicalize(&b).unwrap());
210 assert_eq!(of(&a).unwrap(), of(&b).unwrap());
211 }
212
213 #[test]
214 fn a_file_that_is_not_there_has_no_identity() {
215 let tmp = tempfile::tempdir().unwrap();
216 assert!(of(&tmp.path().join("gone.slpc")).is_err());
217 }
218
219 #[test]
220 fn identity_survives_the_file_being_rewritten_in_place() {
221 // Write-back replaces the container by renaming over it, which gives it
222 // a new inode. The table is keyed while a session is open, so what
223 // matters is that the same path still resolves to whatever is there —
224 // and that a stale key is a miss rather than a wrong hit.
225 let tmp = tempfile::tempdir().unwrap();
226 let p = tmp.path().join("report.slpc");
227 fs::write(&p, b"first").unwrap();
228 let before = of(&p).unwrap();
229
230 let scratch = tmp.path().join("scratch");
231 fs::write(&scratch, b"second").unwrap();
232 fs::rename(&scratch, &p).unwrap();
233 let after = of(&p).unwrap();
234
235 assert_ne!(before, after, "a replaced file is a different file");
236 assert_eq!(after, of(&p).unwrap());
237 }
238}