Skip to main content

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;