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