cedar_policy_core/entities/
conformance.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use std::collections::{BTreeMap, HashMap};
18
19use super::{json::err::TypeMismatchError, EntityTypeDescription, Schema, SchemaType};
20use super::{Eid, EntityUID, Literal};
21use crate::ast::{
22    BorrowedRestrictedExpr, Entity, PartialValue, PartialValueToRestrictedExprError, RestrictedExpr,
23};
24use crate::entities::ExprKind;
25use crate::extensions::{ExtensionFunctionLookupError, Extensions};
26use miette::Diagnostic;
27use smol_str::SmolStr;
28use thiserror::Error;
29pub mod err;
30
31use err::{EntitySchemaConformanceError, InvalidEnumEntityError, UnexpectedEntityTypeError};
32
33/// Struct used to check whether entities conform to a schema
34#[derive(Debug, Clone)]
35pub struct EntitySchemaConformanceChecker<'a, S> {
36    /// Schema to check conformance with
37    schema: &'a S,
38    /// Extensions which are active for the conformance checks
39    extensions: &'a Extensions<'a>,
40}
41
42impl<'a, S> EntitySchemaConformanceChecker<'a, S> {
43    /// Create a new checker
44    pub fn new(schema: &'a S, extensions: &'a Extensions<'a>) -> Self {
45        Self { schema, extensions }
46    }
47}
48
49impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
50    /// Validate an action
51    pub fn validate_action(&self, action: &Entity) -> Result<(), EntitySchemaConformanceError> {
52        let uid = action.uid();
53        let schema_action = self
54            .schema
55            .action(uid)
56            .ok_or_else(|| EntitySchemaConformanceError::undeclared_action(uid.clone()))?;
57        // check that the action exactly matches the schema's definition
58        if !action.deep_eq(&schema_action) {
59            return Err(EntitySchemaConformanceError::action_declaration_mismatch(
60                uid.clone(),
61            ));
62        }
63        Ok(())
64    }
65
66    /// Validate ancestors of an entity
67    pub fn validate_entity_ancestors<'a>(
68        &self,
69        uid: &EntityUID,
70        ancestors: impl Iterator<Item = &'a EntityUID>,
71        schema_etype: &impl EntityTypeDescription,
72    ) -> Result<(), EntitySchemaConformanceError> {
73        // For each ancestor that actually appears in `entity`, ensure the
74        // ancestor type is allowed by the schema
75        for ancestor_euid in ancestors {
76            validate_euid(self.schema, ancestor_euid)
77                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
78            let ancestor_type = ancestor_euid.entity_type();
79            if schema_etype.allowed_parent_types().contains(ancestor_type) {
80                // note that `allowed_parent_types()` was transitively
81                // closed, so it's actually `allowed_ancestor_types()`
82                //
83                // thus, the check passes in this case
84            } else {
85                return Err(EntitySchemaConformanceError::invalid_ancestor_type(
86                    uid.clone(),
87                    ancestor_type.clone(),
88                ));
89            }
90        }
91        Ok(())
92    }
93
94    /// Validate attributes of an entity
95    pub fn validate_entity_attributes<'a>(
96        &self,
97        uid: &EntityUID,
98        attrs: impl Iterator<Item = (&'a SmolStr, &'a PartialValue)>,
99        schema_etype: &impl EntityTypeDescription,
100    ) -> Result<(), EntitySchemaConformanceError> {
101        let attrs: HashMap<&SmolStr, &PartialValue> = attrs.collect();
102        // Ensure that all required attributes for `etype` are actually
103        // included in `entity`
104        for required_attr in schema_etype.required_attrs() {
105            if !attrs.contains_key(&required_attr) {
106                return Err(EntitySchemaConformanceError::missing_entity_attr(
107                    uid.clone(),
108                    required_attr,
109                ));
110            }
111        }
112        // For each attribute that actually appears in `entity`, ensure it
113        // complies with the schema
114        for (attr, val) in attrs {
115            match schema_etype.attr_type(attr) {
116                None => {
117                    // `None` indicates the attribute shouldn't exist -- see
118                    // docs on the `attr_type()` trait method
119                    if !schema_etype.open_attributes() {
120                        return Err(EntitySchemaConformanceError::unexpected_entity_attr(
121                            uid.clone(),
122                            attr.clone(),
123                        ));
124                    }
125                }
126                Some(expected_ty) => {
127                    // typecheck: ensure that the entity attribute value matches
128                    // the expected type
129                    match typecheck_value_against_schematype(val, &expected_ty, self.extensions) {
130                        Ok(()) => {} // typecheck passes
131                        Err(TypecheckError::TypeMismatch(err)) => {
132                            return Err(EntitySchemaConformanceError::type_mismatch(
133                                uid.clone(),
134                                attr.clone(),
135                                err::AttrOrTag::Attr,
136                                err,
137                            ));
138                        }
139                        Err(TypecheckError::ExtensionFunctionLookup(err)) => {
140                            return Err(EntitySchemaConformanceError::extension_function_lookup(
141                                uid.clone(),
142                                attr.clone(),
143                                err::AttrOrTag::Attr,
144                                err,
145                            ));
146                        }
147                    }
148                }
149            }
150            validate_euids_in_partial_value(self.schema, val)
151                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
152        }
153        Ok(())
154    }
155
156    /// Validate tags of an entity
157    pub fn validate_tags<'a>(
158        &self,
159        uid: &EntityUID,
160        tags: impl Iterator<Item = (&'a SmolStr, &'a PartialValue)>,
161        schema_etype: &impl EntityTypeDescription,
162    ) -> Result<(), EntitySchemaConformanceError> {
163        let tags: HashMap<&SmolStr, &PartialValue> = tags.collect();
164        match schema_etype.tag_type() {
165            None => {
166                if let Some((k, _)) = tags.iter().next() {
167                    return Err(EntitySchemaConformanceError::unexpected_entity_tag(
168                        uid.clone(),
169                        k.to_string(),
170                    ));
171                }
172            }
173            Some(expected_ty) => {
174                for (tag, val) in &tags {
175                    match typecheck_value_against_schematype(val, &expected_ty, self.extensions) {
176                        Ok(()) => {} // typecheck passes
177                        Err(TypecheckError::TypeMismatch(err)) => {
178                            return Err(EntitySchemaConformanceError::type_mismatch(
179                                uid.clone(),
180                                tag.to_string(),
181                                err::AttrOrTag::Tag,
182                                err,
183                            ));
184                        }
185                        Err(TypecheckError::ExtensionFunctionLookup(err)) => {
186                            return Err(EntitySchemaConformanceError::extension_function_lookup(
187                                uid.clone(),
188                                tag.to_string(),
189                                err::AttrOrTag::Tag,
190                                err,
191                            ));
192                        }
193                    }
194                }
195            }
196        }
197        for val in tags.values() {
198            validate_euids_in_partial_value(self.schema, val)
199                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
200        }
201        Ok(())
202    }
203
204    /// Validate an entity against the schema, returning an
205    /// [`EntitySchemaConformanceError`] if it does not comply.
206    pub fn validate_entity(&self, entity: &Entity) -> Result<(), EntitySchemaConformanceError> {
207        let uid = entity.uid();
208        let etype = uid.entity_type();
209        if etype.is_action() {
210            self.validate_action(entity)?;
211        } else {
212            validate_euid(self.schema, uid)
213                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
214            let schema_etype = self.schema.entity_type(etype).ok_or_else(|| {
215                let suggested_types = self
216                    .schema
217                    .entity_types_with_basename(&etype.name().basename())
218                    .collect();
219                UnexpectedEntityTypeError {
220                    uid: uid.clone(),
221                    suggested_types,
222                }
223            })?;
224
225            self.validate_entity_attributes(uid, entity.attrs(), &schema_etype)?;
226            self.validate_entity_ancestors(uid, entity.ancestors(), &schema_etype)?;
227            self.validate_tags(uid, entity.tags(), &schema_etype)?;
228        }
229        Ok(())
230    }
231}
232
233/// Return an [`InvalidEnumEntityError`] if `uid`'s eid is not among valid `choices`
234pub fn is_valid_enumerated_entity(
235    choices: &[Eid],
236    uid: &EntityUID,
237) -> Result<(), InvalidEnumEntityError> {
238    choices
239        .iter()
240        .find(|id| uid.eid() == *id)
241        .ok_or_else(|| InvalidEnumEntityError {
242            uid: uid.clone(),
243            choices: choices.to_vec(),
244        })
245        .map(|_| ())
246}
247
248/// Validate if `euid` is valid, provided that it's of an enumerated type
249pub fn validate_euid(schema: &impl Schema, euid: &EntityUID) -> Result<(), InvalidEnumEntityError> {
250    if let Some(desc) = schema.entity_type(euid.entity_type()) {
251        if let Some(choices) = desc.enum_entity_eids() {
252            is_valid_enumerated_entity(&Vec::from(choices), euid)?;
253        }
254    }
255    Ok(())
256}
257
258fn validate_euids_in_subexpressions<'a>(
259    exprs: impl IntoIterator<Item = &'a crate::ast::Expr>,
260    schema: &impl Schema,
261) -> std::result::Result<(), InvalidEnumEntityError> {
262    exprs.into_iter().try_for_each(|e| match e.expr_kind() {
263        ExprKind::Lit(Literal::EntityUID(euid)) => validate_euid(schema, euid.as_ref()),
264        _ => Ok(()),
265    })
266}
267
268/// Validate if enumerated entities in `val` are valid
269pub fn validate_euids_in_partial_value(
270    schema: &impl Schema,
271    val: &PartialValue,
272) -> Result<(), InvalidEnumEntityError> {
273    match val {
274        PartialValue::Value(val) => validate_euids_in_subexpressions(
275            RestrictedExpr::from(val.clone()).subexpressions(),
276            schema,
277        ),
278        PartialValue::Residual(e) => validate_euids_in_subexpressions(e.subexpressions(), schema),
279    }
280}
281
282/// Check whether the given `PartialValue` typechecks with the given `SchemaType`.
283/// If the typecheck passes, return `Ok(())`.
284/// If the typecheck fails, return an appropriate `Err`.
285pub fn typecheck_value_against_schematype(
286    value: &PartialValue,
287    expected_ty: &SchemaType,
288    extensions: &Extensions<'_>,
289) -> Result<(), TypecheckError> {
290    match RestrictedExpr::try_from(value.clone()) {
291        Ok(expr) => typecheck_restricted_expr_against_schematype(
292            expr.as_borrowed(),
293            expected_ty,
294            extensions,
295        ),
296        Err(PartialValueToRestrictedExprError::NontrivialResidual { .. }) => {
297            // this case should be unreachable for the case of `PartialValue`s
298            // which are entity attributes, because a `PartialValue` computed
299            // from a `RestrictedExpr` should only have trivial residuals.
300            // And as of this writing, there are no callers of this function that
301            // pass anything other than entity attributes.
302            // Nonetheless, rather than relying on these delicate invariants,
303            // it's safe to consider this as passing.
304            Ok(())
305        }
306    }
307}
308
309/// Check whether the given `RestrictedExpr` is a valid instance of
310/// `SchemaType`.  We do not have type information for unknowns, so this
311/// function liberally treats unknowns as implementing any schema type.  If the
312/// typecheck passes, return `Ok(())`.  If the typecheck fails, return an
313/// appropriate `Err`.
314pub fn typecheck_restricted_expr_against_schematype(
315    expr: BorrowedRestrictedExpr<'_>,
316    expected_ty: &SchemaType,
317    extensions: &Extensions<'_>,
318) -> Result<(), TypecheckError> {
319    use SchemaType::*;
320    let type_mismatch_err = || {
321        Err(TypeMismatchError::type_mismatch(
322            expected_ty.clone(),
323            expr.try_type_of(extensions),
324            expr.to_owned(),
325        )
326        .into())
327    };
328
329    match expr.expr_kind() {
330        // Check for `unknowns`.  Unless explicitly annotated, we don't have the
331        // information to know whether the unknown value matches the expected type.
332        // For now we consider this as passing -- we can't really report a type
333        // error <https://github.com/cedar-policy/cedar/issues/418>.
334        ExprKind::Unknown(u) => match u.type_annotation.clone().and_then(SchemaType::from_ty) {
335            Some(ty) => {
336                if &ty == expected_ty {
337                    return Ok(());
338                } else {
339                    return type_mismatch_err();
340                }
341            }
342            None => return Ok(()),
343        },
344        // Check for extension function calls. Restricted expressions permit all
345        // extension function calls, including those that aren't constructors.
346        // Checking the return type here before matching on the expected type lets
347        // us handle extension functions that return, e.g., bool and not an extension type.
348        ExprKind::ExtensionFunctionApp { fn_name, .. } => {
349            return match extensions.func(fn_name)?.return_type() {
350                None => {
351                    // This is actually another `unknown` case. The return type
352                    // is `None` only when the function is an "unknown"
353                    Ok(())
354                }
355                Some(rty) => {
356                    if rty == expected_ty {
357                        Ok(())
358                    } else {
359                        type_mismatch_err()
360                    }
361                }
362            };
363        }
364        _ => (),
365    };
366
367    // We know `expr` is a restricted expression, so it must either be an
368    // extension function call or a literal bool, long string, set or record.
369    // This means we don't need to check if it's a `has` or `==` expression to
370    // decide if it typechecks against `Bool`. Anything other an than a boolean
371    // literal is an error. To handle extension function calls, which could
372    // return `Bool`, we have already checked if the expression is an extension
373    // function in the prior `match` expression.
374    match expected_ty {
375        Bool => {
376            if expr.as_bool().is_some() {
377                Ok(())
378            } else {
379                type_mismatch_err()
380            }
381        }
382        Long => {
383            if expr.as_long().is_some() {
384                Ok(())
385            } else {
386                type_mismatch_err()
387            }
388        }
389        String => {
390            if expr.as_string().is_some() {
391                Ok(())
392            } else {
393                type_mismatch_err()
394            }
395        }
396        EmptySet => {
397            if expr.as_set_elements().is_some_and(|e| e.count() == 0) {
398                Ok(())
399            } else {
400                type_mismatch_err()
401            }
402        }
403        Set { .. } if expr.as_set_elements().is_some_and(|e| e.count() == 0) => Ok(()),
404        Set { element_ty: elty } => match expr.as_set_elements() {
405            Some(mut els) => els.try_for_each(|e| {
406                typecheck_restricted_expr_against_schematype(e, elty, extensions)
407            }),
408            None => type_mismatch_err(),
409        },
410        Record { attrs, open_attrs } => match expr.as_record_pairs() {
411            Some(pairs) => {
412                let pairs_map: BTreeMap<&SmolStr, BorrowedRestrictedExpr<'_>> = pairs.collect();
413                // Check that all attributes required by the schema are present
414                // in the record.
415                attrs.iter().try_for_each(|(k, v)| {
416                    if !v.required {
417                        Ok(())
418                    } else {
419                        match pairs_map.get(k) {
420                            Some(inner_e) => typecheck_restricted_expr_against_schematype(
421                                *inner_e,
422                                &v.attr_type,
423                                extensions,
424                            ),
425                            None => Err(TypeMismatchError::missing_required_attr(
426                                expected_ty.clone(),
427                                k.clone(),
428                                expr.to_owned(),
429                            )
430                            .into()),
431                        }
432                    }
433                })?;
434                // Check that all attributes in the record are present (as
435                // required or optional) in the schema.
436                pairs_map
437                    .iter()
438                    .try_for_each(|(k, inner_e)| match attrs.get(*k) {
439                        Some(sch_ty) => typecheck_restricted_expr_against_schematype(
440                            *inner_e,
441                            &sch_ty.attr_type,
442                            extensions,
443                        ),
444                        None => {
445                            if *open_attrs {
446                                Ok(())
447                            } else {
448                                Err(TypeMismatchError::unexpected_attr(
449                                    expected_ty.clone(),
450                                    (*k).clone(),
451                                    expr.to_owned(),
452                                )
453                                .into())
454                            }
455                        }
456                    })?;
457                Ok(())
458            }
459            None => type_mismatch_err(),
460        },
461        // Extension functions are handled by the first `match` in this function.
462        Extension { .. } => type_mismatch_err(),
463        Entity { ty } => match expr.as_euid() {
464            Some(actual_euid) if actual_euid.entity_type() == ty => Ok(()),
465            _ => type_mismatch_err(),
466        },
467    }
468}
469
470/// Errors returned by [`typecheck_value_against_schematype()`] and
471/// [`typecheck_restricted_expr_against_schematype()`]
472#[derive(Debug, Diagnostic, Error)]
473pub enum TypecheckError {
474    /// The given value had a type different than what was expected
475    #[error(transparent)]
476    #[diagnostic(transparent)]
477    TypeMismatch(#[from] TypeMismatchError),
478    /// Error looking up an extension function. This error can occur when
479    /// typechecking a `RestrictedExpr` because that may require getting
480    /// information about any extension functions referenced in the
481    /// `RestrictedExpr`; and it can occur when typechecking a `PartialValue`
482    /// because that may require getting information about any extension
483    /// functions referenced in residuals.
484    #[error(transparent)]
485    #[diagnostic(transparent)]
486    ExtensionFunctionLookup(#[from] ExtensionFunctionLookupError),
487}
488
489#[cfg(test)]
490mod test_typecheck {
491    use std::collections::BTreeMap;
492
493    use cool_asserts::assert_matches;
494    use miette::Report;
495    use smol_str::ToSmolStr;
496
497    use crate::{
498        entities::{
499            conformance::TypecheckError, AttributeType, BorrowedRestrictedExpr, Expr, SchemaType,
500            Unknown,
501        },
502        extensions::Extensions,
503        test_utils::{expect_err, ExpectedErrorMessageBuilder},
504    };
505
506    use super::typecheck_restricted_expr_against_schematype;
507
508    #[test]
509    fn unknown() {
510        typecheck_restricted_expr_against_schematype(
511            BorrowedRestrictedExpr::new(&Expr::unknown(Unknown::new_untyped("foo"))).unwrap(),
512            &SchemaType::Bool,
513            Extensions::all_available(),
514        )
515        .unwrap();
516        typecheck_restricted_expr_against_schematype(
517            BorrowedRestrictedExpr::new(&Expr::unknown(Unknown::new_untyped("foo"))).unwrap(),
518            &SchemaType::String,
519            Extensions::all_available(),
520        )
521        .unwrap();
522        typecheck_restricted_expr_against_schematype(
523            BorrowedRestrictedExpr::new(&Expr::unknown(Unknown::new_untyped("foo"))).unwrap(),
524            &SchemaType::Set {
525                element_ty: Box::new(SchemaType::Extension {
526                    name: "decimal".parse().unwrap(),
527                }),
528            },
529            Extensions::all_available(),
530        )
531        .unwrap();
532    }
533
534    #[test]
535    fn bool() {
536        typecheck_restricted_expr_against_schematype(
537            BorrowedRestrictedExpr::new(&"false".parse().unwrap()).unwrap(),
538            &SchemaType::Bool,
539            Extensions::all_available(),
540        )
541        .unwrap();
542    }
543
544    #[test]
545    fn bool_fails() {
546        assert_matches!(
547            typecheck_restricted_expr_against_schematype(
548                BorrowedRestrictedExpr::new(&"1".parse().unwrap()).unwrap(),
549                &SchemaType::Bool,
550                Extensions::all_available(),
551            ),
552            Err(e@TypecheckError::TypeMismatch(_)) => {
553                expect_err(
554                    "",
555                    &Report::new(e),
556                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type bool, but it actually has type long: `1`").build()
557                );
558            }
559        )
560    }
561
562    #[test]
563    fn long() {
564        typecheck_restricted_expr_against_schematype(
565            BorrowedRestrictedExpr::new(&"1".parse().unwrap()).unwrap(),
566            &SchemaType::Long,
567            Extensions::all_available(),
568        )
569        .unwrap();
570    }
571
572    #[test]
573    fn long_fails() {
574        assert_matches!(
575            typecheck_restricted_expr_against_schematype(
576                BorrowedRestrictedExpr::new(&"false".parse().unwrap()).unwrap(),
577                &SchemaType::Long,
578                Extensions::all_available(),
579            ),
580            Err(e@TypecheckError::TypeMismatch(_)) => {
581                expect_err(
582                    "",
583                    &Report::new(e),
584                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type long, but it actually has type bool: `false`").build()
585                );
586            }
587        )
588    }
589
590    #[test]
591    fn string() {
592        typecheck_restricted_expr_against_schematype(
593            BorrowedRestrictedExpr::new(&r#""foo""#.parse().unwrap()).unwrap(),
594            &SchemaType::String,
595            Extensions::all_available(),
596        )
597        .unwrap();
598    }
599
600    #[test]
601    fn string_fails() {
602        assert_matches!(
603            typecheck_restricted_expr_against_schematype(
604                BorrowedRestrictedExpr::new(&"false".parse().unwrap()).unwrap(),
605                &SchemaType::String,
606                Extensions::all_available(),
607            ),
608            Err(e@TypecheckError::TypeMismatch(_)) => {
609                expect_err(
610                    "",
611                    &Report::new(e),
612                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type string, but it actually has type bool: `false`").build()
613                );
614            }
615        )
616    }
617
618    #[test]
619    fn test_typecheck_set() {
620        typecheck_restricted_expr_against_schematype(
621            BorrowedRestrictedExpr::new(&"[1, 2, 3]".parse().unwrap()).unwrap(),
622            &SchemaType::Set {
623                element_ty: Box::new(SchemaType::Long),
624            },
625            Extensions::all_available(),
626        )
627        .unwrap();
628        typecheck_restricted_expr_against_schematype(
629            BorrowedRestrictedExpr::new(&"[]".parse().unwrap()).unwrap(),
630            &SchemaType::Set {
631                element_ty: Box::new(SchemaType::Bool),
632            },
633            Extensions::all_available(),
634        )
635        .unwrap();
636    }
637
638    #[test]
639    fn test_typecheck_set_fails() {
640        assert_matches!(
641            typecheck_restricted_expr_against_schematype(
642                BorrowedRestrictedExpr::new(&"{}".parse().unwrap()).unwrap(),
643                &SchemaType::Set { element_ty: Box::new(SchemaType::String) },
644                Extensions::all_available(),
645            ),
646            Err(e@TypecheckError::TypeMismatch(_)) => {
647                expect_err(
648                    "",
649                    &Report::new(e),
650                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type [string], but it actually has type record: `{}`").build()
651                );
652            }
653        );
654        assert_matches!(
655            typecheck_restricted_expr_against_schematype(
656                BorrowedRestrictedExpr::new(&"[1, 2, 3]".parse().unwrap()).unwrap(),
657                &SchemaType::Set { element_ty: Box::new(SchemaType::String) },
658                Extensions::all_available(),
659            ),
660            Err(e@TypecheckError::TypeMismatch(_)) => {
661                expect_err(
662                    "",
663                    &Report::new(e),
664                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type string, but it actually has type long: `1`").build()
665                );
666            }
667        );
668        assert_matches!(
669            typecheck_restricted_expr_against_schematype(
670                BorrowedRestrictedExpr::new(&"[1, true]".parse().unwrap()).unwrap(),
671                &SchemaType::Set { element_ty: Box::new(SchemaType::Long) },
672                Extensions::all_available(),
673            ),
674            Err(e@TypecheckError::TypeMismatch(_)) => {
675                expect_err(
676                    "",
677                    &Report::new(e),
678                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type long, but it actually has type bool: `true`").build()
679                );
680            }
681        )
682    }
683
684    #[test]
685    fn test_typecheck_record() {
686        typecheck_restricted_expr_against_schematype(
687            BorrowedRestrictedExpr::new(&"{}".parse().unwrap()).unwrap(),
688            &SchemaType::Record {
689                attrs: BTreeMap::new(),
690                open_attrs: false,
691            },
692            Extensions::all_available(),
693        )
694        .unwrap();
695        typecheck_restricted_expr_against_schematype(
696            BorrowedRestrictedExpr::new(&"{a: 1}".parse().unwrap()).unwrap(),
697            &SchemaType::Record {
698                attrs: BTreeMap::from([(
699                    "a".to_smolstr(),
700                    AttributeType {
701                        attr_type: SchemaType::Long,
702                        required: true,
703                    },
704                )]),
705                open_attrs: false,
706            },
707            Extensions::all_available(),
708        )
709        .unwrap();
710        typecheck_restricted_expr_against_schematype(
711            BorrowedRestrictedExpr::new(&"{}".parse().unwrap()).unwrap(),
712            &SchemaType::Record {
713                attrs: BTreeMap::from([(
714                    "a".to_smolstr(),
715                    AttributeType {
716                        attr_type: SchemaType::Long,
717                        required: false,
718                    },
719                )]),
720                open_attrs: false,
721            },
722            Extensions::all_available(),
723        )
724        .unwrap();
725    }
726
727    #[test]
728    fn test_typecheck_record_fails() {
729        assert_matches!(
730            typecheck_restricted_expr_against_schematype(
731                BorrowedRestrictedExpr::new(&"[]".parse().unwrap()).unwrap(),
732                &SchemaType::Record { attrs: BTreeMap::from([]), open_attrs: false },
733                Extensions::all_available(),
734            ),
735            Err(e@TypecheckError::TypeMismatch(_)) => {
736                expect_err(
737                    "",
738                    &Report::new(e),
739                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type {  }, but it actually has type set: `[]`").build()
740                );
741            }
742        );
743        assert_matches!(
744            typecheck_restricted_expr_against_schematype(
745                BorrowedRestrictedExpr::new(&"{a: false}".parse().unwrap()).unwrap(),
746                &SchemaType::Record { attrs: BTreeMap::from([("a".to_smolstr(), AttributeType { attr_type: SchemaType::Long, required: true })]), open_attrs: false },
747                Extensions::all_available(),
748            ),
749            Err(e@TypecheckError::TypeMismatch(_)) => {
750                expect_err(
751                    "",
752                    &Report::new(e),
753                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type long, but it actually has type bool: `false`").build()
754                );
755            }
756        );
757        assert_matches!(
758            typecheck_restricted_expr_against_schematype(
759                BorrowedRestrictedExpr::new(&"{a: {}}".parse().unwrap()).unwrap(),
760                &SchemaType::Record { attrs: BTreeMap::from([("a".to_smolstr(), AttributeType { attr_type: SchemaType::Long, required: false })]), open_attrs: false },
761                Extensions::all_available(),
762            ),
763            Err(e@TypecheckError::TypeMismatch(_)) => {
764                expect_err(
765                    "",
766                    &Report::new(e),
767                    &ExpectedErrorMessageBuilder::error("type mismatch: value was expected to have type long, but it actually has type record: `{}`").build()
768                );
769            }
770        );
771        assert_matches!(
772            typecheck_restricted_expr_against_schematype(
773                BorrowedRestrictedExpr::new(&"{}".parse().unwrap()).unwrap(),
774                &SchemaType::Record { attrs: BTreeMap::from([("a".to_smolstr(), AttributeType { attr_type: SchemaType::Long, required: true })]), open_attrs: false },
775                Extensions::all_available(),
776            ),
777            Err(e@TypecheckError::TypeMismatch(_)) => {
778                expect_err(
779                    "",
780                    &Report::new(e),
781                    &ExpectedErrorMessageBuilder::error(r#"type mismatch: value was expected to have type { "a" => (required) long }, but it is missing the required attribute `a`: `{}`"#).build()
782                );
783            }
784        );
785        assert_matches!(
786            typecheck_restricted_expr_against_schematype(
787                BorrowedRestrictedExpr::new(&"{a: 1, b: 1}".parse().unwrap()).unwrap(),
788                &SchemaType::Record { attrs: BTreeMap::from([("a".to_smolstr(), AttributeType { attr_type: SchemaType::Long, required: true })]), open_attrs: false },
789                Extensions::all_available(),
790            ),
791            Err(e@TypecheckError::TypeMismatch(_)) => {
792                expect_err(
793                    "",
794                    &Report::new(e),
795                    &ExpectedErrorMessageBuilder::error(r#"type mismatch: value was expected to have type { "a" => (required) long }, but it contains an unexpected attribute `b`: `{"a": 1, "b": 1}`"#).build()
796                );
797            }
798        );
799        assert_matches!(
800            typecheck_restricted_expr_against_schematype(
801                BorrowedRestrictedExpr::new(&"{b: 1}".parse().unwrap()).unwrap(),
802                &SchemaType::Record { attrs: BTreeMap::from([("a".to_smolstr(), AttributeType { attr_type: SchemaType::Long, required: false })]), open_attrs: false },
803                Extensions::all_available(),
804            ),
805            Err(e@TypecheckError::TypeMismatch(_)) => {
806                expect_err(
807                    "",
808                    &Report::new(e),
809                    &ExpectedErrorMessageBuilder::error(r#"type mismatch: value was expected to have type { "a" => (optional) long }, but it contains an unexpected attribute `b`: `{"b": 1}`"#).build()
810                );
811            }
812        );
813    }
814
815    #[test]
816    fn extension() {
817        typecheck_restricted_expr_against_schematype(
818            BorrowedRestrictedExpr::new(&r#"decimal("1.1")"#.parse().unwrap()).unwrap(),
819            &SchemaType::Extension {
820                name: "decimal".parse().unwrap(),
821            },
822            Extensions::all_available(),
823        )
824        .unwrap();
825    }
826
827    #[test]
828    fn non_constructor_extension_function() {
829        typecheck_restricted_expr_against_schematype(
830            BorrowedRestrictedExpr::new(&r#"ip("127.0.0.1").isLoopback()"#.parse().unwrap())
831                .unwrap(),
832            &SchemaType::Bool,
833            Extensions::all_available(),
834        )
835        .unwrap();
836    }
837
838    #[test]
839    fn extension_fails() {
840        assert_matches!(
841            typecheck_restricted_expr_against_schematype(
842                BorrowedRestrictedExpr::new(&r#"decimal("1.1")"#.parse().unwrap()).unwrap(),
843                &SchemaType::Extension { name: "ipaddr".parse().unwrap() },
844                Extensions::all_available(),
845            ),
846            Err(e@TypecheckError::TypeMismatch(_)) => {
847                expect_err(
848                    "",
849                    &Report::new(e),
850                    &ExpectedErrorMessageBuilder::error(r#"type mismatch: value was expected to have type ipaddr, but it actually has type decimal: `decimal("1.1")`"#).build()
851                );
852            }
853        )
854    }
855
856    #[test]
857    fn entity() {
858        typecheck_restricted_expr_against_schematype(
859            BorrowedRestrictedExpr::new(&r#"User::"alice""#.parse().unwrap()).unwrap(),
860            &SchemaType::Entity {
861                ty: "User".parse().unwrap(),
862            },
863            Extensions::all_available(),
864        )
865        .unwrap();
866    }
867
868    #[test]
869    fn entity_fails() {
870        assert_matches!(
871            typecheck_restricted_expr_against_schematype(
872                BorrowedRestrictedExpr::new(&r#"User::"alice""#.parse().unwrap()).unwrap(),
873                &SchemaType::Entity { ty: "Photo".parse().unwrap() },
874                Extensions::all_available(),
875            ),
876            Err(e@TypecheckError::TypeMismatch(_)) => {
877                expect_err(
878                    "",
879                    &Report::new(e),
880                    &ExpectedErrorMessageBuilder::error(r#"type mismatch: value was expected to have type `Photo`, but it actually has type (entity of type `User`): `User::"alice"`"#).build()
881                );
882            }
883        )
884    }
885}