git_policy_cli/config_rules_file.rs
1//! What:
2//! The `rulesFile` option of `security/forbidden-strings`:
3//! which file in the
4//! repository holds the scanner's private rules,
5//! written relative to the
6//! repository's top level.
7//! Why:
8//! The user decided on 2026-10-05 that configuration names the rules file first,
9//! then
10//! `FORBIDDEN_STRINGS_RULES`,
11//! then the default file.
12//! A configured name is checked
13//! when the configuration is read:
14//! it must stay inside the repository by its words
15//! alone,
16//! so a configuration cannot point the scanner at a file elsewhere on the
17//! machine.
18//! A name that is missing on disk is not a configuration error;
19//! loading
20//! the rules reports it when the policy runs.
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! // const rulesFile = parseRulesFile(option); // throws RulesFileRefusal
25//! ```
26
27/// What:
28/// Why a `rulesFile` value was refused.
29/// `#[derive(...)]` generates copying,
30/// debug
31/// printing and `==`.
32/// Why:
33/// The configuration error names the key and explains this one reason.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type RulesFileRefusal = 'empty' | 'absolute' | 'drive' | 'backslash' | 'nul' | 'empty-component' | 'dot-component';
38/// ```
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum RulesFileRefusal {
41 /// The value is empty,
42 /// so it names no file.
43 Empty,
44 /// The value starts with `/`,
45 /// so it does not start at the repository's top level.
46 Absolute,
47 /// The value starts with a drive letter and a colon,
48 /// which Windows reads as another volume.
49 Drive,
50 /// The value holds a backslash,
51 /// which Windows reads as a separator.
52 Backslash,
53 /// The value holds a NUL character,
54 /// which no file name can hold.
55 Nul,
56 /// The value has an empty component:
57 /// two slashes in a row or a final slash.
58 EmptyComponent,
59 /// The value has a `.` or `..` component;
60 /// `..` could leave the repository.
61 DotComponent,
62}
63
64/// What:
65/// The explanation of one refusal,
66/// completing the sentence "the value ...".
67/// Why:
68/// One place owns the words,
69/// so every refusal reads the same.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// function refusalReason(refusal: RulesFileRefusal): string;
74/// ```
75pub fn rules_file_refusal_reason(refusal: RulesFileRefusal) -> &'static str {
76 // `match` names every refusal, so a new one cannot be added without its words.
77 match refusal {
78 RulesFileRefusal::Empty => return "is empty",
79 RulesFileRefusal::Absolute => return "starts with /",
80 RulesFileRefusal::Drive => return "starts with a drive letter",
81 RulesFileRefusal::Backslash => return "contains a backslash; use / between directories",
82 RulesFileRefusal::Nul => return "contains a NUL character",
83 RulesFileRefusal::EmptyComponent => {
84 return "has an empty component (two slashes in a row or a trailing slash)";
85 }
86 RulesFileRefusal::DotComponent => return "has a . or .. component",
87 }
88}
89
90/// What:
91/// Whether one character is `/`,
92/// the separator of the option's components.
93/// Why:
94/// A named predicate for `split`,
95/// because the repository bans anonymous functions.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// const isSlash = (character: string) => character === '/';
100/// ```
101fn is_slash(character: char) -> bool {
102 return character == '/';
103}
104
105/// What:
106/// Whether `text` starts with an ASCII letter and a colon,
107/// as `C:` does.
108/// Why:
109/// Joining such a value to the repository's top level on Windows would name
110/// another volume.
111///
112/// In TS you'd write (pseudocode):
113/// ```ts
114/// const startsWithDrive = (text: string) => /^[A-Za-z]:/.test(text);
115/// ```
116fn starts_with_drive(text: &str) -> bool {
117 let bytes: &[u8] = text.as_bytes();
118 // `bytes.len() > 1` keeps the two indexed reads inside the text.
119 return bytes.len() > 1 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
120}
121
122/// What:
123/// Check one `rulesFile` value.
124/// `Result<(), RulesFileRefusal>` is "accepted" or the
125/// first reason it is not.
126/// Why:
127/// A value that is relative,
128/// uses `/` between non-empty components,
129/// and has no `.`
130/// or `..` component names a file inside the repository on every platform,
131/// whatever
132/// the file system later holds.
133/// The checks run in a fixed order so the first
134/// reason reported is the same on every platform.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// function checkRulesFile(value: string): void; // throws RulesFileRefusal
139/// ```
140pub fn check_rules_file(value: &str) -> Result<(), RulesFileRefusal> {
141 if value.is_empty() {
142 return Err(RulesFileRefusal::Empty);
143 }
144 if value.starts_with('/') {
145 return Err(RulesFileRefusal::Absolute);
146 }
147 if starts_with_drive(value) {
148 return Err(RulesFileRefusal::Drive);
149 }
150 if value.contains('\\') {
151 return Err(RulesFileRefusal::Backslash);
152 }
153 if value.contains('\0') {
154 return Err(RulesFileRefusal::Nul);
155 }
156 for component in value.split(is_slash) {
157 if component.is_empty() {
158 return Err(RulesFileRefusal::EmptyComponent);
159 }
160 if component == "." || component == ".." {
161 return Err(RulesFileRefusal::DotComponent);
162 }
163 }
164 // `Ok(())` is success with no value.
165 return Ok(());
166}
167
168/// Option-value controls stay out of the release executable.
169#[cfg(test)]
170#[path = "config_rules_file_tests.rs"]
171mod tests;