Skip to main content

heddle_fs_prims/
nofollow.rs

1// SPDX-License-Identifier: Apache-2.0
2//! No-follow worktree writes (heddle#2017).
3//!
4//! Tracked symlinks are checked out exactly as Git checks them out, whatever
5//! their target: absolute, `../`-escaping, or dangling. Creating a symlink
6//! writes nothing outside the checkout.
7//!
8//! The hazard is *other* writes that traverse one. A malicious repository can
9//! track `link -> /home/user/.ssh` in one state and `link/authorized_keys` in a
10//! later one, or a user can leave an untracked or ignored symlink where a
11//! later state tracks a directory. A writer that opens `root/link/file` with
12//! ordinary path syscalls follows `link` and writes outside the checkout.
13//!
14//! The rule these helpers enforce, matching Git's checkout: **no worktree
15//! write or removal traverses a symlink below the checkout root.**
16//!
17//! * Directories are created one component at a time with `lstat` + `mkdir`.
18//!   A symlink where a directory belongs is unlinked (which never touches its
19//!   target) and replaced by a real directory, as `git checkout` does for
20//!   tracked and ignored paths.
21//! * Leaf files are created with `O_CREAT | O_EXCL | O_NOFOLLOW` after the old
22//!   leaf is unlinked, so a symlink at the leaf is replaced, not written
23//!   through.
24//! * Removals skip a path whose parent is a symlink or missing, as Git's
25//!   `has_symlink_or_noent_leading_path` does. The tracked path is not in the
26//!   worktree, and whatever the symlink points at is not ours to delete.
27//!
28//! The checkout root itself is trusted. It may legitimately sit beneath
29//! symlinked ancestors (`/var -> /private/var` on macOS, a symlinked home).
30//!
31//! These checks defend against repository *content*. They do not defend
32//! against a concurrent local process that swaps directories for symlinks
33//! between the check and the write; such a process can already write the
34//! worktree directly.
35
36use std::{
37    collections::HashSet,
38    ffi::OsStr,
39    fs::{self, File},
40    io::{self, Write},
41    path::{Component, Path, PathBuf},
42};
43
44use crate::fs_atomic::enrich_fs_error;
45
46/// The plain-name components of `path` beneath `root`. Tree entry names never
47/// contain `..` or a root, and a path built from one that did must not be
48/// written.
49fn components_beneath<'a>(root: &Path, path: &'a Path) -> io::Result<Vec<&'a OsStr>> {
50    let relative = path.strip_prefix(root).map_err(|_| {
51        io::Error::new(
52            io::ErrorKind::InvalidInput,
53            format!(
54                "worktree path {} is outside checkout {}",
55                path.display(),
56                root.display()
57            ),
58        )
59    })?;
60    let mut names = Vec::new();
61    for component in relative.components() {
62        match component {
63            Component::Normal(name) => names.push(name),
64            Component::CurDir => {}
65            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
66                return Err(io::Error::new(
67                    io::ErrorKind::InvalidInput,
68                    format!(
69                        "worktree path {} escapes checkout {}",
70                        path.display(),
71                        root.display()
72                    ),
73                ));
74            }
75        }
76    }
77    Ok(names)
78}
79
80/// Creates and verifies directories beneath a checkout root without ever
81/// traversing a symlink.
82///
83/// Verified directories are cached, so one instance should cover one batch of
84/// writes in which nothing turns a verified directory back into a symlink. A
85/// batch that also writes symlinks must write them after its other leaves and
86/// recheck with [`refuse_symlinked_parent`].
87pub struct NoFollowDirectories<'a> {
88    root: &'a Path,
89    verified: HashSet<PathBuf>,
90}
91
92impl<'a> NoFollowDirectories<'a> {
93    pub fn new(root: &'a Path) -> Self {
94        Self {
95            root,
96            verified: HashSet::new(),
97        }
98    }
99
100    /// Make `dir` and every directory between it and the root real
101    /// directories. Missing ones are created, a symlink in the way is replaced
102    /// by a directory, and any other non-directory is an error.
103    pub fn ensure(&mut self, dir: &Path) -> io::Result<()> {
104        let mut current = self.root.to_path_buf();
105        for name in components_beneath(self.root, dir)? {
106            current.push(name);
107            if self.verified.contains(&current) {
108                continue;
109            }
110            ensure_real_directory(&current)?;
111            self.verified.insert(current.clone());
112        }
113        Ok(())
114    }
115
116    /// [`Self::ensure`] the directory that will hold the leaf `path`.
117    pub fn ensure_parent(&mut self, path: &Path) -> io::Result<()> {
118        let names = components_beneath(self.root, path)?;
119        if names.len() <= 1 {
120            return Ok(());
121        }
122        match path.parent() {
123            Some(parent) => self.ensure(parent),
124            None => Ok(()),
125        }
126    }
127
128    /// Whether every directory between the root and the leaf `path` is a real
129    /// directory. Creates nothing. `false` when one is a symlink, some other
130    /// non-directory, or missing.
131    pub fn parent_is_real_directory(&mut self, path: &Path) -> io::Result<bool> {
132        let names = components_beneath(self.root, path)?;
133        let Some((_, parents)) = names.split_last() else {
134            return Ok(true);
135        };
136        let mut current = self.root.to_path_buf();
137        for name in parents {
138            current.push(name);
139            if self.verified.contains(&current) {
140                continue;
141            }
142            match fs::symlink_metadata(&current) {
143                Ok(metadata) if metadata.is_dir() => {
144                    self.verified.insert(current.clone());
145                }
146                Ok(_) => return Ok(false),
147                Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
148                Err(error) => return Err(enrich_fs_error(&current, "inspecting", error)),
149            }
150        }
151        Ok(true)
152    }
153}
154
155/// Fail if any directory between `root` and the leaf `path` is a symlink.
156/// Uncached: run immediately before the write it guards.
157pub fn refuse_symlinked_parent(root: &Path, path: &Path) -> io::Result<()> {
158    let names = components_beneath(root, path)?;
159    let Some((_, parents)) = names.split_last() else {
160        return Ok(());
161    };
162    let mut current = root.to_path_buf();
163    for name in parents {
164        current.push(name);
165        let metadata = fs::symlink_metadata(&current)
166            .map_err(|error| enrich_fs_error(&current, "inspecting", error))?;
167        if metadata.file_type().is_symlink() {
168            return Err(io::Error::other(format!(
169                "refusing to write {} through symlink {}: worktree writes never follow symlinks",
170                path.strip_prefix(root).unwrap_or(path).display(),
171                current.strip_prefix(root).unwrap_or(&current).display(),
172            )));
173        }
174    }
175    Ok(())
176}
177
178fn ensure_real_directory(path: &Path) -> io::Result<()> {
179    match fs::symlink_metadata(path) {
180        Ok(metadata) if metadata.is_dir() => Ok(()),
181        Ok(metadata) if metadata.file_type().is_symlink() => {
182            // Unlinking a symlink removes the link, never its target. Windows
183            // directory symlinks need `remove_dir`.
184            fs::remove_file(path)
185                .or_else(|_| fs::remove_dir(path))
186                .map_err(|error| enrich_fs_error(path, "removing", error))?;
187            create_directory(path)
188        }
189        Ok(_) => Err(enrich_fs_error(
190            path,
191            "creating",
192            io::Error::new(
193                io::ErrorKind::AlreadyExists,
194                "a file occupies a path that must be a directory",
195            ),
196        )),
197        Err(error) if error.kind() == io::ErrorKind::NotFound => create_directory(path),
198        Err(error) => Err(enrich_fs_error(path, "inspecting", error)),
199    }
200}
201
202fn create_directory(path: &Path) -> io::Result<()> {
203    match fs::create_dir(path) {
204        Ok(()) => Ok(()),
205        // A concurrent creator won; accept it only if it made a real directory.
206        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
207            match fs::symlink_metadata(path) {
208                Ok(metadata) if metadata.is_dir() => Ok(()),
209                _ => Err(enrich_fs_error(path, "creating", error)),
210            }
211        }
212        Err(error) => Err(enrich_fs_error(path, "creating", error)),
213    }
214}
215
216/// Unlink a file or symlink at `path`. Leaves a directory in place, so the
217/// create that follows fails rather than writing into it.
218fn unlink_leaf(path: &Path) -> io::Result<()> {
219    match fs::symlink_metadata(path) {
220        Ok(metadata) if !metadata.is_dir() => fs::remove_file(path)
221            .or_else(|error| {
222                // A Windows directory symlink.
223                if metadata.file_type().is_symlink() {
224                    fs::remove_dir(path)
225                } else {
226                    Err(error)
227                }
228            })
229            .map_err(|error| enrich_fs_error(path, "removing", error)),
230        Ok(_) => Ok(()),
231        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
232        Err(error) => Err(enrich_fs_error(path, "inspecting", error)),
233    }
234}
235
236/// Create a new regular file at `path`, refusing to follow a symlink there.
237/// Fails if anything already occupies the name.
238pub fn create_new_nofollow(path: &Path) -> io::Result<File> {
239    let mut options = fs::OpenOptions::new();
240    options.write(true).create_new(true);
241    #[cfg(unix)]
242    {
243        use std::os::unix::fs::OpenOptionsExt;
244        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
245    }
246    options.open(path)
247}
248
249/// Open an existing file for metadata changes without following a symlink at
250/// `path`.
251pub fn open_existing_nofollow(path: &Path) -> io::Result<File> {
252    let mut options = fs::OpenOptions::new();
253    options.read(true);
254    #[cfg(unix)]
255    {
256        use std::os::unix::fs::OpenOptionsExt;
257        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
258    }
259    options.open(path)
260}
261
262/// Unlink any file or symlink at `path`, then create a fresh regular file
263/// there without following symlinks. The parent must already be verified.
264pub fn create_replacing_leaf(path: &Path) -> io::Result<File> {
265    unlink_leaf(path)?;
266    create_new_nofollow(path).map_err(|error| enrich_fs_error(path, "writing", error))
267}
268
269/// Write `bytes` to the tracked file `path` beneath `root`: parents become
270/// real directories, a leaf symlink is replaced, nothing is followed.
271pub fn write_file_beneath(root: &Path, path: &Path, bytes: &[u8]) -> io::Result<File> {
272    NoFollowDirectories::new(root).ensure_parent(path)?;
273    let mut file = create_replacing_leaf(path)?;
274    file.write_all(bytes)
275        .map_err(|error| enrich_fs_error(path, "writing", error))?;
276    Ok(file)
277}
278
279/// Create the symlink `path -> target` beneath `root`, replacing any file or
280/// symlink already there. Parents become real directories; nothing is
281/// followed.
282#[cfg(unix)]
283pub fn write_symlink_beneath(root: &Path, path: &Path, target: &OsStr) -> io::Result<()> {
284    NoFollowDirectories::new(root).ensure_parent(path)?;
285    unlink_leaf(path)?;
286    std::os::unix::fs::symlink(target, path)
287        .map_err(|error| enrich_fs_error(path, "creating symlink", error))
288}
289
290/// Remove the tracked leaf `path` beneath `root` (a file or a symlink, never
291/// its target). Returns `false` without touching anything when a parent is a
292/// symlink or missing, matching Git: the path is not in the worktree.
293pub fn remove_leaf_beneath(root: &Path, path: &Path) -> io::Result<bool> {
294    if !NoFollowDirectories::new(root).parent_is_real_directory(path)? {
295        return Ok(false);
296    }
297    match fs::symlink_metadata(path) {
298        Ok(metadata) if metadata.is_dir() => Ok(false),
299        Ok(_) => {
300            unlink_leaf(path)?;
301            Ok(true)
302        }
303        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
304        Err(error) => Err(enrich_fs_error(path, "inspecting", error)),
305    }
306}
307
308/// Recursively remove `path` beneath `root` without following symlinks, in
309/// the path itself or in any parent. Skips (returns `false`) when a parent is
310/// a symlink or missing.
311pub fn remove_path_beneath(root: &Path, path: &Path) -> io::Result<bool> {
312    if !NoFollowDirectories::new(root).parent_is_real_directory(path)? {
313        return Ok(false);
314    }
315    match fs::symlink_metadata(path) {
316        Ok(_) => {
317            crate::fs_ops::remove_path_recursively(path)?;
318            Ok(true)
319        }
320        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
321        Err(error) => Err(enrich_fs_error(path, "inspecting", error)),
322    }
323}
324
325/// Upper bound on an in-tree control file such as `.heddleignore` or
326/// `.gitignore`. Real ignore files are a few KiB; anything larger is refused
327/// rather than read into memory.
328pub const MAX_IN_TREE_CONTROL_FILE_BYTES: u64 = 1 << 20;
329
330/// What reading an in-tree control file found.
331#[derive(Debug, PartialEq, Eq)]
332pub enum InTreeControlFile {
333    Absent,
334    Contents(Vec<u8>),
335    /// A symlink, which is never followed. Git ≥ 2.32 refuses to follow
336    /// in-tree `.gitignore` symlinks for the same reason: the target may be
337    /// any file on the machine, or a device such as `/dev/zero`.
338    Symlink,
339    /// A FIFO, device, socket or directory. Reading one could block forever
340    /// or never end.
341    NotRegular,
342    TooLarge(u64),
343}
344
345/// Read a control file the repository itself supplies (`.heddleignore`,
346/// `.gitignore`): only a regular file, opened without following a symlink,
347/// and only up to `max_bytes`.
348pub fn read_in_tree_control_file(path: &Path, max_bytes: u64) -> io::Result<InTreeControlFile> {
349    let metadata = match fs::symlink_metadata(path) {
350        Ok(metadata) => metadata,
351        Err(error) if error.kind() == io::ErrorKind::NotFound => {
352            return Ok(InTreeControlFile::Absent);
353        }
354        Err(error) => return Err(enrich_fs_error(path, "inspecting", error)),
355    };
356    if metadata.file_type().is_symlink() {
357        return Ok(InTreeControlFile::Symlink);
358    }
359    if !metadata.is_file() {
360        return Ok(InTreeControlFile::NotRegular);
361    }
362    if metadata.len() > max_bytes {
363        return Ok(InTreeControlFile::TooLarge(metadata.len()));
364    }
365    let mut options = fs::OpenOptions::new();
366    options.read(true);
367    #[cfg(unix)]
368    {
369        use std::os::unix::fs::OpenOptionsExt;
370        // O_NONBLOCK: if the name was swapped for a FIFO since the lstat, the
371        // open must not block waiting for a writer.
372        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC);
373    }
374    let file = match options.open(path) {
375        Ok(file) => file,
376        Err(error) if error.kind() == io::ErrorKind::NotFound => {
377            return Ok(InTreeControlFile::Absent);
378        }
379        Err(error) => return Err(enrich_fs_error(path, "reading", error)),
380    };
381    if !file.metadata()?.is_file() {
382        return Ok(InTreeControlFile::NotRegular);
383    }
384    let mut contents = Vec::new();
385    io::Read::read_to_end(&mut io::Read::take(file, max_bytes + 1), &mut contents)
386        .map_err(|error| enrich_fs_error(path, "reading", error))?;
387    if contents.len() as u64 > max_bytes {
388        return Ok(InTreeControlFile::TooLarge(contents.len() as u64));
389    }
390    Ok(InTreeControlFile::Contents(contents))
391}
392
393/// Prepare `path` beneath `root` for an ordinary read-modify-write that must
394/// not touch a user's symlinks: missing parent directories are created, but an
395/// existing symlinked parent, or a symlink or non-regular file at the leaf, is
396/// refused rather than followed or replaced. Used where the path is a
397/// convention inside the repository (`.claude/settings.json`) that a tracked
398/// symlink may legitimately redirect elsewhere.
399pub fn prepare_regular_file_beneath(root: &Path, path: &Path) -> io::Result<()> {
400    let names = components_beneath(root, path)?;
401    let Some((_, parents)) = names.split_last() else {
402        return Ok(());
403    };
404    let mut current = root.to_path_buf();
405    for name in parents {
406        current.push(name);
407        match fs::symlink_metadata(&current) {
408            Ok(metadata) if metadata.is_dir() => {}
409            Ok(metadata) if metadata.file_type().is_symlink() => {
410                return Err(io::Error::other(format!(
411                    "refusing to write {} through symlink {}",
412                    path.strip_prefix(root).unwrap_or(path).display(),
413                    current.strip_prefix(root).unwrap_or(&current).display(),
414                )));
415            }
416            Ok(_) => {
417                return Err(enrich_fs_error(
418                    &current,
419                    "creating",
420                    io::Error::new(
421                        io::ErrorKind::AlreadyExists,
422                        "a file occupies a path that must be a directory",
423                    ),
424                ));
425            }
426            Err(error) if error.kind() == io::ErrorKind::NotFound => create_directory(&current)?,
427            Err(error) => return Err(enrich_fs_error(&current, "inspecting", error)),
428        }
429    }
430    match fs::symlink_metadata(path) {
431        Ok(metadata) if metadata.is_file() => Ok(()),
432        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::other(format!(
433            "refusing to write {}: it is a symlink",
434            path.strip_prefix(root).unwrap_or(path).display(),
435        ))),
436        Ok(_) => Err(io::Error::other(format!(
437            "refusing to write {}: it is not a regular file",
438            path.strip_prefix(root).unwrap_or(path).display(),
439        ))),
440        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
441        Err(error) => Err(enrich_fs_error(path, "inspecting", error)),
442    }
443}
444
445#[cfg(all(test, unix))]
446mod tests {
447    use std::{fs, os::unix::fs::symlink};
448
449    use super::*;
450
451    #[test]
452    fn ensure_replaces_symlinked_parent_with_directory() {
453        let root = tempfile::tempdir().unwrap();
454        let outside = tempfile::tempdir().unwrap();
455        symlink(outside.path(), root.path().join("link")).unwrap();
456
457        write_file_beneath(root.path(), &root.path().join("link/sub/file"), b"x").unwrap();
458
459        assert!(
460            fs::symlink_metadata(root.path().join("link"))
461                .unwrap()
462                .is_dir()
463        );
464        assert_eq!(fs::read(root.path().join("link/sub/file")).unwrap(), b"x");
465        assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
466    }
467
468    #[test]
469    fn write_replaces_leaf_symlink_instead_of_following_it() {
470        let root = tempfile::tempdir().unwrap();
471        let outside = tempfile::tempdir().unwrap();
472        let victim = outside.path().join("victim");
473        fs::write(&victim, b"keep").unwrap();
474        symlink(&victim, root.path().join("leaf")).unwrap();
475
476        write_file_beneath(root.path(), &root.path().join("leaf"), b"new").unwrap();
477
478        assert_eq!(fs::read(&victim).unwrap(), b"keep");
479        assert!(
480            fs::symlink_metadata(root.path().join("leaf"))
481                .unwrap()
482                .is_file()
483        );
484    }
485
486    #[test]
487    fn removals_never_reach_through_a_symlinked_parent() {
488        let root = tempfile::tempdir().unwrap();
489        let outside = tempfile::tempdir().unwrap();
490        fs::write(outside.path().join("file"), b"keep").unwrap();
491        fs::create_dir(outside.path().join("dir")).unwrap();
492        symlink(outside.path(), root.path().join("link")).unwrap();
493
494        assert!(!remove_leaf_beneath(root.path(), &root.path().join("link/file")).unwrap());
495        assert!(!remove_path_beneath(root.path(), &root.path().join("link/dir")).unwrap());
496
497        assert_eq!(fs::read(outside.path().join("file")).unwrap(), b"keep");
498        assert!(outside.path().join("dir").is_dir());
499    }
500
501    #[test]
502    fn refuse_symlinked_parent_names_the_symlink() {
503        let root = tempfile::tempdir().unwrap();
504        let outside = tempfile::tempdir().unwrap();
505        symlink(outside.path(), root.path().join("link")).unwrap();
506
507        let error = refuse_symlinked_parent(root.path(), &root.path().join("link/leaf"))
508            .unwrap_err()
509            .to_string();
510
511        assert!(error.contains("through symlink link"), "{error}");
512    }
513
514    #[test]
515    fn paths_outside_or_escaping_the_root_are_rejected() {
516        let root = tempfile::tempdir().unwrap();
517        assert!(write_file_beneath(root.path(), Path::new("/elsewhere/file"), b"x").is_err());
518        assert!(write_file_beneath(root.path(), &root.path().join("../escape"), b"x").is_err());
519    }
520
521    #[test]
522    fn symlink_beneath_writes_arbitrary_targets_exactly() {
523        let root = tempfile::tempdir().unwrap();
524        for (name, target) in [("abs", "/usr/share/dict/words"), ("rel", "../../outside")] {
525            write_symlink_beneath(
526                root.path(),
527                &root.path().join("d").join(name),
528                target.as_ref(),
529            )
530            .unwrap();
531            assert_eq!(
532                fs::read_link(root.path().join("d").join(name)).unwrap(),
533                Path::new(target)
534            );
535        }
536    }
537}