Skip to main content

gatecheck_policy/
lib.rs

1//! Policy parsing and semantic validation for gatecheck.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::fs;
6use std::path::Path;
7
8use gatecheck_types::{GateDefinition, GatePolicy, Requirement};
9
10/// Policy parsing errors.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PolicyError {
13    Read(String),
14    Parse(String),
15}
16
17impl Display for PolicyError {
18    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
19        match self {
20            Self::Read(message) | Self::Parse(message) => formatter.write_str(message),
21        }
22    }
23}
24
25impl Error for PolicyError {}
26
27/// Load policy from disk.
28pub fn load_policy(path: impl AsRef<Path>) -> Result<GatePolicy, PolicyError> {
29    let path = path.as_ref();
30    let contents = fs::read_to_string(path).map_err(|error| {
31        PolicyError::Read(format!("failed to read policy {}: {error}", path.display()))
32    })?;
33    parse_policy(&contents)
34}
35
36/// Parse a gate policy from a restricted TOML subset.
37pub fn parse_policy(input: &str) -> Result<GatePolicy, PolicyError> {
38    let mut id = String::new();
39    let mut version = String::new();
40    let mut profile = String::new();
41    let mut gates = Vec::<GateDefinition>::new();
42    let mut current_gate: Option<GateDefinition> = None;
43
44    let lines: Vec<&str> = input.lines().collect();
45    let mut index = 0usize;
46    while index < lines.len() {
47        let line = strip_comments(lines[index]).trim();
48        index += 1;
49        if line.is_empty() {
50            continue;
51        }
52
53        if line.starts_with('[') && line.ends_with(']') {
54            if let Some(gate) = current_gate.take() {
55                gates.push(gate);
56            }
57            let section = &line[1..line.len() - 1];
58            let gate_id = section
59                .strip_prefix("gates.")
60                .ok_or_else(|| PolicyError::Parse(format!("unsupported section `{section}`")))?;
61            current_gate = Some(GateDefinition {
62                id: gate_id.to_owned(),
63                name: title_case(gate_id),
64                order: 0,
65                depends_on: Vec::new(),
66                requirements: Vec::new(),
67            });
68            continue;
69        }
70
71        let (key, value) = split_key_value(line)?;
72        match current_gate.as_mut() {
73            None => match key {
74                "id" => id = parse_string(value)?,
75                "version" => version = parse_string(value)?,
76                "profile" => profile = parse_string(value)?,
77                other => {
78                    return Err(PolicyError::Parse(format!(
79                        "unsupported top-level key `{other}`"
80                    )))
81                }
82            },
83            Some(gate) => match key {
84                "name" => gate.name = parse_string(value)?,
85                "order" => gate.order = parse_u16(value)?,
86                "depends_on" => gate.depends_on = parse_string_array(value)?,
87                "requires" => {
88                    let mut buffer = value.to_owned();
89                    while bracket_delta(&buffer) > 0 {
90                        if index >= lines.len() {
91                            return Err(PolicyError::Parse(
92                                "unterminated requires array".to_owned(),
93                            ));
94                        }
95                        let next = strip_comments(lines[index]);
96                        buffer.push('\n');
97                        buffer.push_str(next);
98                        index += 1;
99                    }
100                    gate.requirements = parse_requirements(&buffer)?;
101                }
102                other => {
103                    return Err(PolicyError::Parse(format!(
104                        "unsupported gate key `{other}`"
105                    )))
106                }
107            },
108        }
109    }
110
111    if let Some(gate) = current_gate.take() {
112        gates.push(gate);
113    }
114
115    if id.is_empty() {
116        id = "gatecheck.policy".to_owned();
117    }
118    if version.is_empty() {
119        version = "1".to_owned();
120    }
121    if profile.is_empty() {
122        return Err(PolicyError::Parse("missing top-level `profile`".to_owned()));
123    }
124    if gates.is_empty() {
125        return Err(PolicyError::Parse(
126            "policy must declare at least one gate".to_owned(),
127        ));
128    }
129
130    gates.sort_by_key(|gate| gate.order);
131    for gate in &gates {
132        if gate.order == 0 {
133            return Err(PolicyError::Parse(format!(
134                "gate `{}` is missing `order`",
135                gate.id
136            )));
137        }
138    }
139    for window in gates.windows(2) {
140        if window[0].order == window[1].order {
141            return Err(PolicyError::Parse("gate orders must be unique".to_owned()));
142        }
143    }
144
145    Ok(GatePolicy {
146        id,
147        version,
148        profile,
149        gates,
150    })
151}
152
153fn strip_comments(line: &str) -> &str {
154    let mut in_string = false;
155    for (index, character) in line.char_indices() {
156        match character {
157            '"' => in_string = !in_string,
158            '#' if !in_string => return &line[..index],
159            _ => {}
160        }
161    }
162    line
163}
164
165fn split_key_value(line: &str) -> Result<(&str, &str), PolicyError> {
166    let mut in_string = false;
167    for (index, character) in line.char_indices() {
168        match character {
169            '"' => in_string = !in_string,
170            '=' if !in_string => {
171                let key = line[..index].trim();
172                let value = line[index + 1..].trim();
173                return Ok((key, value));
174            }
175            _ => {}
176        }
177    }
178    Err(PolicyError::Parse(format!(
179        "expected key = value, got `{line}`"
180    )))
181}
182
183fn bracket_delta(input: &str) -> i32 {
184    let mut delta = 0_i32;
185    let mut in_string = false;
186    for character in input.chars() {
187        match character {
188            '"' => in_string = !in_string,
189            '[' if !in_string => delta += 1,
190            ']' if !in_string => delta -= 1,
191            _ => {}
192        }
193    }
194    delta
195}
196
197fn parse_string(input: &str) -> Result<String, PolicyError> {
198    let trimmed = input.trim();
199    if !(trimmed.starts_with('"') && trimmed.ends_with('"')) {
200        return Err(PolicyError::Parse(format!(
201            "expected quoted string, got `{input}`"
202        )));
203    }
204    Ok(trimmed[1..trimmed.len() - 1].to_owned())
205}
206
207fn parse_u16(input: &str) -> Result<u16, PolicyError> {
208    input
209        .trim()
210        .parse::<u16>()
211        .map_err(|_| PolicyError::Parse(format!("expected integer, got `{input}`")))
212}
213
214fn parse_string_array(input: &str) -> Result<Vec<String>, PolicyError> {
215    let trimmed = input.trim();
216    if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
217        return Err(PolicyError::Parse(format!(
218            "expected string array, got `{input}`"
219        )));
220    }
221    let inner = &trimmed[1..trimmed.len() - 1];
222    if inner.trim().is_empty() {
223        return Ok(Vec::new());
224    }
225    split_top_level(inner, ',')
226        .into_iter()
227        .map(|piece| parse_string(piece.trim()))
228        .collect()
229}
230
231fn parse_requirements(input: &str) -> Result<Vec<Requirement>, PolicyError> {
232    let trimmed = input.trim();
233    if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
234        return Err(PolicyError::Parse(format!(
235            "expected array of inline tables, got `{input}`"
236        )));
237    }
238    let inner = &trimmed[1..trimmed.len() - 1];
239    let tables = split_inline_tables(inner)?;
240    tables
241        .into_iter()
242        .map(|table| parse_requirement_table(&table))
243        .collect()
244}
245
246fn split_inline_tables(input: &str) -> Result<Vec<String>, PolicyError> {
247    let mut tables = Vec::new();
248    let mut depth = 0_i32;
249    let mut in_string = false;
250    let mut start = None;
251
252    for (index, character) in input.char_indices() {
253        match character {
254            '"' => in_string = !in_string,
255            '{' if !in_string => {
256                if depth == 0 {
257                    start = Some(index);
258                }
259                depth += 1;
260            }
261            '}' if !in_string => {
262                depth -= 1;
263                if depth == 0 {
264                    if let Some(start_index) = start.take() {
265                        tables.push(input[start_index..=index].to_owned());
266                    }
267                }
268            }
269            _ => {}
270        }
271    }
272
273    if depth != 0 {
274        return Err(PolicyError::Parse(
275            "unbalanced inline table braces".to_owned(),
276        ));
277    }
278
279    Ok(tables)
280}
281
282fn parse_requirement_table(table: &str) -> Result<Requirement, PolicyError> {
283    let trimmed = table.trim();
284    if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
285        return Err(PolicyError::Parse(format!(
286            "expected inline table, got `{table}`"
287        )));
288    }
289    let inner = &trimmed[1..trimmed.len() - 1];
290    let pairs = split_top_level(inner, ',');
291    let mut kind = String::new();
292    let mut path = String::new();
293    let mut tool = String::new();
294    let mut check = String::new();
295    let mut name = String::new();
296    let mut key = String::new();
297    let mut min_count = None;
298
299    for pair in pairs {
300        let (field, value) = split_key_value(pair.trim())?;
301        match field {
302            "kind" => kind = parse_string(value)?,
303            "path" => path = parse_string(value)?,
304            "tool" => tool = parse_string(value)?,
305            "check" => check = parse_string(value)?,
306            "name" => name = parse_string(value)?,
307            "key" => key = parse_string(value)?,
308            "min_count" => min_count = Some(parse_u16(value)? as u8),
309            other => {
310                return Err(PolicyError::Parse(format!(
311                    "unsupported requirement field `{other}`"
312                )))
313            }
314        }
315    }
316
317    match kind.as_str() {
318        "artifact_exists" => Ok(Requirement::ArtifactExists { path }),
319        "receipt_pass" => Ok(Requirement::ReceiptPass { tool, check }),
320        "issue_linked" => Ok(Requirement::IssueLinked),
321        "ci_check_passed" => Ok(Requirement::CiCheckPassed { name }),
322        "review_approved" => Ok(Requirement::ReviewApproved {
323            min_count: min_count.unwrap_or(1),
324        }),
325        "conversations_resolved" => Ok(Requirement::ConversationsResolved),
326        "attestation_present" => Ok(Requirement::AttestationPresent { key }),
327        other => Err(PolicyError::Parse(format!(
328            "unsupported requirement kind `{other}`"
329        ))),
330    }
331}
332
333fn split_top_level(input: &str, separator: char) -> Vec<&str> {
334    let mut pieces = Vec::new();
335    let mut in_string = false;
336    let mut brace_depth = 0_i32;
337    let mut bracket_depth = 0_i32;
338    let mut start = 0usize;
339
340    for (index, character) in input.char_indices() {
341        match character {
342            '"' => in_string = !in_string,
343            '{' if !in_string => brace_depth += 1,
344            '}' if !in_string => brace_depth -= 1,
345            '[' if !in_string => bracket_depth += 1,
346            ']' if !in_string => bracket_depth -= 1,
347            _ if !in_string && brace_depth == 0 && bracket_depth == 0 && character == separator => {
348                pieces.push(input[start..index].trim());
349                start = index + character.len_utf8();
350            }
351            _ => {}
352        }
353    }
354
355    let tail = input[start..].trim();
356    if !tail.is_empty() {
357        pieces.push(tail);
358    }
359
360    pieces
361}
362
363fn title_case(input: &str) -> String {
364    input
365        .split(['-', '_'])
366        .filter(|part| !part.is_empty())
367        .map(|part| {
368            let mut chars = part.chars();
369            match chars.next() {
370                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
371                None => String::new(),
372            }
373        })
374        .collect::<Vec<_>>()
375        .join(" ")
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn given_valid_policy_toml_when_parse_then_gates_are_sorted() {
384        let policy = parse_policy(
385            r#"
386                profile = "conveyor-6"
387
388                [gates.verified]
389                order = 2
390                requires = [{ kind = "artifact_exists", path = "verified.md" }]
391
392                [gates.framed]
393                order = 1
394                requires = [{ kind = "issue_linked" }]
395            "#,
396        )
397        .expect("policy");
398
399        assert_eq!(policy.gates[0].id, "framed");
400        assert_eq!(policy.gates[1].id, "verified");
401        assert_eq!(policy.gates[0].name, "Framed");
402    }
403
404    #[test]
405    fn given_duplicate_order_when_parse_then_error_is_returned() {
406        let error = parse_policy(
407            r#"
408                profile = "conveyor-6"
409
410                [gates.a]
411                order = 1
412                requires = [{ kind = "issue_linked" }]
413
414                [gates.b]
415                order = 1
416                requires = [{ kind = "issue_linked" }]
417            "#,
418        )
419        .expect_err("duplicate order");
420
421        assert!(matches!(error, PolicyError::Parse(_)));
422    }
423}