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 pub(crate) fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
249 std::process::Command::new("cmd")
250 .args([
251 "/c",
252 "mklink",
253 "/J",
254 &to.display().to_string(),
255 &from.display().to_string(),
256 ])
257 .output()
258 .is_ok_and(|out| out.status.success())
259 }
260
261 #[cfg(not(windows))]
262 pub(crate) fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
263 std::os::unix::fs::symlink(from, to).is_ok()
264 }
265
266 pub(crate) fn place_hard_link(from: &Path, to: &Path) -> bool {
267 std::fs::hard_link(from, to).is_ok()
268 }
269
270 #[test]
271 fn a_plain_name_clears_and_so_does_one_that_is_not_there_yet() {
272 let tmp = tempfile::tempdir().unwrap();
273 // Resolved first: `at` walks to the filesystem root, and on macOS
274 // every temporary directory sits below `/var`, which is a link.
275 let root = tmp.path().canonicalize().unwrap();
276 let plain = root.join("work").join("a.txt");
277 std::fs::create_dir_all(plain.parent().unwrap()).unwrap();
278 std::fs::write(&plain, b"one").unwrap();
279 WriteTarget::at("write a file", &plain).unwrap();
280 WriteTarget::at("write a file", &root.join("work").join("new.txt")).unwrap();
281 }
282
283 /// The hard-link arm is judged by the link count on every platform:
284 /// both names of a file with two are hard links, and a file that is
285 /// only read-only, or held open by another handle that shares
286 /// nothing, is not an alias - the count is read without asking for
287 /// any access that could collide with either.
288 #[test]
289 #[allow(
290 clippy::permissions_set_readonly_false,
291 reason = "the temporary directory cannot be removed around a read-only file on Windows"
292 )]
293 fn a_second_name_is_a_hard_link_and_nothing_else_is() {
294 let tmp = tempfile::tempdir().unwrap();
295 let root = tmp.path();
296 let first = root.join("first.txt");
297 let second = root.join("second.txt");
298 std::fs::write(&first, b"one inode").unwrap();
299 std::fs::hard_link(&first, &second).unwrap();
300 let read_only = root.join("read-only.txt");
301 std::fs::write(&read_only, b"kept").unwrap();
302 let mut bits = std::fs::metadata(&read_only).unwrap().permissions();
303 bits.set_readonly(true);
304 std::fs::set_permissions(&read_only, bits.clone()).unwrap();
305 let held = root.join("held.txt");
306 std::fs::write(&held, b"held").unwrap();
307 let mut open = std::fs::OpenOptions::new();
308 open.read(true).write(true);
309 #[cfg(windows)]
310 std::os::windows::fs::OpenOptionsExt::share_mode(&mut open, 0);
311 let holder = open.open(&held).unwrap();
312
313 let judged = [&first, &second, &read_only, &held].map(|path| kind_at(path).unwrap());
314 drop(holder);
315 bits.set_readonly(false);
316 std::fs::set_permissions(&read_only, bits).unwrap();
317 assert_eq!(
318 judged,
319 [
320 Some(AliasKind::HardLink),
321 Some(AliasKind::HardLink),
322 None,
323 None
324 ]
325 );
326 }
327
328 /// The link arm of the alias family: at the name or above it, a
329 /// junction or symlink refuses the write whole.
330 #[test]
331 fn a_link_at_the_name_or_above_it_is_refused() {
332 let tmp = tempfile::tempdir().unwrap();
333 let hooks = tmp.path().join(".git").join("hooks");
334 std::fs::create_dir_all(&hooks).unwrap();
335 let hook = hooks.join("pre-run");
336 std::fs::write(&hook, b"hook-body").unwrap();
337 std::fs::create_dir_all(tmp.path().join("work")).unwrap();
338
339 // At the name: the name itself leads into `.git`.
340 let at_name = tmp.path().join("work").join("a-dir");
341 if place_link(true, &hooks, &at_name) {
342 let err = WriteTarget::at("write a file", &at_name).unwrap_err();
343 assert!(
344 matches!(
345 err,
346 StorageError::Alias {
347 kind: AliasKind::Link,
348 ..
349 }
350 ),
351 "{err}"
352 );
353 }
354 // Above the name: the directory the write lands in leads there.
355 let above = tmp.path().join("work").join("alias-dir");
356 if place_link(true, &hooks, &above) {
357 let err = WriteTarget::at("write a file", &above.join("pre-run")).unwrap_err();
358 assert!(
359 matches!(
360 err,
361 StorageError::Alias {
362 kind: AliasKind::Link,
363 ..
364 }
365 ),
366 "{err}"
367 );
368 }
369 assert_eq!(std::fs::read(&hook).unwrap(), b"hook-body");
370 }
371
372 proptest::proptest! {
373 #![proptest_config(proptest::prelude::ProptestConfig { cases: 32, ..proptest::prelude::ProptestConfig::default() })]
374 /// 别名永不落盘: whatever alias this machine can make - a hard
375 /// link at the name, a link at the name, a link above the name
376 /// - aimed at protected or plain metadata, not one byte lands
377 /// through it. A machine that grants no link privilege changes
378 /// nothing either, which is the same property from the other
379 /// side.
380 #[test]
381 fn an_alias_never_lands(
382 kind in 0..3u8,
383 protected in proptest::bool::ANY,
384 ) {
385 let tmp = tempfile::tempdir().unwrap();
386 let root = tmp.path();
387 let (kept, kept_dir) = if protected {
388 let hooks = root.join(".git").join("hooks");
389 std::fs::create_dir_all(&hooks).unwrap();
390 (hooks.join("kept.txt"), hooks)
391 } else {
392 let plain = root.join("plain");
393 std::fs::create_dir_all(&plain).unwrap();
394 (plain.join("kept.txt"), plain)
395 };
396 std::fs::write(&kept, b"kept").unwrap();
397 let zone = root.join("work");
398 std::fs::create_dir_all(&zone).unwrap();
399
400 // 0: a hard link at the name; 1: a link at the name;
401 // 2: a link above the name.
402 let (target, placed) = match kind {
403 0 => {
404 let at = zone.join("out.txt");
405 let placed = place_hard_link(&kept, &at);
406 (at, placed)
407 }
408 1 => {
409 let at = zone.join("out.txt");
410 let placed = place_link(true, &kept_dir, &at);
411 (at, placed)
412 }
413 _ => {
414 let above = zone.join("alias-dir");
415 let placed = place_link(true, &kept_dir, &above);
416 (above.join(kept.file_name().unwrap()), placed)
417 }
418 };
419
420 if placed {
421 let mut vfs = RealFs::new();
422 match WriteTarget::at("write a bundle file", &target) {
423 Ok(cleared) => {
424 // Every alias above is refused; a name that
425 // cleared lands in a fresh entry all the same.
426 land(&mut vfs, cleared, b"landed", Bits::OfReplaced).unwrap();
427 }
428 Err(StorageError::Alias { .. }) => {}
429 Err(other) => panic!("{other}"),
430 }
431 }
432 proptest::prop_assert_eq!(std::fs::read(&kept).unwrap(), b"kept".to_vec());
433 }
434 }
435
436 /// Eight creates of one name, released together: the filesystem
437 /// claims the name for one of them, every other meets the name
438 /// taken, and the bytes on the disk are the winner's.
439 #[test]
440 fn two_racing_creates_admit_one() {
441 let tmp = tempfile::tempdir().unwrap();
442 let root = tmp.path().to_path_buf();
443 let name = root.join("draft.md");
444 let start = std::sync::Arc::new(std::sync::Barrier::new(8));
445 let racers: Vec<_> = (0u8..8)
446 .map(|racer| {
447 let (root, name, start) = (root.clone(), name.clone(), start.clone());
448 std::thread::spawn(move || {
449 let target = WriteTarget::within("create a file", &root, &name).unwrap();
450 start.wait();
451 target.create(&[b'0' + racer]).map(|()| racer)
452 })
453 })
454 .collect();
455 let answers: Vec<Result<u8, StorageError>> = racers
456 .into_iter()
457 .map(|racer| racer.join().unwrap())
458 .collect();
459 let won: Vec<u8> = answers
460 .iter()
461 .filter_map(|answer| answer.as_ref().ok().copied())
462 .collect();
463 assert_eq!(
464 won.len(),
465 1,
466 "exactly one create claims the name: {answers:?}"
467 );
468 assert!(
469 answers
470 .iter()
471 .filter(|answer| answer.is_err())
472 .all(|answer| matches!(answer, Err(StorageError::NameTaken { .. }))),
473 "every other create meets the name taken: {answers:?}"
474 );
475 assert_eq!(std::fs::read(&name).unwrap(), vec![b'0' + won[0]]);
476 }
477}