From 864b1f3658c5e1fa11cac670ba412c43cf496602 Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Tue, 26 May 2026 10:17:39 +0200 Subject: [PATCH] [Console] Preserve spaces in URL query strings (#270881) Closes #227733 ## Summary - Preserves unquoted spaces in Console URL query values instead of treating them as request-line trailing text. - Keeps inline `//` and `#` comment stripping for request URLs. - Adds focused edge-case coverage for the helper and the full `parseLine` request-line path. ## Root Cause - `removeTrailingWhitespaces` stopped at the first unquoted space, so `GET myindex/_search?q=type:organisation AND elastic` was truncated before `AND elastic`. ## Fix - Use the first `?` outside quotes as the query-string boundary. - Preserve spaces inside query values unless the space starts a trailing inline `//` or `#` comment. - Preserve the existing leading-space behavior and ignore comment markers inside quoted query values. ## Before image ## After image ## Test Plan - [x] `node scripts/jest src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts` - [x] `node scripts/eslint src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.ts src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts` - [x] `node scripts/type_check --project src/platform/plugins/shared/console/tsconfig.json` - [x] `node scripts/check_changes.ts` - [x] Manually verified in Console that unquoted query spaces are preserved, trailing `//` and `#` comments are stripped, comment markers inside quoted query values are preserved, quoted `?` path text is not treated as the query delimiter, and trailing non-query text is trimmed before sending the request. Assisted with Cursor using GPT-5.5 Co-authored-by: Cursor (cherry picked from commit 9d7d16103bc774badc83cba0622e7c4fc602e977) --- .../editor/utils/tokens_utils.test.ts | 73 ++++++++++++++++++ .../containers/editor/utils/tokens_utils.ts | 77 ++++++++++++++----- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts b/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts index 7ccb46093f9c2..3a71746630537 100644 --- a/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts +++ b/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.test.ts @@ -21,6 +21,11 @@ describe('tokens_utils', () => { const result = removeTrailingWhitespaces(url); expect(result).toBe(url); }); + it(`doesn't strip if the first character is whitespace`, () => { + const url = ' _search trailing'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe(url); + }); it(`removes any text after the first whitespace`, () => { const url = '_search some_text'; const result = removeTrailingWhitespaces(url); @@ -31,6 +36,49 @@ describe('tokens_utils', () => { const result = removeTrailingWhitespaces(url); expect(result).toBe(url); }); + it(`doesn't treat a question mark inside quotes as query string start`, () => { + const url = '_search/"?literal" trailing_text'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe('_search/"?literal"'); + }); + it(`does not strip unquoted spaces inside query values`, () => { + const url = 'myindex/_search?q=type:organisation AND elastic'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe(url); + }); + it.each([ + [ + 'keeps slashes inside query values', + 'myindex/_search?q=http://example.com/path AND elastic', + ], + ['keeps hashes inside query values', 'myindex/_search?q=tag#1 AND elastic'], + [ + 'keeps comment markers inside quoted query values', + 'myindex/_search?q="organisation // elastic" AND kibana', + ], + [ + 'uses the first question mark outside quotes as query string start', + 'myindex/"?literal"/_search?q=type:organisation AND elastic', + ], + ])('%s', (_, url) => { + const result = removeTrailingWhitespaces(url); + expect(result).toBe(url); + }); + it(`strips inline comment after unquoted query spaces`, () => { + const url = 'myindex/_search?q=type:organisation AND elastic // filter orgs'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe('myindex/_search?q=type:organisation AND elastic'); + }); + it(`strips inline comment after mixed whitespace in query values`, () => { + const url = 'myindex/_search?q=type:organisation AND elastic \t// filter orgs'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe('myindex/_search?q=type:organisation AND elastic'); + }); + it(`strips hash comment after unquoted query spaces`, () => { + const url = 'myindex/_search?q=type:organisation AND elastic # filter orgs'; + const result = removeTrailingWhitespaces(url); + expect(result).toBe('myindex/_search?q=type:organisation AND elastic'); + }); }); describe('parseLine', () => { @@ -48,6 +96,24 @@ describe('tokens_utils', () => { expect(urlPathTokens).toEqual(['_search']); expect(urlParamsTokens[0]).toEqual(['query', '"test1 test2 test3"']); }); + it('preserves unquoted spaces inside query values', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine( + 'GET myindex/_search?q=type:organisation AND elastic' + ); + expect(method).toBe('GET'); + expect(url).toBe('myindex/_search?q=type:organisation AND elastic'); + expect(urlPathTokens).toEqual(['myindex', '_search']); + expect(urlParamsTokens[0]).toEqual(['q', 'type:organisation AND elastic']); + }); + it('uses the first question mark outside quotes to parse url params', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine( + 'GET myindex/"?literal"/_search?q=type:organisation AND elastic' + ); + expect(method).toBe('GET'); + expect(url).toBe('myindex/"?literal"/_search?q=type:organisation AND elastic'); + expect(urlPathTokens).toEqual(['myindex', '"?literal"', '_search']); + expect(urlParamsTokens[0]).toEqual(['q', 'type:organisation AND elastic']); + }); it('works with multiple whitespaces', () => { const { method, url, urlPathTokens, urlParamsTokens } = parseLine( ' GET _search?query="test1 test2 test3" // comment' @@ -197,5 +263,12 @@ describe('tokens_utils', () => { const result = parseUrl(url); expect(result.urlPathTokens).toEqual(['_search', 'test']); }); + + it('uses the first question mark outside quotes for url params', () => { + const url = 'myindex/"?literal"/_search?q=type:organisation AND elastic'; + const result = parseUrl(url); + expect(result.urlPathTokens).toEqual(['myindex', '"?literal"', '_search']); + expect(result.urlParamsTokens[0]).toEqual(['q', 'type:organisation AND elastic']); + }); }); }); diff --git a/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.ts b/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.ts index 7db1b19977f33..062ba3731e5c0 100644 --- a/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.ts +++ b/src/platform/plugins/shared/console/public/application/containers/editor/utils/tokens_utils.ts @@ -54,15 +54,15 @@ export const parseUrl = ( } => { let urlPathTokens: ParsedLineTokens['urlPathTokens'] = []; let urlParamsTokens: ParsedLineTokens['urlParamsTokens'] = []; - const urlParts = url.split(questionMarkRegex); + const queryStringStart = getQueryStringStart(url); // 1st part is the url path - const urlPath = urlParts[0]; + const urlPath = queryStringStart >= 0 ? url.slice(0, queryStringStart) : url; // try to parse into url path tokens (split on slashes, only keep non-empty tokens) if (urlPath) { urlPathTokens = urlPath.split(slashesRegex).filter(Boolean); } // 2nd part is the url params - const urlParams = urlParts[1]; + const urlParams = queryStringStart >= 0 ? url.slice(queryStringStart + 1) : undefined; // try to parse into url param tokens if (urlParams) { urlParamsTokens = urlParams.split(ampersandRegex).map((urlParamsPart) => { @@ -409,28 +409,41 @@ export const parseBody = (value: string): string[] => { }; /* - * This functions removes any trailing inline comments, for example + * This function removes trailing text after the request URL while preserving + * spaces in query values. + * For example: * "_search // comment" -> "_search" - * Ideally the parser would do that, but currently they are included in url. + * "_search?q=foo AND bar // comment" -> "_search?q=foo AND bar" + * Ideally the parser would do that, but currently inline comments are included in url. */ export const removeTrailingWhitespaces = (url: string): string => { - let index = 0; - let whitespaceIndex = -1; - let isQueryParam = false; - let char = url[index]; - while (char) { - if (char === '"') { - isQueryParam = !isQueryParam; - } else if (char === ' ' && !isQueryParam) { - whitespaceIndex = index; - break; - } - index++; - char = url[index]; + if (url.startsWith(' ')) { + return url; } - if (whitespaceIndex > 0) { - return url.slice(0, whitespaceIndex); + + const queryStringStart = getQueryStringStart(url); + let isInQuotes = false; + + for (let index = 0; index < url.length; index++) { + const char = url[index]; + + if (isDoubleQuote(char)) { + isInQuotes = !isInQuotes; + continue; + } + + if (isInQuotes || char !== ' ') { + continue; + } + + const isOutsideQueryString = queryStringStart < 0 || index < queryStringStart; + const shouldTrimTrailingText = + index > 0 && (isOutsideQueryString || isInlineCommentStart(url, index)); + if (shouldTrimTrailingText) { + return url.slice(0, index); + } } + return url; }; @@ -476,6 +489,30 @@ const isHashChar = (char: string): boolean => { const isSlash = (char: string): boolean => { return char === '/'; }; +const isWhitespaceChar = (char: string | undefined): boolean => { + return Boolean(char && whitespacesRegex.test(char)); +}; +const getQueryStringStart = (url: string): number => { + let isInQuotes = false; + + for (let index = 0; index < url.length; index++) { + const char = url[index]; + if (isDoubleQuote(char)) { + isInQuotes = !isInQuotes; + } else if (char === '?' && !isInQuotes) { + return index; + } + } + + return -1; +}; +const isInlineCommentStart = (url: string, index: number): boolean => { + let commentIndex = index; + while (isWhitespaceChar(url[commentIndex])) { + commentIndex++; + } + return url.startsWith('#', commentIndex) || url.startsWith('//', commentIndex); +}; const isStar = (char: string): boolean => { return char === '*'; };