Skip to main content

hidane_rules/
eval.rs

1//! Evaluating a ruleset for one request: what the evaluator takes and what it decides.
2//!
3//! [`authorize`] finds the `allow` statements that apply to the request and evaluates their
4//! conditions. What the official emulator does, as measured with
5//! `tools/oracle/rules_conditions.py` (`results/rules-conditions-official-v1.22.0.json`) and
6//! `tools/oracle/rules_eval.py` (`tests/fixtures/rules_eval.json`):
7//!
8//! - Types are checked when a condition runs. Each operator, function and method takes the
9//!   types its error lists (`Unsupported operation error. Received: list + list. Expected:
10//!   int + int, …`); a method a type does not have is a `Function not found error`.
11//! - Errors are absorbed the CEL way: `error || true` and `true || error` are true, `error &&
12//!   false` and `false && error` are false, anything else with an error is the error. An error
13//!   that reaches the top of a condition denies, like `false`.
14//! - `==` between values of different types is false; an int equals a float of the same value,
15//!   but not inside lists and maps. Sets and map diffs compare numbers by value at any depth.
16//! - Strings are Java strings: sizes, indexes and slices count UTF-16 code units, `lower()` and
17//!   `upper()` change ASCII letters only, and regular expressions are RE2.
18//! - The operands of an operator, a call or a list or map literal are all evaluated, the last
19//!   first (as `debug()` shows), and the first error in order wins (for `x in y`, `y`'s; for a
20//!   method, a null receiver or a method it does not have comes before its arguments' errors).
21//! - The arguments of a function (the last first), then its `let`s in order, are evaluated
22//!   before its result, but an error in one only matters if the function uses it.
23//!
24//! Each condition is evaluated once, so a denial lists each statement once; the official
25//! emulator evaluates again after an error and lists everything twice
26//! (`docs/parity-exceptions.md`).
27//!
28//! [`authorize_traced`] also reports what the official emulator logs and counts for its
29//! coverage report (`tools/oracle/rules_coverage.py`, [`Trace`]): each `debug()` value, and
30//! what each expression evaluated to. Which statements it evaluates shows there: those of
31//! every `match` covering the path, each `match`'s until one allows.
32
33mod constraint;
34mod interp;
35mod library;
36mod list;
37mod time;
38mod trace;
39mod value;
40
41pub use constraint::Comparator;
42pub use trace::{KeyStatus, Observed, Outcome, Segment, Trace};
43pub(crate) use trace::{NodeKey, Traced, hash_val, observe, shows};
44pub use value::java_double;
45
46use std::rc::Rc;
47
48use indexmap::IndexMap;
49
50use crate::{
51    Ruleset, ast,
52    paths::{Binding, Covering},
53};
54use interp::{Frame, Interp, Level};
55pub(crate) use value::Val;
56
57/// A value as rules see it. Firestore values convert to these (`timestamp`, `bytes`, `latlng`,
58/// a reference as a `path`, maps and lists); the others only come from rules.
59#[derive(Debug, Clone, PartialEq)]
60pub enum Value {
61    Null,
62    Bool(bool),
63    Int(i64),
64    Float(f64),
65    String(String),
66    Bytes(Vec<u8>),
67    Timestamp(Timestamp),
68    /// Seconds and nanoseconds.
69    Duration(i64, i32),
70    /// Latitude and longitude, in degrees.
71    LatLng(f64, f64),
72    /// The segments of a path. A reference holds its document's path from `databases`, like
73    /// [`Request::path`] and [`Document::path`].
74    Path(Vec<String>),
75    List(Vec<Value>),
76    Map(Map),
77}
78
79/// A map's entries, in the order the document or the literal holds them. Equality ignores the
80/// order; where rules list keys in another order, the evaluator sorts.
81pub type Map = IndexMap<String, Value>;
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
84pub struct Timestamp {
85    pub seconds: i64,
86    pub nanos: i32,
87}
88
89/// The method a request is checked against. `read` covers `get` and `list`, `write` covers
90/// `create`, `update` and `delete`.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Method {
93    Get,
94    List,
95    Create,
96    Update,
97    Delete,
98}
99
100impl Method {
101    /// The name rules see in `request.method` and denials print.
102    pub fn name(self) -> &'static str {
103        match self {
104            Self::Get => "get",
105            Self::List => "list",
106            Self::Create => "create",
107            Self::Update => "update",
108            Self::Delete => "delete",
109        }
110    }
111
112    fn statement(self) -> ast::Method {
113        match self {
114            Self::Get => ast::Method::Get,
115            Self::List => ast::Method::List,
116            Self::Create => ast::Method::Create,
117            Self::Update => ast::Method::Update,
118            Self::Delete => ast::Method::Delete,
119        }
120    }
121}
122
123/// A document as `resource`, `request.resource` and `get()` give it.
124#[derive(Debug, Clone, PartialEq)]
125pub struct Document {
126    /// The path from `databases`: `databases`, the database ID, `documents`, then the
127    /// document's segments.
128    pub path: Vec<String>,
129    pub data: Map,
130}
131
132/// `request.auth` for a caller with a token.
133#[derive(Debug, Clone, PartialEq)]
134pub struct Auth {
135    /// The token's `sub`; without one, `request.auth.uid` is null.
136    pub uid: Option<String>,
137    /// Every claim of the token.
138    pub token: Map,
139}
140
141/// `request.query` for a list, and the filter that decides it.
142#[derive(Debug, Clone, PartialEq, Default)]
143pub struct Query {
144    pub limit: Option<i64>,
145    pub offset: Option<i64>,
146    /// Field path to `ASC` or `DESC`.
147    pub order_by: Vec<(String, String)>,
148    pub filter: Option<Filter>,
149    /// For a collection group, its collection ID: the list covers the collections with that ID
150    /// at any depth below [`Request::path`] (an empty ID: every collection below it).
151    pub group: Option<String>,
152}
153
154/// A query's filter.
155#[derive(Debug, Clone, PartialEq)]
156pub enum Filter {
157    And(Vec<Filter>),
158    Or(Vec<Filter>),
159    /// A field, by its segments (`a.b` is `["a", "b"]`), and the value it is compared with
160    /// (null for the unary operators; a list for `In`, `NotIn` and `ArrayContainsAny`).
161    Field {
162        field: Vec<String>,
163        op: FilterOp,
164        value: Value,
165    },
166    /// The document's name, and a reference ([`Value::Path`]) or a list of them.
167    Name {
168        op: FilterOp,
169        value: Value,
170    },
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum FilterOp {
175    Lt,
176    Le,
177    Gt,
178    Ge,
179    Eq,
180    Ne,
181    ArrayContains,
182    In,
183    ArrayContainsAny,
184    NotIn,
185    IsNan,
186    IsNull,
187    IsNotNan,
188    IsNotNull,
189}
190
191/// One request, as rules see it.
192#[derive(Debug, Clone, PartialEq)]
193pub struct Request {
194    pub method: Method,
195    /// A create or an update without an existence precondition (a set, a patch, a transform):
196    /// whether the document exists chose the method. A denial then also lists the statements of
197    /// the other of the two, which cannot allow the write (`tools/oracle/rules_writes.py`,
198    /// `rules_denials.py`).
199    pub upsert: bool,
200    /// The path checked, from `databases` (a document, or for a list its collection).
201    pub path: Vec<String>,
202    pub auth: Option<Auth>,
203    pub time: Timestamp,
204    /// The document as it would be after a create or an update; `None` otherwise.
205    pub resource: Option<Document>,
206    pub query: Option<Query>,
207}
208
209/// The documents `get()` / `exists()` read (as they were before the request's writes) and
210/// `getAfter()` / `existsAfter()` read (as they would be after all of them).
211pub trait Documents {
212    fn get(&self, path: &[String]) -> Option<Document>;
213    fn get_after(&self, path: &[String]) -> Option<Document>;
214}
215
216/// The lookups a request may still make (`tools/oracle/rules_lookups.py`): a single read or
217/// write may look up 10 distinct documents, a query 20, and a batch 10 per write and 20 in all.
218/// A document looked up again costs nothing, across the writes of a batch too, and so does the
219/// document a request reads or writes and every document its batch writes
220/// (`tools/oracle/rules_eval.py`).
221#[derive(Debug, Clone)]
222pub struct Budget {
223    /// Distinct documents looked up so far, by the whole request or batch.
224    pub looked_up: Vec<Vec<String>>,
225    /// How many of them the current write or read looked up first.
226    pub this_part: usize,
227    pub part_limit: usize,
228    pub total_limit: usize,
229    /// The documents the batch writes, from `databases` (see [`Budget::add_written`]).
230    pub written: Vec<Vec<String>>,
231}
232
233impl Budget {
234    fn with(part_limit: usize, total_limit: usize) -> Self {
235        Self {
236            looked_up: Vec::new(),
237            this_part: 0,
238            part_limit,
239            total_limit,
240            written: Vec::new(),
241        }
242    }
243
244    /// Records a document the batch writes (or deletes): any write of the batch may look it up
245    /// for free. The document of the request being decided is free without this.
246    pub fn add_written(&mut self, path: Vec<String>) {
247        if !self.written.contains(&path) {
248            self.written.push(path);
249        }
250    }
251
252    /// A single read or write.
253    pub fn single() -> Self {
254        Self::with(10, 10)
255    }
256
257    /// A query.
258    pub fn query() -> Self {
259        Self::with(20, 20)
260    }
261
262    /// A batch of writes; call [`Budget::next_write`] before each one.
263    pub fn batch() -> Self {
264        Self::with(10, 20)
265    }
266
267    pub fn next_write(&mut self) {
268        self.this_part = 0;
269    }
270}
271
272/// What the rules decided.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum Decision {
275    Allow,
276    /// `PERMISSION_DENIED`, with the official emulator's message.
277    Deny(String),
278}
279
280/// Decides one request against `ruleset` (the `cloud.firestore` service). `resource` is the
281/// stored document (`None` if it does not exist, or for a list). The rules' version decides
282/// how recursive wildcards match.
283///
284/// The `allow` statements of every `match` that covers the path and names the method apply, in
285/// source order; one whose condition is true allows. A denial lists each one's result:
286/// `false for 'get' @ L5` or the error instead of `false`, with the line of the condition, or
287/// says `No matching allow statements` when none applies. For an upsert the statements of the
288/// other method are listed too, `create`'s first: each is `false`, or its condition's error.
289/// Their lookups do not count against `budget`.
290///
291/// A list is decided from its query's filter, not from the documents (`list`).
292pub fn authorize(
293    ruleset: &Ruleset,
294    request: &Request,
295    resource: Option<&Document>,
296    documents: &dyn Documents,
297    budget: &mut Budget,
298) -> Decision {
299    authorize_traced(
300        ruleset,
301        request,
302        resource,
303        documents,
304        budget,
305        &mut Trace::new(),
306    )
307}
308
309/// [`authorize`], reporting into `trace` what the conditions evaluated.
310///
311/// An upsert evaluates the statements of both methods, whichever applies, as the official
312/// emulator does: `create`'s, then `update`'s, as [`authorize`] evaluates those of a method
313/// (`tools/oracle/rules_coverage.py`). Only those of the method that applies decide.
314pub fn authorize_traced(
315    ruleset: &Ruleset,
316    request: &Request,
317    resource: Option<&Document>,
318    documents: &dyn Documents,
319    budget: &mut Budget,
320    trace: &mut Trace,
321) -> Decision {
322    let Some(service) = ruleset.service("cloud.firestore") else {
323        return no_match();
324    };
325    if request.method == Method::List {
326        return list::authorize(ruleset, service, request, documents, budget, trace);
327    }
328    let path: Vec<&str> = request.path.iter().map(String::as_str).collect();
329    let coverings = crate::paths::covering(ruleset.version, service, &path);
330    let other = match request.method {
331        Method::Create if request.upsert => Some(Method::Update),
332        Method::Update if request.upsert => Some(Method::Create),
333        _ => None,
334    };
335    let applying = statements(&coverings, request.method);
336    let others = other.map(|other| (other, statements(&coverings, other)));
337    if applying.is_empty() && others.as_ref().is_none_or(|(_, s)| s.is_empty()) {
338        return no_match();
339    }
340
341    let context = Context {
342        ruleset,
343        service,
344        request: request_value(request),
345        resource: resource.map_or(Val::Null, value::resource),
346        target: &request.path,
347        documents,
348    };
349    let Some((other, others)) = others else {
350        return match context.decide(&applying, request.method, budget, trace) {
351            Ok(()) => Decision::Allow,
352            Err(entries) => denied(&entries),
353        };
354    };
355    // `create`'s statements come first: before the decision when `update` applies. The other
356    // method's lookups count against a copy of the budget.
357    if other == Method::Create {
358        context.each(&others, other, &mut budget.clone(), trace);
359    }
360    let decided = context.decide(&applying, request.method, budget, trace);
361    let Err(entries) = decided else {
362        if other == Method::Update {
363            context.each(&others, other, &mut budget.clone(), trace);
364        }
365        return Decision::Allow;
366    };
367    // A denial lists every statement of the other method; `create`'s, evaluated already, are
368    // evaluated again without being traced.
369    let listed = match other {
370        Method::Create => context.listed(&others, other, &mut budget.clone(), &mut Trace::new()),
371        _ => context.listed(&others, other, &mut budget.clone(), trace),
372    };
373    let entries: Vec<String> = match other {
374        Method::Create => listed.into_iter().chain(entries).collect(),
375        _ => entries.into_iter().chain(listed).collect(),
376    };
377    denied(&entries)
378}
379
380fn no_match() -> Decision {
381    Decision::Deny("No matching allow statements".to_owned())
382}
383
384/// A denial listing `entries`.
385fn denied(entries: &[String]) -> Decision {
386    Decision::Deny(format!("\n{}", entries.join(", ")))
387}
388
389/// The `allow` statements for `method` of every `match` in `coverings`, in source order.
390fn statements<'c, 'a>(
391    coverings: &'c [Covering<'a>],
392    method: Method,
393) -> Vec<(&'c ast::Allow, &'c Covering<'a>)> {
394    let method = method.statement();
395    let mut statements = Vec::new();
396    for covering in coverings {
397        for allow in &covering.target().body.allows {
398            if allow
399                .methods
400                .iter()
401                .any(|m| m.method.is_some_and(|m| m.covers(method)))
402            {
403                statements.push((allow, covering));
404            }
405        }
406    }
407    statements.sort_by_key(|(allow, _)| allow.span.start);
408    statements
409}
410
411fn entry(text: &str, method: Method, line: u32) -> String {
412    format!("{text} for '{}' @ L{line}", method.name())
413}
414
415/// What every condition of one request is evaluated with.
416struct Context<'a> {
417    ruleset: &'a Ruleset,
418    service: &'a ast::Service,
419    request: Val,
420    resource: Val,
421    /// The path whose document is free to look up, in the database `get()` may read.
422    target: &'a [String],
423    documents: &'a dyn Documents,
424}
425
426impl Context<'_> {
427    /// Evaluates `statements` in order; if none allows, each one's entry. As the official
428    /// emulator does, the statements of a `match` stop at the first that allows, but those of
429    /// every other `match` are evaluated too (`tools/oracle/rules_coverage.py`).
430    fn decide(
431        &self,
432        statements: &[(&ast::Allow, &Covering<'_>)],
433        method: Method,
434        budget: &mut Budget,
435        trace: &mut Trace,
436    ) -> Result<(), Vec<String>> {
437        let (entries, allowed) = self.each(statements, method, budget, trace);
438        if allowed { Ok(()) } else { Err(entries) }
439    }
440
441    /// The statements of an upsert's method that does not apply, for a denial: each one's
442    /// entry, `false` or its condition's error.
443    fn listed(
444        &self,
445        statements: &[(&ast::Allow, &Covering<'_>)],
446        method: Method,
447        budget: &mut Budget,
448        trace: &mut Trace,
449    ) -> Vec<String> {
450        let mut entries = Vec::with_capacity(statements.len());
451        let mut marks = Vec::with_capacity(statements.len());
452        for (allow, covering) in statements {
453            marks.push(trace.mark());
454            let (text, line) = match &allow.condition {
455                Some(condition) => (
456                    self.evaluate(covering, condition, budget, trace).err(),
457                    condition.span.line,
458                ),
459                None => (None, allow.span.line),
460            };
461            entries.push(entry(text.as_deref().unwrap_or("false"), method, line));
462        }
463        trace.repeat(&marks);
464        entries
465    }
466
467    /// Evaluates `statements` but those after one that allowed in the same `match`. Returns the
468    /// entries of those that did not allow, and whether one did.
469    fn each(
470        &self,
471        statements: &[(&ast::Allow, &Covering<'_>)],
472        method: Method,
473        budget: &mut Budget,
474        trace: &mut Trace,
475    ) -> (Vec<String>, bool) {
476        let mut entries = Vec::with_capacity(statements.len());
477        let mut marks = Vec::with_capacity(statements.len());
478        let mut allowed: Vec<*const ast::Match> = Vec::new();
479        for (allow, covering) in statements {
480            let target = std::ptr::from_ref(covering.target());
481            if allowed.contains(&target) {
482                continue;
483            }
484            marks.push(trace.mark());
485            let Some(condition) = &allow.condition else {
486                allowed.push(target);
487                continue;
488            };
489            let text = match self.evaluate(covering, condition, budget, trace) {
490                Ok(true) => {
491                    allowed.push(target);
492                    continue;
493                }
494                Ok(false) => "false".to_owned(),
495                Err(e) => e,
496            };
497            entries.push(entry(&text, method, condition.span.line));
498        }
499        trace.repeat(&marks);
500        (entries, !allowed.is_empty())
501    }
502
503    /// The condition of a statement at the end of `covering`: true, false or the error's text.
504    fn evaluate(
505        &self,
506        covering: &Covering<'_>,
507        condition: &ast::Expr,
508        budget: &mut Budget,
509        trace: &mut Trace,
510    ) -> Result<bool, String> {
511        let mut interp = Interp::new(
512            levels(self.ruleset, self.service, covering),
513            self.request.clone(),
514            self.resource.clone(),
515            self.target,
516            self.documents,
517            budget,
518            trace,
519            &self.ruleset.parens,
520        );
521        let frame = Frame::condition(covering.chain.len() + 1);
522        interp
523            .eval(condition, frame.scope())
524            .and_then(interp::boolean)
525            .map_err(|e| {
526                if e.message.is_empty() {
527                    // Undecided: the official emulator names the condition's start.
528                    format!(
529                        "evaluation error at L{}:{}",
530                        condition.span.line, condition.span.column
531                    )
532                } else {
533                    e.message
534                }
535            })
536    }
537}
538
539/// The names a condition at the end of `covering` sees: the functions of the file, of the
540/// service and of each `match`, and the variables each `match` binds.
541fn levels<'a>(
542    ruleset: &'a Ruleset,
543    service: &'a ast::Service,
544    covering: &'a Covering<'a>,
545) -> Vec<Level<'a>> {
546    let mut levels = vec![
547        Level {
548            functions: &ruleset.functions,
549            variables: Vec::new(),
550        },
551        Level {
552            functions: &service.body.functions,
553            variables: Vec::new(),
554        },
555    ];
556    let mut bindings = covering.bindings.iter();
557    for m in &covering.chain {
558        let count = m
559            .path
560            .iter()
561            .filter(|s| s.kind.variable().is_some())
562            .count();
563        let variables = bindings
564            .by_ref()
565            .take(count)
566            // A wildcard that took a list's unknown document ID is not bound: using it is a
567            // `Null value error`, as for any unknown name.
568            .filter_map(|(name, binding)| {
569                let value = match binding {
570                    Binding::Segment(s) => Val::str(s),
571                    Binding::Path(p) => Val::path(p.clone()),
572                    Binding::Unbound => return None,
573                };
574                Some((name.as_str(), value))
575            })
576            .collect();
577        levels.push(Level {
578            functions: &m.body.functions,
579            variables,
580        });
581    }
582    levels
583}
584
585/// `request`: `auth` (null without a token, else `uid` and `token`), `method`, `path` and
586/// `time`; `resource` for a write (null for a delete); `query` for a list.
587fn request_value(request: &Request) -> Val {
588    let mut map = IndexMap::new();
589    let auth = match &request.auth {
590        None => Val::Null,
591        Some(auth) => {
592            let mut a = IndexMap::new();
593            a.insert(
594                "uid".to_owned(),
595                auth.uid.as_deref().map_or(Val::Null, Val::str),
596            );
597            a.insert(
598                "token".to_owned(),
599                Val::from(&Value::Map(auth.token.clone())),
600            );
601            Val::Map(Rc::new(a))
602        }
603    };
604    map.insert("auth".to_owned(), auth);
605    map.insert("method".to_owned(), Val::str(request.method.name()));
606    map.insert("path".to_owned(), Val::path(request.path.clone()));
607    map.insert(
608        "time".to_owned(),
609        Val::from(&Value::Timestamp(request.time)),
610    );
611    if matches!(
612        request.method,
613        Method::Create | Method::Update | Method::Delete
614    ) {
615        let resource = request.resource.as_ref().map_or(Val::Null, value::resource);
616        map.insert("resource".to_owned(), resource);
617    }
618    if let Some(query) = &request.query {
619        let mut q = IndexMap::new();
620        q.insert("limit".to_owned(), query.limit.map_or(Val::Null, Val::Int));
621        q.insert("offset".to_owned(), Val::Int(query.offset.unwrap_or(0)));
622        let order_by = query
623            .order_by
624            .iter()
625            .map(|(field, direction)| (field.clone(), Val::str(direction)))
626            .collect();
627        q.insert("orderBy".to_owned(), Val::Map(Rc::new(order_by)));
628        map.insert("query".to_owned(), Val::Map(Rc::new(q)));
629    }
630    Val::Map(Rc::new(map))
631}