Skip to main content

storage/
alias.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Aliases, and the write target that refuses them (`crates/storage/spec/Alias.lean` §8-25).
7//!
8//! A name in a working tree can point at a file other than itself: a
9//! symlink or a junction makes the path lead somewhere else, and a hard
10//! link gives one inode two names. A write through such a name lands
11//! where the name does not say - and a name inside a write domain that
12//! points at `.git` is privilege escalation past every address-level
13//! check, because the address the gate judged is not the file the disk
14//! opens.
15//!
16//! The rule this module owns: **the alias family is refused whole** -
17//! junction, symlink and hard link alike, literally, on every platform.
18//! The alternative, skipping an alias and counting it, lands part of a
19//! write and breaks the all-or-nothing contract the restore face is
20//! built on.
21
22use std::path::{Path, PathBuf};
23
24use crate::error::StorageError;
25
26/// The aliases one name can be. On Windows a junction and a symbolic
27/// link are both reparse points and `file_type().is_symlink()` answers
28/// true for either, which is why they share a variant: refusing the
29/// family is one rule, not one rule per reparse tag. The third member
30/// of the family - the hard link - is a regular file whose link count is
31/// above one (`crates/storage/spec/Alias.lean` §8-25, `crates/storage/Spec.lean` §3).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AliasKind {
34    /// A symbolic link or a directory junction: the name leads
35    /// somewhere else entirely.
36    Link,
37    /// A file with more than one name: one write lands in every name at
38    /// once.
39    HardLink,
40}
41
42impl std::fmt::Display for AliasKind {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            AliasKind::Link => f.write_str("link (symlink or junction)"),
46            AliasKind::HardLink => f.write_str("hard link"),
47        }
48    }
49}
50
51/// What one name is, right now: absent, plain, or an alias.
52///
53/// Absent is an answer, not a failure - a write target that is not
54/// there yet is the ordinary way a file gets created. The question is
55/// asked with `symlink_metadata`, so a dangling link answers `Link`
56/// rather than disappearing. A hard link is one inode under two names,
57/// and a write at one name changes the other, so a regular file with a
58/// link count above one is one.
59pub(crate) fn kind_at(path: &Path) -> Result<Option<AliasKind>, StorageError> {
60    let meta = match std::fs::symlink_metadata(path) {
61        Ok(meta) => meta,
62        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
63        Err(source) => {
64            return Err(StorageError::Io {
65                op: "inspect a write target",
66                path: path.to_path_buf(),
67                source,
68            });
69        }
70    };
71    if meta.file_type().is_symlink() {
72        return Ok(Some(AliasKind::Link));
73    }
74    // Only a regular file counts: a directory's link count measures its
75    // subdirectories, not aliasing.
76    if !meta.is_file() {
77        return Ok(None);
78    }
79    Ok(match links_of(path, &meta)? {
80        Some(links) if links > 1 => Some(AliasKind::HardLink),
81        Some(_) | None => None,
82    })
83}
84
85/// How many names the regular file at `path` has; `None` when it is gone
86/// since `meta` was read.
87#[cfg(unix)]
88fn links_of(_path: &Path, meta: &std::fs::Metadata) -> Result<Option<u64>, StorageError> {
89    use std::os::unix::fs::MetadataExt;
90    Ok(Some(meta.nlink()))
91}
92
93/// How many names the regular file at `path` has, read through a handle,
94/// because std reports the count on Windows only on the unstable
95/// `windows_by_handle` feature. The handle asks for no data access, so it
96/// collides with no other handle's share mode - a file another process
97/// holds exclusively is counted like any other - and it does not follow
98/// a reparse point, although a link never reaches here. `None` when the
99/// file is gone since `_meta` was read.
100#[cfg(windows)]
101fn links_of(path: &Path, _meta: &std::fs::Metadata) -> Result<Option<u64>, StorageError> {
102    use std::os::windows::fs::OpenOptionsExt;
103    /// `FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, as the
104    /// Win32 headers spell them.
105    const NO_FOLLOW: u32 = 0x0020_0000 | 0x0200_0000;
106    let counted = std::fs::OpenOptions::new()
107        .access_mode(0)
108        .custom_flags(NO_FOLLOW)
109        .open(path)
110        .and_then(|file| winapi_util::file::information(&file));
111    match counted {
112        Ok(info) => Ok(Some(info.number_of_links())),
113        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
114        Err(source) => Err(StorageError::Io {
115            op: "count the names of a write target",
116            path: path.to_path_buf(),
117            source,
118        }),
119    }
120}
121
122/// The refusal every face greets an alias with.
123pub(crate) fn refused(op: &'static str, path: &Path, kind: AliasKind) -> StorageError {
124    StorageError::Alias {
125        op,
126        path: path.to_path_buf(),
127        kind,
128    }
129}
130
131/// A write target the city has cleared: no alias at the name or at any
132/// directory above it, so a write here lands exactly where the name
133/// says.
134///
135/// The invariant lives at the one constructor and the field is private,
136/// so an unchecked path cannot be spelled - every write face takes this
137/// value and nothing else (trybuild `tests/ui/write_target.rs`). What
138/// the value proves is that the name held no alias at the moment
139/// `at` asked; the window between that question and the write is the
140/// filesystem's, which is why the walking faces ask the same rule at
141/// their own sampling point.
142pub struct WriteTarget(PathBuf);
143
144/// Names the path it cleared and nothing else: there is no hidden
145/// state to show and a write target's whole story is where it lands.
146impl std::fmt::Debug for WriteTarget {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_tuple("WriteTarget").field(&self.0).finish()
149    }
150}
151
152impl WriteTarget {
153    /// The one constructor: `path` and every directory above it are
154    /// examined, and any alias refuses the write whole.
155    ///
156    /// # Errors
157    /// `StorageError::Alias` naming the alias and its kind;
158    /// `StorageError::Io` when a component exists and cannot be examined.
159    pub fn at(op: &'static str, path: &Path) -> Result<WriteTarget, StorageError> {
160        for parent in path.ancestors().skip(1) {
161            if let Some(kind) = kind_at(parent)? {
162                return Err(refused(op, parent, kind));
163            }
164        }
165        if let Some(kind) = kind_at(path)? {
166            return Err(refused(op, path, kind));
167        }
168        Ok(WriteTarget(path.to_path_buf()))
169    }
170
171    /// Clears `path` for a write inside `root`, the directory the person
172    /// chose: `path` and every directory between it and `root` are
173    /// examined, and `root` and everything above it are not, because
174    /// where a person keeps a city is their placement rather than a
175    /// write a run could redirect (`crates/storage/spec/Bundle.lean` §8-12). A `path` outside
176    /// `root` is examined up to the filesystem root, as [`WriteTarget::at`]
177    /// does.
178    ///
179    /// The bound is found by comparing path components as spelled, so the
180    /// caller builds `path` by joining onto the same `root` it passes; a
181    /// `root` in another spelling (a trailing separator, a `.` component,
182    /// other case on Windows) is not found, and the walk falls back to the
183    /// filesystem root, which is safe but loses the bound.
184    ///
185    /// # Errors
186    /// As [`WriteTarget::at`], for an alias at or below the bound.
187    pub fn within(op: &'static str, root: &Path, path: &Path) -> Result<WriteTarget, StorageError> {
188        for at in path.ancestors().take_while(|at| *at != root) {
189            if let Some(kind) = kind_at(at)? {
190                return Err(refused(op, at, kind));
191            }
192        }
193        Ok(WriteTarget(path.to_path_buf()))
194    }
195
196    #[must_use]
197    pub fn as_path(&self) -> &Path {
198        &self.0
199    }
200
201    /// Replaces the file at this name with `bytes`: a staging file
202    /// beside it, flushed, given the replaced file's permissions and
203    /// renamed over the name (`crates/storage/Spec.lean` §8-12, §8-32). A reader finds
204    /// the old file or the whole new one.
205    ///
206    /// # Errors
207    /// `StorageError::Io` for a write, flush, permission copy or rename
208    /// the filesystem refuses; the name keeps its previous content.
209    pub fn replace(self, bytes: &[u8]) -> Result<(), StorageError> {
210        crate::bundle::landing::land(
211            &mut crate::real_fs::RealFs::new(),
212            self,
213            bytes,
214            crate::bundle::landing::Bits::OfReplaced,
215        )
216    }
217
218    /// Creates the file at this name only when nothing stands there:
219    /// the name is claimed by the filesystem at the moment of the write,
220    /// so of two racing creates one succeeds (`crates/storage/spec/Alias.lean` §8-32).
221    ///
222    /// # Errors
223    /// `StorageError::NameTaken` when anything stands at the name, with
224    /// nothing written; `StorageError::Io` for a create, write or flush
225    /// the filesystem refuses.
226    pub fn create(self, bytes: &[u8]) -> Result<(), StorageError> {
227        crate::bundle::landing::create(self, bytes)
228    }
229}
230
231#[cfg(test)]
232#[allow(
233    clippy::unwrap_used,
234    clippy::expect_used,
235    clippy::panic,
236    clippy::indexing_slicing,
237    reason = "test code"
238)]
239pub(crate) mod tests {
240    use super::*;
241    use crate::bundle::landing::{Bits, land};
242    use crate::real_fs::RealFs;
243
244    /// Places a junction at `to` leading to `from` (a symlink off
245    /// Windows). `false` when this machine hands out no link at all -
246    /// in that arm nothing on the disk has changed.
247    #[cfg(windows)]
248    #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
249    pub(crate) fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
250        std::process::Command::new("cmd")
251            .args([
252                "/c",
253                "mklink",
254                "/J",
255                &to.display().to_string(),
256                &from.display().to_string(),
257            ])
258            .output()
259            .is_ok_and(|out| out.status.success())
260    }
261
262    #[cfg(not(windows))]
263    pub(crate) fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
264        std::os::unix::fs::symlink(from, to).is_ok()
265    }
266
267    pub(crate) fn place_hard_link(from: &Path, to: &Path) -> bool {
268        std::fs::hard_link(from, to).is_ok()
269    }
270
271    #[test]
272    fn a_plain_name_clears_and_so_does_one_that_is_not_there_yet() {
273        let tmp = tempfile::tempdir().unwrap();
274        // Resolved first: `at` walks to the filesystem root, and on macOS
275        // every temporary directory sits below `/var`, which is a link.
276        let root = tmp.path().canonicalize().unwrap();
277        let plain = root.join("work").join("a.txt");
278        std::fs::create_dir_all(plain.parent().unwrap()).unwrap();
279        std::fs::write(&plain, b"one").unwrap();
280        WriteTarget::at("write a file", &plain).unwrap();
281        WriteTarget::at("write a file", &root.join("work").join("new.txt")).unwrap();
282    }
283
284    /// The hard-link arm is judged by the link count on every platform:
285    /// both names of a file with two are hard links, and a file that is
286    /// only read-only, or held open by another handle that shares
287    /// nothing, is not an alias - the count is read without asking for
288    /// any access that could collide with either.
289    #[test]
290    #[allow(
291        clippy::permissions_set_readonly_false,
292        reason = "the temporary directory cannot be removed around a read-only file on Windows"
293    )]
294    fn a_second_name_is_a_hard_link_and_nothing_else_is() {
295        let tmp = tempfile::tempdir().unwrap();
296        let root = tmp.path();
297        let first = root.join("first.txt");
298        let second = root.join("second.txt");
299        std::fs::write(&first, b"one inode").unwrap();
300        std::fs::hard_link(&first, &second).unwrap();
301        let read_only = root.join("read-only.txt");
302        std::fs::write(&read_only, b"kept").unwrap();
303        let mut bits = std::fs::metadata(&read_only).unwrap().permissions();
304        bits.set_readonly(true);
305        std::fs::set_permissions(&read_only, bits.clone()).unwrap();
306        let held = root.join("held.txt");
307        std::fs::write(&held, b"held").unwrap();
308        let mut open = std::fs::OpenOptions::new();
309        open.read(true).write(true);
310        #[cfg(windows)]
311        std::os::windows::fs::OpenOptionsExt::share_mode(&mut open, 0);
312        let holder = open.open(&held).unwrap();
313
314        let judged = [&first, &second, &read_only, &held].map(|path| kind_at(path).unwrap());
315        drop(holder);
316        bits.set_readonly(false);
317        std::fs::set_permissions(&read_only, bits).unwrap();
318        assert_eq!(
319            judged,
320            [
321                Some(AliasKind::HardLink),
322                Some(AliasKind::HardLink),
323                None,
324                None
325            ]
326        );
327    }
328
329    /// The link arm of the alias family: at the name or above it, a
330    /// junction or symlink refuses the write whole.
331    #[test]
332    fn a_link_at_the_name_or_above_it_is_refused() {
333        let tmp = tempfile::tempdir().unwrap();
334        let hooks = tmp.path().join(".git").join("hooks");
335        std::fs::create_dir_all(&hooks).unwrap();
336        let hook = hooks.join("pre-run");
337        std::fs::write(&hook, b"hook-body").unwrap();
338        std::fs::create_dir_all(tmp.path().join("work")).unwrap();
339
340        // At the name: the name itself leads into `.git`.
341        let at_name = tmp.path().join("work").join("a-dir");
342        if place_link(true, &hooks, &at_name) {
343            let err = WriteTarget::at("write a file", &at_name).unwrap_err();
344            assert!(
345                matches!(
346                    err,
347                    StorageError::Alias {
348                        kind: AliasKind::Link,
349                        ..
350                    }
351                ),
352                "{err}"
353            );
354        }
355        // Above the name: the directory the write lands in leads there.
356        let above = tmp.path().join("work").join("alias-dir");
357        if place_link(true, &hooks, &above) {
358            let err = WriteTarget::at("write a file", &above.join("pre-run")).unwrap_err();
359            assert!(
360                matches!(
361                    err,
362                    StorageError::Alias {
363                        kind: AliasKind::Link,
364                        ..
365                    }
366                ),
367                "{err}"
368            );
369        }
370        assert_eq!(std::fs::read(&hook).unwrap(), b"hook-body");
371    }
372
373    proptest::proptest! {
374        #![proptest_config(proptest::prelude::ProptestConfig { cases: 32, ..proptest::prelude::ProptestConfig::default() })]
375        /// 别名永不落盘: whatever alias this machine can make - a hard
376        /// link at the name, a link at the name, a link above the name
377        /// - aimed at protected or plain metadata, not one byte lands
378        /// through it. A machine that grants no link privilege changes
379        /// nothing either, which is the same property from the other
380        /// side.
381        #[test]
382        fn an_alias_never_lands(
383            kind in 0..3u8,
384            protected in proptest::bool::ANY,
385        ) {
386            let tmp = tempfile::tempdir().unwrap();
387            let root = tmp.path();
388            let (kept, kept_dir) = if protected {
389                let hooks = root.join(".git").join("hooks");
390                std::fs::create_dir_all(&hooks).unwrap();
391                (hooks.join("kept.txt"), hooks)
392            } else {
393                let plain = root.join("plain");
394                std::fs::create_dir_all(&plain).unwrap();
395                (plain.join("kept.txt"), plain)
396            };
397            std::fs::write(&kept, b"kept").unwrap();
398            let zone = root.join("work");
399            std::fs::create_dir_all(&zone).unwrap();
400
401            // 0: a hard link at the name; 1: a link at the name;
402            // 2: a link above the name.
403            let (target, placed) = match kind {
404                0 => {
405                    let at = zone.join("out.txt");
406                    let placed = place_hard_link(&kept, &at);
407                    (at, placed)
408                }
409                1 => {
410                    let at = zone.join("out.txt");
411                    let placed = place_link(true, &kept_dir, &at);
412                    (at, placed)
413                }
414                _ => {
415                    let above = zone.join("alias-dir");
416                    let placed = place_link(true, &kept_dir, &above);
417                    (above.join(kept.file_name().unwrap()), placed)
418                }
419            };
420
421            if placed {
422                let mut vfs = RealFs::new();
423                match WriteTarget::at("write a bundle file", &target) {
424                    Ok(cleared) => {
425                        // Every alias above is refused; a name that
426                        // cleared lands in a fresh entry all the same.
427                        land(&mut vfs, cleared, b"landed", Bits::OfReplaced).unwrap();
428                    }
429                    Err(StorageError::Alias { .. }) => {}
430                    Err(other) => panic!("{other}"),
431                }
432            }
433            proptest::prop_assert_eq!(std::fs::read(&kept).unwrap(), b"kept".to_vec());
434        }
435    }
436
437    /// Eight creates of one name, released together: the filesystem
438    /// claims the name for one of them, every other meets the name
439    /// taken, and the bytes on the disk are the winner's.
440    #[test]
441    fn two_racing_creates_admit_one() {
442        let tmp = tempfile::tempdir().unwrap();
443        let root = tmp.path().to_path_buf();
444        let name = root.join("draft.md");
445        let start = std::sync::Arc::new(std::sync::Barrier::new(8));
446        let racers: Vec<_> = (0u8..8)
447            .map(|racer| {
448                let (root, name, start) = (root.clone(), name.clone(), start.clone());
449                std::thread::spawn(move || {
450                    let target = WriteTarget::within("create a file", &root, &name).unwrap();
451                    start.wait();
452                    target.create(&[b'0' + racer]).map(|()| racer)
453                })
454            })
455            .collect();
456        let answers: Vec<Result<u8, StorageError>> = racers
457            .into_iter()
458            .map(|racer| racer.join().unwrap())
459            .collect();
460        let won: Vec<u8> = answers
461            .iter()
462            .filter_map(|answer| answer.as_ref().ok().copied())
463            .collect();
464        assert_eq!(
465            won.len(),
466            1,
467            "exactly one create claims the name: {answers:?}"
468        );
469        assert!(
470            answers
471                .iter()
472                .filter(|answer| answer.is_err())
473                .all(|answer| matches!(answer, Err(StorageError::NameTaken { .. }))),
474            "every other create meets the name taken: {answers:?}"
475        );
476        assert_eq!(std::fs::read(&name).unwrap(), vec![b'0' + won[0]]);
477    }
478}