Skip to main content

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;