github-advanced-security[bot] commented on code in PR #18126: URL: https://github.com/apache/druid/pull/18126#discussion_r2140671752
########## web-console/src/utils/hjson-context.ts: ########## @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface HjsonContext { + /** + * The path of keys leading to the current position, e.g., ["query", "dataSource"] + * For arrays, includes the index as a string key, e.g., ["filters", "0", "dimension"] + * Empty array if at root level + */ + path: string[]; + + /** + * Whether the cursor is positioned where a key should be entered (true) + * or where a value should be entered (false) + */ + isEditingKey: boolean; + + /** + * If editing a value (isEditingKey === false), this is the key for that value + * If editing a key (isEditingKey === true), this is undefined + */ + currentKey?: string; + + /** + * Whether the cursor is positioned inside a comment (single-line or multi-line) + */ + isEditingComment: boolean; + + /** + * The current JSON object being edited at the cursor position. + * This is the object that contains the property/value being typed. + * For completions, this provides context about what properties already exist. + */ + currentObject: any; +} + +/** + * Analyzes an Hjson string (from start to cursor position) and returns + * context information about where the cursor is positioned within the JSON structure + * + * @param hjson - The Hjson text from the beginning of the document to the cursor position + * @returns Context information about the cursor position + */ +export function getHjsonContext(hjson: string): HjsonContext { + // Empty input + if (!hjson.trim()) { + return { + path: [], + isEditingKey: true, + currentKey: undefined, + isEditingComment: false, + currentObject: {}, + }; + } + + // State machine state + const path: string[] = []; + const containerStack: { type: 'object' | 'array'; index: number }[] = []; + const objectStack: any[] = [{}]; + + let state: 'normal' | 'string' | 'single-comment' | 'multi-comment' = 'normal'; + let stringDelim = ''; + let token = ''; + let currentKey: string | undefined; + let afterColon = false; + + // Comment preservation + let preCommentKey: string | undefined; + let preCommentAfterColon = false; + + // Process each character + for (let i = 0; i < hjson.length; i++) { + const ch = hjson[i]; + const next = hjson[i + 1]; + + // State transitions + if (state === 'string') { + token += ch; + if (ch === stringDelim && hjson[i - 1] !== '\\') { + state = 'normal'; + // If in array expecting value, push completed string + if (afterColon && currentKey && containerStack.length > 0) { + const container = containerStack[containerStack.length - 1]; + if (container.type === 'array') { + objectStack[objectStack.length - 1].push(parseValue(token)); + token = ''; + afterColon = false; + } + } + } + continue; + } + + if (state === 'single-comment') { + if (ch === '\n') state = 'normal'; + continue; + } + + if (state === 'multi-comment') { + if (ch === '*' && next === '/') { + state = 'normal'; + i++; // Skip '/' + } + continue; + } + + // Normal state processing + // Check for comment start + if (ch === '/' && next === '/') { + preCommentKey = currentKey; + preCommentAfterColon = afterColon; + state = 'single-comment'; + i++; // Skip second '/' + continue; + } + + if (ch === '/' && next === '*') { + preCommentKey = currentKey; + preCommentAfterColon = afterColon; + state = 'multi-comment'; + i++; // Skip '*' + continue; + } + + // String start + if (ch === '"' || ch === "'") { + state = 'string'; + stringDelim = ch; + token += ch; + continue; + } + + // Structural characters + switch (ch) { + case '{': { + // Handle Hjson no-comma case + if (afterColon && currentKey && token.trim()) { + const value = tryExtractValueAndKey(token.trim()); + if (value.hasKey) { + getCurrentObject()[currentKey] = parseValue(value.value); + path.push(extractKey(value.key!)); + } else { + getCurrentObject()[currentKey] = parseValue(token.trim()); + if (token.trim()) path.push(currentKey); + } + } else if (token.trim()) { + path.push(extractKey(token)); + } else if (afterColon && currentKey) { + path.push(currentKey); + } else if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + path.push(String(containerStack[containerStack.length - 1].index)); + } + + containerStack.push({ type: 'object', index: 0 }); + objectStack.push({}); + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case '[': { + if (token.trim()) { + path.push(extractKey(token)); + } else if (afterColon && currentKey) { + path.push(currentKey); + } + + containerStack.push({ type: 'array', index: 0 }); + objectStack.push([]); + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case '}': + case ']': { + // Complete pending value + if (token.trim()) { + if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + // We're in an array, add the item + const arr = objectStack[objectStack.length - 1] as any[]; + arr.push(parseValue(token.trim())); + } else if (afterColon && currentKey) { + // We're completing an object property + getCurrentObject()[currentKey] = parseValue(token.trim()); + } + } + + // Pop container + const completed = objectStack.pop(); + containerStack.pop(); + if (path.length > 0) { + const key = path.pop()!; + if (objectStack.length > 0 && completed !== undefined) { + const parent = objectStack[objectStack.length - 1]; + if (!Array.isArray(parent)) { + parent[key] = completed; + } + } + } + + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case ':': { + currentKey = extractKey(token); + afterColon = true; + token = ''; + break; + } + + case ',': { + // Complete value + if (token.trim()) { + if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + // We're in an array, add the item + const arr = objectStack[objectStack.length - 1] as any[]; + arr.push(parseValue(token.trim())); + } else if (afterColon && currentKey) { + // We're completing an object property + getCurrentObject()[currentKey] = parseValue(token.trim()); + } + } + + // Update array index + if (containerStack.length > 0) { + const container = containerStack[containerStack.length - 1]; + if (container.type === 'array') { + container.index++; + } + } + + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + default: { + if (/\s/.test(ch)) { + // Newline can complete a value in Hjson + if (ch === '\n' && afterColon && currentKey && token.trim()) { + getCurrentObject()[currentKey] = parseValue(token.trim()); + token = ''; + currentKey = undefined; + afterColon = false; + } + } else { + token += ch; + } + } + } + } + + // Final state determination + // Handle Hjson no-comma between value and next key + if (afterColon && currentKey && token.trim()) { + const value = tryExtractValueAndKey(token.trim()); + if (value.hasKey) { + getCurrentObject()[currentKey] = parseValue(value.value); + token = value.key!; + afterColon = false; + currentKey = undefined; + } + } + + // Determine context + let isEditingKey = true; Review Comment: ## Useless assignment to local variable The initial value of isEditingKey is unused, since it is always overwritten. [Show more details](https://github.com/apache/druid/security/code-scanning/9228) ########## web-console/src/utils/hjson-context.ts: ########## @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface HjsonContext { + /** + * The path of keys leading to the current position, e.g., ["query", "dataSource"] + * For arrays, includes the index as a string key, e.g., ["filters", "0", "dimension"] + * Empty array if at root level + */ + path: string[]; + + /** + * Whether the cursor is positioned where a key should be entered (true) + * or where a value should be entered (false) + */ + isEditingKey: boolean; + + /** + * If editing a value (isEditingKey === false), this is the key for that value + * If editing a key (isEditingKey === true), this is undefined + */ + currentKey?: string; + + /** + * Whether the cursor is positioned inside a comment (single-line or multi-line) + */ + isEditingComment: boolean; + + /** + * The current JSON object being edited at the cursor position. + * This is the object that contains the property/value being typed. + * For completions, this provides context about what properties already exist. + */ + currentObject: any; +} + +/** + * Analyzes an Hjson string (from start to cursor position) and returns + * context information about where the cursor is positioned within the JSON structure + * + * @param hjson - The Hjson text from the beginning of the document to the cursor position + * @returns Context information about the cursor position + */ +export function getHjsonContext(hjson: string): HjsonContext { + // Empty input + if (!hjson.trim()) { + return { + path: [], + isEditingKey: true, + currentKey: undefined, + isEditingComment: false, + currentObject: {}, + }; + } + + // State machine state + const path: string[] = []; + const containerStack: { type: 'object' | 'array'; index: number }[] = []; + const objectStack: any[] = [{}]; + + let state: 'normal' | 'string' | 'single-comment' | 'multi-comment' = 'normal'; + let stringDelim = ''; + let token = ''; + let currentKey: string | undefined; + let afterColon = false; + + // Comment preservation + let preCommentKey: string | undefined; + let preCommentAfterColon = false; + + // Process each character + for (let i = 0; i < hjson.length; i++) { + const ch = hjson[i]; + const next = hjson[i + 1]; + + // State transitions + if (state === 'string') { + token += ch; + if (ch === stringDelim && hjson[i - 1] !== '\\') { + state = 'normal'; + // If in array expecting value, push completed string + if (afterColon && currentKey && containerStack.length > 0) { + const container = containerStack[containerStack.length - 1]; + if (container.type === 'array') { + objectStack[objectStack.length - 1].push(parseValue(token)); + token = ''; + afterColon = false; + } + } + } + continue; + } + + if (state === 'single-comment') { + if (ch === '\n') state = 'normal'; + continue; + } + + if (state === 'multi-comment') { + if (ch === '*' && next === '/') { + state = 'normal'; + i++; // Skip '/' + } + continue; + } + + // Normal state processing + // Check for comment start + if (ch === '/' && next === '/') { + preCommentKey = currentKey; + preCommentAfterColon = afterColon; + state = 'single-comment'; + i++; // Skip second '/' + continue; + } + + if (ch === '/' && next === '*') { + preCommentKey = currentKey; + preCommentAfterColon = afterColon; + state = 'multi-comment'; + i++; // Skip '*' + continue; + } + + // String start + if (ch === '"' || ch === "'") { + state = 'string'; + stringDelim = ch; + token += ch; + continue; + } + + // Structural characters + switch (ch) { + case '{': { + // Handle Hjson no-comma case + if (afterColon && currentKey && token.trim()) { + const value = tryExtractValueAndKey(token.trim()); + if (value.hasKey) { + getCurrentObject()[currentKey] = parseValue(value.value); + path.push(extractKey(value.key!)); + } else { + getCurrentObject()[currentKey] = parseValue(token.trim()); + if (token.trim()) path.push(currentKey); + } + } else if (token.trim()) { + path.push(extractKey(token)); + } else if (afterColon && currentKey) { + path.push(currentKey); + } else if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + path.push(String(containerStack[containerStack.length - 1].index)); + } + + containerStack.push({ type: 'object', index: 0 }); + objectStack.push({}); + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case '[': { + if (token.trim()) { + path.push(extractKey(token)); + } else if (afterColon && currentKey) { + path.push(currentKey); + } + + containerStack.push({ type: 'array', index: 0 }); + objectStack.push([]); + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case '}': + case ']': { + // Complete pending value + if (token.trim()) { + if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + // We're in an array, add the item + const arr = objectStack[objectStack.length - 1] as any[]; + arr.push(parseValue(token.trim())); + } else if (afterColon && currentKey) { + // We're completing an object property + getCurrentObject()[currentKey] = parseValue(token.trim()); + } + } + + // Pop container + const completed = objectStack.pop(); + containerStack.pop(); + if (path.length > 0) { + const key = path.pop()!; + if (objectStack.length > 0 && completed !== undefined) { + const parent = objectStack[objectStack.length - 1]; + if (!Array.isArray(parent)) { + parent[key] = completed; + } + } + } + + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + case ':': { + currentKey = extractKey(token); + afterColon = true; + token = ''; + break; + } + + case ',': { + // Complete value + if (token.trim()) { + if ( + containerStack.length > 0 && + containerStack[containerStack.length - 1].type === 'array' + ) { + // We're in an array, add the item + const arr = objectStack[objectStack.length - 1] as any[]; + arr.push(parseValue(token.trim())); + } else if (afterColon && currentKey) { + // We're completing an object property + getCurrentObject()[currentKey] = parseValue(token.trim()); + } + } + + // Update array index + if (containerStack.length > 0) { + const container = containerStack[containerStack.length - 1]; + if (container.type === 'array') { + container.index++; + } + } + + token = ''; + currentKey = undefined; + afterColon = false; + break; + } + + default: { + if (/\s/.test(ch)) { + // Newline can complete a value in Hjson + if (ch === '\n' && afterColon && currentKey && token.trim()) { + getCurrentObject()[currentKey] = parseValue(token.trim()); + token = ''; + currentKey = undefined; + afterColon = false; + } + } else { + token += ch; + } + } + } + } + + // Final state determination + // Handle Hjson no-comma between value and next key + if (afterColon && currentKey && token.trim()) { + const value = tryExtractValueAndKey(token.trim()); + if (value.hasKey) { + getCurrentObject()[currentKey] = parseValue(value.value); + token = value.key!; + afterColon = false; + currentKey = undefined; + } + } + + // Determine context + let isEditingKey = true; + let finalKey: string | undefined; + + if (containerStack.length === 0) { + // Root level - partial keys should not be considered as currentKey + isEditingKey = true; + finalKey = undefined; + } else { + const container = containerStack[containerStack.length - 1]; + if (container.type === 'array') { + isEditingKey = false; + finalKey = String(container.index); + } else { + isEditingKey = !afterColon; + if (afterColon) { + finalKey = currentKey; + } else if ( + token.trim() && + ((getCurrentObject() && Object.keys(getCurrentObject()).length > 0) || + containerStack.length > 1) + ) { + // Set currentKey for partial keys in non-empty objects (trailing comma case) or nested contexts + finalKey = extractKey(token); + } + } + } + + // Handle comments + const isInComment = state === 'single-comment' || state === 'multi-comment'; + if (isInComment) { + isEditingKey = !preCommentAfterColon; + finalKey = preCommentKey; + } + + return { + path, + isEditingKey, + currentKey: finalKey, + isEditingComment: isInComment, + currentObject: getCurrentObject(), + }; + + function getCurrentObject(): any { + if (objectStack.length === 0) return {}; + const current = objectStack[objectStack.length - 1]; + return Array.isArray(current) ? objectStack[objectStack.length - 2] || {} : current; + } +} + +function extractKey(token: string): string { + const trimmed = token.trim().replace(/:$/, '').trim(); + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + const match = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(trimmed); + return match ? match[1] : trimmed; +} + +function parseValue(token: string): any { + const trimmed = token.trim(); + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + if (trimmed === 'null') return null; + + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return trimmed.includes('.') ? parseFloat(trimmed) : parseInt(trimmed, 10); + } + + return trimmed; +} + +function tryExtractValueAndKey(token: string): { value: string; key?: string; hasKey: boolean } { + // Try patterns for value followed by key + const patterns = [ + /^(".*?"|'.*?'|\S+)\s+([a-zA-Z_"'].*)$/, // With space + /^(".*?"|'.*?')([a-zA-Z_].*)$/, // No space after quoted + ]; + + for (const pattern of patterns) { + const match = pattern.exec(token); Review Comment: ## Polynomial regular expression used on uncontrolled data This [regular expression](1) that depends on [library input](2) may run slow on strings starting with '! "' and with many repetitions of '" "'. This [regular expression](3) that depends on [library input](2) may run slow on strings starting with '""A' and with many repetitions of '"a'. [Show more details](https://github.com/apache/druid/security/code-scanning/9227) -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
