diff --git a/CHANGELOG.md b/CHANGELOG.md index f766abb8cbfdf..2d231642a74d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Choose the best performing node when writing with append-only index ([#20065](https://github.com/opensearch-project/OpenSearch/pull/20065)) - Add security policy to allow `accessUnixDomainSocket` in `transport-grpc` module ([#20463](https://github.com/opensearch-project/OpenSearch/pull/20463), [#20649](https://github.com/opensearch-project/OpenSearch/pull/20649)) - Add range validations in query builder and field mapper ([#20497](https://github.com/opensearch-project/OpenSearch/issues/20497)) +- Add validation of the `_source` object to reject contradicting and ambiguous requests. ([#20612](https://github.com/opensearch-project/OpenSearch/issues/20612)) - Support TLS cert hot-reload for Arrow Flight transport ([#20700](https://github.com/opensearch-project/OpenSearch/pull/20700)) - [Workload Management] Enhance Scroll API support for autotagging ([#20151](https://github.com/opensearch-project/OpenSearch/pull/20151)) - Add indices to search request slowlog ([#20588](https://github.com/opensearch-project/OpenSearch/pull/20588)) diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/common/FetchSourceContextProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/common/FetchSourceContextProtoUtilsTests.java index 4547be75fe2fd..7efaa1f11e21b 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/common/FetchSourceContextProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/common/FetchSourceContextProtoUtilsTests.java @@ -8,6 +8,7 @@ package org.opensearch.transport.grpc.proto.request.common; +import org.opensearch.OpenSearchException; import org.opensearch.core.common.Strings; import org.opensearch.protobufs.BulkRequest; import org.opensearch.protobufs.SearchRequest; @@ -352,4 +353,23 @@ public void testFromProtoWithSourceConfigFilterBothIncludesAndExcludes() { assertArrayEquals("includes should match", new String[] { "include1", "include2" }, context.includes()); assertArrayEquals("excludes should match", new String[] { "exclude1", "exclude2" }, context.excludes()); } + + public void testFromProtoWithSourceConfigFilterAmbiguousIncludesAndExcludes() { + // Create a SourceConfig with filter includes and excludes + final SourceConfig sourceConfig = SourceConfig.newBuilder() + .setFilter( + SourceFilter.newBuilder() + .addIncludes("theSameEntry") + .addIncludes("include2") + .addExcludes("theSameEntry") + .addExcludes("exclude2") + .build() + ) + .build(); + + // Exception when attempting to convert to FetchSourceContext + final OpenSearchException e = expectThrows(OpenSearchException.class, () -> FetchSourceContextProtoUtils.fromProto(sourceConfig)); + + assertEquals("The same entry [theSameEntry] cannot be both included and excluded in _source.", e.getMessage()); + } } diff --git a/rest-api-spec/src/main/resources/rest-api-spec/test/search/10_source_filtering.yml b/rest-api-spec/src/main/resources/rest-api-spec/test/search/10_source_filtering.yml index 4de2e8142f6ec..170c1dc4d4135 100644 --- a/rest-api-spec/src/main/resources/rest-api-spec/test/search/10_source_filtering.yml +++ b/rest-api-spec/src/main/resources/rest-api-spec/test/search/10_source_filtering.yml @@ -34,6 +34,64 @@ setup: - length: { hits.hits: 1 } - is_false: hits.hits.0._source +--- +"_source as an empty object": + - skip: + version: " - 3.5.0" + reason: "validation was added later" + - do: + search: { body: { _source: { }, query: { match_all: {} } } } + catch: bad_request + - match: { status: 400 } + - match: { error.type: parsing_exception } + - match: { error.reason: "Expected at least one of [includes] or [excludes]" } + +--- +"_source as an empty includes array": + - skip: + version: " - 3.5.0" + reason: "validation was added later" + - do: + search: { body: { _source: [], query: { match_all: {} } } } + catch: bad_request + - match: { status: 400 } + - match: { error.type: parsing_exception } + - match: { error.reason: "Expected at least one value for an array of [includes]" } + +--- +"_source with an empty excludes array": + - skip: + version: " - 3.5.0" + reason: "validation was added later" + - do: + search: + body: + _source: + includes: [ include.field1, include.field2 ] + excludes: [] + query: { match_all: {} } + catch: bad_request + - match: { status: 400 } + - match: { error.type: parsing_exception } + - match: { error.reason: "Expected at least one value for an array of [excludes]" } + +--- +"_source with an ambiguous field": + - skip: + version: " - 3.5.0" + reason: "validation was added later" + - do: + search: + body: + _source: + includes: [ include.field1, include.field2 ] + excludes: [ include.field1 ] + query: { match_all: {} } + catch: bad_request + - match: { status: 400 } + - match: { error.type: parsing_exception } + - match: { error.reason: "The same entry [include.field1] cannot be both included and excluded in _source." } + --- "no filtering": - do: { search: { body: { query: { match_all: {} } } } } diff --git a/server/src/main/java/org/opensearch/search/fetch/subphase/FetchSourceContext.java b/server/src/main/java/org/opensearch/search/fetch/subphase/FetchSourceContext.java index 2bdb311c0003f..07280f08a857b 100644 --- a/server/src/main/java/org/opensearch/search/fetch/subphase/FetchSourceContext.java +++ b/server/src/main/java/org/opensearch/search/fetch/subphase/FetchSourceContext.java @@ -32,6 +32,7 @@ package org.opensearch.search.fetch.subphase; +import org.opensearch.OpenSearchException; import org.opensearch.common.Booleans; import org.opensearch.common.annotation.PublicApi; import org.opensearch.common.xcontent.support.XContentMapValues; @@ -47,10 +48,12 @@ import org.opensearch.rest.RestRequest; import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; -import java.util.List; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.function.Function; /** @@ -66,6 +69,9 @@ public class FetchSourceContext implements Writeable, ToXContentObject { public static final FetchSourceContext FETCH_SOURCE = new FetchSourceContext(true); public static final FetchSourceContext DO_NOT_FETCH_SOURCE = new FetchSourceContext(false); + + private static final String AMBIGUOUS_FIELD_MESSAGE = "The same entry [{}] cannot be both included and excluded in _source."; + private final boolean fetchSource; private final String[] includes; private final String[] excludes; @@ -75,6 +81,7 @@ public FetchSourceContext(boolean fetchSource, String[] includes, String[] exclu this.fetchSource = fetchSource; this.includes = includes == null ? Strings.EMPTY_ARRAY : includes; this.excludes = excludes == null ? Strings.EMPTY_ARRAY : excludes; + validateAmbiguousFields(); } public FetchSourceContext(boolean fetchSource) { @@ -87,6 +94,19 @@ public FetchSourceContext(StreamInput in) throws IOException { excludes = in.readStringArray(); } + /** + * The same entry cannot be both included and excluded in _source. + * Since the constructors are public, this validation is required to be called in the constructor. + * */ + private void validateAmbiguousFields() { + Set includeSet = new HashSet<>(Arrays.asList(this.includes)); + for (String exclude : this.excludes) { + if (includeSet.contains(exclude)) { + throw new OpenSearchException(AMBIGUOUS_FIELD_MESSAGE, exclude); + } + } + } + @Override public void writeTo(StreamOutput out) throws IOException { out.writeBoolean(fetchSource); @@ -133,113 +153,166 @@ public static FetchSourceContext parseFromRestRequest(RestRequest request) { } if (fetchSource != null || sourceIncludes != null || sourceExcludes != null) { - return new FetchSourceContext(fetchSource == null ? true : fetchSource, sourceIncludes, sourceExcludes); + return new FetchSourceContext(fetchSource == null || fetchSource, sourceIncludes, sourceExcludes); } return null; } public static FetchSourceContext fromXContent(XContentParser parser) throws IOException { XContentParser.Token token = parser.currentToken(); - boolean fetchSource = true; - String[] includes = Strings.EMPTY_ARRAY; - String[] excludes = Strings.EMPTY_ARRAY; - if (token == XContentParser.Token.VALUE_BOOLEAN) { - fetchSource = parser.booleanValue(); - } else if (token == XContentParser.Token.VALUE_STRING) { - includes = new String[] { parser.text() }; - } else if (token == XContentParser.Token.START_ARRAY) { - ArrayList list = new ArrayList<>(); - while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { - list.add(parser.text()); + switch (token) { + case XContentParser.Token.VALUE_BOOLEAN -> { + return new FetchSourceContext(parser.booleanValue()); + } + case XContentParser.Token.VALUE_STRING -> { + String[] includes = new String[] { parser.text() }; + return new FetchSourceContext(true, includes, null); + } + case XContentParser.Token.START_ARRAY -> { + String[] includes = parseSourceFieldArray(parser, INCLUDES_FIELD, null).toArray(new String[0]); + return new FetchSourceContext(true, includes, null); + } + case XContentParser.Token.START_OBJECT -> { + return parseSourceObject(parser); + } + default -> { + throw new ParsingException( + parser.getTokenLocation(), + "Expected one of [" + + XContentParser.Token.VALUE_BOOLEAN + + ", " + + XContentParser.Token.VALUE_STRING + + ", " + + XContentParser.Token.START_ARRAY + + ", " + + XContentParser.Token.START_OBJECT + + "] but found [" + + token + + "]" + ); + } + } + // MUST never reach here + } + + public static FetchSourceContext parseSourceObject(XContentParser parser) throws IOException { + XContentParser.Token token = parser.currentToken(); + Set includes = Collections.emptySet(); + Set excludes = Collections.emptySet(); + String currentFieldName = null; + if (token != XContentParser.Token.START_OBJECT) { + throw new ParsingException( + parser.getTokenLocation(), + "Expected a " + XContentParser.Token.START_OBJECT + " but got a " + token + " in [" + parser.currentName() + "]." + ); + } + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + continue; // only field name is required in this iteration } - includes = list.toArray(new String[0]); - } else if (token == XContentParser.Token.START_OBJECT) { - String currentFieldName = null; - while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { - if (token == XContentParser.Token.FIELD_NAME) { - currentFieldName = parser.currentName(); - } else if (token == XContentParser.Token.START_ARRAY) { + if (currentFieldName == null) { + throw new ParsingException( + parser.getTokenLocation(), + "Expected a field name but got a " + token + " in [" + parser.currentName() + "]." + ); + } + // process field value + switch (token) { + case XContentParser.Token.START_ARRAY -> { if (INCLUDES_FIELD.match(currentFieldName, parser.getDeprecationHandler())) { - List includesList = new ArrayList<>(); - while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { - if (token == XContentParser.Token.VALUE_STRING) { - includesList.add(parser.text()); - } else { - throw new ParsingException( - parser.getTokenLocation(), - "Unknown key for a " + token + " in [" + currentFieldName + "].", - parser.getTokenLocation() - ); - } - } - includes = includesList.toArray(new String[0]); + includes = parseSourceFieldArray(parser, INCLUDES_FIELD, excludes); } else if (EXCLUDES_FIELD.match(currentFieldName, parser.getDeprecationHandler())) { - List excludesList = new ArrayList<>(); - while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { - if (token == XContentParser.Token.VALUE_STRING) { - excludesList.add(parser.text()); - } else { - throw new ParsingException( - parser.getTokenLocation(), - "Unknown key for a " + token + " in [" + currentFieldName + "].", - parser.getTokenLocation() - ); - } - } - excludes = excludesList.toArray(new String[0]); + excludes = parseSourceFieldArray(parser, EXCLUDES_FIELD, includes); } else { throw new ParsingException( parser.getTokenLocation(), - "Unknown key for a " + token + " in [" + currentFieldName + "].", - parser.getTokenLocation() + "Unknown key for a " + token + " in [" + currentFieldName + "]." ); } - } else if (token == XContentParser.Token.VALUE_STRING) { + } + case XContentParser.Token.VALUE_STRING -> { if (INCLUDES_FIELD.match(currentFieldName, parser.getDeprecationHandler())) { - includes = new String[] { parser.text() }; + String includeEntry = parser.text(); + if (excludes.contains(includeEntry)) { + throw new ParsingException(parser.getTokenLocation(), AMBIGUOUS_FIELD_MESSAGE, includeEntry); + } + includes = Collections.singleton(includeEntry); } else if (EXCLUDES_FIELD.match(currentFieldName, parser.getDeprecationHandler())) { - excludes = new String[] { parser.text() }; + String excludeEntry = parser.text(); + if (includes.contains(excludeEntry)) { + throw new ParsingException(parser.getTokenLocation(), AMBIGUOUS_FIELD_MESSAGE, excludeEntry); + } + excludes = Collections.singleton(excludeEntry); } else { throw new ParsingException( parser.getTokenLocation(), - "Unknown key for a " + token + " in [" + currentFieldName + "].", - parser.getTokenLocation() + "Unknown key for a " + token + " in [" + currentFieldName + "]." ); } - } else { - throw new ParsingException( - parser.getTokenLocation(), - "Unknown key for a " + token + " in [" + currentFieldName + "].", - parser.getTokenLocation() - ); } + default -> { + throw new ParsingException(parser.getTokenLocation(), "Unknown key for a " + token + " in [" + currentFieldName + "]."); + } + } + } + if (includes.isEmpty() && excludes.isEmpty()) { + // no valid field names -> empty or unrecognized fields; not allowed + throw new ParsingException( + parser.getTokenLocation(), + "Expected at least one of [" + INCLUDES_FIELD.getPreferredName() + "] or [" + EXCLUDES_FIELD.getPreferredName() + "]" + ); + } + return new FetchSourceContext(true, includes.toArray(new String[0]), excludes.toArray(new String[0])); + } + + private static Set parseSourceFieldArray(XContentParser parser, ParseField parseField, Set opposite) + throws IOException { + Set sourceArr = new LinkedHashSet<>(); // include or exclude lists, LinkedHashSet preserves the order of fields + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + if (parser.currentToken() == XContentParser.Token.VALUE_STRING) { + String entry = parser.text(); + if (opposite != null && opposite.contains(entry)) { + throw new ParsingException(parser.getTokenLocation(), AMBIGUOUS_FIELD_MESSAGE, entry); + } + sourceArr.add(entry); + } else { + throw new ParsingException( + parser.getTokenLocation(), + "Unknown key for a " + parser.currentToken() + " in [" + parser.currentName() + "]." + ); } - } else { + } + if (sourceArr.isEmpty()) { throw new ParsingException( parser.getTokenLocation(), - "Expected one of [" - + XContentParser.Token.VALUE_BOOLEAN - + ", " - + XContentParser.Token.START_OBJECT - + "] but found [" - + token - + "]", - parser.getTokenLocation() + "Expected at least one value for an array of [" + parseField.getPreferredName() + "]" ); } - return new FetchSourceContext(fetchSource, includes, excludes); + return sourceArr; } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - if (fetchSource) { - builder.startObject(); + if (!fetchSource) { + // do not fetch source + builder.value(false); + return builder; + } + if (includes.length == 0 && excludes.length == 0) { + // no empty arrays + builder.value(true); + return builder; + } + + builder.startObject(); + if (includes.length > 0) { builder.array(INCLUDES_FIELD.getPreferredName(), includes); + } + if (excludes.length > 0) { builder.array(EXCLUDES_FIELD.getPreferredName(), excludes); - builder.endObject(); - } else { - builder.value(false); } + builder.endObject(); return builder; } diff --git a/server/src/test/java/org/opensearch/search/fetch/subphase/FetchSourceContextTests.java b/server/src/test/java/org/opensearch/search/fetch/subphase/FetchSourceContextTests.java new file mode 100644 index 0000000000000..17f50494e58c0 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/fetch/subphase/FetchSourceContextTests.java @@ -0,0 +1,282 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.search.fetch.subphase; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.ParsingException; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; +import java.util.Arrays; + +public class FetchSourceContextTests extends OpenSearchTestCase { + + private XContentParser createSourceParser(XContentBuilder source) throws IOException { + XContentParser parser = createParser(source); + parser.nextToken(); // move to start object + parser.nextToken(); // move to field name "_source" + parser.nextToken(); // move to _source value to parse + return parser; + } + + public void testFetchSource() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source", true).endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertEquals(FetchSourceContext.FETCH_SOURCE, result); + } + + public void testDoNotFetchSource() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source", false).endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertEquals(FetchSourceContext.DO_NOT_FETCH_SOURCE, result); + } + + public void testFetchSourceString() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source", "include1").endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertTrue(result.fetchSource()); // fetch source + assertArrayEquals(new String[] { "include1" }, result.includes()); // single include + assertEquals(0, result.excludes().length); // no excludes + } + + public void testFetchSourceArray() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startArray() + .value("include1") + .value("include2") + .value("include2") + .endArray() + .endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertTrue(result.fetchSource()); // fetch source + // validate includes + assertEquals(2, result.includes().length); // no duplicates + assertTrue(Arrays.asList(result.includes()).containsAll(Arrays.asList("include1", "include2"))); + // validate no excludes + assertEquals(0, result.excludes().length); + } + + public void testFetchSourceExplicitEmptyArrayNotAllowed() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source").startArray().endArray().endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals( + "Expected at least one value for an array of [" + FetchSourceContext.INCLUDES_FIELD.getPreferredName() + "]", + result.getMessage() + ); + } + + public void testFetchSourceAsObject() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes", "include1") + .field("excludes", "exclude1") + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertTrue(result.fetchSource()); // fetch source + assertArrayEquals(new String[] { "include1" }, result.includes()); // single include + assertArrayEquals(new String[] { "exclude1" }, result.excludes()); // single exclude + } + + public void testFetchSourceAsObjectBothIncludeAndExcludeArrays() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes") + .startArray() + .value("iii") + .endArray() + .field("excludes") + .startArray() + .value("aaa") + .value("bbb") + .endArray() + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + FetchSourceContext result = FetchSourceContext.fromXContent(parser); + assertTrue(result.fetchSource()); + // validate includes + assertArrayEquals(new String[] { "iii" }, result.includes()); + // validate excludes + assertEquals(2, result.excludes().length); // no duplicates + assertTrue(Arrays.asList(result.excludes()).containsAll(Arrays.asList("aaa", "bbb"))); + } + + public void testFetchSourceObjectEmptyObjectNotAllowed() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source").startObject().endObject().endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals( + "Expected at least one of [" + + FetchSourceContext.INCLUDES_FIELD.getPreferredName() + + "] or [" + + FetchSourceContext.EXCLUDES_FIELD.getPreferredName() + + "]", + result.getMessage() + ); + } + + public void testFetchSourceObjectExplicitEmptyArraysNotAllowed() throws IOException { + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes", "include1") + .field("excludes") + .startArray() + .endArray() + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals( + "Expected at least one value for an array of [" + FetchSourceContext.EXCLUDES_FIELD.getPreferredName() + "]", + result.getMessage() + ); + } + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("excludes") + .startArray() + .value("exclude1") + .endArray() + .field("includes") + .startArray() + .endArray() + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals( + "Expected at least one value for an array of [" + FetchSourceContext.INCLUDES_FIELD.getPreferredName() + "]", + result.getMessage() + ); + } + } + + public void testFetchSourceAsObjectConflictingEntries() throws IOException { + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes") + .value("AAA") + .field("excludes") + .value("AAA") + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals("The same entry [AAA] cannot be both included and excluded in _source.", result.getMessage()); + } + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes") + .value("AAA") + .field("excludes") + .startArray() + .value("AAA") + .value("BBB") + .endArray() + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals("The same entry [AAA] cannot be both included and excluded in _source.", result.getMessage()); + } + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes") + .startArray() + .value("AAA") + .value("BBB") + .endArray() + .field("excludes") + .value("AAA") + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals("The same entry [AAA] cannot be both included and excluded in _source.", result.getMessage()); + } + { + final XContentBuilder source = XContentFactory.jsonBuilder() + .startObject() + .field("_source") + .startObject() + .field("includes") + .startArray() + .value("AAA") + .value("BBB") + .endArray() + .field("excludes") + .startArray() + .value("BBB") + .value("CCC") + .endArray() + .endObject() + .endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.fromXContent(parser)); + assertEquals("The same entry [BBB] cannot be both included and excluded in _source.", result.getMessage()); + } + } + + public void testParseSourceObjectInvalidInput() throws IOException { + final XContentBuilder source = XContentFactory.jsonBuilder().startObject().field("_source", true).endObject(); + final XContentParser parser = createSourceParser(source); + + ParsingException result = expectThrows(ParsingException.class, () -> FetchSourceContext.parseSourceObject(parser)); + assertEquals("Expected a START_OBJECT but got a VALUE_BOOLEAN in [_source].", result.getMessage()); + } +}