Skip to main content

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}