git_policy_cli/command_config.rs
1//! What:
2//! The form and file scope of a `git config` region,
3//! read as Git 2.56.0 reads it.
4//! Why:
5//! The require-root policy exempts `git config` when it works on the per-user or
6//! system file,
7//! or lists.
8//! Whether `--global` is that option or a value being stored
9//! depends on the form:
10//! `git config user.name --global` writes the text `--global`
11//! into the repository's own configuration.
12//!
13//! Gotcha:
14//! Git also refuses two different actions of the form without a subcommand word
15//! (`--list --get`);
16//! that conflict is not modeled,
17//! so such a region still reports
18//! its listing.
19//! Git then runs nothing.
20//!
21//! In TS you'd write (pseudocode):
22//! ```ts
23//! // parseConfigRegion(['--global', 'user.name']).global === true
24//! // parseConfigRegion(['user.name', '--global']).global === false
25//! ```
26
27/// What:
28/// Bring the option groups,
29/// the tokenizer and its questions into this file.
30/// Why:
31/// This module only picks the form's groups and interprets what the tokenizer found.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import { parseOptions } from './command_options.ts';
36/// ```
37use super::command_config_table::{
38 CONFIG_STOP_MODE, DISPLAY_ROWS, GET_FILTER_ROWS, GET_OTHER_ROWS, GLOBAL, LEGACY_ACTION_ROWS,
39 LEGACY_OTHER_ROWS, LIST, LIST_OTHER_ROWS, LOCATION_ROWS, SET_ROWS, SYSTEM, TYPE_ROWS,
40 UNSET_ROWS,
41};
42use super::command_options::{
43 DEFAULT_MODE, OptionError, OptionSpec, ParseMode, ParsedOptions, WrapperOccurrence,
44 parse_options,
45};
46use super::command_options_query::{is_enabled, is_stated};
47/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
48use std::ffi::OsString;
49
50/// What:
51/// The form `cmd_config` dispatches to (builtin/config.c:1633-1660).
52/// An `enum` is a
53/// closed set of named alternatives.
54/// Why:
55/// `Legacy` is the form without a subcommand word,
56/// where actions are options such as
57/// `--list` and `--get`.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type ConfigForm = 'list' | 'get' | 'set' | 'unset' | 'rename-section' | 'remove-section' | 'edit' | 'legacy';
62/// ```
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum ConfigForm {
65 /// `git config list`.
66 List,
67 /// `git config get`.
68 Get,
69 /// `git config set`.
70 Set,
71 /// `git config unset`.
72 Unset,
73 /// `git config rename-section`.
74 RenameSection,
75 /// `git config remove-section`.
76 RemoveSection,
77 /// `git config edit`.
78 Edit,
79 /// No subcommand word:
80 /// the action is an option or implied by the argument count.
81 Legacy,
82}
83
84/// What:
85/// Facts of one `git config` region.
86/// `Vec<WrapperOccurrence>` is an owned growable
87/// list of wrapper-only flags.
88/// Why:
89/// The require-root decision reads the scope and the listing action;
90/// the caller
91/// removes its wrapper-only flags by the positions reported here.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// type ConfigRegion = { form: ConfigForm; global: boolean; system: boolean; lists: boolean; wrapper: WrapperOccurrence[] };
96/// ```
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct ConfigRegion {
99 /// The form Git dispatches to.
100 pub form: ConfigForm,
101 /// `--global` is on after every use was applied.
102 pub global: bool,
103 /// `--system` is on after every use was applied.
104 pub system: bool,
105 /// The command lists variables:
106 /// `git config list`,
107 /// or `-l`/`--list` without a
108 /// subcommand word.
109 pub lists: bool,
110 /// Wrapper-only flags in option position;
111 /// `token` indexes the region.
112 pub wrapper: Vec<WrapperOccurrence>,
113}
114
115/// What:
116/// The form a first region token selects,
117/// or nothing.
118/// `&[u8]` borrows the token.
119/// Why:
120/// Git matches the seven words exactly,
121/// without abbreviation (parse-options.c
122/// `parse_subcommand`).
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// const form = FORMS.get(word);
127/// ```
128fn form_of_word(word: &[u8]) -> Option<ConfigForm> {
129 // `(&[u8], ConfigForm)` is a pair of a spelling and the form it selects.
130 let words: [(&[u8], ConfigForm); 7] = [
131 (b"list", ConfigForm::List),
132 (b"get", ConfigForm::Get),
133 (b"set", ConfigForm::Set),
134 (b"unset", ConfigForm::Unset),
135 (b"rename-section", ConfigForm::RenameSection),
136 (b"remove-section", ConfigForm::RemoveSection),
137 (b"edit", ConfigForm::Edit),
138 ];
139 for (spelling, form) in words {
140 if spelling == word {
141 // `Some(x)` is the "present" case of `Option`.
142 return Some(form);
143 }
144 }
145 // `None` is the "absent" case of `Option`.
146 return None;
147}
148
149/// What:
150/// The complete option table of one form,
151/// assembled from Git's groups in Git's
152/// order.
153/// `Vec<OptionSpec>` is an owned list,
154/// built once per parse.
155/// Why:
156/// The completion control compares each assembled table with the binary's.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// function configTable(form: ConfigForm): OptionSpec[] { return groups[form].flat(); }
161/// ```
162pub fn config_table(form: ConfigForm) -> Vec<OptionSpec> {
163 // `&[&[OptionSpec]]` borrows a list of borrowed groups; `match` picks one per form.
164 let groups: &[&[OptionSpec]] = match form {
165 ConfigForm::List => &[LOCATION_ROWS, DISPLAY_ROWS, TYPE_ROWS, LIST_OTHER_ROWS],
166 ConfigForm::Get => &[
167 LOCATION_ROWS,
168 GET_FILTER_ROWS,
169 DISPLAY_ROWS,
170 TYPE_ROWS,
171 GET_OTHER_ROWS,
172 ],
173 ConfigForm::Set => &[LOCATION_ROWS, TYPE_ROWS, SET_ROWS],
174 ConfigForm::Unset => &[LOCATION_ROWS, UNSET_ROWS],
175 ConfigForm::RenameSection | ConfigForm::RemoveSection | ConfigForm::Edit => {
176 &[LOCATION_ROWS]
177 }
178 ConfigForm::Legacy => &[
179 LOCATION_ROWS,
180 LEGACY_ACTION_ROWS,
181 DISPLAY_ROWS,
182 TYPE_ROWS,
183 LEGACY_OTHER_ROWS,
184 ],
185 };
186 let mut table: Vec<OptionSpec> = Vec::<OptionSpec>::new();
187 for group in groups {
188 // `extend_from_slice` copies the group's rows onto the end of the table.
189 table.extend_from_slice(group);
190 }
191 return table;
192}
193
194/// What:
195/// Parse the region after `git config`.
196/// `Result<A, B>` is "either success `A` or
197/// failure `B`";
198/// `wrapper_flags` are the caller's wrapper-only spellings.
199/// Why:
200/// Git looks at the first token only to pick the form.
201/// A subcommand word selects its
202/// table;
203/// a leading `--` is dropped by that first look,
204/// so the form without a
205/// subcommand then reads the rest as options again (config.c:1643-1660).
206/// Leading
207/// wrapper flags are skipped first,
208/// because the wrapper removes them before Git
209/// runs:
210/// `git config --cli-git-keep-going list` reaches Git as `git config list`.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// function parseConfigRegion(region: string[], wrapperFlags: string[]): ConfigRegion; // throws OptionError
215/// ```
216pub fn parse_config_region(
217 region: &[OsString],
218 wrapper_flags: &[&[u8]],
219) -> Result<ConfigRegion, OptionError> {
220 let mut wrapper: Vec<WrapperOccurrence> = Vec::<WrapperOccurrence>::new();
221 // Region index of the first token that is not a wrapper flag.
222 let mut first: usize = 0;
223 while first < region.len() {
224 let token: &[u8] = region[first].as_encoded_bytes();
225 // Index of the matching wrapper flag; the list length means "none".
226 let mut flag: usize = 0;
227 while flag < wrapper_flags.len() && wrapper_flags[flag] != token {
228 flag += 1;
229 }
230 if flag == wrapper_flags.len() {
231 break;
232 }
233 wrapper.push(WrapperOccurrence { flag, token: first });
234 first += 1;
235 }
236 let mut form: ConfigForm = ConfigForm::Legacy;
237 // Region index of the first token the form's own parser reads.
238 let mut offset: usize = first;
239 // `if let Some(found) = ...` runs only when a token follows the wrapper flags.
240 if let Some(found) = region.get(first) {
241 let word: &[u8] = found.as_encoded_bytes();
242 if let Some(named) = form_of_word(word) {
243 form = named;
244 offset = first + 1;
245 } else if word == b"--" {
246 offset = first + 1;
247 }
248 }
249 let mode: ParseMode = if form == ConfigForm::List || form == ConfigForm::Edit {
250 DEFAULT_MODE
251 } else {
252 CONFIG_STOP_MODE
253 };
254 let table: Vec<OptionSpec> = config_table(form);
255 // `®ion[offset..]` borrows the tokens from `offset` to the end.
256 let outcome: Result<ParsedOptions, OptionError> =
257 parse_options(®ion[offset..], table.as_slice(), mode, wrapper_flags);
258 // `match` on a `Result` handles both cases; token indexes are moved back to the region.
259 let parsed: ParsedOptions = match outcome {
260 Ok(found) => found,
261 Err(refusal) => {
262 // `Err(x)` is the failure case of `Result`.
263 return Err(OptionError {
264 kind: refusal.kind,
265 token: refusal.token + offset,
266 });
267 }
268 };
269 for occurrence in &parsed.wrapper {
270 wrapper.push(WrapperOccurrence {
271 flag: occurrence.flag,
272 token: occurrence.token + offset,
273 });
274 }
275 // `Ok(x)` is the success case of `Result`.
276 return Ok(ConfigRegion {
277 form,
278 global: is_enabled(&parsed, GLOBAL),
279 system: is_enabled(&parsed, SYSTEM),
280 lists: form == ConfigForm::List || (form == ConfigForm::Legacy && is_stated(&parsed, LIST)),
281 wrapper,
282 });
283}
284
285/// Forms,
286/// scopes and option positions,
287/// with real Git 2.56.0 controls.
288#[cfg(test)]
289#[path = "command_config_tests.rs"]
290mod tests;