1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
//! Explicit filesystem leaf-link reads, separate from strict reference publication.
#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::Path;
use super::store::{check_path, io_error, parse_loose};
use super::{Backend, RefName, ReferenceError, ReferenceObservation, References, Target, packed};
impl References<'_> {
/// Reads one reference while explicitly permitting final-file filesystem symlinks.
///
/// Relative link destinations are relative to their containing directory. Link text such as
/// `refs/heads/main` is a filesystem path, not Git's legacy Git-directory-relative symbolic
/// reference syntax. Only `ref: ...` file contents produce a symbolic [`Target`]. Symlink
/// ancestors of the original name or any followed destination remain unsupported; destinations
/// outside the repository are otherwise permitted. This method never changes write/CAS policy.
///
/// A successful loose read retains the requested name and clears the packed peeled hint.
/// Missing paths and followed links ending at a directory permit normal packed fallback.
/// Malformed contents and other I/O errors do not. An original directory remains a conflict.
/// Per-worktree names never fall back to packed storage. Observations are owned but live reads
/// are not atomic across link hops; this is not a sandbox against adversarial path replacement.
///
/// `max_symlink_hops` bounds followed links, including cycles. `max_bytes` bounds aggregate
/// link destination bytes and file bytes, including packed fallback. Files are read with at
/// most one excess byte to detect exhaustion. Reftable uses [`Self::read_observation`] and
/// its configured stack limits instead; these filesystem limits do not apply there.
///
/// # Errors
///
/// Reports unsupported ancestors/nonregular files, I/O and malformed data, namespace conflicts,
/// and [`ReferenceError::Limit`] on exhausted byte or link budgets. No storage is modified.
///
/// ```
/// use girt::refs::RefName;
/// use girt::{InitKind, ObjectFormat, Repository};
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(
/// ObjectFormat::Sha1,
/// directory.path().join("repo"),
/// InitKind::Bare,
/// )?;
/// let observation = repo
/// .references()?
/// .read_observation_following_leaf_symlinks(&RefName::HEAD, 32, 64 * 1024)?;
/// assert!(observation.is_some());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn read_observation_following_leaf_symlinks(
&self,
name: &RefName,
max_symlink_hops: usize,
max_bytes: usize,
) -> Result<Option<ReferenceObservation>, ReferenceError> {
if self.reference_backend == Backend::Reftable {
return self.read_observation(name);
}
let mut path = self.path(name)?;
let mut remaining = max_bytes;
let mut hops = 0;
loop {
check_ancestors(&path)?;
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(source) => return Err(io_error(&path, source)),
};
if metadata.file_type().is_symlink() {
if hops == max_symlink_hops {
return Err(ReferenceError::Limit("reference filesystem symlink hops"));
}
let destination = fs::read_link(&path).map_err(|source| io_error(&path, source))?;
remaining = remaining
.checked_sub(destination.as_os_str().as_encoded_bytes().len())
.ok_or(ReferenceError::Limit("reference bytes"))?;
#[expect(clippy::expect_used, reason = "reference paths lie below a root")]
let parent = path.parent().expect("reference path has a parent");
path = parent.join(destination);
hops += 1;
continue;
}
if metadata.is_dir() {
if hops != 0 {
break;
}
return Err(ReferenceError::Conflict(path));
}
if !metadata.is_file() {
return Err(ReferenceError::Unsupported("non-regular reference file"));
}
let Some(bytes) = read_bounded(&path, &mut remaining)? else {
break;
};
return Ok(Some(ReferenceObservation {
name: name.clone(),
target: parse_loose(self.object_format, &bytes, &path)?,
peeled_hint: None,
}));
}
if name.per_worktree() {
return Ok(None);
}
let packed_path = self.common_dir.join("packed-refs");
check_path(&packed_path)?;
let bytes = read_bounded(&packed_path, &mut remaining)?.unwrap_or_default();
let records = packed::parse(self.object_format, &bytes, &packed_path)?;
Ok(records.get(name).map(|record| ReferenceObservation {
name: name.clone(),
target: Target::Direct(record.target),
peeled_hint: record.peeled_hint,
}))
}
}
fn check_ancestors(path: &Path) -> Result<(), ReferenceError> {
for ancestor in path.ancestors().skip(1) {
match fs::symlink_metadata(ancestor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(ReferenceError::Unsupported(
"filesystem symlink in reference ancestor",
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(ReferenceError::Conflict(ancestor.into()));
}
Ok(_) => (),
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
Err(source) => return Err(io_error(ancestor, source)),
}
}
Ok(())
}
fn read_bounded(path: &Path, remaining: &mut usize) -> Result<Option<Vec<u8>>, ReferenceError> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(io_error(path, source)),
};
let mut bytes = Vec::new();
file.take(remaining.saturating_add(1) as u64)
.read_to_end(&mut bytes)
.map_err(|source| io_error(path, source))?;
*remaining = remaining
.checked_sub(bytes.len())
.ok_or(ReferenceError::Limit("reference bytes"))?;
Ok(Some(bytes))
}
#[cfg(all(test, unix))]
#[allow(clippy::unwrap_used, reason = "test fixtures")]
mod tests;