Skip to main content

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