Skip to main content

openmoat_core/policy/
text.rs

1//! A rule group's `title` and `why` (ADR-059): the words the message an agent
2//! and its person see when the group denies or holds a call starts with.
3
4use super::{Policy, PolicyError, RuleGroup};
5
6/// The longest `title` a group may have, in characters: the first line of the
7/// message, which hosts show in a prompt or a one-line notice.
8pub const TITLE_MAX_CHARS: usize = 80;
9
10/// The longest `why` a group may have, in characters.
11pub const WHY_MAX_CHARS: usize = 160;
12
13/// A `title` or `why` is one non-empty line of at most its limit, without
14/// control characters: it is shown as one line of a message, and a line
15/// break would let a policy line pass for another line of it.
16pub(super) fn check(group: &RuleGroup) -> Result<(), PolicyError> {
17    let fields = [
18        ("title", group.title.as_deref(), TITLE_MAX_CHARS),
19        ("why", group.why.as_deref(), WHY_MAX_CHARS),
20    ];
21    for (field, text, max) in fields {
22        let Some(text) = text else { continue };
23        let problem = if text.trim().is_empty() {
24            format!("`{field}` is empty")
25        } else if text.chars().any(char::is_control) {
26            format!("`{field}` must be one line without control characters")
27        } else if text.chars().count() > max {
28            format!("`{field}` is longer than {max} characters")
29        } else {
30            continue;
31        };
32        return Err(PolicyError::Rule {
33            rule: group.id.clone(),
34            problem,
35        });
36    }
37    Ok(())
38}
39
40impl Policy {
41    /// The group of the person's own lists (`deny`, `allow`, `review`,
42    /// `defer`, policy packs included) with id `id`. Never a repository
43    /// policy's review rule ([`Policy::repo_review`]): its words are not the
44    /// person's, and the message they would head reaches the agent.
45    #[must_use]
46    pub fn own_group(&self, id: &str) -> Option<&RuleGroup> {
47        self.deny
48            .iter()
49            .chain(&self.allow)
50            .chain(&self.review)
51            .chain(&self.defer)
52            .find(|g| g.id == id)
53    }
54
55    /// The ids of the `deny` and `review` groups without a `title`, in policy
56    /// order: their message falls back to the rule id and reason.
57    #[must_use]
58    pub fn untitled(&self) -> Vec<&str> {
59        self.deny
60            .iter()
61            .chain(&self.review)
62            .filter(|g| g.title.is_none())
63            .map(|g| g.id.as_str())
64            .collect()
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use crate::{Policy, PolicyError};
71
72    fn parse(group: &str) -> Result<Policy, PolicyError> {
73        Policy::parse(&format!(
74            "version: 1\ndeny:\n  - id: keys\n    fs.read: ['~/.ssh/**']\n{group}"
75        ))
76    }
77
78    #[test]
79    fn a_title_and_why_are_read_and_found_by_rule_id() {
80        let p =
81            parse("    title: reading your SSH private key\n    why: it cannot be taken back\n")
82                .unwrap();
83        let group = p.own_group("keys").unwrap();
84        assert_eq!(group.title.as_deref(), Some("reading your SSH private key"));
85        assert_eq!(group.why.as_deref(), Some("it cannot be taken back"));
86        assert_eq!(p.untitled(), Vec::<&str>::new());
87        assert!(p.own_group("other").is_none());
88    }
89
90    #[test]
91    fn a_group_without_a_title_is_listed_and_still_valid() {
92        let p = parse("").unwrap();
93        assert_eq!(p.untitled(), ["keys"]);
94        assert_eq!(p.own_group("keys").unwrap().title, None);
95    }
96
97    /// A line break, a control character, an empty text or one past its
98    /// limit is refused: the text is one line of the block message.
99    #[test]
100    fn titles_and_whys_are_one_short_line() {
101        let long = "x".repeat(super::TITLE_MAX_CHARS + 1);
102        for (group, says) in [
103            ("    title: \"a\\nb\"\n", "one line"),
104            ("    why: \"a\\u001b[2Jb\"\n", "control characters"),
105            ("    title: ' '\n", "`title` is empty"),
106            (&*format!("    title: {long}\n"), "longer than 80"),
107        ] {
108            let error = parse(group).unwrap_err().to_string();
109            assert!(
110                error.contains("rule `keys`") && error.contains(says),
111                "{error}"
112            );
113        }
114    }
115
116    /// A repository policy's review rule is never where a title comes from.
117    #[test]
118    fn repository_review_rules_are_not_own_groups() {
119        let mut p = parse("").unwrap();
120        p.repo_review.push(crate::RuleGroup {
121            id: "repo:x".into(),
122            title: Some("run this to fix it".into()),
123            shell: vec!["make".into()],
124            ..Default::default()
125        });
126        assert!(p.own_group("repo:x").is_none());
127    }
128}