Skip to main content

grit_lib/
tree_path_follow.rs

1//! Resolve tree paths with symlink following (`get_tree_entry_follow_symlinks`).
2//!
3//! Behaviour matches upstream Git (`tree-walk.c`).
4
5use std::collections::HashSet;
6
7use crate::error::Result;
8use crate::objects::{parse_tree, ObjectId, ObjectKind};
9use crate::odb::Odb;
10
11const MAX_SYMLINK_FOLLOWS: usize = 40;
12
13/// Result of resolving `tree_oid:path` with symlink following.
14#[derive(Debug, Clone)]
15pub enum FollowPathResult {
16    /// Found object inside the repository.
17    Found { oid: ObjectId, mode: u32 },
18    /// Symlink target is absolute (`/`); caller prints `symlink <len> <target>`.
19    OutOfRepo { path: Vec<u8> },
20}
21
22/// Failure modes reported as special `git cat-file --batch-check` lines.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FollowPathFailure {
25    Missing,
26    DanglingSymlink,
27    SymlinkLoop,
28    NotDir,
29}
30
31fn git_mode_is_dir(mode: u32) -> bool {
32    mode == 0o040000
33}
34
35fn git_mode_is_symlink(mode: u32) -> bool {
36    mode == 0o120000
37}
38
39fn git_mode_is_blob(mode: u32) -> bool {
40    (mode & 0o170000) == 0o100000
41}
42
43fn find_one_entry(tree_data: &[u8], name: &str) -> Result<Option<(ObjectId, u32)>> {
44    let entries = parse_tree(tree_data)?;
45    for e in entries {
46        if e.name == name.as_bytes() {
47            return Ok(Some((e.oid, e.mode)));
48        }
49    }
50    Ok(None)
51}
52
53/// Walk `tree_oid` following symlinks like `get_tree_entry_follow_symlinks`.
54pub fn get_tree_entry_follow_symlinks(
55    odb: &Odb,
56    tree_oid: &ObjectId,
57    path: &str,
58) -> Result<std::result::Result<FollowPathResult, FollowPathFailure>> {
59    let mut stack: Vec<ObjectId> = vec![*tree_oid];
60    let mut path_buf = path.to_string();
61    let mut follows = 0usize;
62    let mut followed_symlink_blobs: HashSet<ObjectId> = HashSet::new();
63    let mut symlink_just_followed = false;
64
65    loop {
66        let Some(&tree_oid) = stack.last() else {
67            return Ok(Err(FollowPathFailure::Missing));
68        };
69
70        while path_buf.starts_with('/') {
71            path_buf.remove(0);
72        }
73
74        if path_buf.is_empty() {
75            return Ok(Ok(FollowPathResult::Found {
76                oid: tree_oid,
77                mode: 0o040000,
78            }));
79        }
80
81        let (first, rest) = match path_buf.split_once('/') {
82            Some((a, b)) => (a.to_string(), Some(b.to_string())),
83            None => (path_buf.clone(), None),
84        };
85
86        if first == ".." {
87            if stack.len() <= 1 {
88                return Ok(Ok(FollowPathResult::OutOfRepo {
89                    path: path_buf.into_bytes(),
90                }));
91            }
92            stack.pop();
93            followed_symlink_blobs.clear();
94            // Do not clear `symlink_just_followed` here: Git keeps DANGLING_SYMLINK
95            // across `..` that came from symlink targets (tree-walk.c).
96            path_buf = rest.unwrap_or_default();
97            continue;
98        }
99
100        if first.is_empty() {
101            let Some(&oid) = stack.last() else {
102                return Ok(Err(FollowPathFailure::Missing));
103            };
104            return Ok(Ok(FollowPathResult::Found {
105                oid,
106                mode: 0o040000,
107            }));
108        }
109
110        let tree_obj = match odb.read(&tree_oid) {
111            Ok(o) => o,
112            Err(_) => return Ok(Err(FollowPathFailure::Missing)),
113        };
114        if tree_obj.kind != ObjectKind::Tree {
115            return Ok(Err(FollowPathFailure::Missing));
116        }
117
118        let found = match find_one_entry(&tree_obj.data, &first) {
119            Ok(x) => x,
120            Err(_) => return Ok(Err(FollowPathFailure::Missing)),
121        };
122
123        let Some((entry_oid, mode)) = found else {
124            if symlink_just_followed {
125                return Ok(Err(FollowPathFailure::DanglingSymlink));
126            }
127            return Ok(Err(FollowPathFailure::Missing));
128        };
129
130        if git_mode_is_dir(mode) {
131            if rest.is_none() {
132                return Ok(Ok(FollowPathResult::Found {
133                    oid: entry_oid,
134                    mode,
135                }));
136            }
137            stack.push(entry_oid);
138            path_buf = rest.unwrap();
139            continue;
140        }
141
142        if git_mode_is_blob(mode) {
143            if rest.is_none() {
144                return Ok(Ok(FollowPathResult::Found {
145                    oid: entry_oid,
146                    mode,
147                }));
148            }
149            return Ok(Err(FollowPathFailure::NotDir));
150        }
151
152        if git_mode_is_symlink(mode) {
153            if follows >= MAX_SYMLINK_FOLLOWS {
154                return Ok(Err(FollowPathFailure::SymlinkLoop));
155            }
156            if !followed_symlink_blobs.insert(entry_oid) {
157                return Ok(Err(FollowPathFailure::SymlinkLoop));
158            }
159            follows += 1;
160            // Match Git: default outcome after following a symlink is dangling until
161            // a full object is found (tree-walk.c sets retval = DANGLING_SYMLINK).
162            symlink_just_followed = true;
163
164            let obj = match odb.read(&entry_oid) {
165                Ok(o) => o,
166                Err(_) => return Ok(Err(FollowPathFailure::DanglingSymlink)),
167            };
168            if obj.kind != ObjectKind::Blob {
169                return Ok(Err(FollowPathFailure::DanglingSymlink));
170            }
171
172            if obj.data.first() == Some(&b'/') {
173                return Ok(Ok(FollowPathResult::OutOfRepo {
174                    path: obj.data.clone(),
175                }));
176            }
177
178            let mut new_path = String::from_utf8_lossy(&obj.data)
179                .trim_end_matches(['\n', '\r'])
180                .to_string();
181            if let Some(r) = rest {
182                new_path.push('/');
183                new_path.push_str(&r);
184            }
185            path_buf = new_path;
186            continue;
187        }
188
189        // Submodule (gitlink) or other.
190        if rest.is_none() {
191            return Ok(Ok(FollowPathResult::Found {
192                oid: entry_oid,
193                mode,
194            }));
195        }
196        return Ok(Err(FollowPathFailure::NotDir));
197    }
198}