Skip to main content

hidane_rules/
lib.rs

1//! Firestore Security Rules as hidane implements them: the lexer, the parser and the checks of
2//! the official emulator's compiler (`PUT :securityRules`), with its diagnostics.
3//!
4//! [`compile`] takes the files of a ruleset and returns the [`Ruleset`] (if it compiled) and
5//! the [`Issue`]s: warnings for a ruleset that compiles, warnings and errors for one that does
6//! not, in the official emulator's words and positions. [`eval::authorize`] decides a request
7//! against a ruleset.
8//!
9//! What the official emulator does, as measured with `tools/oracle/rules_compile.py`
10//! (`crates/hidane/tests/fixtures/rules_compile.json`):
11//!
12//! - Every file is parsed and its paths checked. Only the first file is checked further and
13//!   becomes the ruleset; the others only add their errors.
14//! - A syntax error stops there: the message lists the syntax errors and the grammar's own
15//!   (`Missing 'if' before conditional expression.`). Otherwise the version, imports and
16//!   `match` paths are checked (an unsupported version stops), then namespaces used as
17//!   variables and expressions nested too deep, and, if nothing was an error, the names,
18//!   functions and types.
19//! - Issues are ordered by line, column and text; one about the whole ruleset (the old version
20//!   warning) has no position and comes first.
21
22mod ast;
23mod check;
24pub mod coverage;
25mod diag;
26pub mod eval;
27mod lexer;
28mod parser;
29pub mod paths;
30
31pub use ast::*;
32pub use diag::{Issue, Position, Severity};
33
34/// A file of a ruleset, as `PUT :securityRules` sends it.
35#[derive(Debug, Clone, Default)]
36pub struct SourceFile {
37    pub name: Option<String>,
38    pub content: String,
39}
40
41/// What [`compile`] found.
42#[derive(Debug, Clone)]
43pub struct Compilation {
44    /// The ruleset, when there is no error.
45    pub ruleset: Option<Ruleset>,
46    pub issues: Vec<Issue>,
47}
48
49impl Compilation {
50    pub fn has_errors(&self) -> bool {
51        self.issues.iter().any(|i| i.severity == Severity::Error)
52    }
53
54    /// The official emulator's message for a ruleset that does not compile: every issue, one
55    /// per line.
56    pub fn error_message(&self) -> String {
57        let mut message = "Error compiling rules:".to_owned();
58        for issue in &self.issues {
59            message.push('\n');
60            message.push_str(&issue.line());
61        }
62        message
63    }
64}
65
66/// Compiles the files of a ruleset. Without files there is nothing to compile: the result is
67/// an error about empty content.
68///
69/// Parsing and checking recurse as deep as the input nests, so they run on a thread of their
70/// own with a large stack; the ruleset that comes back is at most a few thousand levels deep.
71pub fn compile(files: &[SourceFile]) -> Compilation {
72    std::thread::scope(|scope| {
73        let thread = std::thread::Builder::new()
74            .name("hidane-rules".into())
75            .stack_size(COMPILE_STACK)
76            .spawn_scoped(scope, || compile_here(files));
77        match thread {
78            Ok(thread) => thread
79                .join()
80                .unwrap_or_else(|panic| std::panic::resume_unwind(panic)),
81            // Without a thread, on this one: the parser's limits still bound the depth.
82            Err(_) => compile_here(files),
83        }
84    })
85}
86
87/// The stack of the compiling thread. Only the part used is committed.
88const COMPILE_STACK: usize = 256 * 1024 * 1024;
89
90fn compile_here(files: &[SourceFile]) -> Compilation {
91    if files.is_empty() || files.iter().any(|f| f.content.is_empty()) {
92        return Compilation {
93            ruleset: None,
94            issues: vec![Issue {
95                file_name: None,
96                position: None,
97                description: "Rules content empty. Compilation aborted.".to_owned(),
98                severity: Severity::Error,
99            }],
100        };
101    }
102    let mut parsed: Vec<parser::Parsed> = files.iter().map(|f| parser::parse(&f.content)).collect();
103    let finish = |parsed: Vec<parser::Parsed>, ruleset| {
104        let mut issues = Vec::new();
105        for (file, p) in files.iter().zip(parsed) {
106            let mut diags = p.diags;
107            diag::sort(&mut diags);
108            let offsets = diag::Offsets::new(&file.content);
109            issues.extend(
110                diags
111                    .into_iter()
112                    .map(|d| d.into_issue(&offsets, file.name.as_deref())),
113            );
114        }
115        Compilation { ruleset, issues }
116    };
117    if parsed.iter().any(|p| p.syntax_error) {
118        return finish(parsed, None);
119    }
120    let mut supported = true;
121    let mut versions = Vec::new();
122    for p in &mut parsed {
123        let version = check::version(&p.file, &mut p.diags);
124        let too_deep = check::structure(&p.file, version, &mut p.diags);
125        supported &= version.is_some() && !too_deep;
126        versions.push(version);
127    }
128    if !supported {
129        return finish(parsed, None);
130    }
131    for p in &mut parsed {
132        check::packages(&p.file, &mut p.diags);
133        check::numbers(&p.file, &mut p.diags);
134        check::complexity(&p.file, &mut p.diags);
135    }
136    let has_errors = |parsed: &[parser::Parsed]| {
137        parsed
138            .iter()
139            .flat_map(|p| &p.diags)
140            .any(|d| d.severity == Severity::Error)
141    };
142    if has_errors(&parsed) {
143        return finish(parsed, None);
144    }
145    let version = versions[0].unwrap_or(Version::V1);
146    let first = &mut parsed[0];
147    check::semantics(&first.file, version, &mut first.diags);
148    if has_errors(&parsed) {
149        return finish(parsed, None);
150    }
151    let first = std::mem::take(&mut parsed[0].file);
152    let ruleset = Ruleset {
153        version,
154        file_name: files[0].name.clone(),
155        functions: first.functions,
156        services: first.services,
157        parens: first.parens,
158    };
159    finish(parsed, Some(ruleset))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn file(name: &str, content: &str) -> SourceFile {
167        SourceFile {
168            name: Some(name.to_owned()),
169            content: content.to_owned(),
170        }
171    }
172
173    fn rules(condition: &str) -> String {
174        format!(
175            "rules_version = '2';\nservice cloud.firestore {{\n  match /databases/{{database}}/documents {{\n    match /a/{{x}} {{\n      allow read: if {condition};\n    }}\n  }}\n}}\n"
176        )
177    }
178
179    #[test]
180    fn a_ruleset_with_warnings() {
181        let c = compile(&[file("f.rules", &rules("'😀' != '' && foo() && x != null"))]);
182        assert!(!c.has_errors());
183        let ruleset = c.ruleset.expect("compiled");
184        assert_eq!(ruleset.version, Version::V2);
185        assert!(ruleset.service("cloud.firestore").is_some());
186        let issues: Vec<_> = c
187            .issues
188            .iter()
189            .map(|i| {
190                let p = i.position.unwrap();
191                (
192                    p.line,
193                    p.column,
194                    p.current_offset,
195                    p.end_offset,
196                    i.description.as_str(),
197                )
198            })
199            .collect();
200        // Columns and offsets count code points, and the end offset is inclusive.
201        assert_eq!(
202            issues,
203            vec![
204                (5, 35, 142, 144, "Invalid function name: foo."),
205                (
206                    5,
207                    46,
208                    153,
209                    154,
210                    "The sub-expressions are not comparable, so this comparison will always return true."
211                ),
212            ]
213        );
214        assert_eq!(c.issues[0].file_name.as_deref(), Some("f.rules"));
215    }
216
217    #[test]
218    fn errors_come_with_the_warnings_found_with_them() {
219        let src = "service cloud.firestore {\n  function f() { let a = 1; return a == 1; }\n  match /a { allow read: if f(); }\n}\n";
220        let c = compile(&[file("f.rules", src)]);
221        assert!(c.has_errors() && c.ruleset.is_none());
222        assert_eq!(
223            c.error_message(),
224            "Error compiling rules:\n\
225             L0:0 Ruleset uses old version (version [1]). Please update to the latest version (version [2]).\n\
226             L2:22 Let bindings are not allowed in rules_version=\"1\";"
227        );
228    }
229
230    #[test]
231    fn only_the_first_file_is_checked_in_full() {
232        let c = compile(&[
233            file("a.rules", &rules("true")),
234            file("b.rules", &rules("foo()")),
235        ]);
236        assert!(c.issues.is_empty() && c.ruleset.is_some());
237        let c = compile(&[
238            file("a.rules", &rules("true")),
239            file("b.rules", &rules("true &&")),
240        ]);
241        assert!(c.has_errors());
242        assert_eq!(c.issues[0].file_name.as_deref(), Some("b.rules"));
243        let c = compile(&[file("a.rules", &rules("true")), file("b.rules", "")]);
244        assert_eq!(
245            c.error_message(),
246            "Error compiling rules:\nL0:0 Rules content empty. Compilation aborted."
247        );
248    }
249
250    #[test]
251    fn stages() {
252        // A syntax error hides the path checks; without one, they come with the grammar's.
253        let bad_path = |condition: &str| {
254            format!(
255                "rules_version = '2';\nservice s {{\n  match /{{a}}/{{a}} {{\n    allow read: {condition};\n  }}\n}}\n"
256            )
257        };
258        let c = compile(&[file("f", &bad_path("if true &&"))]);
259        assert_eq!(c.issues.len(), 1);
260        let c = compile(&[file("f", &bad_path("true"))]);
261        let lines: Vec<_> = c.issues.iter().map(Issue::line).collect();
262        assert_eq!(
263            lines,
264            vec![
265                "L3:15 Variable a already exists in the PathTemplate.",
266                "L4:17 Missing 'if' before conditional expression.",
267            ]
268        );
269        // An unsupported version stops before the namespaces are checked.
270        let c = compile(&[file(
271            "f",
272            "rules_version = '3';\nservice s { match /a { allow read: if math == 1; } }",
273        )]);
274        assert_eq!(
275            c.error_message(),
276            "Error compiling rules:\nL1:1 Unsupported rules version 3."
277        );
278    }
279
280    #[test]
281    fn int_literals_beyond_64_bits() {
282        for (condition, error) in [
283            ("-9223372036854775808 == 1", None),
284            ("- 9223372036854775808 == 1", None),
285            (
286                "-9223372036854775809 == 1",
287                Some("L5:23 Invalid number literal 9223372036854775809."),
288            ),
289            (
290                "-(9223372036854775808) == 1",
291                Some("L5:24 Invalid number literal 9223372036854775808."),
292            ),
293            (
294                "1 - 9223372036854775808 == 1",
295                Some("L5:26 Invalid number literal 9223372036854775808."),
296            ),
297            (
298                "[9223372036854775808] == 1",
299                Some("L5:23 Invalid number literal 9223372036854775808."),
300            ),
301        ] {
302            let c = compile(&[file("f", &rules(condition))]);
303            let lines: Vec<String> = c.issues.iter().map(Issue::line).collect();
304            assert_eq!(lines, error.into_iter().collect::<Vec<_>>(), "{condition}");
305        }
306    }
307
308    /// Input nested far beyond the limits is refused, not a stack overflow.
309    #[test]
310    fn deep_input() {
311        let service = |body: String| format!("rules_version = '2';\nservice s {{\n{body}}}\n");
312        for (src, first) in [
313            (
314                rules(&format!("{}true{}", "(".repeat(50_000), ")".repeat(50_000))),
315                "L5:121 Expression is too complex to evaluate safely.",
316            ),
317            (
318                rules(&format!("{}true", "!".repeat(50_000))),
319                "L5:121 Expression is too complex to evaluate safely.",
320            ),
321            (
322                rules(&vec!["true"; 200_000].join(" && ")),
323                "L5:22 Expression is too complex to evaluate safely.",
324            ),
325            (
326                rules(&format!("request{} == 1", ".a".repeat(100_000))),
327                "L5:4029 Expression is too complex to evaluate safely.",
328            ),
329            (
330                service(format!(
331                    "{}allow read;\n{}",
332                    "match /a {\n".repeat(5_000),
333                    "}\n".repeat(5_000)
334                )),
335                "L66:1 Rule hierarchy is too complex to evaluate safely.",
336            ),
337        ] {
338            let c = compile(&[file("f", &src)]);
339            assert!(c.has_errors());
340            assert_eq!(c.issues[0].line(), first);
341        }
342    }
343}