Skip to main content

git_policy_cli/
repo_read_refs.rs

1//! What:
2//!  The files ref storage read in process: one ref by exact name, the symbolic chain behind it, and the list of every ref.
3//! Why:
4//!  A loose file wins over `packed-refs`; a symbolic file names another ref; both are plain text.
5//!  Every spelling the reader is not sure of (a link, a name outside a plain alphabet, a damaged value, a linked
6//!  worktree's split of per-worktree and shared refs) is `Unsure`, which sends the caller to Git.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const refs = new Refs(commonDir, hashHexLength); refs.lookup('refs/heads/main'); refs.list();
11//! ```
12
13#![cfg(unix)]
14
15use super::private_storage::read_regular_file;
16use std::path::{Path, PathBuf};
17
18/// The longest loose ref file or `packed-refs` line this reader looks at.
19const LOOSE_LIMIT: u64 = 4096;
20
21/// The largest `packed-refs` file this reader looks at.
22const PACKED_LIMIT: u64 = 256 << 20;
23
24/// How many symbolic ref hops are followed.
25const SYMBOLIC_DEPTH_LIMIT: usize = 5;
26
27/// What a ref holds.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum RefValue {
30    /// An object name as lower-case hexadecimal.
31    Direct(String),
32    /// The name of another ref.
33    Symbolic(String),
34}
35
36/// The answer to a lookup.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum Lookup {
39    /// The ref exists with this value.
40    Found(RefValue),
41    /// Neither a loose file nor a packed line names it.
42    Missing,
43    /// Something here is outside the plain layouts.
44    Unsure,
45}
46
47/// The shared refs of one repository whose worktree owns its Git directory.
48pub struct Refs {
49    /// The common directory, which holds `refs/` and `packed-refs`.
50    directory: PathBuf,
51    /// The number of hexadecimal digits of an object name.
52    hex_length: usize,
53}
54
55/// Whether `name` is a ref name of a plain alphabet: `refs/` and components of ASCII letters, digits and `._-`
56/// that Git always accepts.
57pub fn is_plain_ref_name(name: &str) -> bool {
58    let Some(rest) = name.strip_prefix("refs/") else {
59        return false;
60    };
61    if rest.is_empty() || rest.ends_with('.') || rest.contains("..") {
62        return false;
63    }
64    return rest.split('/').all(|component| {
65        return !component.is_empty()
66            && !component.starts_with('.')
67            && !component.ends_with(".lock")
68            && component.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'); });
69    });
70}
71
72impl Refs {
73    /// Readers for the refs under `directory`, with object names of `hex_length` digits.
74    pub fn new(directory: &Path, hex_length: usize) -> Refs {
75        return Refs { directory: directory.to_path_buf(), hex_length };
76    }
77
78    /// Whether `text` is a plain, non-null, lower-case object name.
79    fn is_name(&self, text: &str) -> bool {
80        return text.len() == self.hex_length
81            && text.bytes().all(|byte| { return matches!(byte, b'0'..=b'9' | b'a'..=b'f'); })
82            && !text.bytes().all(|byte| { return byte == b'0'; });
83    }
84
85    /// The value of a loose file's text.
86    fn parse_loose(&self, text: &str) -> Option<RefValue> {
87        let line: &str = text.strip_suffix('\n')?;
88        if let Some(target) = line.strip_prefix("ref: ") {
89            return is_plain_ref_name(target).then(|| { return RefValue::Symbolic(target.to_string()); });
90        }
91        return self.is_name(line).then(|| { return RefValue::Direct(line.to_string()); });
92    }
93
94    /// The packed lines as `(name, object name)`, or `None` when the file is not a plain `packed-refs`.
95    fn packed(&self) -> Option<Vec<(String, String)>> {
96        let bytes: Vec<u8> = match read_regular_file(self.directory.join("packed-refs").as_path(), PACKED_LIMIT) {
97            Ok(bytes) => bytes,
98            Err(super::private_storage::ReadRefusal::Missing) => return Some(Vec::new()),
99            Err(_) => return None,
100        };
101        let text: &str = std::str::from_utf8(bytes.as_slice()).ok()?;
102        let mut packed: Vec<(String, String)> = Vec::new();
103        // A peeled line (`^<name>`) is valid only directly after a ref line.
104        let mut after_ref: bool = false;
105        for (position, line) in text.lines().enumerate() {
106            if line.starts_with('#') {
107                if position != 0 {
108                    return None;
109                }
110                continue;
111            }
112            if let Some(peeled) = line.strip_prefix('^') {
113                if !after_ref || !self.is_name(peeled) {
114                    return None;
115                }
116                after_ref = false;
117                continue;
118            }
119            let (object, name) = line.split_once(' ')?;
120            if !self.is_name(object) || !is_plain_ref_name(name) {
121                return None;
122            }
123            packed.push((name.to_string(), object.to_string()));
124            after_ref = true;
125        }
126        return Some(packed);
127    }
128
129    /// What the `HEAD` file of `git_dir` holds, when it is a plain symbolic or direct spelling.
130    pub fn lookup_head(&self, git_dir: &Path) -> Option<RefValue> {
131        let bytes: Vec<u8> = read_regular_file(git_dir.join("HEAD").as_path(), LOOSE_LIMIT).ok()?;
132        return self.parse_loose(std::str::from_utf8(bytes.as_slice()).ok()?);
133    }
134
135    /// What ref `name` holds, loose first.
136    pub fn lookup(&self, name: &str) -> Lookup {
137        if !is_plain_ref_name(name) {
138            return Lookup::Unsure;
139        }
140        let path: PathBuf = self.directory.join(name);
141        match std::fs::symlink_metadata(&path) {
142            Ok(metadata) if metadata.is_file() => {
143                let Ok(bytes) = read_regular_file(path.as_path(), LOOSE_LIMIT) else {
144                    return Lookup::Unsure;
145                };
146                let Some(value) = std::str::from_utf8(bytes.as_slice()).ok().and_then(|text| { return self.parse_loose(text); }) else {
147                    return Lookup::Unsure;
148                };
149                return Lookup::Found(value);
150            }
151            Ok(metadata) if metadata.is_dir() => {}
152            Ok(_) => return Lookup::Unsure,
153            // A file where a directory is expected (`refs/heads/a` as a file, asked for `refs/heads/a/b`) is absence.
154            Err(error) if matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => {}
155            Err(_) => return Lookup::Unsure,
156        }
157        let Some(packed) = self.packed() else {
158            return Lookup::Unsure;
159        };
160        return match packed.into_iter().find(|(candidate, _)| { return candidate == name; }) {
161            Some((_, object)) => Lookup::Found(RefValue::Direct(object)),
162            None => Lookup::Missing,
163        };
164    }
165
166    /// The object name `name` resolves to through symbolic refs; `None` when it is missing or unsure.
167    pub fn resolve(&self, name: &str) -> Option<String> {
168        let mut current: String = name.to_string();
169        for _ in 0..SYMBOLIC_DEPTH_LIMIT {
170            match self.lookup(current.as_str()) {
171                Lookup::Found(RefValue::Direct(object)) => return Some(object),
172                Lookup::Found(RefValue::Symbolic(target)) => current = target,
173                Lookup::Missing | Lookup::Unsure => return None,
174            }
175        }
176        return None;
177    }
178
179    /// Every ref under `refs/`, sorted by name, loose over packed; `None` when anything is not plain.
180    pub fn list(&self) -> Option<Vec<(String, RefValue)>> {
181        let mut found: std::collections::BTreeMap<String, RefValue> = std::collections::BTreeMap::new();
182        for (name, object) in self.packed()? {
183            found.insert(name, RefValue::Direct(object));
184        }
185        let mut pending: Vec<String> = vec![String::from("refs")];
186        while let Some(directory) = pending.pop() {
187            let listing = match std::fs::read_dir(self.directory.join(&directory)) {
188                Ok(listing) => listing,
189                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
190                Err(_) => return None,
191            };
192            for candidate in listing {
193                let entry = candidate.ok()?;
194                let name: String = format!("{directory}/{}", entry.file_name().to_str()?);
195                let kind = entry.file_type().ok()?;
196                if kind.is_dir() {
197                    pending.push(name);
198                } else if kind.is_file() && !name.ends_with(".lock") {
199                    match self.lookup(name.as_str()) {
200                        Lookup::Found(value) => {
201                            found.insert(name, value);
202                        }
203                        Lookup::Missing | Lookup::Unsure => return None,
204                    }
205                } else {
206                    return None;
207                }
208            }
209        }
210        return Some(found.into_iter().collect());
211    }
212}
213
214#[cfg(test)]
215#[path = "repo_read_refs_tests.rs"]
216mod tests;