diff --git a/Cargo.lock b/Cargo.lock index df587fbd24..63424b0ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -425,6 +425,7 @@ dependencies = [ "nonempty", "ref-cast", "regex", + "rstest", "rustc-literal-escaper", "serde", "serde-wasm-bindgen", diff --git a/cedar-policy-core/Cargo.toml b/cedar-policy-core/Cargo.toml index f9ad14ebaf..7ff7aa974e 100644 --- a/cedar-policy-core/Cargo.toml +++ b/cedar-policy-core/Cargo.toml @@ -83,6 +83,7 @@ similar-asserts = "2.0.0" cool_asserts = "2.0" miette = { version = "7.6.0", features = ["fancy"] } insta = { version = "1.47.2", features = ["glob"] } +rstest = "0.26.1" [lints] workspace = true diff --git a/cedar-policy-core/src/validator.rs b/cedar-policy-core/src/validator.rs index 580600519d..8c97fbe4f1 100644 --- a/cedar-policy-core/src/validator.rs +++ b/cedar-policy-core/src/validator.rs @@ -52,12 +52,14 @@ pub use schema::err::*; pub use schema::*; mod deprecated_schema_compat; pub mod json_schema; +pub(crate) mod schema_type_depth; mod str_checks; pub use str_checks::confusable_string_checks; pub mod cedar_schema; pub mod typecheck; use typecheck::Typechecker; mod partition_nonempty; +mod topo_sort; pub mod types; /// Used to select how a policy will be validated. diff --git a/cedar-policy-core/src/validator/cedar_schema.rs b/cedar-policy-core/src/validator/cedar_schema.rs index 543754f898..3d1fb17f30 100644 --- a/cedar-policy-core/src/validator/cedar_schema.rs +++ b/cedar-policy-core/src/validator/cedar_schema.rs @@ -18,6 +18,8 @@ mod ast; pub use ast::Path; +/// Depth computation for schema types +pub(crate) mod depth; mod err; pub mod fmt; pub mod parser; diff --git a/cedar-policy-core/src/validator/cedar_schema/ast.rs b/cedar-policy-core/src/validator/cedar_schema/ast.rs index 2691a60461..cd43b1934f 100644 --- a/cedar-policy-core/src/validator/cedar_schema/ast.rs +++ b/cedar-policy-core/src/validator/cedar_schema/ast.rs @@ -307,6 +307,35 @@ pub enum Type { Record(Vec>>), } +impl Drop for Type { + fn drop(&mut self) { + let mut work: Vec> = Vec::new(); + collect_child_types(self, &mut work); + while let Some(mut type_node) = work.pop() { + collect_child_types(&mut type_node.node, &mut work); + } + } +} + +/// Extract all `Node` from `ty`, moving them out of `ty` and appending +/// them to `out`. After this call, `ty` holds no recursive `Node`, so it +/// can be dropped without risking stack overflow due to recursion. +fn collect_child_types(ty: &mut Type, out: &mut Vec>) { + match ty { + Type::Ident(_) => {} + Type::Set(inner) => { + let dummy = Node::with_maybe_source_loc(Type::Record(vec![]), None); + out.push(std::mem::replace(inner.as_mut(), dummy)); + } + Type::Record(attrs) => { + for attr_node in attrs.iter_mut() { + let dummy = Node::with_maybe_source_loc(Type::Record(vec![]), None); + out.push(std::mem::replace(&mut attr_node.node.data.ty, dummy)); + } + } + } +} + /// Primitive Type Definitions #[derive(Debug, Clone)] pub enum PrimitiveType { diff --git a/cedar-policy-core/src/validator/cedar_schema/depth.rs b/cedar-policy-core/src/validator/cedar_schema/depth.rs new file mode 100644 index 0000000000..e3d384792e --- /dev/null +++ b/cedar-policy-core/src/validator/cedar_schema/depth.rs @@ -0,0 +1,151 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Iterative depth computation for Cedar schema type AST. + +use itertools::Either; + +use super::ast::{ActionDecl, AppDecl, Declaration, EntityDecl, Namespace, Schema, Type}; +use crate::parser::Node; + +/// Iteratively compute the maximum nesting depth across all types in a schema. +pub(crate) fn schema_type_depth(schema: &Schema) -> usize { + schema + .iter() + .map(|ns| namespace_type_depth(&ns.data)) + .max() + .unwrap_or(0) +} + +fn namespace_type_depth(ns: &Namespace) -> usize { + ns.decls + .iter() + .map(|d| declaration_type_depth(&d.data.node)) + .max() + .unwrap_or(0) +} + +fn declaration_type_depth(decl: &Declaration) -> usize { + match decl { + Declaration::Entity(entity_decl) => entity_decl_type_depth(entity_decl), + Declaration::Action(action_decl) => action_decl_type_depth(action_decl), + Declaration::Type(type_decl) => type_depth(&type_decl.def), + } +} + +fn entity_decl_type_depth(decl: &EntityDecl) -> usize { + match decl { + EntityDecl::Standard(standard) => { + let attrs_d = standard + .attrs + .node + .iter() + .map(|a| 1 + type_depth(&a.node.data.ty)) + .max() + .unwrap_or(0); + let tags_d = standard + .tags + .iter() + .map(|a| type_depth(&a)) + .max() + .unwrap_or(0); + attrs_d.max(tags_d) + } + EntityDecl::Enum(_) => 0, + } +} + +fn action_decl_type_depth(decl: &ActionDecl) -> usize { + let Some(app_decls) = &decl.app_decls else { + return 0; + }; + app_decls + .node + .iter() + .map(|decl| app_decl_type_depth(&decl.node)) + .max() + .unwrap_or(0) +} + +fn app_decl_type_depth(decl: &AppDecl) -> usize { + match decl { + AppDecl::Context(Either::Right(attrs)) => attrs + .node + .iter() + .map(|a| 1 + type_depth(&a.node.data.ty)) + .max() + .unwrap_or(0), + AppDecl::PR(_) | AppDecl::Context(Either::Left(_)) => 0, + } +} + +fn type_depth(root: &Node) -> usize { + let mut stack: Vec<(&Node, usize)> = vec![(root, 0)]; + let mut max_depth: usize = 0; + + while let Some((ty, depth)) = stack.pop() { + max_depth = max_depth.max(depth); + + match &ty.node { + Type::Ident(_) => {} + Type::Set(inner) => { + stack.push((inner, depth + 1)); + } + Type::Record(attrs) => { + for attr_node in attrs { + stack.push((&attr_node.node.data.ty, depth + 1)); + } + } + } + } + + max_depth +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::validator::cedar_schema::parser::parse_schema; + use rstest::rstest; + + fn schema_depth_of(src: &str) -> usize { + let schema = parse_schema(src).expect("parse failed"); + schema_type_depth(&schema) + } + + #[rstest] + #[case::primitive_entity("entity Foo;", 0)] + #[case::entity_with_attribute("entity Foo = { bar: Long };", 1)] + #[case::nested_record("entity Foo = { bar: { baz: Long } };", 2)] + #[case::set_of_long("entity Foo = { bar: Set };", 2)] + #[case::set_of_set("entity Foo = { bar: Set> };", 3)] + #[case::deeply_nested_record("entity Foo = { a: { b: { c: Long } } };", 3)] + #[case::common_type("type T = Set>;", 2)] + #[case::entity_tags("entity Foo tags Set;", 1)] + #[case::entity_tags_nested("entity Foo tags Set>;", 2)] + #[case::enum_entity(r#"entity Flavor enum ["Vanilla", "Chocolate"];"#, 0)] + #[case::action_context( + "entity User; entity File; action Read appliesTo { principal: User, resource: File, context: { level: Set } };", + 2 + )] + #[case::multiple_namespaces( + "namespace A { entity Foo = { x: Long }; } namespace B { entity Bar = { x: { y: { z: Long } } }; }", + 3 + )] + fn schema_depth(#[case] src: &str, #[case] expected: usize) { + assert_eq!(schema_depth_of(src), expected); + } +} diff --git a/cedar-policy-core/src/validator/cedar_schema/parser.rs b/cedar-policy-core/src/validator/cedar_schema/parser.rs index 84ffdb0a25..a6433d27ef 100644 --- a/cedar-policy-core/src/validator/cedar_schema/parser.rs +++ b/cedar-policy-core/src/validator/cedar_schema/parser.rs @@ -97,6 +97,14 @@ pub enum CedarSchemaParseErrors { #[error(transparent)] #[diagnostic(transparent)] JsonError(#[from] ToJsonSchemaErrors), + /// Schema type nesting depth exceeds the configured limit + #[error("schema type depth {depth} exceeds the configured limit of {limit}")] + TypeTooDeep { + /// Actual depth of the deepest type + depth: usize, + /// Configured limit + limit: usize, + }, } /// Parse a schema fragment, in the Cedar syntax, into a [`json_schema::Fragment`], @@ -116,6 +124,51 @@ pub fn parse_cedar_schema_fragment<'a>( Ok(tuple) } +/// Like [`parse_cedar_schema_fragment`], but rejects the schema if any type's +/// effective nesting depth exceeds `depth_limit`. Effective depth accounts for +/// depth introduced through chains of common type (typedef) references. +pub fn parse_cedar_schema_fragment_with_depth_limit<'a>( + src: &str, + extensions: &Extensions<'a>, + depth_limit: usize, +) -> Result< + ( + json_schema::Fragment, + impl Iterator + 'a, + ), + CedarSchemaParseErrors, +> { + let ast: Schema = parse_collect_errors(&*SCHEMA_PARSER, grammar::SchemaParser::parse, src)?; + let syntactic_depth = super::depth::schema_type_depth(&ast); + if syntactic_depth > depth_limit { + return Err(CedarSchemaParseErrors::TypeTooDeep { + depth: syntactic_depth, + limit: depth_limit, + }); + } + // The syntactic check passed, so nothing directly exceeds the limit, but + // there might still be types composed from common types that will exceed it + // after inlining the type definitions. + let (fragment, warnings) = cedar_schema_to_json_schema(ast, extensions)?; + // Now check effective depth (after inlining common types) to ensure we + // avoid any downstream stack overflow caused by recursion on the inlined + // types. We first need to resolve `RawName` to `InternalName` to know + // exactly what each common type refers to when computing the depth. + if let Ok(resolved) = fragment.to_internal_name_fragment_with_resolved_types() { + if let Some(depth) = + crate::validator::schema_type_depth::fragment_effective_depth(&resolved) + { + if depth > depth_limit { + return Err(CedarSchemaParseErrors::TypeTooDeep { + depth, + limit: depth_limit, + }); + } + } + } + Ok((fragment, warnings)) +} + /// Parse schema from text pub fn parse_schema(text: &str) -> Result { parse_collect_errors(&*SCHEMA_PARSER, grammar::SchemaParser::parse, text) diff --git a/cedar-policy-core/src/validator/cedar_schema/to_json_schema.rs b/cedar-policy-core/src/validator/cedar_schema/to_json_schema.rs index 2a48214907..5d358df1f4 100644 --- a/cedar-policy-core/src/validator/cedar_schema/to_json_schema.rs +++ b/cedar-policy-core/src/validator/cedar_schema/to_json_schema.rs @@ -97,16 +97,28 @@ fn is_valid_ext_type(ty: &Id, extensions: &Extensions<'_>) -> bool { } /// Convert a `Type` into the JSON representation of the type. -pub fn cedar_type_to_json_type(ty: Node) -> json_schema::Type { - let variant = match ty.node { +pub fn cedar_type_to_json_type(mut ty: Node) -> json_schema::Type { + // Some annoying use of `std::mem::replace` here because we have `Type` + // implement `Drop` to avoid stack overflows when dropping deeply nested + // types and Rust won't let you move out of a type with `Drop`. + let variant = match &mut ty.node { Type::Set(t) => json_schema::TypeVariant::Set { - element: Box::new(cedar_type_to_json_type(*t)), + element: Box::new(cedar_type_to_json_type(std::mem::replace( + t.as_mut(), + Node::new(Type::Record(Vec::new())), + ))), }, Type::Ident(p) => json_schema::TypeVariant::EntityOrCommon { - type_name: RawName::from(p), + type_name: RawName::from(std::mem::replace( + p, + Path::new(Id::new_unchecked_const(""), [], None), + )), }, Type::Record(fields) => json_schema::TypeVariant::Record(json_schema::RecordType { - attributes: fields.into_iter().map(convert_attr_decl).collect(), + attributes: std::mem::take(fields) + .into_iter() + .map(convert_attr_decl) + .collect(), additional_attributes: false, }), }; diff --git a/cedar-policy-core/src/validator/json_schema.rs b/cedar-policy-core/src/validator/json_schema.rs index 07f9341d6e..a2fff1b1dd 100644 --- a/cedar-policy-core/src/validator/json_schema.rs +++ b/cedar-policy-core/src/validator/json_schema.rs @@ -46,7 +46,10 @@ use thiserror::Error; use crate::validator::{ cedar_schema::{ - self, fmt::ToCedarSchemaSyntaxError, parser::parse_cedar_schema_fragment, SchemaWarning, + self, + fmt::ToCedarSchemaSyntaxError, + parser::{parse_cedar_schema_fragment, parse_cedar_schema_fragment_with_depth_limit}, + SchemaWarning, }, err::{schema_errors::*, Result}, AllDefs, CedarSchemaError, CedarSchemaParseError, ConditionalName, RawName, ReferenceType, @@ -176,6 +179,43 @@ impl Fragment { serde_json::from_reader(file).map_err(|e| JsonDeserializationError::new(e, None).into()) } + /// Like [`Self::from_json_str`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + pub fn from_json_str_with_depth_limit(json: &str, depth_limit: usize) -> Result { + let fragment = Self::from_json_str(json)?; + fragment.check_depth_limit(depth_limit)?; + Ok(fragment) + } + + /// Like [`Self::from_json_value`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + pub fn from_json_value_with_depth_limit( + json: serde_json::Value, + depth_limit: usize, + ) -> Result { + let fragment = Self::from_json_value(json)?; + fragment.check_depth_limit(depth_limit)?; + Ok(fragment) + } + + /// Check that the effective type depth does not exceed `depth_limit`. + fn check_depth_limit(&self, depth_limit: usize) -> Result<()> { + use crate::validator::schema_type_depth::fragment_effective_depth; + let resolved = self.to_internal_name_fragment_with_resolved_types()?; + if let Some(depth) = fragment_effective_depth(&resolved) { + if depth > depth_limit { + return Err(SchemaError::TypeTooDeep( + super::err::schema_errors::TypeTooDeepError { + depth, + limit: depth_limit, + }, + ) + .into()); + } + } + Ok(()) + } + /// Parse the schema (in the Cedar schema syntax) from a string pub fn from_cedarschema_str<'a>( src: &str, @@ -186,6 +226,18 @@ impl Fragment { .map_err(|e| CedarSchemaParseError::new(e, src).into()) } + /// Like [`Self::from_cedarschema_str`], but rejects the schema if any + /// type's nesting depth exceeds `depth_limit`. + pub fn from_cedarschema_str_with_depth_limit<'a>( + src: &str, + extensions: &Extensions<'a>, + depth_limit: usize, + ) -> std::result::Result<(Self, impl Iterator + 'a), CedarSchemaError> + { + parse_cedar_schema_fragment_with_depth_limit(src, extensions, depth_limit) + .map_err(|e| CedarSchemaParseError::new(e, src).into()) + } + /// Parse the schema (in the Cedar schema syntax) from a reader pub fn from_cedarschema_file<'a>( mut file: impl std::io::Read, diff --git a/cedar-policy-core/src/validator/schema.rs b/cedar-policy-core/src/validator/schema.rs index 65dbd86028..1c3ba83222 100644 --- a/cedar-policy-core/src/validator/schema.rs +++ b/cedar-policy-core/src/validator/schema.rs @@ -526,6 +526,32 @@ impl ValidatorSchema { ) } + /// Like [`Self::from_json_value`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + pub fn from_json_value_with_depth_limit( + json: serde_json::Value, + extensions: &Extensions<'_>, + depth_limit: usize, + ) -> Result { + Self::from_schema_frag( + json_schema::Fragment::::from_json_value_with_depth_limit(json, depth_limit)?, + extensions, + ) + } + + /// Like [`Self::from_json_str`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + pub fn from_json_str_with_depth_limit( + json: &str, + extensions: &Extensions<'_>, + depth_limit: usize, + ) -> Result { + Self::from_schema_frag( + json_schema::Fragment::::from_json_str_with_depth_limit(json, depth_limit)?, + extensions, + ) + } + /// Construct a [`ValidatorSchema`] directly from a file containing the /// Cedar schema syntax. pub fn from_cedarschema_file<'a>( @@ -552,6 +578,24 @@ impl ValidatorSchema { Ok(schema_and_warnings) } + /// Like [`Self::from_cedarschema_str`], but rejects the schema if any + /// type's nesting depth exceeds `depth_limit`. + pub fn from_cedarschema_str_with_depth_limit<'a>( + src: &str, + extensions: &Extensions<'a>, + depth_limit: usize, + ) -> std::result::Result<(Self, impl Iterator + 'a), CedarSchemaError> + { + let (fragment, warnings) = json_schema::Fragment::from_cedarschema_str_with_depth_limit( + src, + extensions, + depth_limit, + )?; + let schema_and_warnings = + Self::from_schema_frag(fragment, extensions).map(|schema| (schema, warnings))?; + Ok(schema_and_warnings) + } + /// Helper function to construct a [`ValidatorSchema`] from a single [`json_schema::Fragment`]. pub(crate) fn from_schema_frag( schema_file: json_schema::Fragment, @@ -1403,104 +1447,16 @@ impl<'a> CommonTypeResolver<'a> { Self { defs, graph } } - /// Perform topological sort on the dependency graph - /// - /// Let A -> B denote the RHS of type `A` refers to type `B` (i.e., `A` - /// depends on `B`) - /// - /// `topo_sort(A -> B -> C)` produces [C, B, A] - /// - /// If there is a cycle, a type name involving in this cycle is the error - /// - /// It implements a variant of Kahn's algorithm - fn topo_sort(&self) -> std::result::Result, InternalName> { - // The in-degree map - // Note that the keys of this map may be a superset of all common type - // names - let mut indegrees: HashMap<&InternalName, usize> = HashMap::new(); - for (ty_name, deps) in self.graph.iter() { - // Ensure that declared common types have values in `indegrees` - indegrees.entry(ty_name).or_insert(0); - for dep in deps { - match indegrees.entry(dep) { - std::collections::hash_map::Entry::Occupied(mut o) => { - o.insert(o.get() + 1); - } - std::collections::hash_map::Entry::Vacant(v) => { - v.insert(1); - } - } - } - } - - // The set that contains type names with zero incoming edges - let mut work_set: HashSet<&'a InternalName> = HashSet::new(); - let mut res: Vec<&'a InternalName> = Vec::new(); - - // Find all type names with zero incoming edges - for (name, degree) in indegrees.iter() { - let name = *name; - if *degree == 0 { - work_set.insert(name); - // The result only contains *declared* type names - if self.graph.contains_key(name) { - res.push(name); - } - } - } - - // Pop a node - while let Some(name) = work_set.iter().next().copied() { - work_set.remove(name); - if let Some(deps) = self.graph.get(name) { - for dep in deps { - if let Some(degree) = indegrees.get_mut(dep) { - // There will not be any underflows here because - // in order for the in-degree to underflow, `dep`'s - // in-degree must be 0 at this point - // The only possibility where a node's in-degree - // becomes 0 is through the subtraction below, which - // means it has been visited and hence has 0 in-degrees - // In other words, all its in-coming edges have been - // "removed" and hence contradicts with the fact that - // one of them is being "removed" - *degree -= 1; - if *degree == 0 { - work_set.insert(dep); - if self.graph.contains_key(dep) { - res.push(dep); - } - } - } - } - } - } - - // The set of nodes that have not been added to the result - // i.e., there are still in-coming edges and hence exists a cycle - let mut set: HashSet<&InternalName> = HashSet::from_iter(self.graph.keys().copied()); - for name in res.iter() { - set.remove(name); - } - - if let Some(cycle) = set.into_iter().next() { - Err(cycle.clone()) - } else { - // We need to reverse the result because, e.g., - // `res` is now [A,B,C] for A -> B -> C because no one depends on A - res.reverse(); - Ok(res) - } - } - // Resolve common type references, returning a map from (fully-qualified) // [`InternalName`] of a common type to its [`Type`] definition fn resolve( &self, extensions: &Extensions<'_>, ) -> Result> { - let sorted_names = self.topo_sort().map_err(|n| { - SchemaError::CycleInCommonTypeReferences(CycleInCommonTypeReferencesError { ty: n }) + let sorted_names = super::topo_sort::topo_sort(&self.graph).map_err(|n| { + SchemaError::CycleInCommonTypeReferences(CycleInCommonTypeReferencesError { + ty: n.clone(), + }) })?; let mut tys: HashMap<&'a InternalName, LocatedType> = HashMap::new(); diff --git a/cedar-policy-core/src/validator/schema/err.rs b/cedar-policy-core/src/validator/schema/err.rs index 2e37697576..98bf54f68e 100644 --- a/cedar-policy-core/src/validator/schema/err.rs +++ b/cedar-policy-core/src/validator/schema/err.rs @@ -257,6 +257,10 @@ pub enum SchemaError { #[error(transparent)] #[diagnostic(transparent)] ActionInvariantViolation(#[from] schema_errors::ActionInvariantViolationError), + /// Schema type nesting depth exceeds the configured limit + #[error(transparent)] + #[diagnostic(transparent)] + TypeTooDeep(#[from] schema_errors::TypeTooDeepError), } impl From> for SchemaError { @@ -892,4 +896,18 @@ pub mod schema_errors { impl_diagnostic_from_method_on_nonempty_field!(euids, loc); } + + /// Schema type nesting depth exceeds the configured limit + // + // CAUTION: this type is publicly exported in `cedar-policy`. + // Don't make fields `pub`, don't make breaking changes, and use caution + // when adding public methods. + #[derive(Debug, Diagnostic, Error)] + #[error("schema type depth {depth} exceeds the configured limit of {limit}")] + pub struct TypeTooDeepError { + /// Actual depth of the deepest type + pub(crate) depth: usize, + /// Configured limit + pub(crate) limit: usize, + } } diff --git a/cedar-policy-core/src/validator/schema_type_depth.rs b/cedar-policy-core/src/validator/schema_type_depth.rs new file mode 100644 index 0000000000..30e8a490a8 --- /dev/null +++ b/cedar-policy-core/src/validator/schema_type_depth.rs @@ -0,0 +1,197 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Effective depth computation for schema types, resolving common type references. + +use std::collections::{HashMap, HashSet}; + +use crate::ast::InternalName; + +use super::json_schema::{EntityTypeKind, Fragment, NamespaceDefinition, Type, TypeVariant}; +use super::topo_sort::topo_sort; + +/// Compute the maximum effective type depth across all types in a schema fragment, +/// resolving common type references to account for depth introduced through +/// chains of reference. This is the depth the types will have after common type +/// references are inlined. +/// +/// Requires a `Fragment` where all type references are fully +/// qualified. Use [`Fragment::to_internal_name_fragment_with_resolved_types`] to obtain one. +/// +/// Returns `None` if there are cycles (reported separately by the validator). +pub(crate) fn fragment_effective_depth(fragment: &Fragment) -> Option { + let common_type_depths = resolve_all_common_type_depths(fragment)?; + + let max_depth = fragment + .0 + .values() + .map(|ns_def| namespace_depth(ns_def, &common_type_depths)) + .chain(common_type_depths.values().copied()) + .max() + .unwrap_or(0); + + Some(max_depth) +} + +/// Compute the maximum effective depth across all types declared in a +/// namespace. Does not account for common types declared in the namespace but +/// used elsewhere. All common type depths are checked together in +/// `fragment_effective_depth`. +fn namespace_depth( + ns_def: &NamespaceDefinition, + common_type_depths: &HashMap, +) -> usize { + let entity_depths = ns_def.entity_types.values().filter_map(|ety| { + if let EntityTypeKind::Standard(standard) = &ety.kind { + let shape = type_depth(&standard.shape.0, common_type_depths); + let tags = standard + .tags + .as_ref() + .map(|t| type_depth(t, common_type_depths)) + .unwrap_or(0); + Some(shape.max(tags)) + } else { + None + } + }); + let action_depths = ns_def + .actions + .values() + .filter_map(|a| a.applies_to.as_ref()) + .map(|applies_to| type_depth(&applies_to.context.0, common_type_depths)); + entity_depths.chain(action_depths).max().unwrap_or(0) +} + +/// Build a lookup map from fully-qualified [`InternalName`] to the common type definition. +fn build_common_type_map( + fragment: &Fragment, +) -> HashMap> { + fragment + .0 + .iter() + .flat_map(|(ns_name, ns_def)| { + ns_def.common_types.iter().map(move |(ct_id, ct)| { + let key = InternalName::unqualified_name(ct_id.as_ref().clone().into(), None) + .qualify_with_name(ns_name.as_ref()); + (key, &ct.ty) + }) + }) + .collect() +} + +/// Resolve depths of all common types using topological sort. +/// Returns `None` if there is a cycle. +fn resolve_all_common_type_depths( + fragment: &Fragment, +) -> Option> { + let common_types = build_common_type_map(fragment); + let graph: HashMap<&InternalName, HashSet<&InternalName>> = common_types + .iter() + .map(|(key, ty)| { + let deps = ty + .common_type_references() + .filter(|name| common_types.contains_key(*name)) + .collect(); + (key, deps) + }) + .collect(); + + let sorted = topo_sort(&graph).ok()?; + + let mut common_type_depths: HashMap = HashMap::new(); + for &key in &sorted { + #[expect( + clippy::unwrap_used, + reason = "topo_sort only returns keys that exist in the input graph, which was built from common_types" + )] + let ty = common_types.get(key).unwrap(); + let depth = type_depth(ty, &common_type_depths); + common_type_depths.insert(key.clone(), depth); + } + Some(common_type_depths) +} + +/// Compute the structural depth of a type, substituting resolved depths for +/// common type references. We can directly recurse on `ty` because we assume we +/// have already checked its direct depth and we do not follow common type +/// references, instead looking them up in `common_type_defs`. +fn type_depth(ty: &Type, common_type_defs: &HashMap) -> usize { + match ty { + Type::Type { ty: variant, .. } => match variant { + TypeVariant::String + | TypeVariant::Long + | TypeVariant::Boolean + | TypeVariant::Entity { .. } + | TypeVariant::Extension { .. } => 0, + TypeVariant::Set { element } => 1 + type_depth(element, common_type_defs), + TypeVariant::Record(record) => { + record + .attributes + .values() + .map(|attr| type_depth(&attr.ty, common_type_defs)) + .max() + // If empty, the depth of a record is 0. If non-empty, the + // depth is one more than the deepest member. + .map(|d| 1 + d) + .unwrap_or(0) + } + TypeVariant::EntityOrCommon { type_name } => { + common_type_defs.get(type_name).copied().unwrap_or(0) + } + }, + Type::CommonTypeRef { type_name, .. } => { + common_type_defs.get(type_name).copied().unwrap_or(0) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::Extensions; + use crate::validator::cedar_schema::parser::parse_cedar_schema_fragment; + use rstest::rstest; + + fn effective_depth_of_cedar(src: &str) -> Option { + let extensions = Extensions::all_available(); + let (fragment, _warnings) = parse_cedar_schema_fragment(src, &extensions).unwrap(); + let resolved = fragment + .to_internal_name_fragment_with_resolved_types() + .unwrap(); + fragment_effective_depth(&resolved) + } + + #[rstest] + #[case::flat_entity("entity Foo;", 0)] + #[case::entity_with_attr("entity Foo = { bar: Long };", 1)] + #[case::set_of_long_attr("entity Foo = { bar: Set };", 2)] + #[case::common_type_no_extra_depth("type T = Long; entity Foo = { bar: T };", 1)] + #[case::common_type_chain( + "type T1 = Set; type T2 = Set; entity Foo = { bar: T2 };", + 3 + )] + #[case::long_typedef_chain("type T1 = Set; type T2 = Set; type T3 = Set;", 3)] + #[case::record_with_typedef("type T = { inner: Long }; entity Foo = { bar: T };", 2)] + #[case::entity_type_reference("entity Bar; entity Foo = { bar: Bar };", 1)] + #[case::action_context_with_typedef( + "type Deep = Set>; entity User; entity File; action Read appliesTo { principal: User, resource: File, context: { x: Deep } };", + 3 + )] + #[case::tags_with_typedef("type Deep = Set>; entity Foo tags Deep;", 2)] + fn effective_depth(#[case] src: &str, #[case] expected: usize) { + assert_eq!(effective_depth_of_cedar(src), Some(expected)); + } +} diff --git a/cedar-policy-core/src/validator/topo_sort.rs b/cedar-policy-core/src/validator/topo_sort.rs new file mode 100644 index 0000000000..2349d37c30 --- /dev/null +++ b/cedar-policy-core/src/validator/topo_sort.rs @@ -0,0 +1,141 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Generic topological sort using Kahn's algorithm. + +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; + +/// Topologically sort nodes given a dependency graph. +/// +/// `graph` maps each node to the set of nodes it depends on (i.e., must come +/// before it in the output). Dependencies that don't appear as keys in the +/// graph are ignored. +/// +/// Returns nodes in dependency order (leaves first), or `Err(node)` with a +/// cycle participant. +/// +/// This implements a variant of Kahn's algorithm. +pub(crate) fn topo_sort<'a, N>(graph: &HashMap<&'a N, HashSet<&'a N>>) -> Result, &'a N> +where + N: Eq + Hash, +{ + // The in-degree map + let mut indegrees: HashMap<&N, usize> = HashMap::new(); + for (ty_name, deps) in graph.iter() { + indegrees.entry(ty_name).or_insert(0); + for dep in deps { + if graph.contains_key(dep) { + *indegrees.entry(dep).or_insert(0) += 1; + } + } + } + + // The set that contains names with zero incoming edges + let mut work_set: HashSet<&N> = HashSet::new(); + let mut res: Vec<&N> = Vec::new(); + + for (name, degree) in indegrees.iter() { + let name = *name; + if *degree == 0 { + work_set.insert(name); + if graph.contains_key(name) { + res.push(name); + } + } + } + + // Pop a node + while let Some(name) = work_set.iter().next().copied() { + work_set.remove(name); + if let Some(deps) = graph.get(name) { + for dep in deps { + if let Some(degree) = indegrees.get_mut(dep) { + *degree -= 1; + if *degree == 0 { + work_set.insert(dep); + if graph.contains_key(dep) { + res.push(dep); + } + } + } + } + } + } + + // Nodes not in the result have incoming edges remaining, i.e., a cycle + let mut remaining: HashSet<&N> = HashSet::from_iter(graph.keys().copied()); + for name in res.iter() { + remaining.remove(name); + } + + if let Some(cycle) = remaining.into_iter().next() { + Err(cycle) + } else { + res.reverse(); + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_graph() { + let graph: HashMap<&&str, HashSet<&&str>> = HashMap::new(); + assert_eq!(topo_sort(&graph).unwrap(), Vec::<&&str>::new()); + } + + #[test] + fn single_node_no_deps() { + let graph: HashMap<&&str, HashSet<&&str>> = HashMap::from([(&"A", HashSet::new())]); + assert_eq!(topo_sort(&graph).unwrap(), vec![&"A"]); + } + + #[test] + fn linear_chain() { + let a = "A"; + let b = "B"; + let c = "C"; + let graph: HashMap<&&str, HashSet<&&str>> = HashMap::from([ + (&a, HashSet::from([&b])), + (&b, HashSet::from([&c])), + (&c, HashSet::new()), + ]); + let sorted = topo_sort(&graph).unwrap(); + let pos = |n: &&str| sorted.iter().position(|x| *x == n).unwrap(); + assert!(pos(&c) < pos(&b)); + assert!(pos(&b) < pos(&a)); + } + + #[test] + fn cycle_detected() { + let a = "A"; + let b = "B"; + let graph: HashMap<&&str, HashSet<&&str>> = + HashMap::from([(&a, HashSet::from([&b])), (&b, HashSet::from([&a]))]); + assert!(topo_sort(&graph).is_err()); + } + + #[test] + fn deps_outside_graph_ignored() { + let a = "A"; + let z = "Z"; + let graph: HashMap<&&str, HashSet<&&str>> = HashMap::from([(&a, HashSet::from([&z]))]); + assert_eq!(topo_sort(&graph).unwrap(), vec![&"A"]); + } +} diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs index 49d3681c09..d9315ebc83 100644 --- a/cedar-policy/src/api.rs +++ b/cedar-policy/src/api.rs @@ -1984,6 +1984,42 @@ impl Schema { )) } + /// Like [`Self::from_json_value`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + /// + /// This function can be used to limit the maximum recursion depth, avoiding + /// stack overflows when parsing schemas with unknown depth. + pub fn from_json_value_with_depth_limit( + json: serde_json::Value, + depth_limit: usize, + ) -> Result { + Ok(Self( + cedar_policy_core::validator::ValidatorSchema::from_json_value_with_depth_limit( + json, + Extensions::all_available(), + depth_limit, + )?, + )) + } + + /// Like [`Self::from_json_str`], but rejects the schema if any type's + /// effective nesting depth exceeds `depth_limit`. + /// + /// This function can be used to limit the maximum recursion depth, avoiding + /// stack overflows when parsing schemas with unknown depth. + pub fn from_json_str_with_depth_limit( + json: &str, + depth_limit: usize, + ) -> Result { + Ok(Self( + cedar_policy_core::validator::ValidatorSchema::from_json_str_with_depth_limit( + json, + Extensions::all_available(), + depth_limit, + )?, + )) + } + /// Parse the schema from a reader, in the Cedar schema format. pub fn from_cedarschema_file( file: impl std::io::Read, @@ -2008,6 +2044,24 @@ impl Schema { Ok((Self(schema), warnings)) } + /// Like [`Self::from_cedarschema_str`], but rejects the schema if any + /// type's nesting depth exceeds `depth_limit`. + /// + /// This function can be used to limit the maximum recursion depth, avoiding + /// stack overflows when parsing schemas with unknown depth. + pub fn from_cedarschema_str_with_depth_limit( + src: &str, + depth_limit: usize, + ) -> Result<(Self, impl Iterator), CedarSchemaError> { + let (schema, warnings) = + cedar_policy_core::validator::ValidatorSchema::from_cedarschema_str_with_depth_limit( + src, + Extensions::all_available(), + depth_limit, + )?; + Ok((Self(schema), warnings)) + } + /// Extract from the schema an [`Entities`] containing the action entities /// declared in the schema. pub fn action_entities(&self) -> Result { diff --git a/cedar-policy/src/test/test.rs b/cedar-policy/src/test/test.rs index 85d5fb4258..b0bcd8206f 100644 --- a/cedar-policy/src/test/test.rs +++ b/cedar-policy/src/test/test.rs @@ -12272,3 +12272,203 @@ mod tolerant_ast_tests { let _ = policy.action_constraint(); } } + +#[cfg(test)] +mod test_schema_depth_limit { + use super::*; + use cool_asserts::assert_matches; + + #[test] + fn schema_parse_with_depth_limit() { + let src = "entity Foo = { bar: Set };"; + assert!(Schema::from_cedarschema_str_with_depth_limit(src, 2) + .map(|(s, _)| s) + .is_ok()); + assert_matches!( + Schema::from_cedarschema_str_with_depth_limit(src, 1).map(|(s, _)| s), + Err(e) => { + assert!( + e.to_string().contains("schema type depth 2 exceeds the configured limit of 1"), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn schema_nested_record_depth_limit() { + let src = "entity Foo = { a: { b: { c: Long } } };"; + assert!(Schema::from_cedarschema_str_with_depth_limit(src, 3) + .map(|(s, _)| s) + .is_ok()); + assert_matches!( + Schema::from_cedarschema_str_with_depth_limit(src, 2).map(|(s, _)| s), + Err(e) => { + assert!( + e.to_string().contains("schema type depth 3 exceeds the configured limit of 2"), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn very_deep_schema_type_no_stack_overflow() { + let depth = 10000; + let src = format!( + "entity Foo = {{ x: {} Long {} }};", + "Set<".repeat(depth), + ">".repeat(depth), + ); + assert_matches!( + Schema::from_cedarschema_str_with_depth_limit(&src, depth).map(|(s, _)| s), + Err(e) => { + assert!( + e.to_string().contains(&format!( + "schema type depth {} exceeds the configured limit of {depth}", + depth + 1 + )), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn typedef_chain_detected() { + let src = r#" + type T1 = Set; + type T2 = Set; + type T3 = Set; + entity Foo = { bar: T3 }; + "#; + assert!(Schema::from_cedarschema_str_with_depth_limit(src, 4) + .map(|(s, _)| s) + .is_ok()); + assert_matches!( + Schema::from_cedarschema_str_with_depth_limit(src, 3).map(|(s, _)| s), + Err(e) => { + assert!( + e.to_string().contains("schema type depth 4 exceeds the configured limit of 3"), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn very_deep_typedef_chain_no_stack_overflow() { + let depth = 10000; + let mut src = String::from("type T0 = Set;\n"); + for i in 1..depth { + src.push_str(&format!("type T{i} = Set;\n", i - 1)); + } + src.push_str(&format!("entity Foo = {{ x: T{} }};\n", depth - 1)); + // effective depth = depth + 1 (entity attr adds 1) + assert_matches!( + Schema::from_cedarschema_str_with_depth_limit(&src, depth).map(|(s, _)| s), + Err(e) => { + assert!( + e.to_string().contains("exceeds the configured limit of"), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn very_deep_typedef_chain_json_no_stack_overflow() { + let depth = 10000; + let mut common_types = serde_json::Map::new(); + common_types.insert( + "T0".to_string(), + serde_json::json!({ "type": "Set", "element": { "type": "Long" } }), + ); + for i in 1..depth { + common_types.insert( + format!("T{i}"), + serde_json::json!({ "type": "Set", "element": { "type": "EntityOrCommon", "name": format!("T{}", i - 1) } }), + ); + } + let json = serde_json::json!({ + "": { + "commonTypes": common_types, + "entityTypes": { + "Foo": { + "shape": { + "type": "Record", + "attributes": { + "x": { "type": "EntityOrCommon", "name": format!("T{}", depth - 1) } + } + } + } + }, + "actions": {} + } + }); + assert_matches!( + Schema::from_json_value_with_depth_limit(json, depth), + Err(e) => { + assert!( + e.to_string().contains("exceeds the configured limit of"), + "unexpected error message: {e}", + ); + } + ); + } + + #[test] + fn serde_recursion_limit_rejects_deep_json() { + let depth = 10000; + // Build a structurally deep JSON string: Set>> + let mut json = r#"{"": {"commonTypes": {"T": "#.to_string(); + for _ in 0..depth { + json.push_str(r#"{"type": "Set", "element": "#); + } + json.push_str(r#"{"type": "Long"}"#); + for _ in 0..depth { + json.push('}'); + } + json.push_str(r#"}, "entityTypes": {}, "actions": {}}}"#); + + let result = Schema::from_json_str(&json); + assert!(result.is_err(), "serde should reject deeply nested JSON"); + assert!( + result.unwrap_err().to_string().contains("recursion limit"), + "expected serde recursion limit error", + ); + } + + #[test] + fn json_schema_with_depth_limit() { + let json = serde_json::json!({ + "": { + "commonTypes": { + "T1": { "type": "Set", "element": { "type": "Long" } }, + "T2": { "type": "Set", "element": { "type": "EntityOrCommon", "name": "T1" } } + }, + "entityTypes": { + "Foo": { + "shape": { + "type": "Record", + "attributes": { + "bar": { "type": "EntityOrCommon", "name": "T2" } + } + } + } + }, + "actions": {} + } + }); + assert!(Schema::from_json_value_with_depth_limit(json.clone(), 3).is_ok()); + assert_matches!( + Schema::from_json_value_with_depth_limit(json, 2), + Err(e) => { + assert!( + e.to_string().contains("schema type depth 3 exceeds the configured limit of 2"), + "unexpected error message: {e}", + ); + } + ); + } +}