diff --git a/src/main/java/graphql/schema/idl/SchemaTypeDirectivesChecker.java b/src/main/java/graphql/schema/idl/SchemaTypeDirectivesChecker.java index 4c3e373e37..18011537ff 100644 --- a/src/main/java/graphql/schema/idl/SchemaTypeDirectivesChecker.java +++ b/src/main/java/graphql/schema/idl/SchemaTypeDirectivesChecker.java @@ -30,11 +30,16 @@ import graphql.schema.idl.errors.MissingTypeError; import graphql.schema.idl.errors.NotAnInputTypeError; +import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import static graphql.Assert.assertNotNull; import static graphql.introspection.Introspection.DirectiveLocation.ARGUMENT_DEFINITION; import static graphql.introspection.Introspection.DirectiveLocation.ENUM; import static graphql.introspection.Introspection.DirectiveLocation.ENUM_VALUE; @@ -182,6 +187,9 @@ private static boolean isNoNullArgWithoutDefaultValue(InputValueDefinition defin } private void commonCheck(Collection directiveDefinitions, List errors) { + List directiveDefinitionsList = new ArrayList<>(directiveDefinitions); + Map directiveDefinitionsByName = getByName(directiveDefinitionsList, DirectiveDefinition::getName, mergeFirst()); + directiveDefinitions.forEach(directiveDefinition -> { assertTypeName(directiveDefinition, errors); directiveDefinition.getInputValueDefinitions().forEach(inputValueDefinition -> { @@ -192,6 +200,73 @@ private void commonCheck(Collection directiveDefinitions, L } }); }); + checkIndirectDirectiveCycles(directiveDefinitionsByName, errors); + } + + private static Map directiveReferences(DirectiveDefinition directiveDefinition) { + Map result = new LinkedHashMap<>(); + for (InputValueDefinition inputValueDefinition : directiveDefinition.getInputValueDefinitions()) { + recordDirectiveReferences(directiveDefinition, result, inputValueDefinition); + } + return result; + } + + private static void recordDirectiveReferences(DirectiveDefinition directiveDefinition, + Map result, + InputValueDefinition inputValueDefinition) { + for (Directive directive : inputValueDefinition.getDirectives()) { + if (directive.getName().equals(directiveDefinition.getName())) { + continue; + } + result.putIfAbsent(directive.getName(), inputValueDefinition); + } + } + + private static void checkIndirectDirectiveCycles( + Map directiveDefinitionsByName, + List errors) { + Set checked = new LinkedHashSet<>(); + // Insertion order records the active recursion path for the error message. + LinkedHashSet currentPath = new LinkedHashSet<>(); + for (DirectiveDefinition directiveDefinition : directiveDefinitionsByName.values()) { + checkDirectiveReferencesForCycles(directiveDefinition, directiveDefinitionsByName, checked, currentPath, errors); + } + } + + private static void checkDirectiveReferencesForCycles(DirectiveDefinition directiveDefinition, + Map directiveDefinitionsByName, + Set checked, + LinkedHashSet currentPath, + List errors) { + String directiveName = directiveDefinition.getName(); + if (checked.contains(directiveName)) { + return; + } + + currentPath.add(directiveName); + for (Map.Entry reference : directiveReferences(directiveDefinition).entrySet()) { + String referencedDirectiveName = reference.getKey(); + if (currentPath.contains(referencedDirectiveName)) { + DirectiveDefinition repeatedDirective = assertNotNull(directiveDefinitionsByName.get(referencedDirectiveName)); + String cyclePath = directiveCyclePath(referencedDirectiveName, currentPath); + errors.add(new DirectiveIllegalReferenceError(repeatedDirective, reference.getValue(), cyclePath)); + continue; + } + DirectiveDefinition referencedDirective = directiveDefinitionsByName.get(referencedDirectiveName); + if (referencedDirective != null) { + checkDirectiveReferencesForCycles(referencedDirective, directiveDefinitionsByName, checked, currentPath, errors); + } + } + currentPath.remove(directiveName); + checked.add(directiveName); + } + + private static String directiveCyclePath(String repeatedDirectiveName, LinkedHashSet currentPath) { + List pathList = new ArrayList<>(currentPath); + int cycleStart = pathList.indexOf(repeatedDirectiveName); + List cyclePath = new ArrayList<>(pathList.subList(cycleStart, pathList.size())); + cyclePath.add(repeatedDirectiveName); + return String.join(" -> ", cyclePath); } private static void assertTypeName(NamedNode node, List errors) { @@ -224,4 +299,4 @@ private static TypeDefinition findTypeDefFromRegistry(String typeName, TypeDe } return typeRegistry.scalars().get(typeName); } -} \ No newline at end of file +} diff --git a/src/main/java/graphql/schema/idl/errors/DirectiveIllegalReferenceError.java b/src/main/java/graphql/schema/idl/errors/DirectiveIllegalReferenceError.java index 44fb541e53..2bcd34db39 100644 --- a/src/main/java/graphql/schema/idl/errors/DirectiveIllegalReferenceError.java +++ b/src/main/java/graphql/schema/idl/errors/DirectiveIllegalReferenceError.java @@ -12,4 +12,11 @@ public DirectiveIllegalReferenceError(DirectiveDefinition directive, NamedNode l directive.getName(), location.getName(), lineCol(location) )); } -} \ No newline at end of file + + public DirectiveIllegalReferenceError(DirectiveDefinition directive, NamedNode location, String cyclePath) { + super(directive, + String.format("'%s' must not reference itself via directive cycle '%s' on '%s''%s'", + directive.getName(), cyclePath, location.getName(), lineCol(location) + )); + } +} diff --git a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy index 5c268ebd0f..3f375eeb8e 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy @@ -25,6 +25,7 @@ import graphql.schema.GraphQLType import graphql.schema.GraphQLTypeUtil import graphql.schema.GraphQLUnionType import graphql.schema.GraphqlTypeComparatorRegistry +import graphql.schema.idl.errors.DirectiveIllegalReferenceError import graphql.schema.idl.errors.NotAnInputTypeError import graphql.schema.idl.errors.NotAnOutputTypeError import graphql.schema.idl.errors.SchemaProblem @@ -2270,6 +2271,27 @@ class SchemaGeneratorTest extends Specification { schema != null } + def "#4201 indirect cyclical directive definitions are rejected without stack overflow"() { + given: + def registry = new SchemaParser().parse(''' + directive @foo(x: Int @bar(y: 1)) on FIELD_DEFINITION | ARGUMENT_DEFINITION + directive @bar(y: Int @foo(x: 2)) on FIELD_DEFINITION | ARGUMENT_DEFINITION + + type Query { + field: String @foo(x: 10) @bar(y: 20) + } + ''') + + when: + UnExecutableSchemaGenerator.makeUnExecutableSchema(registry) + + then: + def e = thrown(SchemaProblem) + e.errors.size() == 1 + e.errors.get(0) instanceof DirectiveIllegalReferenceError + e.errors.get(0).getMessage().contains("'foo' must not reference itself via directive cycle 'foo -> bar -> foo'") + } + def "code registry default data fetcher is respected"() { def sdl = ''' type Query { diff --git a/src/test/groovy/graphql/schema/idl/SchemaTypeDirectivesCheckerTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaTypeDirectivesCheckerTest.groovy index 887fe97f5c..f8096c2b28 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaTypeDirectivesCheckerTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaTypeDirectivesCheckerTest.groovy @@ -232,6 +232,66 @@ class SchemaTypeDirectivesCheckerTest extends Specification { errors.get(0).getMessage() == "'invalidExample' must not reference itself on 'arg''[@2:39]'" } + def "directive must not indirectly reference itself - #name"() { + given: + def registry = parse(spec) + def errors = [] + + when: + new SchemaTypeDirectivesChecker(registry, RuntimeWiring.newRuntimeWiring().build()).checkTypeDirectives(errors) + + then: + errors.size() == 1 + errors.get(0) instanceof DirectiveIllegalReferenceError + errors.get(0).getMessage().contains(cycleMessage) + + where: + name << ["two directives", "three directives"] + spec << [ + ''' + directive @foo(arg: String @bar) on ARGUMENT_DEFINITION + directive @bar(arg: String @foo) on ARGUMENT_DEFINITION + + type Query { + f1 : String + } + ''', + ''' + directive @dirA(x: Int @dirB(y: 1)) on ARGUMENT_DEFINITION + directive @dirB(y: Int @dirC(z: 2)) on ARGUMENT_DEFINITION + directive @dirC(z: Int @dirA(x: 3)) on ARGUMENT_DEFINITION + + type Query { + f1 : String + } + ''' + ] + cycleMessage << [ + "'foo' must not reference itself via directive cycle 'foo -> bar -> foo'", + "'dirA' must not reference itself via directive cycle 'dirA -> dirB -> dirC -> dirA'" + ] + } + + def "acyclic directive references are allowed"() { + given: + def registry = parse(''' + directive @foo(arg: String @bar) on ARGUMENT_DEFINITION + directive @bar(arg: String @baz) on ARGUMENT_DEFINITION + directive @baz on ARGUMENT_DEFINITION + + type Query { + f1 : String + } + ''') + def errors = [] + + when: + new SchemaTypeDirectivesChecker(registry, RuntimeWiring.newRuntimeWiring().build()).checkTypeDirectives(errors) + + then: + errors.isEmpty() + } + def "directive must not begin with '__'"() { given: def spec = '''