From 6b32c1eab409fbbbacb787f1f5d2ab509b8f55cb Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Thu, 23 Mar 2023 14:06:15 +1300 Subject: [PATCH 01/11] Add more running checks in some loops --- src/main/java/graphql/schema/diffing/DiffImpl.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/graphql/schema/diffing/DiffImpl.java b/src/main/java/graphql/schema/diffing/DiffImpl.java index 0ce80046a1..b91bdc48dd 100644 --- a/src/main/java/graphql/schema/diffing/DiffImpl.java +++ b/src/main/java/graphql/schema/diffing/DiffImpl.java @@ -172,6 +172,8 @@ private void addChildToQueue(MappingEntry parentEntry, costMatrix[i - level].set(j, cost); j++; } + + runningCheck.check(); } HungarianAlgorithm hungarianAlgorithm = new HungarianAlgorithm(costMatrixForHungarianAlgo); @@ -278,6 +280,8 @@ private void calculateRestOfChildren(List availableTargetVertices, sibling.availableTargetVertices = availableTargetVertices; siblings.add(sibling); + + runningCheck.check(); } siblings.add(LAST_ELEMENT); @@ -390,6 +394,7 @@ private double calcLowerBoundMappingCost(Vertex v, anchoredVerticesCost++; } + runningCheck.check(); } Multiset intersection = Multisets.intersection(multisetLabelsV, multisetLabelsU); From 24de5c70338027111ea883547117c1c3ad0765b2 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Thu, 23 Mar 2023 14:07:12 +1300 Subject: [PATCH 02/11] Fix argument deletion when type is deleted --- .../diffing/ana/EditOperationAnalyzer.java | 9 ++++ .../ana/EditOperationAnalyzerTest.groovy | 52 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java index 888974dc83..2c399a5dd1 100644 --- a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java +++ b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java @@ -1688,15 +1688,24 @@ private void argumentDeleted(EditOperation editOperation) { Vertex fieldsContainerForField = oldSchemaGraph.getFieldsContainerForField(field); if (fieldsContainerForField.isOfType(SchemaGraph.OBJECT)) { Vertex object = fieldsContainerForField; + if (isObjectDeleted(object.getName())) { + return; + } getObjectModification(object.getName()).getDetails().add(new ObjectFieldArgumentDeletion(field.getName(), deletedArgument.getName())); } else { assertTrue(fieldsContainerForField.isOfType(SchemaGraph.INTERFACE)); Vertex interfaze = fieldsContainerForField; + if (isInterfaceDeleted(interfaze.getName())) { + return; + } getInterfaceModification(interfaze.getName()).getDetails().add(new InterfaceFieldArgumentDeletion(field.getName(), deletedArgument.getName())); } } else { assertTrue(fieldOrDirective.isOfType(SchemaGraph.DIRECTIVE)); Vertex directive = fieldOrDirective; + if (isDirectiveDeleted(directive.getName())) { + return; + } getDirectiveModification(directive.getName()).getDetails().add(new DirectiveArgumentDeletion(deletedArgument.getName())); } diff --git a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy index 99c7071f1c..e5fde987fa 100644 --- a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy @@ -5,10 +5,10 @@ import graphql.schema.diffing.SchemaDiffing import spock.lang.Specification import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveDeletion -import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveObjectFieldArgumentLocation import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveDirectiveArgumentLocation -import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveObjectFieldLocation import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveInterfaceFieldArgumentLocation +import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveObjectFieldArgumentLocation +import static graphql.schema.diffing.ana.SchemaDifference.AppliedDirectiveObjectFieldLocation import static graphql.schema.diffing.ana.SchemaDifference.DirectiveAddition import static graphql.schema.diffing.ana.SchemaDifference.DirectiveArgumentAddition import static graphql.schema.diffing.ana.SchemaDifference.DirectiveArgumentDefaultValueModification @@ -32,6 +32,7 @@ import static graphql.schema.diffing.ana.SchemaDifference.InputObjectFieldRename import static graphql.schema.diffing.ana.SchemaDifference.InputObjectFieldTypeModification import static graphql.schema.diffing.ana.SchemaDifference.InputObjectModification import static graphql.schema.diffing.ana.SchemaDifference.InterfaceAddition +import static graphql.schema.diffing.ana.SchemaDifference.InterfaceDeletion import static graphql.schema.diffing.ana.SchemaDifference.InterfaceFieldAddition import static graphql.schema.diffing.ana.SchemaDifference.InterfaceFieldArgumentAddition import static graphql.schema.diffing.ana.SchemaDifference.InterfaceFieldArgumentDefaultValueModification @@ -1917,7 +1918,6 @@ class EditOperationAnalyzerTest extends Specification { when: def changes = calcDiff(oldSdl, newSdl) then: - true changes.inputObjectDifferences["Echo"] instanceof InputObjectModification def diff = changes.inputObjectDifferences["Echo"] as InputObjectModification @@ -1937,6 +1937,52 @@ class EditOperationAnalyzerTest extends Specification { directiveDeletion[0].name == "d" } + def "interface deleted with field argument"() { + given: + def oldSdl = ''' + type Query { + node: Node + } + interface Node { + echo(test: String): String + } + ''' + def newSdl = ''' + type Query { + node: ID + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.interfaceDifferences["Node"] instanceof InterfaceDeletion + } + + def "object deleted with field argument"() { + given: + def oldSdl = ''' + type Query { + node: Node + } + type Node { + echo(test: String): String + } + ''' + def newSdl = ''' + type Query { + node: ID + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.objectDifferences["Node"] instanceof ObjectDeletion + } + EditOperationAnalysisResult calcDiff( String oldSdl, String newSdl From 34064419e1b105391eddc2ac7b1296627005d044 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Thu, 23 Mar 2023 14:07:20 +1300 Subject: [PATCH 03/11] Fix field rename on description change --- .../diffing/ana/EditOperationAnalyzer.java | 12 +++-- .../ana/EditOperationAnalyzerTest.groovy | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java index 2c399a5dd1..afd33677f7 100644 --- a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java +++ b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java @@ -740,18 +740,22 @@ private void handleEnumValueChanged(EditOperation editOperation) { private void fieldChanged(EditOperation editOperation) { Vertex field = editOperation.getTargetVertex(); Vertex fieldsContainerForField = newSchemaGraph.getFieldsContainerForField(field); + + String oldName = editOperation.getSourceVertex().getName(); + String newName = field.getName(); + if (oldName.equals(newName)) { + // Something else like description could have changed + return; + } + if (fieldsContainerForField.isOfType(SchemaGraph.OBJECT)) { Vertex object = fieldsContainerForField; ObjectModification objectModification = getObjectModification(object.getName()); - String oldName = editOperation.getSourceVertex().getName(); - String newName = field.getName(); objectModification.getDetails().add(new ObjectFieldRename(oldName, newName)); } else { assertTrue(fieldsContainerForField.isOfType(SchemaGraph.INTERFACE)); Vertex interfaze = fieldsContainerForField; InterfaceModification interfaceModification = getInterfaceModification(interfaze.getName()); - String oldName = editOperation.getSourceVertex().getName(); - String newName = field.getName(); interfaceModification.getDetails().add(new InterfaceFieldRename(oldName, newName)); } } diff --git a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy index e5fde987fa..a05ecee20e 100644 --- a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy @@ -1937,6 +1937,58 @@ class EditOperationAnalyzerTest extends Specification { directiveDeletion[0].name == "d" } + def "object field description changed"() { + given: + def oldSdl = ''' + type Query { + " Hello" + echo: String + } + ''' + def newSdl = ''' + type Query { + "Test " + echo: String + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + // no changes + changes.objectDifferences["Query"] == null + } + + def "interface field description changed"() { + given: + def oldSdl = ''' + type Query { + node: Node + } + interface Node { + " Hello" + echo: String + } + ''' + def newSdl = ''' + type Query { + node: Node + } + interface Node { + "World" + echo: String + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + // no changes + changes.interfaceDifferences["Node"] == null + } + def "interface deleted with field argument"() { given: def oldSdl = ''' From 5f204f280d2776291d1f3a680a21d7e64cf6f6f7 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Fri, 24 Mar 2023 15:16:49 +1100 Subject: [PATCH 04/11] Have a limit on how many characters are presented to the Parser --- src/main/java/graphql/parser/Parser.java | 119 ++++++++++++------ .../java/graphql/parser/ParserOptions.java | 34 +++++ .../java/graphql/parser/SafeTokenReader.java | 95 ++++++++++++++ .../ParseCancelledTooManyCharsException.java | 16 +++ src/main/resources/i18n/Parsing.properties | 1 + .../graphql/parser/ParserOptionsTest.groovy | 25 +++- .../graphql/parser/ParserStressTest.groovy | 12 ++ .../graphql/parser/SafeTokenReaderTest.groovy | 18 +++ 8 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 src/main/java/graphql/parser/SafeTokenReader.java create mode 100644 src/main/java/graphql/parser/exceptions/ParseCancelledTooManyCharsException.java create mode 100644 src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy diff --git a/src/main/java/graphql/parser/Parser.java b/src/main/java/graphql/parser/Parser.java index ce476c89bb..a2bf6b9058 100644 --- a/src/main/java/graphql/parser/Parser.java +++ b/src/main/java/graphql/parser/Parser.java @@ -14,6 +14,7 @@ import graphql.parser.antlr.GraphqlParser; import graphql.parser.exceptions.ParseCancelledException; import graphql.parser.exceptions.ParseCancelledTooDeepException; +import graphql.parser.exceptions.ParseCancelledTooManyCharsException; import org.antlr.v4.runtime.BaseErrorListener; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CodePointCharStream; @@ -25,6 +26,7 @@ import org.antlr.v4.runtime.atn.PredictionMode; import org.antlr.v4.runtime.tree.ParseTreeListener; import org.antlr.v4.runtime.tree.TerminalNode; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.io.Reader; @@ -33,6 +35,7 @@ import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.function.Consumer; /** * This can parse graphql syntax, both Query syntax and Schema Definition Language (SDL) syntax, into an @@ -259,6 +262,57 @@ private Node parseImpl(ParserEnvironment environment, BiFunction node = (Node) contextAndNode[1]; + + Token stop = parserRuleContext.getStop(); + List allTokens = tokens.getTokens(); + if (stop != null && allTokens != null && !allTokens.isEmpty()) { + Token last = allTokens.get(allTokens.size() - 1); + // + // do we have more tokens in the stream than we consumed in the parse? + // if yes then it's invalid. We make sure it's the same channel + boolean notEOF = last.getType() != Token.EOF; + boolean lastGreaterThanDocument = last.getTokenIndex() > stop.getTokenIndex(); + boolean sameChannel = last.getChannel() == stop.getChannel(); + if (notEOF && lastGreaterThanDocument && sameChannel) { + throw bailStrategy.mkMoreTokensException(last); + } + } + return node; + } + + private static MultiSourceReader setupMultiSourceReader(ParserEnvironment environment, ParserOptions parserOptions) { MultiSourceReader multiSourceReader; Reader reader = environment.getDocument(); if (reader instanceof MultiSourceReader) { @@ -269,13 +323,31 @@ private Node parseImpl(ParserEnvironment environment, BiFunction onTooManyCharacters = it -> { + throw new ParseCancelledTooManyCharsException(environment.getI18N(), maxCharacters); + }; + return new SafeTokenReader(multiSourceReader, maxCharacters, onTooManyCharacters); + } + + @NotNull + private static CodePointCharStream setupCharStream(SafeTokenReader safeTokenReader) { CodePointCharStream charStream; try { - charStream = CharStreams.fromReader(multiSourceReader); + charStream = CharStreams.fromReader(safeTokenReader); } catch (IOException e) { throw new UncheckedIOException(e); } + return charStream; + } + @NotNull + private static GraphqlLexer setupGraphqlLexer(ParserEnvironment environment, MultiSourceReader multiSourceReader, CodePointCharStream charStream) { GraphqlLexer lexer = new GraphqlLexer(charStream); lexer.removeErrorListeners(); lexer.addErrorListener(new BaseErrorListener() { @@ -296,8 +368,11 @@ public void syntaxError(Recognizer recognizer, Object offendingSymbol, int throw new InvalidSyntaxException(msg, sourceLocation, null, preview, null); } }); + return lexer; + } - // this lexer wrapper allows us to stop lexing when too many tokens are in place. This prevents DOS attacks. + @NotNull + private SafeTokenSource getSafeTokenSource(ParserEnvironment environment, ParserOptions parserOptions, MultiSourceReader multiSourceReader, GraphqlLexer lexer) { int maxTokens = parserOptions.getMaxTokens(); int maxWhitespaceTokens = parserOptions.getMaxWhitespaceTokens(); BiConsumer onTooManyTokens = (maxTokenCount, token) -> throwIfTokenProblems( @@ -306,45 +381,7 @@ public void syntaxError(Recognizer recognizer, Object offendingSymbol, int maxTokenCount, multiSourceReader, ParseCancelledException.class); - SafeTokenSource safeTokenSource = new SafeTokenSource(lexer, maxTokens, maxWhitespaceTokens, onTooManyTokens); - - CommonTokenStream tokens = new CommonTokenStream(safeTokenSource); - - GraphqlParser parser = new GraphqlParser(tokens); - parser.removeErrorListeners(); - parser.getInterpreter().setPredictionMode(PredictionMode.SLL); - - ExtendedBailStrategy bailStrategy = new ExtendedBailStrategy(multiSourceReader, environment); - parser.setErrorHandler(bailStrategy); - - // preserve old protected call semantics - remove at some point - GraphqlAntlrToLanguage toLanguage = getAntlrToLanguage(tokens, multiSourceReader, environment); - - setupParserListener(environment, multiSourceReader, parser, toLanguage); - - - // - // parsing starts ...... now! - // - Object[] contextAndNode = nodeFunction.apply(parser, toLanguage); - ParserRuleContext parserRuleContext = (ParserRuleContext) contextAndNode[0]; - Node node = (Node) contextAndNode[1]; - - Token stop = parserRuleContext.getStop(); - List allTokens = tokens.getTokens(); - if (stop != null && allTokens != null && !allTokens.isEmpty()) { - Token last = allTokens.get(allTokens.size() - 1); - // - // do we have more tokens in the stream than we consumed in the parse? - // if yes then it's invalid. We make sure it's the same channel - boolean notEOF = last.getType() != Token.EOF; - boolean lastGreaterThanDocument = last.getTokenIndex() > stop.getTokenIndex(); - boolean sameChannel = last.getChannel() == stop.getChannel(); - if (notEOF && lastGreaterThanDocument && sameChannel) { - throw bailStrategy.mkMoreTokensException(last); - } - } - return node; + return new SafeTokenSource(lexer, maxTokens, maxWhitespaceTokens, onTooManyTokens); } private void setupParserListener(ParserEnvironment environment, MultiSourceReader multiSourceReader, GraphqlParser parser, GraphqlAntlrToLanguage toLanguage) { diff --git a/src/main/java/graphql/parser/ParserOptions.java b/src/main/java/graphql/parser/ParserOptions.java index 6256166075..77306ceb57 100644 --- a/src/main/java/graphql/parser/ParserOptions.java +++ b/src/main/java/graphql/parser/ParserOptions.java @@ -11,6 +11,16 @@ */ @PublicApi public class ParserOptions { + /** + * A graphql hacking vector is to send nonsensical queries that contain a repeated characters that burn lots of parsing CPU time and burn + * memory representing a document that won't ever execute. To prevent this for most users, graphql-java + * sets this value to 512KB. ANTLR parsing time is linear to the number of characters presented. The more you + * allow the longer it takes. + *

+ * If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this + * JVM wide. + */ + public static final int MAX_QUERY_CHARACTERS = 1024 * 1024; // 1 MB /** * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn @@ -47,6 +57,7 @@ public class ParserOptions { .captureSourceLocation(true) .captureLineComments(true) .readerTrackData(true) + .maxCharacters(MAX_QUERY_CHARACTERS) .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) .maxRuleDepth(MAX_RULE_DEPTH) @@ -57,6 +68,7 @@ public class ParserOptions { .captureSourceLocation(true) .captureLineComments(false) // #comments are not useful in query parsing .readerTrackData(true) + .maxCharacters(MAX_QUERY_CHARACTERS) .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) .maxRuleDepth(MAX_RULE_DEPTH) @@ -67,6 +79,7 @@ public class ParserOptions { .captureSourceLocation(true) .captureLineComments(true) // #comments are useful in SDL parsing .readerTrackData(true) + .maxCharacters(Integer.MAX_VALUE) .maxTokens(Integer.MAX_VALUE) // we are less worried about a billion laughs with SDL parsing since the call path is not facing attackers .maxWhitespaceTokens(Integer.MAX_VALUE) .maxRuleDepth(Integer.MAX_VALUE) @@ -171,6 +184,7 @@ public static void setDefaultSdlParserOptions(ParserOptions options) { private final boolean captureSourceLocation; private final boolean captureLineComments; private final boolean readerTrackData; + private final int maxCharacters; private final int maxTokens; private final int maxWhitespaceTokens; private final int maxRuleDepth; @@ -181,6 +195,7 @@ private ParserOptions(Builder builder) { this.captureSourceLocation = builder.captureSourceLocation; this.captureLineComments = builder.captureLineComments; this.readerTrackData = builder.readerTrackData; + this.maxCharacters = builder.maxCharacters; this.maxTokens = builder.maxTokens; this.maxWhitespaceTokens = builder.maxWhitespaceTokens; this.maxRuleDepth = builder.maxRuleDepth; @@ -233,6 +248,18 @@ public boolean isReaderTrackData() { return readerTrackData; } + /** + * A graphql hacking vector is to send nonsensical queries that contain a repeated characters that burn lots of parsing CPU time and burn + * memory representing a document that won't ever execute. To prevent this for most users, graphql-java + * sets this value to 1MB. + * + * @return the maximum number of characters the parser will accept, after which an exception will be thrown. + */ + public int getMaxCharacters() { + return maxCharacters; + } + + /** * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burns * memory representing a document that won't ever execute. To prevent this you can set a maximum number of parse @@ -287,6 +314,7 @@ public static class Builder { private boolean captureLineComments = true; private boolean readerTrackData = true; private ParsingListener parsingListener = ParsingListener.NOOP; + private int maxCharacters = MAX_QUERY_CHARACTERS; private int maxTokens = MAX_QUERY_TOKENS; private int maxWhitespaceTokens = MAX_WHITESPACE_TOKENS; private int maxRuleDepth = MAX_RULE_DEPTH; @@ -298,6 +326,7 @@ public static class Builder { this.captureIgnoredChars = parserOptions.captureIgnoredChars; this.captureSourceLocation = parserOptions.captureSourceLocation; this.captureLineComments = parserOptions.captureLineComments; + this.maxCharacters = parserOptions.maxCharacters; this.maxTokens = parserOptions.maxTokens; this.maxWhitespaceTokens = parserOptions.maxWhitespaceTokens; this.maxRuleDepth = parserOptions.maxRuleDepth; @@ -324,6 +353,11 @@ public Builder readerTrackData(boolean readerTrackData) { return this; } + public Builder maxCharacters(int maxCharacters) { + this.maxCharacters = maxCharacters; + return this; + } + public Builder maxTokens(int maxTokens) { this.maxTokens = maxTokens; return this; diff --git a/src/main/java/graphql/parser/SafeTokenReader.java b/src/main/java/graphql/parser/SafeTokenReader.java new file mode 100644 index 0000000000..be102be0d2 --- /dev/null +++ b/src/main/java/graphql/parser/SafeTokenReader.java @@ -0,0 +1,95 @@ +package graphql.parser; + +import graphql.Internal; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.Reader; +import java.nio.CharBuffer; +import java.util.function.Consumer; + +/** + * This reader will only emit a maximum number of characters from it. This is used to protect us from evil input. + *

+ * If a graphql system does not have some max HTTP input limit, then this will help protect the system. This is a limit + * of last resort. Ideally the http input should be limited, but if its not, we have this. + */ +@Internal +public class SafeTokenReader extends Reader { + + private final Reader delegate; + private final int maxCharacters; + private final Consumer whenMaxCharactersExceeded; + private int count; + + public SafeTokenReader(Reader delegate, int maxCharacters, Consumer whenMaxCharactersExceeded) { + this.delegate = delegate; + this.maxCharacters = maxCharacters; + this.whenMaxCharactersExceeded = whenMaxCharactersExceeded; + count = 0; + } + + private int checkHowMany(int read, int howMany) { + if (read != -1) { + count += howMany; + if (count > maxCharacters) { + whenMaxCharactersExceeded.accept(maxCharacters); + } + } + return read; + } + + @Override + public int read(char @NotNull [] buff, int off, int len) throws IOException { + int howMany = delegate.read(buff, off, len); + return checkHowMany(howMany, howMany); + } + + @Override + public int read() throws IOException { + int ch = delegate.read(); + return checkHowMany(ch, 1); + } + + @Override + public int read(@NotNull CharBuffer target) throws IOException { + int howMany = delegate.read(target); + return checkHowMany(howMany, howMany); + } + + @Override + public int read( char @NotNull [] buff) throws IOException { + int howMany = delegate.read(buff); + return checkHowMany(howMany, howMany); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public long skip(long n) throws IOException { + return delegate.skip(n); + } + + @Override + public boolean ready() throws IOException { + return delegate.ready(); + } + + @Override + public boolean markSupported() { + return delegate.markSupported(); + } + + @Override + public void mark(int readAheadLimit) throws IOException { + delegate.mark(readAheadLimit); + } + + @Override + public void reset() throws IOException { + delegate.reset(); + } +} diff --git a/src/main/java/graphql/parser/exceptions/ParseCancelledTooManyCharsException.java b/src/main/java/graphql/parser/exceptions/ParseCancelledTooManyCharsException.java new file mode 100644 index 0000000000..1c84189f65 --- /dev/null +++ b/src/main/java/graphql/parser/exceptions/ParseCancelledTooManyCharsException.java @@ -0,0 +1,16 @@ +package graphql.parser.exceptions; + +import graphql.Internal; +import graphql.i18n.I18n; +import graphql.parser.InvalidSyntaxException; +import org.jetbrains.annotations.NotNull; + +@Internal +public class ParseCancelledTooManyCharsException extends InvalidSyntaxException { + + @Internal + public ParseCancelledTooManyCharsException(@NotNull I18n i18N, int maxCharacters) { + super(i18N.msg("ParseCancelled.tooManyChars", maxCharacters), + null, null, null, null); + } +} diff --git a/src/main/resources/i18n/Parsing.properties b/src/main/resources/i18n/Parsing.properties index a45ae1114b..91a7f276f4 100644 --- a/src/main/resources/i18n/Parsing.properties +++ b/src/main/resources/i18n/Parsing.properties @@ -20,6 +20,7 @@ InvalidSyntaxMoreTokens.full=Invalid syntax encountered. There are extra tokens # ParseCancelled.full=More than {0} ''{1}'' tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled. ParseCancelled.tooDeep=More than {0} deep ''{1}'' rules have been entered. To prevent Denial Of Service attacks, parsing has been cancelled. +ParseCancelled.tooManyChars=More than {0} characters have been presented. To prevent Denial Of Service attacks, parsing has been cancelled. # InvalidUnicode.trailingLeadingSurrogate=Invalid unicode encountered. Trailing surrogate must be preceded with a leading surrogate. Offending token ''{0}'' at line {1} column {2} InvalidUnicode.leadingTrailingSurrogate=Invalid unicode encountered. Leading surrogate must be followed by a trailing surrogate. Offending token ''{0}'' at line {1} column {2} diff --git a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy index 422a0898f7..83ed13e9c6 100644 --- a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy +++ b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy @@ -7,6 +7,8 @@ class ParserOptionsTest extends Specification { static defaultOperationOptions = ParserOptions.getDefaultOperationParserOptions() static defaultSdlOptions = ParserOptions.getDefaultSdlParserOptions() + static final int ONE_MB = 1024 * 1024 + void setup() { ParserOptions.setDefaultParserOptions(defaultOptions) ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) @@ -21,6 +23,7 @@ class ParserOptionsTest extends Specification { def "lock in default settings"() { expect: + defaultOptions.getMaxCharacters() == ONE_MB defaultOptions.getMaxTokens() == 15_000 defaultOptions.getMaxWhitespaceTokens() == 200_000 defaultOptions.isCaptureSourceLocation() @@ -35,6 +38,7 @@ class ParserOptionsTest extends Specification { !defaultOperationOptions.isCaptureIgnoredChars() defaultOptions.isReaderTrackData() + defaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE defaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE defaultSdlOptions.getMaxWhitespaceTokens() == Integer.MAX_VALUE defaultSdlOptions.isCaptureSourceLocation() @@ -44,11 +48,23 @@ class ParserOptionsTest extends Specification { } def "can set in new option JVM wide"() { - def newDefaultOptions = defaultOptions.transform({ it.captureIgnoredChars(true).readerTrackData(false) }) + def newDefaultOptions = defaultOptions.transform({ + it.captureIgnoredChars(true) + .readerTrackData(false) + } ) def newDefaultOperationOptions = defaultOperationOptions.transform( - { it.captureIgnoredChars(true).captureLineComments(true).maxWhitespaceTokens(300_000) }) + { + it.captureIgnoredChars(true) + .captureLineComments(true) + .maxCharacters(1_000_000) + .maxWhitespaceTokens(300_000) + }) def newDefaultSDlOptions = defaultSdlOptions.transform( - { it.captureIgnoredChars(true).captureLineComments(true).maxWhitespaceTokens(300_000) }) + { + it.captureIgnoredChars(true) + .captureLineComments(true) + .maxWhitespaceTokens(300_000) + }) when: ParserOptions.setDefaultParserOptions(newDefaultOptions) @@ -61,6 +77,7 @@ class ParserOptionsTest extends Specification { then: + currentDefaultOptions.getMaxCharacters() == ONE_MB currentDefaultOptions.getMaxTokens() == 15_000 currentDefaultOptions.getMaxWhitespaceTokens() == 200_000 currentDefaultOptions.isCaptureSourceLocation() @@ -68,6 +85,7 @@ class ParserOptionsTest extends Specification { currentDefaultOptions.isCaptureIgnoredChars() !currentDefaultOptions.isReaderTrackData() + currentDefaultOperationOptions.getMaxCharacters() == 1_000_000 currentDefaultOperationOptions.getMaxTokens() == 15_000 currentDefaultOperationOptions.getMaxWhitespaceTokens() == 300_000 currentDefaultOperationOptions.isCaptureSourceLocation() @@ -75,6 +93,7 @@ class ParserOptionsTest extends Specification { currentDefaultOperationOptions.isCaptureIgnoredChars() currentDefaultOperationOptions.isReaderTrackData() + currentDefaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE currentDefaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE currentDefaultSdlOptions.getMaxWhitespaceTokens() == 300_000 currentDefaultSdlOptions.isCaptureSourceLocation() diff --git a/src/test/groovy/graphql/parser/ParserStressTest.groovy b/src/test/groovy/graphql/parser/ParserStressTest.groovy index 68a3991e49..70fbc9a414 100644 --- a/src/test/groovy/graphql/parser/ParserStressTest.groovy +++ b/src/test/groovy/graphql/parser/ParserStressTest.groovy @@ -4,6 +4,7 @@ import graphql.ExecutionInput import graphql.TestUtil import graphql.parser.exceptions.ParseCancelledException import graphql.parser.exceptions.ParseCancelledTooDeepException +import graphql.parser.exceptions.ParseCancelledTooManyCharsException import spock.lang.Specification import static graphql.parser.ParserEnvironment.newParserEnvironment @@ -158,6 +159,17 @@ class ParserStressTest extends Specification { thrown(ParseCancelledException) // too many tokens will catch this wide queries } + def "single character attack parse test"() { + String text = "q".repeat(10_000_000) + + when: + def parserEnvironment = newParserEnvironment().document(text).parserOptions(defaultOperationOptions).build() + Parser.parse(parserEnvironment) + + then: + thrown(ParseCancelledTooManyCharsException) + } + String mkDeepQuery(int howMany) { def field = 'f(a:"")' StringBuilder sb = new StringBuilder("query q{") diff --git a/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy b/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy new file mode 100644 index 0000000000..e96fe93b9c --- /dev/null +++ b/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy @@ -0,0 +1,18 @@ +package graphql.parser + +import spock.lang.Specification + +class SafeTokenReaderTest extends Specification { + + def "will count how many its read and stop after max"() { + when: + StringReader sr = new StringReader("0123456789") + SafeTokenReader safeReader = new SafeTokenReader(sr, 5, + { Integer maxChars -> throw new RuntimeException("max " + maxChars) }) + safeReader.readLine() + + then: + def rte = thrown(RuntimeException) + rte.message == "max 5" + } +} From 30c1e6c3b26fec177e274663f0f7cc5d60618cfa Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 27 Mar 2023 09:15:47 +1100 Subject: [PATCH 05/11] Have a limit on how many characters are presented to the Parser- java 8 --- src/test/groovy/graphql/parser/ParserStressTest.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/groovy/graphql/parser/ParserStressTest.groovy b/src/test/groovy/graphql/parser/ParserStressTest.groovy index 70fbc9a414..2eb359fc3f 100644 --- a/src/test/groovy/graphql/parser/ParserStressTest.groovy +++ b/src/test/groovy/graphql/parser/ParserStressTest.groovy @@ -160,7 +160,7 @@ class ParserStressTest extends Specification { } def "single character attack parse test"() { - String text = "q".repeat(10_000_000) + String text = "q" * 10_000_000 when: def parserEnvironment = newParserEnvironment().document(text).parserOptions(defaultOperationOptions).build() From ceb63c7a3ca35d6b2ea713e5b5f895957f88af1e Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 27 Mar 2023 10:07:52 +1100 Subject: [PATCH 06/11] Have a limit on how many characters are presented to the Parser- tweaked test and doco --- src/main/java/graphql/parser/ParserOptions.java | 9 +++++---- .../graphql/parser/ParserStressTest.groovy | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/graphql/parser/ParserOptions.java b/src/main/java/graphql/parser/ParserOptions.java index 77306ceb57..965e71876d 100644 --- a/src/main/java/graphql/parser/ParserOptions.java +++ b/src/main/java/graphql/parser/ParserOptions.java @@ -12,9 +12,10 @@ @PublicApi public class ParserOptions { /** - * A graphql hacking vector is to send nonsensical queries that contain a repeated characters that burn lots of parsing CPU time and burn - * memory representing a document that won't ever execute. To prevent this for most users, graphql-java - * sets this value to 512KB. ANTLR parsing time is linear to the number of characters presented. The more you + * A graphql hacking vector is to send nonsensical queries with large tokens that contain a repeated characters + * that burn lots of parsing CPU time and burn memory representing a document that won't ever execute. + * To prevent this for most users, graphql-java sets this value to 1MB. + * ANTLR parsing time is linear to the number of characters presented. The more you * allow the longer it takes. *

* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this @@ -23,7 +24,7 @@ public class ParserOptions { public static final int MAX_QUERY_CHARACTERS = 1024 * 1024; // 1 MB /** - * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn + * A graphql hacking vector is to send nonsensical queries with lots of tokens that burn lots of parsing CPU time and burn * memory representing a document that won't ever execute. To prevent this for most users, graphql-java * sets this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you * allow the longer it takes. diff --git a/src/test/groovy/graphql/parser/ParserStressTest.groovy b/src/test/groovy/graphql/parser/ParserStressTest.groovy index 2eb359fc3f..c058d56a2f 100644 --- a/src/test/groovy/graphql/parser/ParserStressTest.groovy +++ b/src/test/groovy/graphql/parser/ParserStressTest.groovy @@ -2,6 +2,7 @@ package graphql.parser import graphql.ExecutionInput import graphql.TestUtil +import graphql.language.Document import graphql.parser.exceptions.ParseCancelledException import graphql.parser.exceptions.ParseCancelledTooDeepException import graphql.parser.exceptions.ParseCancelledTooManyCharsException @@ -159,8 +160,9 @@ class ParserStressTest extends Specification { thrown(ParseCancelledException) // too many tokens will catch this wide queries } - def "single character attack parse test"() { + def "large single token attack parse can be prevented"() { String text = "q" * 10_000_000 + text = "query " + text + " {f}" when: def parserEnvironment = newParserEnvironment().document(text).parserOptions(defaultOperationOptions).build() @@ -170,6 +172,18 @@ class ParserStressTest extends Specification { thrown(ParseCancelledTooManyCharsException) } + def "inside limits single token attack parse will be accepted"() { + String text = "q" * 900_000 + text = "query " + text + " {f}" + + when: + def parserEnvironment = newParserEnvironment().document(text).parserOptions(defaultOperationOptions).build() + def document = Parser.parse(parserEnvironment) + + then: + document != null // its parsed - its invalid of course but parsed + } + String mkDeepQuery(int howMany) { def field = 'f(a:"")' StringBuilder sb = new StringBuilder("query q{") From df576421d533c4dce48c8fedfe5b8ae5b6c4b723 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Mon, 27 Mar 2023 14:37:56 +1300 Subject: [PATCH 07/11] Fix argument added to new type --- .../diffing/ana/EditOperationAnalyzer.java | 11 ++- .../ana/EditOperationAnalyzerTest.groovy | 88 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java index afd33677f7..d88b887057 100644 --- a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java +++ b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java @@ -1723,19 +1723,26 @@ private void argumentAdded(EditOperation editOperation) { Vertex fieldsContainerForField = newSchemaGraph.getFieldsContainerForField(field); if (fieldsContainerForField.isOfType(SchemaGraph.OBJECT)) { Vertex object = fieldsContainerForField; + if (isObjectAdded(object.getName())) { + return; + } getObjectModification(object.getName()).getDetails().add(new ObjectFieldArgumentAddition(field.getName(), addedArgument.getName())); } else { assertTrue(fieldsContainerForField.isOfType(SchemaGraph.INTERFACE)); Vertex interfaze = fieldsContainerForField; + if (isInterfaceAdded(interfaze.getName())) { + return; + } getInterfaceModification(interfaze.getName()).getDetails().add(new InterfaceFieldArgumentAddition(field.getName(), addedArgument.getName())); } } else { assertTrue(fieldOrDirective.isOfType(SchemaGraph.DIRECTIVE)); Vertex directive = fieldOrDirective; + if (isDirectiveAdded(directive.getName())) { + return; + } getDirectiveModification(directive.getName()).getDetails().add(new DirectiveArgumentAddition(addedArgument.getName())); - } - } private void changedEnum(EditOperation editOperation) { diff --git a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy index a05ecee20e..82723fafaf 100644 --- a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy @@ -2035,6 +2035,94 @@ class EditOperationAnalyzerTest extends Specification { changes.objectDifferences["Node"] instanceof ObjectDeletion } + def "directive deleted with argument"() { + given: + def oldSdl = ''' + type Query { + node: String + } + directive @test(message: String) on FIELD + ''' + def newSdl = ''' + type Query { + node: String + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.directiveDifferences["test"] instanceof DirectiveDeletion + } + + def "interface added with field argument"() { + given: + def oldSdl = ''' + type Query { + node: ID + } + ''' + def newSdl = ''' + type Query { + node: Node + } + interface Node { + echo(test: String): String + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.interfaceDifferences["Node"] instanceof InterfaceAddition + } + + def "object added with field argument"() { + given: + def oldSdl = ''' + type Query { + node: ID + } + ''' + def newSdl = ''' + type Query { + node: Node + } + type Node { + echo(test: String): String + } + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.objectDifferences["Node"] instanceof ObjectAddition + } + + def "directive added with argument"() { + given: + def oldSdl = ''' + type Query { + node: String + } + ''' + def newSdl = ''' + type Query { + node: String + } + directive @test(message: String) on FIELD + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.directiveDifferences["test"] instanceof DirectiveAddition + } + EditOperationAnalysisResult calcDiff( String oldSdl, String newSdl From d89882f043bdf6f0a21b13ee521151f2f1553382 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 27 Mar 2023 14:47:12 +1100 Subject: [PATCH 08/11] This has a listener to show what tests failed --- build.gradle | 28 +++++++++++++++++++ .../groovy/graphql/AlwaysFailsTest.groovy | 25 +++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/test/groovy/graphql/AlwaysFailsTest.groovy diff --git a/build.gradle b/build.gradle index e961cb5fe5..7e3e0a927a 100644 --- a/build.gradle +++ b/build.gradle @@ -220,13 +220,41 @@ artifacts { archives javadocJar } +List failedTests = [] + test { testLogging { events "FAILED", "SKIPPED" exceptionFormat = "FULL" } + + afterTest { TestDescriptor descriptor, result -> + if (result.getFailedTestCount() > 0) { + failedTests.add(descriptor) + } + } } +/* + * The gradle.buildFinished callback is deprecated BUT there does not seem to be a decent alternative in gradle 7 + * So progress over perfection here + * + * See https://github.com/gradle/gradle/issues/20151 + */ +gradle. buildFinished { + if (!failedTests.isEmpty()) { + println "\n\n" + println "============================" + println "These are the test failures" + println "============================" + for (td in failedTests) { + println "${td.getClassName()}.${td.getDisplayName()}" + } + println "============================" + } +} + + allprojects { tasks.withType(Javadoc) { exclude('**/antlr/**') diff --git a/src/test/groovy/graphql/AlwaysFailsTest.groovy b/src/test/groovy/graphql/AlwaysFailsTest.groovy new file mode 100644 index 0000000000..b63b6bdc5b --- /dev/null +++ b/src/test/groovy/graphql/AlwaysFailsTest.groovy @@ -0,0 +1,25 @@ +package graphql + +import spock.lang.Specification + +/** + * This is only really useful for testing the gradle builds etc... + * and in general would not be needed to test graphql-java + */ +class AlwaysFailsTest extends Specification{ + + def "this test fails"() { + when: + true + then: + assert false + } + + def "and this test always fails"() { + when: + true + then: + assert false + } + +} From 3d1a2082112a5b3d1212af5da6fe0b35f06362e4 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 27 Mar 2023 14:51:24 +1100 Subject: [PATCH 09/11] This has a listener to show what tests failed - @ignored --- src/test/groovy/graphql/AlwaysFailsTest.groovy | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/groovy/graphql/AlwaysFailsTest.groovy b/src/test/groovy/graphql/AlwaysFailsTest.groovy index b63b6bdc5b..8136ff4f77 100644 --- a/src/test/groovy/graphql/AlwaysFailsTest.groovy +++ b/src/test/groovy/graphql/AlwaysFailsTest.groovy @@ -1,11 +1,13 @@ package graphql +import spock.lang.Ignore import spock.lang.Specification /** * This is only really useful for testing the gradle builds etc... * and in general would not be needed to test graphql-java */ +@Ignore class AlwaysFailsTest extends Specification{ def "this test fails"() { From 0a8ef5379960cd3d7c5ba7895ede0938034b0bc3 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 27 Mar 2023 14:58:07 +1100 Subject: [PATCH 10/11] This has a listener to show what tests failed - better types --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 7e3e0a927a..1a9de91edd 100644 --- a/build.gradle +++ b/build.gradle @@ -228,7 +228,7 @@ test { exceptionFormat = "FULL" } - afterTest { TestDescriptor descriptor, result -> + afterTest { TestDescriptor descriptor, TestResult result -> if (result.getFailedTestCount() > 0) { failedTests.add(descriptor) } From 836b54b1fe28db8c050eb1ce39630d0b32f63fe1 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Wed, 29 Mar 2023 14:58:33 +1300 Subject: [PATCH 11/11] Fix applied argument deleted on field --- .../diffing/ana/EditOperationAnalyzer.java | 7 ++- .../ana/EditOperationAnalyzerTest.groovy | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java index d88b887057..f1ea24f024 100644 --- a/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java +++ b/src/main/java/graphql/schema/diffing/ana/EditOperationAnalyzer.java @@ -300,16 +300,21 @@ private void appliedDirectiveArgumentDeleted(EditOperation editOperation) { Vertex interfaceOrObjective = oldSchemaGraph.getFieldsContainerForField(field); if (interfaceOrObjective.isOfType(SchemaGraph.OBJECT)) { Vertex object = interfaceOrObjective; + if (isObjectDeleted(object.getName())) { + return; + } AppliedDirectiveObjectFieldLocation location = new AppliedDirectiveObjectFieldLocation(object.getName(), field.getName()); getObjectModification(object.getName()).getDetails().add(new AppliedDirectiveArgumentDeletion(location, deletedArgument.getName())); } else { assertTrue(interfaceOrObjective.isOfType(SchemaGraph.INTERFACE)); Vertex interfaze = interfaceOrObjective; + if (isInterfaceDeleted(interfaze.getName())) { + return; + } AppliedDirectiveInterfaceFieldLocation location = new AppliedDirectiveInterfaceFieldLocation(interfaze.getName(), field.getName()); getInterfaceModification(interfaze.getName()).getDetails().add(new AppliedDirectiveArgumentDeletion(location, deletedArgument.getName())); } } - } private void appliedDirectiveArgumentChanged(EditOperation editOperation) { diff --git a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy index 82723fafaf..0c9fcff02e 100644 --- a/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/ana/EditOperationAnalyzerTest.groovy @@ -2123,6 +2123,56 @@ class EditOperationAnalyzerTest extends Specification { changes.directiveDifferences["test"] instanceof DirectiveAddition } + def "delete object with applied directive on field"() { + given: + def oldSdl = ''' + type Query { + user(id: ID!): User + } + directive @id(type: String, owner: String) on FIELD_DEFINITION + type User { + id: ID! @id(type: "user", owner: "profiles") + } + ''' + def newSdl = ''' + type Query { + echo: String + } + directive @id(type: String, owner: String) on FIELD_DEFINITION + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.objectDifferences["User"] instanceof ObjectDeletion + } + + def "delete interface with applied directive on field"() { + given: + def oldSdl = ''' + type Query { + user(id: ID!): User + } + directive @id(type: String, owner: String) on FIELD_DEFINITION + interface User { + id: ID! @id(type: "user", owner: "profiles") + } + ''' + def newSdl = ''' + type Query { + echo: String + } + directive @id(type: String, owner: String) on FIELD_DEFINITION + ''' + + when: + def changes = calcDiff(oldSdl, newSdl) + + then: + changes.interfaceDifferences["User"] instanceof InterfaceDeletion + } + EditOperationAnalysisResult calcDiff( String oldSdl, String newSdl