Skip to main content

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;