git_policy_cli/repo_read_config_text.rs
1//! What:
2//! A reader of Git configuration file text that gives the entries in file order, and gives up on any spelling it
3//! cannot prove it reads as Git reads it.
4//! Why:
5//! The in-process repository reads (location, remotes, upstream) need a few configuration values;
6//! Git answers a wrong or surprising spelling with its own error,
7//! so every spelling outside the plain grammar returns `None` and the caller asks Git.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const entries: { key: string; value: Uint8Array | undefined }[] | undefined = parseConfigText(bytes); // undefined: ask Git
12//! ```
13
14/// The largest configuration file this reader looks at.
15pub const CONFIG_TEXT_LIMIT: usize = 1 << 20;
16
17/// What:
18/// One `name = value` line with its section: the canonical key and the value,
19/// or no value for a bare name (Git reads that as the boolean true).
20/// Why:
21/// Section and name are lower case and the subsection keeps its case,
22/// exactly the spelling `git config --list` prints, so a caller compares keys as text.
23///
24/// In TS you'd write (pseudocode):
25/// ```ts
26/// type ConfigEntry = { key: string; value: Uint8Array | undefined };
27/// ```
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct ConfigEntry {
30 /// `section.subsection.name`, or `section.name` without a subsection.
31 pub key: String,
32 /// The value after escapes and quotes; `None` for a name with no `=`.
33 pub value: Option<Vec<u8>>,
34}
35
36/// What:
37/// A cursor over the bytes of one file.
38/// Why:
39/// The parser consumes the text one byte at a time and every helper must see the same position.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// class Cursor { constructor(public bytes: Uint8Array, public at = 0) {} }
44/// ```
45struct Cursor<'a> {
46 /// The whole file text.
47 bytes: &'a [u8],
48 /// The next byte to read.
49 at: usize,
50}
51
52impl Cursor<'_> {
53 /// The next byte, or `None` at the end.
54 fn peek(&self) -> Option<u8> {
55 return self.bytes.get(self.at).copied();
56 }
57
58 /// Step past the next byte.
59 fn advance(&mut self) {
60 self.at += 1;
61 }
62
63 /// Step past the rest of the line, newline included.
64 fn skip_line(&mut self) {
65 while let Some(byte) = self.peek() {
66 self.advance();
67 if byte == b'\n' {
68 return;
69 }
70 }
71 }
72}
73
74/// Whether `byte` may appear in a section or variable name.
75fn is_name_byte(byte: u8) -> bool {
76 return byte.is_ascii_alphanumeric() || byte == b'-';
77}
78
79/// Read a `[section]`, `[section "sub"]` or `[section.sub]` header after its `[` and give the key prefix.
80/// A newline or any byte outside the grammar returns `None`.
81fn read_header(cursor: &mut Cursor<'_>) -> Option<String> {
82 let mut section: String = String::new();
83 while let Some(byte) = cursor.peek() {
84 if !(byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.') {
85 break;
86 }
87 section.push(byte.to_ascii_lowercase() as char);
88 cursor.advance();
89 }
90 if section.is_empty() {
91 return None;
92 }
93 // A header with a quoted subsection: spaces, then `"..."`, in which `\x` stands for `x`.
94 if cursor.peek() == Some(b' ') || cursor.peek() == Some(b'\t') {
95 while matches!(cursor.peek(), Some(b' ' | b'\t')) {
96 cursor.advance();
97 }
98 if cursor.peek() != Some(b'"') {
99 return None;
100 }
101 cursor.advance();
102 let mut subsection: Vec<u8> = Vec::new();
103 loop {
104 let byte: u8 = cursor.peek()?;
105 cursor.advance();
106 match byte {
107 b'"' => break,
108 b'\n' => return None,
109 b'\\' => {
110 let escaped: u8 = cursor.peek()?;
111 if escaped == b'\n' {
112 return None;
113 }
114 cursor.advance();
115 subsection.push(escaped);
116 }
117 other => subsection.push(other),
118 }
119 }
120 if cursor.peek() != Some(b']') {
121 return None;
122 }
123 cursor.advance();
124 if subsection.contains(&0) {
125 return None;
126 }
127 let text: String = String::from_utf8(subsection).ok()?;
128 return Some(format!("{section}.{text}"));
129 }
130 if cursor.peek() != Some(b']') {
131 return None;
132 }
133 cursor.advance();
134 // The old `[section.sub]` spelling lower-cases the subsection too, which `section` already is.
135 return Some(section);
136}
137
138/// Read the value after `=`: quotes, the escapes `\n \t \b \\ \"`, a backslash-newline continuation, and a `#` or `;`
139/// comment that ends the value. A bad escape or an open quote returns `None`.
140fn read_value(cursor: &mut Cursor<'_>) -> Option<Vec<u8>> {
141 let mut value: Vec<u8> = Vec::new();
142 let mut spaces: usize = 0;
143 let mut quoted: bool = false;
144 while let Some(byte) = cursor.peek() {
145 cursor.advance();
146 // Git's values are C strings; a NUL cannot be represented, so the spelling is left to it.
147 if byte == 0 {
148 return None;
149 }
150 if byte == b'\n' {
151 if quoted {
152 return None;
153 }
154 return Some(value);
155 }
156 if byte.is_ascii_whitespace() && !quoted {
157 if !value.is_empty() {
158 spaces += 1;
159 }
160 continue;
161 }
162 if (byte == b'#' || byte == b';') && !quoted {
163 cursor.skip_line();
164 return Some(value);
165 }
166 // Pending white space is kept only when more text follows it.
167 value.extend(std::iter::repeat_n(b' ', spaces));
168 spaces = 0;
169 if byte == b'\\' {
170 let escaped: u8 = cursor.peek()?;
171 cursor.advance();
172 match escaped {
173 b'\n' => {}
174 b'\r' if cursor.peek() == Some(b'\n') => cursor.advance(),
175 b'n' => value.push(b'\n'),
176 b't' => value.push(b'\t'),
177 b'b' => value.push(8),
178 b'\\' | b'"' => value.push(escaped),
179 _ => return None,
180 }
181 } else if byte == b'"' {
182 quoted = !quoted;
183 } else {
184 value.push(byte);
185 }
186 }
187 if quoted {
188 return None;
189 }
190 return Some(value);
191}
192
193/// What:
194/// Parse the text of one configuration file into its entries, in file order.
195/// Why:
196/// `None` means "some spelling here is outside the plain grammar", never "the file is empty".
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// const entries = parseConfigText(bytes); // undefined: ask Git
201/// ```
202pub fn parse_config_text(text: &[u8]) -> Option<Vec<ConfigEntry>> {
203 if text.len() > CONFIG_TEXT_LIMIT {
204 return None;
205 }
206 let body: &[u8] = text.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(text);
207 let mut cursor: Cursor<'_> = Cursor { bytes: body, at: 0 };
208 let mut prefix: Option<String> = None;
209 let mut entries: Vec<ConfigEntry> = Vec::new();
210 while let Some(byte) = cursor.peek() {
211 if byte.is_ascii_whitespace() {
212 cursor.advance();
213 } else if byte == b'#' || byte == b';' {
214 cursor.skip_line();
215 } else if byte == b'[' {
216 cursor.advance();
217 prefix = Some(read_header(&mut cursor)?);
218 } else if byte.is_ascii_alphabetic() {
219 let section: &str = prefix.as_deref()?;
220 let mut name: String = String::new();
221 while let Some(next) = cursor.peek() {
222 if !is_name_byte(next) {
223 break;
224 }
225 name.push(next.to_ascii_lowercase() as char);
226 cursor.advance();
227 }
228 while matches!(cursor.peek(), Some(b' ' | b'\t' | b'\r')) {
229 cursor.advance();
230 }
231 let key: String = format!("{section}.{name}");
232 match cursor.peek() {
233 Some(b'=') => {
234 cursor.advance();
235 entries.push(ConfigEntry { key, value: Some(read_value(&mut cursor)?) });
236 }
237 Some(b'\n') | None => entries.push(ConfigEntry { key, value: None }),
238 Some(_) => return None,
239 }
240 } else {
241 return None;
242 }
243 }
244 return Some(entries);
245}
246
247/// What:
248/// The last value of `key`, with `Some(None)` for a bare name and `None` when the key is absent.
249/// Why:
250/// Git's single-valued reads take the last entry of the files in order.
251///
252/// In TS you'd write (pseudocode):
253/// ```ts
254/// entries.findLast((entry) => entry.key === key)
255/// ```
256pub fn last_entry<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a ConfigEntry> {
257 return entries.iter().rev().find(|entry| { return entry.key == key; });
258}
259
260/// What:
261/// Git's boolean reading of a value: `true yes on 1` and a bare name are true, `false no off 0` are false.
262/// Why:
263/// Any other spelling (an empty value, a larger number) is left to Git, so the answer is `None`.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// const parseBool = (value?: string) => value === undefined ? true : { true: true, false: false /* ... */ }[value.toLowerCase()];
268/// ```
269pub fn parse_bool(value: Option<&[u8]>) -> Option<bool> {
270 let Some(text) = value else {
271 return Some(true);
272 };
273 let lowered: String = String::from_utf8(text.to_vec()).ok()?.to_ascii_lowercase();
274 return match lowered.as_str() {
275 "true" | "yes" | "on" | "1" => Some(true),
276 "false" | "no" | "off" | "0" => Some(false),
277 _ => None,
278 };
279}
280
281#[cfg(test)]
282#[path = "repo_read_config_text_tests.rs"]
283mod tests;