UNPKG

azurite

Version:

An open source Azure Storage API compatible server

531 lines 21.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ParserContext = void 0; const tslib_1 = require("tslib"); const StorageError_1 = tslib_1.__importDefault(require("../../errors/StorageError")); const StorageErrorFactory_1 = tslib_1.__importDefault(require("../../errors/StorageErrorFactory")); const AndNode_1 = tslib_1.__importDefault(require("./QueryNodes/AndNode")); const ConstantNode_1 = tslib_1.__importDefault(require("./QueryNodes/ConstantNode")); const EqualsNode_1 = tslib_1.__importDefault(require("./QueryNodes/EqualsNode")); const ExpressionNode_1 = tslib_1.__importDefault(require("./QueryNodes/ExpressionNode")); const GreaterThanEqualNode_1 = tslib_1.__importDefault(require("./QueryNodes/GreaterThanEqualNode")); const GreaterThanNode_1 = tslib_1.__importDefault(require("./QueryNodes/GreaterThanNode")); const KeyNode_1 = tslib_1.__importDefault(require("./QueryNodes/KeyNode")); const LessThanEqualNode_1 = tslib_1.__importDefault(require("./QueryNodes/LessThanEqualNode")); const LessThanNode_1 = tslib_1.__importDefault(require("./QueryNodes/LessThanNode")); const NotEqualsNode_1 = tslib_1.__importDefault(require("./QueryNodes/NotEqualsNode")); const OrNode_1 = tslib_1.__importDefault(require("./QueryNodes/OrNode")); /** * This file is used to parse query string for Azure Blob filter by tags and x-ms-if-tags conditions. * https://learn.microsoft.com/en-us/azure/storage/blobs/storage-manage-find-blobs?tabs=azure-portal * https://learn.microsoft.com/en-us/rest/api/storageservices/specifying-conditional-headers-for-blob-service-operations */ var ComparisonType; (function (ComparisonType) { ComparisonType[ComparisonType["Equal"] = 0] = "Equal"; ComparisonType[ComparisonType["Greater"] = 1] = "Greater"; ComparisonType[ComparisonType["Less"] = 2] = "Less"; ComparisonType[ComparisonType["NotEqual"] = 3] = "NotEqual"; })(ComparisonType || (ComparisonType = {})); function parseQuery(requestContext, query, conditionsHeader) { return new QueryParser(requestContext, query, conditionsHeader).visit(); } exports.default = parseQuery; /** * A recursive descent parser for Azure Blob filter by tags query syntax. * * This parser is implemented using a recursive descent strategy, which composes * layers of syntax hierarchy, roughly corresponding to the structure of an EBNF * grammar. Each layer of the hierarchy is implemented as a method which consumes * the syntax for that layer, and then calls the next layer of the hierarchy. * * So for example, the syntax tree that we currently use is composed of: * - QUERY := EXPRESSION * - EXPRESSION := OR * - OR := AND ("or" OR)* * - AND := UNARY ("and" AND)* * - UNARY := ("not")? EXPRESSION_GROUP * - EXPRESSION_GROUP := ("(" EXPRESSION ")") | BINARY * - BINARY := IDENTIFIER_OR_CONSTANT (OPERATOR IDENTIFIER_OR_CONSTANT)? * - IDENTIFIER_OR_CONSTANT := CONSTANT | IDENTIFIER * - CONSTANT := STRING */ class QueryParser { constructor(requestContext, query, conditionHeader) { this.comparisonNodes = {}; this.comparisonCount = 0; this.queryString = query; this.requestContext = requestContext; this.query = new ParserContext(this.requestContext, query, conditionHeader); this.conditionHeader = conditionHeader; } validateWithPreviousComparison(key, currentComparison) { if (this.conditionHeader) return; if (currentComparison === ComparisonType.NotEqual) { return; } if (this.comparisonNodes[key]) { for (let i = 0; i < this.comparisonNodes[key].existedComparison.length; ++i) { if (currentComparison === ComparisonType.Equal) { throw new Error("can't have multiple conditions for a single tag unless they define a range"); } if (currentComparison === ComparisonType.Greater && (this.comparisonNodes[key].existedComparison[i] === ComparisonType.Less || this.comparisonNodes[key].existedComparison[i] === ComparisonType.Equal)) { throw new Error("can't have multiple conditions for a single tag unless they define a range"); } if (currentComparison === ComparisonType.Less && (this.comparisonNodes[key].existedComparison[i] === ComparisonType.Greater || this.comparisonNodes[key].existedComparison[i] === ComparisonType.Equal)) { throw new Error("can't have multiple conditions for a single tag unless they define a range"); } } } return; } appendComparionNode(key, currentComparison) { if (this.conditionHeader) { return; } if (key !== '@container') { if (!this.comparisonNodes.hasOwnProperty(key)) { ++this.comparisonCount; } } if (this.comparisonCount > 10) { throw new StorageError_1.default(400, `InvalidQueryParameterValue`, `Error parsing query: there can be at most 10 unique tags in a query`, this.requestContext.contextId, { QueryParameterName: `where`, QueryParameterValue: this.queryString }); } if (this.comparisonNodes[key]) { this.comparisonNodes[key].existedComparison.push(currentComparison); } else { this.comparisonNodes[key] = { key: key, existedComparison: [currentComparison] }; } } /** * Visits the root of the query syntax tree, returning the corresponding root node. * * @returns {IQueryNode} */ visit() { return this.visitQuery(); } /** * Visits the QUERY layer of the query syntax tree, returning the appropriate node. * * @returns {IQueryNode} */ visitQuery() { const tree = this.visitExpression(); this.query.skipWhitespace(); this.query.assertEndOfQuery(); return tree; } /** * Visits the EXPRESSION layer of the query syntax tree, returning the appropriate node. * * EXPRESSION := OR * * @returns {IQueryNode} */ visitExpression() { return this.visitOr(); } /** * Visits the OR layer of the query syntax tree, returning the appropriate node. * * OR := AND ("or" OR)* * * @returns {IQueryNode} */ visitOr() { const left = this.visitAnd(); this.query.skipWhitespace(); if (this.query.consume("or", true)) { if (!this.conditionHeader) { this.query.throw(`unexpected or`); } const right = this.visitOr(); return new OrNode_1.default(left, right); } else { return left; } } /** * Visits the AND layer of the query syntax tree, returning the appropriate node. * * AND := UNARY ("and" AND)* * * @returns {IQueryNode} */ visitAnd() { const left = this.visitUnary(); this.query.skipWhitespace(); if (this.query.consume("and", true)) { const right = this.visitAnd(); return new AndNode_1.default(left, right); } else { return left; } } /** * Visits the UNARY layer of the query syntax tree, returning the appropriate node. * * UNARY := ("not")? EXPRESSION_GROUP * * @returns {IQueryNode} */ visitUnary() { this.query.skipWhitespace(); const right = this.visitExpressionGroup(); return right; } /** * Visits the EXPRESSION_GROUP layer of the query syntax tree, returning the appropriate node. * * EXPRESSION_GROUP := ("(" OR ")") | BINARY * * @returns {IQueryNode} */ visitExpressionGroup() { this.query.skipWhitespace(); if (this.query.consume("(")) { const child = this.visitExpression(); this.query.skipWhitespace(); this.query.consume(")") || this.query.throw(`Expected a ')' to close the expression group, but found '${this.query.peek()}' instead.`); return new ExpressionNode_1.default(child); } else { return this.visitBinary(); } } /** * Visits the BINARY layer of the query syntax tree, returning the appropriate node. * * BINARY := IDENTIFIER_OR_CONSTANT (OPERATOR IDENTIFIER_OR_CONSTANT)? * * @returns {IQueryNode} */ visitBinary() { const left = this.visitKey(); this.query.skipWhitespace(); const operator = this.query.consumeOneOf(true, "=", ">=", "<=", "<>", ">", "<"); if (operator) { const right = this.visitValue(); switch (operator) { case "=": this.validateWithPreviousComparison(left.toString(), ComparisonType.Equal); this.appendComparionNode(left.toString(), ComparisonType.Equal); return new EqualsNode_1.default(left, right); case "<>": if (!this.conditionHeader) { this.query.throw(`unexpected <>`); } this.validateWithPreviousComparison(left.toString(), ComparisonType.NotEqual); this.appendComparionNode(left.toString(), ComparisonType.NotEqual); return new NotEqualsNode_1.default(left, right); case ">=": this.validateWithPreviousComparison(left.toString(), ComparisonType.Greater); this.appendComparionNode(left.toString(), ComparisonType.Greater); return new GreaterThanEqualNode_1.default(left, right); case ">": this.validateWithPreviousComparison(left.toString(), ComparisonType.Greater); this.appendComparionNode(left.toString(), ComparisonType.Greater); return new GreaterThanNode_1.default(left, right); case "<": this.validateWithPreviousComparison(left.toString(), ComparisonType.Less); this.appendComparionNode(left.toString(), ComparisonType.Less); return new LessThanNode_1.default(left, right); case "<=": this.validateWithPreviousComparison(left.toString(), ComparisonType.Less); this.appendComparionNode(left.toString(), ComparisonType.Less); return new LessThanEqualNode_1.default(left, right); } } return left; } /** * Visits the IDENTIFIER_OR_CONSTANT layer of the query syntax tree, returning the appropriate node. * * IDENTIFIER_OR_CONSTANT := CONSTANT | IDENTIFIER * * @returns {IQueryNode} */ visitValue() { this.query.skipWhitespace(); if (`'`.includes(this.query.peek())) { return this.visitString(); } this.query.throw('expecting tag value'); } ContainsInvalidTagKeyCharacter(key) { for (let c of key) { if (!(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_')) { return true; } } return false; } validateKey(key) { if (key.startsWith("@")) { if (this.conditionHeader) { this.query.throw(""); } if (key !== "@container") { this.query.throw(`unsupported parameter '${key}'`); } // Key is @container, no need for further check. return; } if (!this.conditionHeader && ((key.length == 0) || (key.length > 128))) { this.query.throw('tag must be between 1 and 128 characters in length'); } if (this.ContainsInvalidTagKeyCharacter(key)) { this.query.throw(`unexpected '${key}'`); } } validateValue(value) { if (!this.conditionHeader && (value.length > 256)) { this.query.throw(`tag value must be between 0 and 256 characters in length`); } for (let c of value) { if (!(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == ' ' || c == '+' || c == '-' || c == '.' || c == '/' || c == ':' || c == '=' || c == '_')) { this.query.throw(`'${c}' not permitted in tag name or value`); } } } /** * Visits the STRING layer of the query syntax tree, returning the appropriate node. * * Strings are wrapped in either single quotes (') or double quotes (") and may contain * doubled-up quotes to introduce a literal. */ visitString(isAKey = false) { const openCharacter = this.query.take(); /** * Strings are terminated by the same character that opened them. * But we also allow doubled-up characters to represent a literal, which means we need to only terminate a string * when we receive an odd-number of closing characters followed by a non-closing character. * * Conceptually, this is represented by the following state machine: * * - start: normal * - normal+(current: !') -> normal * - normal+(current: ', next: ') -> escaping * - normal+(current: ', next: !') -> end * - escaping+(current: ') -> normal * * We can implement this using the state field of the `take` method's predicate. */ const content = this.query.take((c, peek, state) => { if (state === "escaping") { return "normal"; } else if (c === openCharacter && peek === openCharacter) { return "escaping"; } else if (c !== openCharacter) { return "normal"; } else { return false; } }); this.query.consume(openCharacter) || this.query.throw(`Expected a \`${openCharacter}\` to close the string, but found ${this.query.peek()} instead.`); if (isAKey) { const keyName = content.replace(new RegExp(`${openCharacter}${openCharacter}`, 'g'), openCharacter); this.validateKey(keyName); return new KeyNode_1.default(keyName); } else { const value = content.replace(new RegExp(`${openCharacter}${openCharacter}`, 'g'), openCharacter); this.validateValue(value); return new ConstantNode_1.default(value); } } /** * Visits the IDENTIFIER layer of the query syntax tree, returning the appropriate node. * * Identifiers are a sequence of characters which are not whitespace. * * @returns {IQueryNode} */ visitKey() { // A key name can be surrounded by double quotes. if (`"`.includes(this.query.peek())) { return this.visitString(true); } else { const identifier = this.query.take(c => !!c.trim() && c !== '=' && c != '>' && c !== '<') || this.query.throw(`Expected a valid identifier, but found '${this.query.peek()}' instead.`); this.validateKey(identifier); return new KeyNode_1.default(identifier); } } } /** * Provides the logic and helper functions for consuming tokens from a query string. * This includes low level constructs like peeking at the next character, consuming a * specific sequence of characters, and skipping whitespace. */ class ParserContext { constructor(requestContext, query, conditionHeader) { this.requestContext = requestContext; this.query = query; this.conditionHeader = conditionHeader; this.tokenPosition = 0; } /** * Asserts that the query has been fully consumed. * * This method should be called after the parser has finished consuming the known parts of the query. * Any remaining query after this point is indicative of a syntax error. */ assertEndOfQuery() { if (this.tokenPosition < this.query.length) { this.throw(`Unexpected token '${this.peek()}'.`); } } /** * Retrieves the next character in the query without advancing the parser. * * @returns {string} A single character, or `undefined` if the end of the query has been reached. */ peek() { return this.query[this.tokenPosition]; } /** * Advances the parser past any whitespace characters. */ skipWhitespace() { while (this.query[this.tokenPosition] && !this.query[this.tokenPosition].trim()) { this.tokenPosition++; } } /** * Attempts to consume a given sequence of characters from the query, * advancing the parser if the sequence is found. * * @param {string} sequence The sequence of characters which should be consumed. * @param {boolean} ignoreCase Whether or not the case of the characters should be ignored. * @returns {boolean} `true` if the sequence was consumed, `false` otherwise. */ consume(sequence, ignoreCase = false) { const normalize = ignoreCase ? (s) => s.toLowerCase() : (s) => s; if (normalize(this.query.substring(this.tokenPosition, this.tokenPosition + sequence.length)) === normalize(sequence)) { this.tokenPosition += sequence.length; return true; } return false; } /** * Attempts to consume one of a given set of sequences from the query, * advancing the parser if one of the sequences is found. * * Sequences are tested in the order they are provided, and the first * sequence which is found is consumed. As such, it is important to * avoid prefixes appearing before their longer counterparts. * * @param {boolean} ignoreCase Whether or not the case of the characters should be ignored. * @param {string[]} options The list of character sequences which should be consumed. * @returns {string | null} The sequence which was consumed, or `null` if none of the sequences were found. */ consumeOneOf(ignoreCase = false, ...options) { for (const option of options) { if (this.consume(option, ignoreCase)) { return option; } } return null; } /** * Consumes a sequence of characters from the query based on a character predicate function. * * The predicate function is called for each character in the query, and the sequence is * consumed until the predicate returns `false` or the end of the query is reached. * * @param {Function} predicate The function which determines which characters should be consumed. * @returns {string} The sequence of characters which were consumed. */ take(predicate) { const start = this.tokenPosition; let until = this.tokenPosition; if (predicate) { let state; while (this.query[until]) { state = predicate(this.query[until], this.query[until + 1], state); if (!state) { break; } until++; } } else { // If no predicate is provided, then just take one character until++; } this.tokenPosition = until; return this.query.substring(start, until); } /** * Consumes a sequence of characters from the query based on a character predicate function, * and then consumes a terminating sequence of characters (throwing an exception if these are not found). * * This function is particularly useful for consuming sequences of characters which are surrounded * by a prefix and suffix, such as strings. * * @param {string} prefix The prefix which should be consumed. * @param {Function} predicate The function which determines which characters should be consumed. * @param {string} suffix The suffix which should be consumed. * @returns {string | null} The sequence of characters which were consumed, or `null` if the prefix was not found. */ takeWithTerminator(prefix, predicate, suffix) { if (!this.consume(prefix)) { return null; } const value = this.take(predicate); this.consume(suffix) || this.throw(`Expected "${suffix}" to close the "${prefix}...${suffix}", but found '${this.peek()}' instead.`); return value; } /** * Throws an exception with a message indicating the position of the parser in the query. * @param {string} message The message to include in the exception. */ throw(message) { if (this.conditionHeader) { throw StorageErrorFactory_1.default.getInvalidHeaderValue(this.requestContext.contextId, { HeaderName: this.conditionHeader, HeaderValue: this.query }); } else { throw new StorageError_1.default(400, `InvalidQueryParameterValue`, `Error parsing query at or near character position ${this.tokenPosition}: ${message}`, this.requestContext.contextId, { QueryParameterName: `where`, QueryParameterValue: this.query }); } } } exports.ParserContext = ParserContext; //# sourceMappingURL=QueryParser.js.map